• Mahmoud Aglan's avatar
    fix(financial): make the double-entry ledger say what actually happened · 8d251d14
    Mahmoud Aglan authored
    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>
    8d251d14
Name
Last commit
Last update
..
POSService.php Loading commit data...
ReceiptService.php Loading commit data...