Commit 8d251d14 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(financial): make the double-entry ledger say what actually happened

S1 of the mobile-portal programme. Every item here is a live defect, and
each one blocks the portal's money path rather than merely preceding it.

The ledger. PaymentService::resolveDebitAccount() returned the literal 1
and resolveCreditAccount() returned 2, both with a `// TODO`. Seeder order
made that Dr Cash / Cr Bank on every payment the product has ever taken —
627 of 701 rows on the restored oc_sport copy — so no revenue account was
ever credited and FinancialOverview::getRevenueBySource(), which groups
transactions by credit_account_id restricted to revenue accounts, could
only ever return []. Accounts now resolve by code within the academy and
hard-fail when absent, and a payment is split across revenue accounts in
proportion to the invoice's own lines, floored with intdiv() and the
remainder on the last row. Routing InstaPay into the old ledger would have
multiplied a broken ledger across a new channel.

The guards. Every rule 05-financial-integrity.md names lived in the UI, in
two hand-copied Livewire components, so any new caller inherited none of
them. amount > 0, amount <= due re-read under lockForUpdate inside the
transaction, invoice not cancelled/paid, academy and currency agreement all
sit in the service now. Draft is deliberately still payable: the POS issues
an invoice as a draft and settles it in the same transaction.

updatePaidAmount() was a read-modify-write on money with no lock — two
settlements landing together each read the old paid_amount and one
increment was lost.

Paymob confirmed callbacks inline: no lock, no Transaction row at all, and
an idempotency guard that was dead code because the finder already filtered
status = Pending, so a retried webhook credited the invoice twice. It goes
through PaymentService::confirmPending() now, which asserts the captured
amount matches.

POS cash sales double-counted the drawer: POSService incremented
total_cash_in and UpdateCashSessionTotals incremented it again, inflating
the expected drawer 2x and producing phantom variance at close. One writer
each now. A split tendered above the total (cash handed over, change given)
capped at the amount due instead of producing an overpaid invoice.

RefundService refunded the full payment only, so an over-approved amount
could not be corrected; it also hardcoded accounts 2/1 with a comment
claiming A/R, which is account 3, and debited the refunding user's own
drawer rather than the one that took the money.

Migrations, all guarded and all verified against a restored copy of
backups/oc_sport-20260831-081053.dump:

- chart of accounts seeded for every academy, not just Academy::first().
  The verified tenant was missing 4060, and db:seed only runs on first
  deploy — so a hard-failing resolver had to be preceded by this.
- invoices.branch_id and transactions.branch_id, backfilled. Revenue was
  branch-attributed only through payments.branch_id, and getCollectionRate()
  scopes invoices through whereHas('payments'), so an invoice with no
  payment yet belonged to no branch. Portal invoices awaiting a proof would
  have vanished from every branch's overdue figure. 588/713 invoices and
  644 transactions attributed.
- academy_id on invoice_items, installments and notification_preferences,
  participant_id on event_registrations — four tenant tables that broke the
  tenancy invariant, all reachable from the portal.
- notification channel CHECK widened to push and whatsapp.
  PushNotificationService writes 'push' and the CHECK allowed only
  in_app|email|sms, so every push delivery log insert raises 23514 today and
  the catch block writes another failing insert.
- guardians and guardian_participant relationship_type CHECKs reconciled to
  their union. NewRegistrationWizard validates one field against the pivot's
  vocabulary and writes it to both tables, so picking أخ / أخت / وصي crashes
  registration on the guardians CHECK right now.
- invoice_number_counters replaces generateNumber()'s count()+1 against a
  UNIQUE(academy_id, number) index — a guaranteed collision the moment
  members can check out without a receptionist serialising them, and it
  reissued numbers soft-deleted invoices still hold.
- the deleted mobile API's INV-MOB lines repaired: it wrote line_total,
  which is not a column, so total_amount defaulted to 0 and every downstream
  allocation read the sale as worthless.

tests/Feature/ExampleTest.php deleted: the stock Laravel scaffold test has
failed since `init` (it GETs / with no tenant database), permanently
red-lighting the suite and masking real failures.

