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:
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user