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:
Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
Please register or sign in to comment