Files
clinicpro/src/Package/Service/CreditLedgerService.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

134 lines
5.4 KiB
PHP

<?php
namespace App\Package\Service;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\ClinicService\Entity\ServiceItem;
use App\Package\Entity\PatientPackage;
use App\Package\Entity\SessionCreditLedger;
use App\Package\Repository\SessionCreditLedgerRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\DBAL\LockMode;
/**
* تنها نویسندهٔ دفتر اعتبار.
*
* هیچ کلاس دیگری نباید در `session_credit_ledger` بنویسد؛ اگر بنویسد، قواعد این کلاس
* (مصرف یکتا per نوبت، مانده‌ای که منفی نمی‌شود) دور زده می‌شوند و دفتر همان چیزی
* می‌شود که قرار بود نباشد: عددی که کسی نمی‌داند از کجا آمده.
*/
final class CreditLedgerService
{
public function __construct(
private readonly SessionCreditLedgerRepository $ledger,
private readonly EntityManagerInterface $em,
) {}
/** مانده = جمع delta ها. هیچ ستون ذخیره‌شده‌ای نیست. */
public function balance(PatientPackage $package): int
{
return $this->ledger->sumDelta($package);
}
public function record(
PatientPackage $package,
string $kind,
int $delta,
?Appointment $appointment = null,
?ServiceItem $service = null,
?string $reason = null,
?User $by = null,
bool $flush = true,
): SessionCreditLedger {
$row = new SessionCreditLedger($package, $kind, $delta, $appointment, $service, $reason, $by);
$this->em->persist($row);
if ($flush) {
$this->em->flush();
}
return $row;
}
/**
* مصرف یک جلسه — `false` یعنی «اعتباری نبود»، نه خطا.
*
* بیمار بدون اعتبار باید بتواند نقدی بپردازد؛ استثنا پرتاب کردن اینجا یعنی
* رزروِ کاملاً معتبر شکست بخورد.
*
* قفل بدبینانه روی همان یک ردیف پکیج است. برخلاف اسلات‌های تسک ۰۷ — که نرخ رقابت
* بالا و ده‌ها ردیف درگیر دارند — اینجا یک بیمار و یک پکیج است، پس هزینهٔ قفل
* ناچیز و سادگی‌اش برنده است.
*/
public function consume(PatientPackage $package, Appointment $appointment, ?ServiceItem $service = null): bool
{
// قفل بدون تراکنش معنا ندارد؛ خواندن و نوشتن باید در یک واحد اتمی باشند
// وگرنه دو درخواست هم‌زمان هر دو ماندهٔ ۱ را می‌بینند.
return $this->em->wrapInTransaction(function () use ($package, $appointment, $service): bool {
$locked = $this->em->find(PatientPackage::class, $package->getId(), LockMode::PESSIMISTIC_WRITE);
if ($locked === null || $locked->isExpired()) {
return false;
}
// همین نوبت قبلاً مصرف کرده؟ `confirm` idempotent است و اجرای دومش نباید
// جلسهٔ دوم بخورد. بررسی **پیش از** درج است نه گرفتنِ استثنا: نقض کلید
// یکتا در Doctrine خودِ EntityManager را می‌بندد و بقیهٔ همان request را
// هم می‌سوزاند. کلید یکتا آخرین خط دفاع می‌ماند، نه مسیر عادی.
if ($this->ledger->findForAppointment($appointment, SessionCreditLedger::KIND_CONSUME) !== null) {
return true;
}
if ($this->balance($locked) <= 0) {
return false;
}
$this->record($locked, SessionCreditLedger::KIND_CONSUME, -1, $appointment, $service);
return true;
});
}
/**
* بازگشت اعتبار هنگام لغو — ردیف `consume` **حذف نمی‌شود**.
*
* فعلاً هر لغوی اعتبار را کامل برمی‌گرداند. سیاست واقعی (لغو دیرهنگام، جریمه،
* عدم‌حضور) کارِ تسک ۱۳ است و همان‌جا این متد یک پارامتر سیاست می‌گیرد؛ پرچم
* نیم‌کاره اینجا فقط رفتاری می‌ساخت که هیچ‌کس تنظیمش نمی‌کند.
*
* @return bool `false` یعنی این نوبت اصلاً از پکیج مصرف نکرده بود
*/
public function refund(Appointment $appointment, ?User $by = null): bool
{
$consumed = $this->ledger->findForAppointment($appointment, SessionCreditLedger::KIND_CONSUME);
if ($consumed === null) {
return false;
}
if ($this->ledger->findForAppointment($appointment, SessionCreditLedger::KIND_REFUND) !== null) {
return false;
}
$this->record(
$consumed->getPatientPackage(),
SessionCreditLedger::KIND_REFUND,
-$consumed->getDelta(),
$appointment,
$consumed->getServiceItem(),
'بازگشت اعتبار با لغو نوبت',
$by,
);
return true;
}
/** @return SessionCreditLedger[] */
public function history(PatientPackage $package): array
{
return $this->ledger->historyFor($package);
}
}