feat(package): session packages backed by a credit ledger

"Six laser sessions" is the common case in an aesthetics clinic: the patient
pays once and books the sessions later.

Credit is a ledger, not a counter. No table has a remaining/used_count column
and a schema test enforces that — the balance is always SUM(delta) over
append-only rows, so every number a patient sees has a full history behind it.
Corrections are new rows, never edits.

- purchase / consume / refund / adjustment / expiry, each with a reason, an
  author and the appointment it belongs to
- consume happens in confirm(), never in quote(): if the preview consumed, a
  page refresh would cost the patient a session
- cancelling adds a refund row; the consume row stays
- FIFO across a patient's packages — the oldest is closest to expiring
- an empty package is not an error, it just does not apply and the patient pays
- adjust/expire need a doctor or clinic role, and adjust always needs a reason
- app:package:expire writes the closing row so "where did my 3 sessions go?"
  always has an answer

Consume takes a pessimistic lock on the one package row. That is the opposite
of task 07's slot buckets, and docs/api/package.md carries the table explaining
why, so nobody unifies them later.

Idempotency checks for an existing consume row before inserting rather than
catching the unique violation: in Doctrine that exception closes the
EntityManager and burns the rest of the request. The unique key stays as the
last line of defence.

Admin: PackagesPage, a packages tab on the patient record, and a ledger page
whose running-balance column shows where the final number came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-31 11:11:03 +03:30
co-authored by Claude Opus 5
parent d6294242b7
commit ca9648732d
35 changed files with 3205 additions and 78 deletions
+47
View File
@@ -1204,3 +1204,50 @@ export interface PolicySimulationRun {
rows: SimulationRow[];
warning: string | null;
}
// ── پکیج و اعتبار جلسات (تسک ۱۱) ─────────────────────────────────────────────
export interface PackageDefinition {
uuid: string;
name: string;
session_count: number;
price_rials: number;
/** `null` یعنی بی‌پایان */
validity_days: number | null;
active: boolean;
services: { uuid: string; name: string }[];
created_at: number;
}
export interface PatientPackage {
uuid: string;
package_uuid: string;
package_name: string;
patient_uuid: string;
session_count: number;
price_paid_rials: number;
purchased_at: number;
valid_to: number | null;
expired: boolean;
/** همیشه از جمع دفتر می‌آید — هیچ ستونی در دیتابیس نیست */
balance: number;
}
export interface CreditLedgerRow {
uuid: string;
kind: 'purchase' | 'consume' | 'refund' | 'adjustment' | 'expiry';
delta: number;
appointment_uuid: string | null;
service_uuid: string | null;
service_name: string | null;
reason: string | null;
created_by: string | null;
created_at: number;
/** در UI محاسبه‌شده نیست — سرور همان جمع تجمعی را می‌دهد */
running_balance: number;
}
export interface CreditLedger {
package: PatientPackage;
rows: CreditLedgerRow[];
}