feat(payments): refund engine (best-effort revoke, never negative)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
The last money-intake slice: reverse a paid order best-effort, exactly once. All refunds are admin-triggered (E7) — no rail pushes an unsolicited refund (Robokassa via its refund API / cabinet, VK via support, Telegram via refundStarPayment), so this ships the engine they all converge on, not a webhook. The Refund method matches the paid order, appends a refund ledger row (idempotent on (provider, provider_refund_id) — distinct from the fund's payment id, so both coexist), and revokes the funded chips floored at 0 (never negative — D27, balances_chips_chk). When the chips were already spent, the unrecoverable remainder is recorded as a per-account loss + abuse flag in the new additive payments.account_risk table (read by the E7 report). The refund ledger row's chip delta is what was actually reclaimed (the ledger stays reconcilable); the full reversal rides in the snapshot; the order stays paid. Additive migration (a new table only) -> rollback-safe, no contour wipe. Robokassa refund-status polling is deferred (a worker not worth it at low chargeback volume); failed events are not wired (no rail signals a hard post-charge server decline). Tests: integration (full revoke; revoke-after-spend = floor-0 + loss + abuse; duplicate idempotent; unpaid-order guard). Docs: PAYMENTS(+ru) §9, PLAN (E5 -> DONE).
This commit is contained in:
@@ -34,7 +34,7 @@ status — without re-deriving decisions.
|
||||
| E2 | Currency + benefit core | 1 | DONE |
|
||||
| E3 | Wallet UI | 1 | DONE |
|
||||
| E4 | Durability (PITR) | 2 | DONE |
|
||||
| E5 | Payment intake | 2 | WIP |
|
||||
| E5 | Payment intake | 2 | DONE |
|
||||
| E6 | Ads | 2 | TODO |
|
||||
| E7 | Admin & reports | 2 | TODO |
|
||||
| E8 | Guest limits | — | TODO |
|
||||
@@ -504,7 +504,7 @@ maintenance window). Migrations stay expand-contract so image rollback remains D
|
||||
|
||||
## E5 — Payment intake
|
||||
|
||||
**Status:** WIP · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12.
|
||||
**Status:** DONE · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12.
|
||||
|
||||
**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice), Robokassa first.
|
||||
Resolved: match the order by a Robokassa **`Shp_order`** custom parameter, not the numeric `InvId`
|
||||
@@ -534,8 +534,18 @@ order account's language. A completed `successful_payment` is persisted to a pur
|
||||
`Fund` (source=`telegram`, idempotent on `telegram_payment_charge_id`, honours an expired order),
|
||||
re-driven at startup and every 30 s. The rail is wired by `TELEGRAM_STARS_OUTBOX_DIR` (defaults to the
|
||||
bot `/data` volume) but stays **inert until a chip pack carries an XTR price**, so seeding a Stars price
|
||||
in the admin is the go-live. Remaining: refunds; and hiding the ad banner on a no-ads purchase (a
|
||||
spend-path `NotifyBanner`, deferred with the owner's agreement).
|
||||
in the admin is the go-live. Finally **refunds** are delivered on `feature/payment-intake-refunds`: a
|
||||
single `Refund` engine (`internal/payments`) reverses a paid order best-effort, exactly once —
|
||||
idempotent on `(provider, provider_refund_id)`, revoking the funded chips **floored at 0** (never
|
||||
negative, D27), and recording the unrecoverable remainder (chips already spent) as a per-account
|
||||
**loss + abuse flag** in the new additive `payments.account_risk` table (read by the E7 report). The
|
||||
refund ledger row's chip delta is what was actually reclaimed (the ledger stays reconcilable); the
|
||||
full reversal rides in its snapshot; the order stays `paid`. **No rail pushes an unsolicited refund**
|
||||
— all are admin-triggered (E7): Robokassa refund API / cabinet (auto-polling deferred — a worker not
|
||||
worth it at low chargeback volume), VK via support, Telegram `refundStarPayment`. `failed` events are
|
||||
not wired (no rail signals a hard post-charge server decline). The migration is **additive** (a new
|
||||
table only), so E5 stays rollback-safe / no contour wipe. That closes E5. Deferred to a later stage:
|
||||
hiding the ad banner on a no-ads purchase (a spend-path `NotifyBanner`, with the owner's agreement).
|
||||
|
||||
**Goal.** Accept real money on all three rails into the payments domain: order-flow,
|
||||
verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher,
|
||||
@@ -577,9 +587,13 @@ receipts, and refunds.
|
||||
**Receipts (§12).** Robokassa self-employed НПД receipt on payment (provider config); VK
|
||||
handles Votes tax itself; TG Stars — no receipt.
|
||||
|
||||
**Refunds (§9).** ToS non-refundable; admin manual refund (ties to `accountdelete`); external
|
||||
`refunded` events honoured — best-effort benefit revoke (never negative; record loss + abuse
|
||||
flag if spent), ledger `refund` row. Ledger export-ready (reconciliation not built).
|
||||
**Refunds (§9).** ToS non-refundable. **All refunds are admin-triggered** (E7): no rail pushes an
|
||||
unsolicited refund — Robokassa refund API / cabinet (auto-polling deferred as a low-value worker),
|
||||
VK via support, Telegram `refundStarPayment`. One `Refund` engine reverses a paid order best-effort,
|
||||
exactly once (idempotent on `(provider, provider_refund_id)`): revoke floored at 0 (never negative),
|
||||
unrecoverable remainder → per-account loss + abuse flag (`payments.account_risk`), a `refund` ledger
|
||||
row (chip delta = revoked, full reversal in the snapshot). `failed` events are not wired (no rail
|
||||
signals a hard post-charge server decline). Ledger export-ready (reconciliation not built).
|
||||
|
||||
**Tests.**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user