feat(financial): InstaPay transfers, reviewed before they become money
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:
Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
This diff is collapsed.
Please register or sign in to comment