• 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
..
PaymentProofFileController.php Loading commit data...