• Mahmoud Aglan's avatar
    feat(financial): InstaPay transfers, reviewed before they become money · 2c95f617
    Mahmoud Aglan authored
    S5. The academy publishes a handle, the member transfers and records what
    they sent, and staff turn that claim into a payment only after matching it
    against the academy's own statement.
    
    An unverified screenshot must never create a Payment. `transactions` are
    immutable and recordPayment() forces status = Confirmed and posts to the
    ledger immediately, so a proof is a separate object with its own lifecycle
    and only approval calls recordPayment() — double-entry happens exactly once
    and nothing in the ledger is ever edited.
    
    **A screenshot is not evidence.** It is a convenience. The control is
    `sender_reference`, unique per academy per method behind a partial index,
    which kills replay, cross-invoice reuse and "someone else's transfer against
    my invoice" in one constraint. The reviewer types the amount from the
    statement; `amount_claimed` is what the payer said and is never what gets
    posted.
    
    Concurrency is a conditional UPDATE, not a disabled button. Two reviewers
    open the queue and both see an enabled Approve; the second one's UPDATE
    matches zero rows and raises InvalidStatusTransitionException. A row that has
    left `pending` is frozen by a BEFORE UPDATE trigger — approving a proof is
    the moral equivalent of taking cash, and Auditable::createAuditLog() takes
    its user from auth() at boot and silently writes nothing when it cannot
    resolve an academy, so the approval facts are columns on the row rather than
    an audit-log dependency.
    
    Overpayment is capped at what is due and the excess is deposited to the
    member's wallet in the same transaction. InvoiceStatus::Overpaid exists but
    nothing consumes it and it drives due_amount negative, after which
    getCollectionRate() and ParticipantBillingService start summing negatives.
    
    branch_id is NOT NULL on a proof. Revenue is branch-attributed only through
    payments, so a NULL-branch payment lands in the all-branches total and in no
    branch — the columns stop summing with no error anywhere.
    
    E6 decided as recommended: all five method CHECKs that lacked `instapay` get
    it, the till included. Reception will take an InstaPay transfer within a
    month of launch, and the failure mode of leaving the POS out is a Postgres
    23514 at the till in front of a customer. pos_transactions and
    pos_split_payments also gain `bank_transfer`, which they never had.
    
    The review queue ships before the member-facing upload, on purpose: a proof
    that can be submitted and never reviewed is a promise to a member that nobody
    is keeping.
    
    Proof files go to the private disk and are streamed by a controller that
    authorises the submitter, a co-guardian of the same member, and staff holding
    payments.approve_proof — Content-Disposition: attachment, nosniff, no-store.
    The parent excuse form wrote its medical attachments to the public disk; that
    is the mistake not to repeat.
    
    Verified against a restored copy of backups/oc_sport-20260831-081053.dump: a
    600 EGP transfer against a 500 EGP invoice posts 500 to the invoice
    (Dr 1010 Bank / Cr 4000 Training Revenue, branch attributed) and 100 to the
    wallet; duplicate reference, self-approval, zero amount, second approval and
    editing a settled row are all refused.
    
    Suite: 87 passed, 3 skipped locally; 11 InstaPay tests pass against the
    restored tenant.
    Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
    2c95f617
Name
Last commit
Last update
..
AccountType.php Loading commit data...
CashSessionStatus.php Loading commit data...
ExpenseCategory.php Loading commit data...
InvoiceStatus.php Loading commit data...
PaymentMethod.php Loading commit data...
PaymentStatus.php Loading commit data...
SubscriptionPaymentCase.php Loading commit data...
TransactionType.php Loading commit data...