backend phase 10
This commit is contained in:
@@ -52,6 +52,20 @@ DEBIT nurse_payable nurse_payout_refunded_irr
|
||||
<pre><code>DEBIT nurse_clawback_receivable amount_irr (nurse_id set; nurse now owes the platform)
|
||||
CREDIT refund_payable amount_irr</code></pre>
|
||||
<p>Recovered by <strong>netting against the nurse's next <code>nurse_payable</code></strong> at batch time, or marked <code>written_off</code> if uncollectable. A <code>nurse_clawbacks</code> row carries the lifecycle (<code>pending</code> / <code>recovered</code> / <code>written_off</code>). This is unavoidable because <strong>Iranian payouts are real bank transfers — hard/impossible to reverse</strong> — so the right defense is <em>gating payout on the dispute window</em>, with clawback as the fallback.</p>
|
||||
<h2 id="32a-implementation-rules-confirmed-in-build-backend-b10">3.2a Implementation rules confirmed in build (backend b10) <a class="anchor" href="#32a-implementation-rules-confirmed-in-build-backend-b10" aria-hidden="true">#</a></h2>
|
||||
<p>These are the exact orderings the card-capture rail was built to (see <code>dev/contracts/domains/payments.md</code>):</p>
|
||||
<ul>
|
||||
<li><strong>Upsert the webhook event first, then re-verify server-side, then confirm.</strong> Every PSP callback lands in</li>
|
||||
</ul>
|
||||
<p> <code>payment_webhook_events</code> keyed on <code>UNIQUE(provider_code, external_event_id)</code> <strong>before</strong> any money state changes; a duplicate replay is a <strong>no-op</strong>. Only a new, signature-valid <em>success</em> event proceeds — and it is <strong>re-verified against the gateway</strong> (amount + reference) before the capture posts. A callback with an invalid signature is stored <code>ignored</code> and mutates nothing (<strong>never trust a callback alone</strong>).</p>
|
||||
<ul>
|
||||
<li><strong>A unique-violation on confirm is an idempotent no-op success, not an error.</strong> The filtered</li>
|
||||
</ul>
|
||||
<p> <code>UNIQUE(booking_id) WHERE status='succeeded'</code> is the authoritative anti-double-capture backstop: if a concurrent confirm races past the Redis lock, the second one fails the constraint and is treated as "already captured → success".</p>
|
||||
<ul>
|
||||
<li>**Payment is initiated against the accepted <em>request</em>, not a booking.** Because a <code>bookings</code> row exists only</li>
|
||||
</ul>
|
||||
<p> on capture, <code>payment_transactions.booking_id</code> is <strong>nullable</strong> and bound at confirm (when the booking is created/loaded). The captured gross is the request's frozen amount (variant price × session count).</p>
|
||||
<h2 id="33-why-the-ledger-not-more-columns">3.3 Why the ledger, not more columns <a class="anchor" href="#33-why-the-ledger-not-more-columns" aria-hidden="true">#</a></h2>
|
||||
<p>A marketplace that holds escrow, pays out weekly minus commission, and handles refunds + clawbacks has exactly the shape double-entry was invented for. The MVP cost is <strong>one table + posting discipline</strong>. The alternative (more money columns on bookings/payouts) cannot answer "how much is held but unreleased" without fragile joins and makes bank/Shaparak reconciliation nearly impossible. Keep the per-booking fee snapshot as the <em>pricing</em> record; the ledger is the <em>financial-truth / reconciliation</em> layer posted alongside.</p>
|
||||
<a class="back-to-top" href="#">↑ Back to top</a>
|
||||
|
||||
@@ -58,6 +58,23 @@ DEBIT nurse_clawback_receivable amount_irr (nurse_id set; nurse now owes th
|
||||
```
|
||||
Recovered by **netting against the nurse's next `nurse_payable`** at batch time, or marked `written_off` if uncollectable. A `nurse_clawbacks` row carries the lifecycle (`pending` / `recovered` / `written_off`). This is unavoidable because **Iranian payouts are real bank transfers — hard/impossible to reverse** — so the right defense is *gating payout on the dispute window*, with clawback as the fallback.
|
||||
|
||||
## 3.2a Implementation rules confirmed in build (backend b10)
|
||||
|
||||
These are the exact orderings the card-capture rail was built to (see `dev/contracts/domains/payments.md`):
|
||||
|
||||
- **Upsert the webhook event first, then re-verify server-side, then confirm.** Every PSP callback lands in
|
||||
`payment_webhook_events` keyed on `UNIQUE(provider_code, external_event_id)` **before** any money state
|
||||
changes; a duplicate replay is a **no-op**. Only a new, signature-valid *success* event proceeds — and it is
|
||||
**re-verified against the gateway** (amount + reference) before the capture posts. A callback with an invalid
|
||||
signature is stored `ignored` and mutates nothing (**never trust a callback alone**).
|
||||
- **A unique-violation on confirm is an idempotent no-op success, not an error.** The filtered
|
||||
`UNIQUE(booking_id) WHERE status='succeeded'` is the authoritative anti-double-capture backstop: if a
|
||||
concurrent confirm races past the Redis lock, the second one fails the constraint and is treated as
|
||||
"already captured → success".
|
||||
- **Payment is initiated against the accepted *request*, not a booking.** Because a `bookings` row exists only
|
||||
on capture, `payment_transactions.booking_id` is **nullable** and bound at confirm (when the booking is
|
||||
created/loaded). The captured gross is the request's frozen amount (variant price × session count).
|
||||
|
||||
## 3.3 Why the ledger, not more columns
|
||||
|
||||
A marketplace that holds escrow, pays out weekly minus commission, and handles refunds + clawbacks has exactly the shape double-entry was invented for. The MVP cost is **one table + posting discipline**. The alternative (more money columns on bookings/payouts) cannot answer "how much is held but unreleased" without fragile joins and makes bank/Shaparak reconciliation nearly impossible. Keep the per-booking fee snapshot as the *pricing* record; the ledger is the *financial-truth / reconciliation* layer posted alongside.
|
||||
|
||||
Reference in New Issue
Block a user