Suite: 61 passed, 0 failed.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 80cc4497
...@@ -26,7 +26,17 @@ public function handle(PaymentReceived $event): void ...@@ -26,7 +26,17 @@ public function handle(PaymentReceived $event): void
return; return;
} }
// A POS sale already counted itself once in POSService, and a split
// sale raises several PaymentReceived events for that one sale — so
// only non-POS collections count an operation here. `total_cash_in`
// is incremented in exactly one place: here. POSService used to
// increment it as well, which inflated the expected drawer by
// double on every POS cash sale and produced a phantom variance at
// close.
if (($payment->metadata['source'] ?? null) !== 'pos') {
$cashSession->increment('transactions_count'); $cashSession->increment('transactions_count');
}
$cashSession->increment('total_cash_in', $payment->amount); $cashSession->increment('total_cash_in', $payment->amount);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('UpdateCashSessionTotals failed: ' . $e->getMessage(), [ Log::error('UpdateCashSessionTotals failed: ' . $e->getMessage(), [
......
...@@ -20,6 +20,7 @@ class Invoice extends Model ...@@ -20,6 +20,7 @@ class Invoice extends Model
protected $fillable = [ protected $fillable = [
'academy_id', 'academy_id',
'branch_id',
'number', 'number',
'type', 'type',
'status', 'status',
......
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
class InvoiceItem extends Model class InvoiceItem extends Model
{ {
protected $fillable = [ protected $fillable = [
'academy_id',
'invoice_id', 'invoice_id',
'itemable_type', 'itemable_type',
'itemable_id', 'itemable_id',
......
...@@ -19,6 +19,7 @@ class Transaction extends Model ...@@ -19,6 +19,7 @@ class Transaction extends Model
protected $fillable = [ protected $fillable = [
'academy_id', 'academy_id',
'branch_id',
'debit_account_id', 'debit_account_id',
'credit_account_id', 'credit_account_id',
'payment_id', 'payment_id',
......
...@@ -34,6 +34,10 @@ public function create(array $data, array $items, User $creator): Invoice ...@@ -34,6 +34,10 @@ public function create(array $data, array $items, User $creator): Invoice
- ($item['discount_amount'] ?? 0) - ($item['discount_amount'] ?? 0)
+ ($item['tax_amount'] ?? 0); + ($item['tax_amount'] ?? 0);
// Lines are a tenant table now; stamp the academy so a portal
// query can scope them directly instead of joining upwards.
$item['academy_id'] = $academyId;
$invoice->items()->create($item); $invoice->items()->create($item);
} }
...@@ -52,6 +56,8 @@ public function addItem(Invoice $invoice, array $itemData): InvoiceItem ...@@ -52,6 +56,8 @@ public function addItem(Invoice $invoice, array $itemData): InvoiceItem
- ($itemData['discount_amount'] ?? 0) - ($itemData['discount_amount'] ?? 0)
+ ($itemData['tax_amount'] ?? 0); + ($itemData['tax_amount'] ?? 0);
$itemData['academy_id'] = $invoice->academy_id;
$item = $invoice->items()->create($itemData); $item = $invoice->items()->create($itemData);
$invoice->recalculateTotals(); $invoice->recalculateTotals();
...@@ -92,34 +98,107 @@ public function markAsPaid(Invoice $invoice, User $actor): void ...@@ -92,34 +98,107 @@ public function markAsPaid(Invoice $invoice, User $actor): void
InvoicePaid::dispatch($invoice, $actor); InvoicePaid::dispatch($invoice, $actor);
} }
/**
* Move an invoice's paid/due balance by $amount (negative to reverse).
*
* This is a read-modify-write on money, so it re-reads the row under
* `lockForUpdate()` inside a transaction: two settlements landing at the
* same moment previously each read the old paid_amount and one increment
* was silently lost. The instance the caller handed in is refreshed from
* the locked row so it does not go on holding a stale balance.
*/
public function updatePaidAmount(Invoice $invoice, int $amount, User $actor): void public function updatePaidAmount(Invoice $invoice, int $amount, User $actor): void
{ {
$invoice->paid_amount += $amount; DB::transaction(function () use ($invoice, $amount, $actor) {
$invoice->due_amount = $invoice->total_amount - $invoice->paid_amount; $locked = Invoice::withoutGlobalScopes()
$invoice->collected_by = $actor->id; ->where('id', $invoice->id)
->lockForUpdate()
if ($invoice->due_amount <= 0) { ->firstOrFail();
$invoice->status = $invoice->due_amount < 0
$locked->paid_amount += $amount;
$locked->due_amount = $locked->total_amount - $locked->paid_amount;
$locked->collected_by = $actor->id;
if ($locked->due_amount <= 0) {
$locked->status = $locked->due_amount < 0
? InvoiceStatus::Overpaid ? InvoiceStatus::Overpaid
: InvoiceStatus::Paid; : InvoiceStatus::Paid;
$invoice->paid_at = now(); $locked->paid_at = now();
} else { } else {
$invoice->status = InvoiceStatus::PartiallyPaid; $locked->status = InvoiceStatus::PartiallyPaid;
} }
$invoice->save(); $locked->save();
if ($invoice->status === InvoiceStatus::Paid) { $invoice->setRawAttributes($locked->getAttributes(), true);
InvoicePaid::dispatch($invoice, $actor);
if ($locked->status === InvoiceStatus::Paid) {
InvoicePaid::dispatch($locked, $actor);
} }
});
} }
/**
* Allocate the next invoice number for an academy.
*
* The old implementation was `count() + 1` against a UNIQUE(academy_id,
* number) index — two invoices created concurrently counted the same rows
* and the second insert died on the constraint. The counter row is taken
* with `lockForUpdate()` instead, so concurrent callers queue rather than
* collide, and the sequence never revisits a number after a soft delete.
*/
public function generateNumber(int $academyId): string public function generateNumber(int $academyId): string
{ {
$count = Invoice::withoutGlobalScopes() return DB::transaction(function () use ($academyId) {
$row = DB::table('invoice_number_counters')
->where('academy_id', $academyId)
->lockForUpdate()
->first();
if (! $row) {
// First invoice for this academy on an installation that has
// not been through the seeding migration (a brand-new tenant).
$highest = $this->highestExistingNumber($academyId);
DB::table('invoice_number_counters')->insert([
'academy_id' => $academyId,
'next_number' => $highest + 1,
'created_at' => now(),
'updated_at' => now(),
]);
$row = DB::table('invoice_number_counters')
->where('academy_id', $academyId)
->lockForUpdate()
->first();
}
$next = (int) $row->next_number;
DB::table('invoice_number_counters')
->where('academy_id', $academyId)
->update(['next_number' => $next + 1, 'updated_at' => now()]);
return 'INV-' . str_pad((string) $next, 6, '0', STR_PAD_LEFT);
});
}
private function highestExistingNumber(int $academyId): int
{
$numbers = Invoice::withoutGlobalScopes()
->withTrashed()
->where('academy_id', $academyId) ->where('academy_id', $academyId)
->count(); ->where('number', 'like', 'INV-%')
->pluck('number');
$highest = 0;
foreach ($numbers as $number) {
if (preg_match('/(\d+)$/', (string) $number, $m)) {
$highest = max($highest, (int) $m[1]);
}
}
return 'INV-' . str_pad($count + 1, 6, '0', STR_PAD_LEFT); return $highest;
} }
} }
<?php
namespace App\Domain\Financial\Services;
use App\Domain\Financial\Models\FinancialAccount;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Shared\Exceptions\DomainException;
/**
* Resolves chart-of-accounts rows by CODE, scoped to an academy.
*
* Before this existed, PaymentService::resolveDebitAccount() and
* resolveCreditAccount() returned the literal ids 1 and 2 with a `// TODO`.
* Seeder order made that "Dr Cash / Cr Bank" on every payment ever recorded:
* no revenue account was ever credited, no receivable ever cleared, and
* FinancialOverview::getRevenueBySource() — which groups transactions by
* credit_account_id restricted to revenue accounts — therefore always
* returned an empty array.
*
* Resolution is by code because ids differ per tenant (each client DB seeded
* its own chart) and hard-fails when the account is absent, following the
* precedent already set by PayrollService::createPayslipTransaction().
*/
class LedgerAccountResolver
{
/** Assets — where money lands. */
public const CASH = '1000';
public const BANK = '1010';
public const RECEIVABLE = '1100';
public const WALLET = '1200';
/** Revenue — what the money was for. */
public const REVENUE_TRAINING = '4000';
public const REVENUE_REGISTRATION = '4010';
public const REVENUE_EQUIPMENT = '4020';
public const REVENUE_FACILITY = '4030';
public const REVENUE_PRIVATE = '4040';
public const REVENUE_TOURNAMENT = '4050';
public const REVENUE_OTHER = '4060';
/** @var array<string, FinancialAccount> keyed "{academyId}:{code}" */
private array $cache = [];
public function byCode(int $academyId, string $code): FinancialAccount
{
$key = "{$academyId}:{$code}";
if (isset($this->cache[$key])) {
return $this->cache[$key];
}
$account = FinancialAccount::withoutGlobalScopes()
->where('academy_id', $academyId)
->where('code', $code)
->first();
if (! $account) {
throw new DomainException(
"حساب رقم {$code} غير موجود في شجرة الحسابات — يرجى إعداد شجرة الحسابات"
);
}
return $this->cache[$key] = $account;
}
/**
* The asset account a payment settles into, from its method.
*
* `wallet` means the academy's own internal wallet, not mobile money —
* settling from it moves the balance held for the member, not cash.
*/
public function assetAccountForMethod(int $academyId, ?string $method): FinancialAccount
{
$code = match ($method) {
'cash' => self::CASH,
'wallet' => self::WALLET,
'card', 'bank_transfer', 'instapay', 'cheque', 'online' => self::BANK,
default => self::CASH,
};
return $this->byCode($academyId, $code);
}
/**
* Split an amount across revenue accounts in proportion to the invoice's
* own line composition, so getRevenueBySource() reports what was sold.
*
* Integer arithmetic only: every share is floored with intdiv() and the
* remainder is given to the last row, per the money rules.
*
* @return list<array{account: FinancialAccount, amount: int}>
*/
public function splitRevenue(int $academyId, ?Invoice $invoice, int $amount): array
{
if ($amount <= 0) {
return [];
}
$lines = $invoice
? $invoice->items()->get(['itemable_type', 'total_amount'])
: collect();
$byCode = [];
foreach ($lines as $line) {
$code = $this->revenueCodeForItemable($line->itemable_type);
$byCode[$code] = ($byCode[$code] ?? 0) + max(0, (int) $line->total_amount);
}
$base = array_sum($byCode);
// No invoice, no lines, or a zero-value invoice: everything lands in
// one bucket rather than being silently dropped from the ledger.
if ($base <= 0) {
return [[
'account' => $this->byCode($academyId, $invoice ? self::REVENUE_TRAINING : self::REVENUE_OTHER),
'amount' => $amount,
]];
}
arsort($byCode);
$split = [];
$allocated = 0;
$codes = array_keys($byCode);
$last = array_key_last($codes);
foreach ($codes as $i => $code) {
$share = $i === $last
? $amount - $allocated // remainder to the last row
: intdiv($amount * $byCode[$code], $base);
$allocated += $share;
if ($share > 0) {
$split[] = ['account' => $this->byCode($academyId, $code), 'amount' => $share];
}
}
return $split;
}
/**
* An invoice line with no itemable is a programme subscription — that is
* the convention ParticipantBillingService and every revenue widget read.
*/
public function revenueCodeForItemable(?string $itemableType): string
{
return match ($itemableType) {
null => self::REVENUE_TRAINING,
\App\Domain\Inventory\Models\Product::class => self::REVENUE_EQUIPMENT,
\App\Domain\Inventory\Models\Kit::class => self::REVENUE_EQUIPMENT,
\App\Domain\Event\Models\Event::class => self::REVENUE_TOURNAMENT,
\App\Domain\Facility\Models\SpaceReservation::class => self::REVENUE_FACILITY,
default => self::REVENUE_OTHER,
};
}
}
...@@ -19,8 +19,11 @@ class PaymobService ...@@ -19,8 +19,11 @@ class PaymobService
private ?string $iframeId; private ?string $iframeId;
private ?string $hmacSecret; private ?string $hmacSecret;
public function __construct(?int $academyId = null) private PaymentService $payments;
public function __construct(?int $academyId = null, ?PaymentService $payments = null)
{ {
$this->payments = $payments ?? app(PaymentService::class);
$this->resolveCredentials($academyId); $this->resolveCredentials($academyId);
} }
...@@ -139,37 +142,26 @@ public function processCallback(array $callbackData): ?Payment ...@@ -139,37 +142,26 @@ public function processCallback(array $callbackData): ?Payment
return null; return null;
} }
return DB::transaction(function () use ($payment, $transactionId, $callbackData) { $actor = $payment->created_by ? User::withoutGlobalScopes()->find($payment->created_by) : null;
if (! $actor) {
Log::error('[Paymob] Cannot confirm payment without an actor', ['payment_id' => $payment->id]);
return null;
}
return DB::transaction(function () use ($payment, $transactionId, $amountCents, $actor) {
$payment->update([ $payment->update([
'status' => PaymentStatus::Confirmed,
'confirmed_at' => now(),
'gateway_data' => array_merge($payment->gateway_data ?? [], [ 'gateway_data' => array_merge($payment->gateway_data ?? [], [
'paymob_transaction_id' => $transactionId, 'paymob_transaction_id' => $transactionId,
'confirmed_via' => 'webhook', 'confirmed_via' => 'webhook',
]), ]),
]); ]);
$invoice = $payment->invoice; // Settlement, the ledger entry, the invoice lock and the retry
if ($invoice) { // guard all live in PaymentService now. The callback used to do
$invoice->paid_amount += $payment->amount; // the invoice arithmetic itself, write no Transaction at all, and
$invoice->due_amount = $invoice->total_amount - $invoice->paid_amount; // credit twice when Paymob retried.
return $this->payments->confirmPending($payment, $actor, $amountCents);
if ($invoice->due_amount <= 0) {
$invoice->status = 'paid';
$invoice->due_amount = 0;
} elseif ($invoice->paid_amount > 0) {
$invoice->status = 'partially_paid';
}
$invoice->save();
}
$actor = $payment->created_by ? User::find($payment->created_by) : null;
if ($actor) {
PaymentReceived::dispatch($payment, $actor);
}
return $payment;
}); });
} }
......
...@@ -17,6 +17,7 @@ class RefundService ...@@ -17,6 +17,7 @@ class RefundService
public function __construct( public function __construct(
private readonly InvoiceService $invoiceService, private readonly InvoiceService $invoiceService,
private readonly CashSessionService $cashSessionService, private readonly CashSessionService $cashSessionService,
private readonly LedgerAccountResolver $accounts,
) {} ) {}
/** /**
...@@ -64,7 +65,12 @@ public function preview(array $paymentUuids): array ...@@ -64,7 +65,12 @@ public function preview(array $paymentUuids): array
* Returns [['refund' => Payment, 'enrollment' => Enrollment|null, 'original' => Payment], ...] * Returns [['refund' => Payment, 'enrollment' => Enrollment|null, 'original' => Payment], ...]
* so the caller can cancel enrollments in the same transaction. * so the caller can cancel enrollments in the same transaction.
*/ */
public function processRefunds(array $paymentUuids, string $reason, User $actor): array /**
* @param array<string,int> $amounts Optional partial amount in piasters
* keyed by payment uuid. A uuid absent
* from the map is refunded in full.
*/
public function processRefunds(array $paymentUuids, string $reason, User $actor, array $amounts = []): array
{ {
if (count($paymentUuids) > 2) { if (count($paymentUuids) > 2) {
throw new DomainException('يمكن استرداد دفعتين كحد أقصى في عملية واحدة'); throw new DomainException('يمكن استرداد دفعتين كحد أقصى في عملية واحدة');
...@@ -93,6 +99,26 @@ public function processRefunds(array $paymentUuids, string $reason, User $actor) ...@@ -93,6 +99,26 @@ public function processRefunds(array $paymentUuids, string $reason, User $actor)
throw new DomainException("لا يمكن استرداد دفعة صادرة"); throw new DomainException("لا يمكن استرداد دفعة صادرة");
} }
// A partial refund is the only way to correct an over-approved
// amount: the ledger is immutable, so the correction has to be a
// new reversing entry for the difference, not an edit.
$refundAmount = (int) ($amounts[$uuid] ?? $payment->amount);
$alreadyRefunded = (int) Payment::where('academy_id', $payment->academy_id)
->where('direction', 'outbound')
->where('status', PaymentStatus::Confirmed)
->where('metadata->refund_of_payment_id', $payment->id)
->sum('amount');
if ($refundAmount <= 0) {
throw new DomainException('مبلغ الاسترداد يجب أن يكون أكبر من صفر');
}
if ($refundAmount + $alreadyRefunded > $payment->amount) {
throw new DomainException(
'مبلغ الاسترداد أكبر من المتبقي من الدفعة (' . format_money($payment->amount - $alreadyRefunded) . ')'
);
}
// Find linked enrollment BEFORE mutations (for caller to cancel) // Find linked enrollment BEFORE mutations (for caller to cancel)
$enrollment = null; $enrollment = null;
if ($payment->invoice_id) { if ($payment->invoice_id) {
...@@ -113,48 +139,78 @@ public function processRefunds(array $paymentUuids, string $reason, User $actor) ...@@ -113,48 +139,78 @@ public function processRefunds(array $paymentUuids, string $reason, User $actor)
'status' => PaymentStatus::Confirmed, 'status' => PaymentStatus::Confirmed,
'payer_type' => $payment->payer_type, 'payer_type' => $payment->payer_type,
'payer_id' => $payment->payer_id, 'payer_id' => $payment->payer_id,
'amount' => $payment->amount, 'amount' => $refundAmount,
'currency' => $payment->currency ?? 'EGP', 'currency' => $payment->currency ?? 'EGP',
'payment_date' => now()->toDateString(), 'payment_date' => now()->toDateString(),
'confirmed_at' => now(), 'confirmed_at' => now(),
'notes' => "استرداد للدفعة {$payment->reference}" . ($reason ? " — {$reason}" : ''), 'notes' => "استرداد للدفعة {$payment->reference}" . ($reason ? " — {$reason}" : ''),
'created_by' => $actor->id, 'created_by' => $actor->id,
'metadata' => ['refund_of_payment_id' => $payment->id],
]); ]);
// Mark original payment as refunded // A payment is only "refunded" once nothing of it is left.
if ($refundAmount + $alreadyRefunded >= $payment->amount) {
$payment->update(['status' => PaymentStatus::Refunded]); $payment->update(['status' => PaymentStatus::Refunded]);
}
// Create double-entry transaction for the refund // Reverse the ledger entries the original payment produced: debit
// back the revenue accounts it credited, credit the asset account
// the money is leaving. The old code hardcoded ids 2 and 1 with a
// comment claiming Accounts Receivable — which is account 3.
$asset = $this->accounts->assetAccountForMethod(
$refundPayment->academy_id,
$refundPayment->method?->value ?? (string) $refundPayment->method
);
$splits = $this->accounts->splitRevenue(
$refundPayment->academy_id,
$refundPayment->invoice,
$refundAmount
);
foreach ($splits as $split) {
Transaction::create([ Transaction::create([
'academy_id' => $refundPayment->academy_id, 'academy_id' => $refundPayment->academy_id,
'debit_account_id' => 2, // Accounts Receivable (reversing the original credit) 'branch_id' => $refundPayment->branch_id,
'credit_account_id' => 1, // Cash/Bank (money going out) 'debit_account_id' => $split['account']->id,
'credit_account_id' => $asset->id,
'payment_id' => $refundPayment->id, 'payment_id' => $refundPayment->id,
'invoice_id' => $refundPayment->invoice_id, 'invoice_id' => $refundPayment->invoice_id,
'amount' => $refundPayment->amount, 'amount' => $split['amount'],
'currency' => $refundPayment->currency ?? 'EGP', 'currency' => $refundPayment->currency ?? 'EGP',
'type' => TransactionType::Refund, 'type' => TransactionType::Refund,
'description' => "استرداد: {$payment->reference}", 'description' => "استرداد: {$payment->reference}",
'transaction_date' => now()->toDateString(), 'transaction_date' => now()->toDateString(),
'created_by' => $actor->id, 'created_by' => $actor->id,
]); ]);
}
// Deduct from cash session if this was a cash payment // Deduct from the session that TOOK the money where it is still
// open, not from whichever drawer the person issuing the refund
// happens to have open — that debited the wrong cashier.
if ($payment->method === PaymentMethod::Cash) { if ($payment->method === PaymentMethod::Cash) {
$cashSession = CashSession::where('user_id', $actor->id) $cashSession = null;
if ($payment->cash_session_id) {
$cashSession = CashSession::where('id', $payment->cash_session_id)
->where('status', 'open')
->first();
}
$cashSession ??= CashSession::where('user_id', $actor->id)
->where('status', 'open') ->where('status', 'open')
->first(); ->first();
if ($cashSession) { if ($cashSession) {
$this->cashSessionService->recordCashOut($cashSession, $payment->amount); $this->cashSessionService->recordCashOut($cashSession, $refundAmount);
} }
} }
// Reverse the invoice balance // Reverse the invoice balance
if ($payment->invoice_id) { if ($payment->invoice_id) {
$this->invoiceService->updatePaidAmount( $this->invoiceService->updatePaidAmount(
$payment->invoice->fresh(), $payment->invoice()->withoutGlobalScopes()->lockForUpdate()->first(),
-$payment->amount, -$refundAmount,
$actor $actor
); );
} }
......
...@@ -209,17 +209,12 @@ public function processTransaction( ...@@ -209,17 +209,12 @@ public function processTransaction(
} }
} }
// Update cash session totals // One sale is one transaction on the drawer, whatever it was paid
// with. `total_cash_in` is deliberately NOT touched here: the
// UpdateCashSessionTotals listener already increments it from the
// PaymentReceived event, so incrementing it here too inflated the
// expected drawer by exactly double on every POS cash sale.
$cashSession->increment('transactions_count'); $cashSession->increment('transactions_count');
if (in_array($paymentMethod, ['cash', 'split'])) {
$cashAmount = $paymentMethod === 'cash'
? $amountToPayNow
: (int) collect($splitPayments)->where('method', 'cash')->sum('amount');
if ($cashAmount > 0) {
$cashSession->increment('total_cash_in', $cashAmount);
}
}
// Step 10: Dispatch events (fires after commit via ShouldDispatchAfterCommit) // Step 10: Dispatch events (fires after commit via ShouldDispatchAfterCommit)
POSTransactionCompleted::dispatch($posTransaction, $cashier); POSTransactionCompleted::dispatch($posTransaction, $cashier);
...@@ -260,6 +255,19 @@ private function validatePayment(POSPaymentMethod $method, int $total, ?Particip ...@@ -260,6 +255,19 @@ private function validatePayment(POSPaymentMethod $method, int $total, ?Particip
*/ */
private function recordSinglePayment($invoice, string $method, int $amount, User $cashier, ?Participant $participant, int $branchId): void private function recordSinglePayment($invoice, string $method, int $amount, User $cashier, ?Participant $participant, int $branchId): void
{ {
// A split may legitimately be tendered above the total — the cashier
// types the cash the customer handed over and gives change back. Only
// what settles the invoice is a payment; the change is not revenue.
// Without this cap the last split now hits recordPayment()'s
// "amount > due" guard, and before the guard existed it silently
// produced an overpaid invoice with a negative due_amount.
$remaining = (int) $invoice->fresh()->due_amount;
$amount = min($amount, $remaining);
if ($amount <= 0) {
return;
}
// Wallet deduction // Wallet deduction
if ($method === 'wallet' && $participant) { if ($method === 'wallet' && $participant) {
$wallet = $this->walletService->getOrCreateWallet($participant, $participant->academy_id); $wallet = $this->walletService->getOrCreateWallet($participant, $participant->academy_id);
...@@ -278,6 +286,9 @@ private function recordSinglePayment($invoice, string $method, int $amount, User ...@@ -278,6 +286,9 @@ private function recordSinglePayment($invoice, string $method, int $amount, User
'direction' => 'inbound', 'direction' => 'inbound',
'currency' => 'EGP', 'currency' => 'EGP',
'payment_date' => now()->toDateString(), 'payment_date' => now()->toDateString(),
// Provenance, so the cash-session listener does not count a POS
// sale a second time.
'metadata' => ['source' => 'pos'],
], $cashier); ], $cashier);
} }
......
<?php
use App\Domain\Financial\Models\FinancialAccount;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* The chart of accounts is a record every client needs, not client-specific
* content, so it belongs in a migration exactly as schema does.
*
* FinancialAccountsSeeder only ever seeded `Academy::first()`, and db:seed only
* runs when RUN_SEED_ON_FIRST_DEPLOY=true — so any academy created after the
* first one has no chart at all, and accounts added to the seeder after a
* tenant was installed never reached it. On the verified oc_sport copy, 4060
* (External Revenue) is in the seeder and missing from the database.
*
* PaymentService now resolves accounts by code and hard-fails when one is
* absent, so this has to land before it: a missing account would turn every
* payment into a DomainException at the till.
*/
return new class extends Migration
{
private const ACCOUNTS = [
// Assets
['code' => '1000', 'name' => 'Cash', 'name_ar' => 'النقدية', 'type' => 'asset', 'category' => 'current_asset'],
['code' => '1010', 'name' => 'Bank Account', 'name_ar' => 'الحساب البنكي', 'type' => 'asset', 'category' => 'current_asset'],
['code' => '1100', 'name' => 'Accounts Receivable', 'name_ar' => 'المدينون', 'type' => 'asset', 'category' => 'current_asset'],
['code' => '1200', 'name' => 'Wallets Receivable', 'name_ar' => 'أرصدة المحافظ', 'type' => 'asset', 'category' => 'current_asset'],
// Revenue
['code' => '4000', 'name' => 'Training Revenue', 'name_ar' => 'إيرادات التدريب', 'type' => 'revenue', 'category' => 'operating'],
['code' => '4010', 'name' => 'Registration Fees', 'name_ar' => 'رسوم التسجيل', 'type' => 'revenue', 'category' => 'operating'],
['code' => '4020', 'name' => 'Equipment Sales', 'name_ar' => 'مبيعات المعدات', 'type' => 'revenue', 'category' => 'operating'],
['code' => '4030', 'name' => 'Facility Rental', 'name_ar' => 'إيجار الملاعب', 'type' => 'revenue', 'category' => 'operating'],
['code' => '4040', 'name' => 'Private Sessions', 'name_ar' => 'الحصص الخاصة', 'type' => 'revenue', 'category' => 'operating'],
['code' => '4050', 'name' => 'Tournament Fees', 'name_ar' => 'رسوم البطولات', 'type' => 'revenue', 'category' => 'operating'],
['code' => '4060', 'name' => 'External Revenue', 'name_ar' => 'إيرادات خارجية', 'type' => 'revenue', 'category' => 'operating'],
// Expenses
['code' => '5000', 'name' => 'Trainer Salaries', 'name_ar' => 'رواتب المدربين', 'type' => 'expense', 'category' => 'operating'],
['code' => '5010', 'name' => 'Facility Rent', 'name_ar' => 'إيجار المنشآت', 'type' => 'expense', 'category' => 'operating'],
['code' => '5020', 'name' => 'Equipment Purchases', 'name_ar' => 'مشتريات المعدات', 'type' => 'expense', 'category' => 'operating'],
['code' => '5030', 'name' => 'Utilities', 'name_ar' => 'المرافق', 'type' => 'expense', 'category' => 'operating'],
['code' => '5040', 'name' => 'Marketing', 'name_ar' => 'التسويق', 'type' => 'expense', 'category' => 'operating'],
['code' => '5050', 'name' => 'Miscellaneous Expenses', 'name_ar' => 'مصروفات نثرية', 'type' => 'expense', 'category' => 'operating'],
// Liability
['code' => '2000', 'name' => 'Accounts Payable', 'name_ar' => 'الدائنون', 'type' => 'liability', 'category' => 'current_liability'],
['code' => '2010', 'name' => 'Unearned Revenue', 'name_ar' => 'إيرادات مقدمة', 'type' => 'liability', 'category' => 'current_liability'],
['code' => '2020', 'name' => 'Refunds Payable', 'name_ar' => 'مستردات مستحقة', 'type' => 'liability', 'category' => 'current_liability'],
];
public function up(): void
{
if (! Schema::hasTable('financial_accounts') || ! Schema::hasTable('academies')) {
return;
}
$academyIds = DB::table('academies')->pluck('id');
foreach ($academyIds as $academyId) {
foreach (self::ACCOUNTS as $account) {
FinancialAccount::withoutGlobalScopes()->firstOrCreate(
['academy_id' => $academyId, 'code' => $account['code']],
$account + ['academy_id' => $academyId, 'is_system' => true, 'is_active' => true]
);
}
}
}
public function down(): void
{
// Deliberately empty: these are system accounts that live transactions
// reference by id. Removing them would orphan the ledger.
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Revenue is branch-attributed only through `payments.branch_id`. `invoices`
* carries no branch at all — POSService passes 'branch_id' into
* InvoiceService::create() and mass assignment drops it, because it was never
* in Invoice::$fillable.
*
* Two consequences, both visible in the branch columns of FinancialOverview:
*
* 1. getCollectionRate() scopes invoices through whereHas('payments', branch),
* so an invoice with no payments yet belongs to no branch. Portal invoices
* waiting on an InstaPay proof would vanish from every branch's overdue
* figure — inflating the collection rate exactly when collections are worst.
* 2. `transactions` has no branch either, so the ledger cannot be read per
* branch even once PaymentService credits real revenue accounts.
*
* Both columns are additive and nullable. NOT NULL is enforced at the service
* boundary for portal-originated money, not by a constraint on a populated
* table.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('invoices') && ! Schema::hasColumn('invoices', 'branch_id')) {
Schema::table('invoices', function (Blueprint $table) {
$table->foreignId('branch_id')->nullable()->after('academy_id')
->constrained('branches')->nullOnDelete();
});
// Backfill from the branch that actually took the money, then from
// the participant the invoice bills.
DB::statement("
UPDATE invoices i
SET branch_id = p.branch_id
FROM (
SELECT DISTINCT ON (invoice_id) invoice_id, branch_id
FROM payments
WHERE invoice_id IS NOT NULL AND branch_id IS NOT NULL
ORDER BY invoice_id, created_at ASC
) p
WHERE p.invoice_id = i.id AND i.branch_id IS NULL
");
if (Schema::hasTable('participants') && Schema::hasColumn('participants', 'branch_id')) {
DB::statement("
UPDATE invoices i
SET branch_id = pt.branch_id
FROM participants pt
WHERE i.branch_id IS NULL
AND i.billable_type = 'App\\\\Domain\\\\Participant\\\\Models\\\\Participant'
AND i.billable_id = pt.id
AND pt.branch_id IS NOT NULL
");
}
Schema::table('invoices', function (Blueprint $table) {
$table->index(['academy_id', 'branch_id'], 'invoices_academy_branch_index');
});
}
if (Schema::hasTable('transactions') && ! Schema::hasColumn('transactions', 'branch_id')) {
Schema::table('transactions', function (Blueprint $table) {
$table->foreignId('branch_id')->nullable()->after('academy_id')
->constrained('branches')->nullOnDelete();
});
DB::statement("
UPDATE transactions t
SET branch_id = p.branch_id
FROM payments p
WHERE t.payment_id = p.id AND t.branch_id IS NULL AND p.branch_id IS NOT NULL
");
Schema::table('transactions', function (Blueprint $table) {
$table->index(['academy_id', 'branch_id', 'transaction_date'], 'transactions_academy_branch_date_index');
});
}
}
public function down(): void
{
if (Schema::hasTable('transactions') && Schema::hasColumn('transactions', 'branch_id')) {
Schema::table('transactions', function (Blueprint $table) {
$table->dropIndex('transactions_academy_branch_date_index');
$table->dropConstrainedForeignId('branch_id');
});
}
if (Schema::hasTable('invoices') && Schema::hasColumn('invoices', 'branch_id')) {
Schema::table('invoices', function (Blueprint $table) {
$table->dropIndex('invoices_academy_branch_index');
$table->dropConstrainedForeignId('branch_id');
});
}
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* "Every tenant table has academy_id" is a hard invariant, and four tables
* break it. Each is reachable from the member portal, where a query that
* escapes the tenant scope is a cross-academy read rather than a reporting
* quirk:
*
* - invoice_items — the portal renders invoice lines
* - installments — the portal lists and pays instalments
* - notification_preferences — per-event push preferences
* - event_registrations — links only person_id, so "which of my children
* is registered for this event" is unanswerable
* as modelled; the portal's events screen needs
* exactly that.
*
* Every column is nullable and backfilled from its parent. The models keep
* resolving through their parent relation; the column exists so a query can be
* scoped directly and an index can be built on it.
*/
return new class extends Migration
{
public function up(): void
{
$this->addAcademyId('invoice_items', 'invoice_id', 'invoices');
$this->addAcademyId('notification_preferences', null, null);
// installments reach an academy through payment_plans → invoices.
if (Schema::hasTable('installments') && ! Schema::hasColumn('installments', 'academy_id')) {
Schema::table('installments', function (Blueprint $table) {
$table->foreignId('academy_id')->nullable()->after('id')
->constrained('academies')->cascadeOnDelete();
});
if (Schema::hasTable('payment_plans')) {
DB::statement("
UPDATE installments i
SET academy_id = pp.academy_id
FROM payment_plans pp
WHERE pp.id = i.payment_plan_id AND i.academy_id IS NULL
");
}
Schema::table('installments', function (Blueprint $table) {
$table->index(['academy_id', 'status', 'due_date'], 'installments_academy_status_due_index');
});
}
// event_registrations already carries academy_id; what it lacks is the
// participant the registration is for.
if (Schema::hasTable('event_registrations') && ! Schema::hasColumn('event_registrations', 'participant_id')) {
Schema::table('event_registrations', function (Blueprint $table) {
$table->foreignId('participant_id')->nullable()->after('person_id')
->constrained('participants')->nullOnDelete();
});
// One person may hold several participant rows across academies;
// resolve within the registration's own academy only, and only
// when the answer is unambiguous.
if (Schema::hasTable('participants')) {
DB::statement("
UPDATE event_registrations er
SET participant_id = sub.pid
FROM (
SELECT p.person_id, p.academy_id, MIN(p.id) AS pid, COUNT(*) AS n
FROM participants p
GROUP BY p.person_id, p.academy_id
) sub
WHERE er.participant_id IS NULL
AND er.person_id = sub.person_id
AND er.academy_id = sub.academy_id
AND sub.n = 1
");
}
Schema::table('event_registrations', function (Blueprint $table) {
$table->index(['academy_id', 'participant_id'], 'event_reg_academy_participant_index');
});
}
}
private function addAcademyId(string $table, ?string $parentKey, ?string $parentTable): void
{
if (! Schema::hasTable($table) || Schema::hasColumn($table, 'academy_id')) {
return;
}
Schema::table($table, function (Blueprint $t) {
$t->foreignId('academy_id')->nullable()->after('id')
->constrained('academies')->cascadeOnDelete();
});
if ($parentKey && $parentTable && Schema::hasTable($parentTable)) {
DB::statement("
UPDATE {$table} c
SET academy_id = p.academy_id
FROM {$parentTable} p
WHERE p.id = c.{$parentKey} AND c.academy_id IS NULL
");
}
if ($table === 'notification_preferences' && Schema::hasTable('users')) {
DB::statement("
UPDATE notification_preferences np
SET academy_id = u.academy_id
FROM users u
WHERE u.id = np.user_id AND np.academy_id IS NULL
");
}
Schema::table($table, function (Blueprint $t) use ($table) {
$t->index('academy_id', "{$table}_academy_id_index");
});
}
public function down(): void
{
foreach (['invoice_items', 'notification_preferences', 'installments'] as $table) {
if (Schema::hasTable($table) && Schema::hasColumn($table, 'academy_id')) {
Schema::table($table, function (Blueprint $t) {
$t->dropConstrainedForeignId('academy_id');
});
}
}
if (Schema::hasTable('event_registrations') && Schema::hasColumn('event_registrations', 'participant_id')) {
Schema::table('event_registrations', function (Blueprint $t) {
$t->dropConstrainedForeignId('participant_id');
});
}
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Two CHECK vocabularies are wrong today, and both are live crashes.
*
* 1. notification_templates.channel and notification_logs.channel allow only
* in_app|email|sms, while NotificationChannel has whatsapp and push and
* PushNotificationService writes 'push'. Every push delivery log insert
* raises 23514 right now — and the catch block writes another failing
* insert. Push logging is 100% broken and this blocks the portal's push
* work outright.
*
* 2. guardians.relationship_type and guardian_participant.relationship_type
* carry different value lists, and NewRegistrationWizard validates one
* field ('brother','sister','guardian' allowed) and writes it to BOTH.
* A receptionist who picks أخ / أخت / وصي crashes registration on the
* guardians CHECK. Reconciling to the union of the two lists fixes the
* crash without invalidating a single existing row.
*
* Widening a CHECK is safe against a populated table: every row that passed
* the old list passes the superset. DROP + ADD run inside the migration's own
* transaction, so a failure leaves the original constraint in place.
*/
return new class extends Migration
{
private const RELATIONSHIP_TYPES = [
'father', 'mother', 'brother', 'sister', 'sibling',
'uncle', 'aunt', 'grandfather', 'grandmother',
'guardian', 'legal_guardian', 'other',
];
private const NOTIFICATION_CHANNELS = ['in_app', 'email', 'sms', 'push', 'whatsapp'];
public function up(): void
{
$this->widen('notification_templates', 'channel', 'notification_templates_channel_check', self::NOTIFICATION_CHANNELS);
$this->widen('notification_logs', 'channel', 'notification_logs_channel_check', self::NOTIFICATION_CHANNELS);
$this->widen('guardians', 'relationship_type', 'guardians_relationship_type_check', self::RELATIONSHIP_TYPES);
$this->widen('guardian_participant', 'relationship_type', 'guardian_participant_relationship_type_check', self::RELATIONSHIP_TYPES);
}
private function widen(string $table, string $column, string $constraint, array $values): void
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
return;
}
if (DB::getDriverName() !== 'pgsql') {
return;
}
$list = implode(', ', array_map(fn ($v) => "'{$v}'", $values));
DB::statement("ALTER TABLE {$table} DROP CONSTRAINT IF EXISTS {$constraint}");
DB::statement("ALTER TABLE {$table} ADD CONSTRAINT {$constraint} CHECK ({$column} IN ({$list}))");
}
public function down(): void
{
if (DB::getDriverName() !== 'pgsql') {
return;
}
$this->widen('notification_templates', 'channel', 'notification_templates_channel_check', ['in_app', 'email', 'sms']);
$this->widen('notification_logs', 'channel', 'notification_logs_channel_check', ['in_app', 'email', 'sms']);
$this->widen('guardians', 'relationship_type', 'guardians_relationship_type_check', ['father', 'mother', 'grandfather', 'grandmother', 'uncle', 'aunt', 'sibling', 'legal_guardian', 'other']);
$this->widen('guardian_participant', 'relationship_type', 'guardian_participant_relationship_type_check', ['father', 'mother', 'brother', 'sister', 'uncle', 'aunt', 'grandfather', 'grandmother', 'guardian', 'other']);
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* InvoiceService::generateNumber() used `count() + 1` against a
* UNIQUE(academy_id, number) index. Two invoices created at the same instant
* counted the same rows, produced the same number and the second insert died
* on the constraint — a guaranteed collision as soon as members can check out
* from the portal without a receptionist serialising them by hand.
*
* A counter row taken with lockForUpdate() serialises allocation per academy
* and never revisits a number after a soft delete (count() did, because
* soft-deleted invoices leave the unique index occupied).
*
* Seeded from the highest number each academy has actually issued, not from
* the row count, so a tenant with gaps does not immediately collide.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('invoice_number_counters')) {
Schema::create('invoice_number_counters', function (Blueprint $table) {
$table->foreignId('academy_id')->primary()->constrained()->cascadeOnDelete();
$table->unsignedBigInteger('next_number')->default(1);
$table->timestamps();
});
}
if (! Schema::hasTable('academies') || ! Schema::hasTable('invoices')) {
return;
}
foreach (DB::table('academies')->pluck('id') as $academyId) {
$exists = DB::table('invoice_number_counters')->where('academy_id', $academyId)->exists();
if ($exists) {
continue;
}
// Read the numeric suffix of every INV-… number this academy has
// issued, soft-deleted rows included: the unique index still holds
// them, so reusing one would fail.
$highest = 0;
$numbers = DB::table('invoices')
->where('academy_id', $academyId)
->where('number', 'like', 'INV-%')
->pluck('number');
foreach ($numbers as $number) {
if (preg_match('/(\d+)$/', (string) $number, $m)) {
$highest = max($highest, (int) $m[1]);
}
}
DB::table('invoice_number_counters')->insert([
'academy_id' => $academyId,
'next_number' => $highest + 1,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
public function down(): void
{
Schema::dropIfExists('invoice_number_counters');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* The deleted mobile API's OrderController built invoice lines by hand with
* keys that are not columns — `academy_id`, `description_ar` and `line_total`.
* Laravel does not call preventSilentlyDiscardingAttributes() anywhere in this
* codebase, so those keys were dropped and `total_amount` took its default of
* zero. Every line written by that controller says the sale was worth nothing.
*
* The damage is downstream, not on the row:
* - ParticipantBillingService::allocate() `continue`s on a zero part, so
* productPaid() returns no key at all and the per-product ownership screen
* shows 0 paid for a product the member demonstrably bought.
* - FinancialOverview::getRevenueBreakdown() counts the payment in the total
* and in neither the subscription nor the product bucket, so the two stop
* reconciling to the total.
* - Any later recalculateTotals() on such an invoice sets total_amount to 0,
* due_amount to -paid_amount and the status to overpaid, after which
* getCollectionRate() starts summing negative numbers.
*
* The controller is gone. This repairs what it left behind, from the line's
* own quantity and unit price — the two values it did write correctly. Scoped
* to the INV-MOB series so it can never touch a line that is legitimately
* zero (a fully discounted item, a free kit).
*
* The verified oc_sport tenant has no INV-MOB invoices; other installations may.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('invoice_items') || ! Schema::hasTable('invoices')) {
return;
}
if (DB::getDriverName() !== 'pgsql') {
return;
}
$repaired = DB::update("
UPDATE invoice_items ii
SET total_amount = (ii.quantity * ii.unit_price)
- COALESCE(ii.discount_amount, 0)
+ COALESCE(ii.tax_amount, 0)
FROM invoices i
WHERE i.id = ii.invoice_id
AND ii.total_amount = 0
AND ii.unit_price > 0
AND i.number LIKE 'INV-MOB-%'
");
if ($repaired > 0) {
// Bring the headers back in line with the repaired lines. Only the
// invoices just touched, and only their subtotal/total/due — the
// paid amount is what actually changed hands and is not recomputed.
DB::update("
UPDATE invoices i
SET subtotal_amount = s.line_sum,
total_amount = s.line_sum - COALESCE(i.discount_amount,0)
+ COALESCE(i.tax_amount,0) + COALESCE(i.service_fee_amount,0),
due_amount = (s.line_sum - COALESCE(i.discount_amount,0)
+ COALESCE(i.tax_amount,0) + COALESCE(i.service_fee_amount,0))
- COALESCE(i.paid_amount,0)
FROM (
SELECT invoice_id, SUM(total_amount) AS line_sum
FROM invoice_items GROUP BY invoice_id
) s
WHERE s.invoice_id = i.id AND i.number LIKE 'INV-MOB-%'
");
}
}
public function down(): void
{
// Deliberately empty: the previous state was a zero written by a bug.
}
};
<?php
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_the_application_returns_a_successful_response(): void
{
$response = $this->get('/');
$response->assertStatus(200);
}
}
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment