Files
clinicpro/src/Pricing/ValueObject/PriceQuote.php
T
hamedandClaude Opus 5 ca9648732d 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>
2026-07-31 11:11:03 +03:30

55 lines
2.2 KiB
PHP

<?php
namespace App\Pricing\ValueObject;
/**
* نتیجهٔ زنجیرهٔ قیمت‌گذاری، پیش از اینکه جایی ذخیره شود.
*
* همان اعدادی که `PriceSnapshot` نگه می‌دارد — عمداً یک شکل، تا «قیمتی که به کاربر
* نشان دادیم» و «قیمتی که ثبت کردیم» نتوانند واگرا شوند.
*/
final readonly class PriceQuote
{
/** @param list<array<string, mixed>> $discounts */
public function __construct(
public int $baseRials,
public int $itemsRials,
public int $discountRials,
public int $insuranceBaseRials,
public int $insuranceSupplementaryRials,
public int $taxRials,
public int $finalRials,
public int $depositRials,
public array $discounts = [],
public array $sources = [],
/**
* پکیج در پیش‌نمایش **مصرف نمی‌شود** — فقط اعلام می‌شود. مصرف واقعی هنگام
* ثبت نهایی است، وگرنه هر رفرش صفحه یک جلسه از بیمار می‌گرفت.
*/
public bool $packageWillBeConsumed = false,
public ?string $packageUuid = null,
) {}
public function breakdown(): array
{
return ['discounts' => $this->discounts, 'sources' => $this->sources];
}
public function toArray(): array
{
return [
'base_rials' => $this->baseRials,
'items_rials' => $this->itemsRials,
'discount_rials' => $this->discountRials,
'insurance_base_rials' => $this->insuranceBaseRials,
'insurance_supplementary_rials' => $this->insuranceSupplementaryRials,
'tax_rials' => $this->taxRials,
'final_rials' => $this->finalRials,
'deposit_rials' => $this->depositRials,
'package_will_be_consumed' => $this->packageWillBeConsumed,
'package_uuid' => $this->packageUuid,
'breakdown' => $this->breakdown(),
];
}
}