feat(payments): refund engine (best-effort revoke, never negative) #227

Merged
developer merged 1 commits from feature/payment-intake-refunds into development 2026-07-09 21:26:33 +00:00
Owner

The last E5 slice — refunds. Closes E5 (all four money pieces: Robokassa, VK, Telegram Stars, refunds).

One Refund engine (internal/payments) reverses a paid order best-effort, exactly once:

  • idempotent on (provider, provider_refund_id) — the refund id is distinct from the fund's payment id, so both rows coexist under the existing partial-unique index;
  • revokes the funded chips floored at 0 (never negative — D27, balances_chips_chk);
  • chips already spent → the unrecoverable remainder is 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 = what was actually reclaimed (ledger stays reconcilable), the full reversal rides in the snapshot; the order stays paid.

No webhooks/worker. No rail pushes an unsolicited refund — all 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 not wired (no rail signals a hard post-charge server decline).

Migration 00012 is additive (a new table only) → applies via goose incrementally, rollback-safe, no contour wipe.

Tests: integration (full revoke; revoke-after-spend = floor-0 + loss + abuse; duplicate idempotent; unpaid-order guard). Local: all modules build+vet+gofmt, backend unit, full payments integration suite green. Docs: PAYMENTS(+ru) §9, PLAN (E5 -> DONE).

**The last E5 slice — refunds. Closes E5** (all four money pieces: Robokassa, VK, Telegram Stars, refunds). One `Refund` engine (`internal/payments`) reverses a paid order best-effort, exactly once: - idempotent on `(provider, provider_refund_id)` — the refund id is distinct from the fund's payment id, so both rows coexist under the existing partial-unique index; - revokes the funded chips **floored at 0** (never negative — D27, `balances_chips_chk`); - chips already spent → the unrecoverable remainder is 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 = what was actually reclaimed (ledger stays reconcilable), the **full** reversal rides in the snapshot; the order stays `paid`. **No webhooks/worker.** No rail pushes an unsolicited refund — all 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 not wired (no rail signals a hard post-charge server decline). **Migration 00012 is additive** (a new table only) → applies via goose incrementally, **rollback-safe, no contour wipe**. **Tests:** integration (full revoke; revoke-after-spend = floor-0 + loss + abuse; duplicate idempotent; unpaid-order guard). Local: all modules build+vet+gofmt, backend unit, full payments integration suite green. **Docs:** PAYMENTS(+ru) §9, PLAN (E5 -> DONE).
developer added 1 commit 2026-07-09 21:21:40 +00:00
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
21fb14facf
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).
owner approved these changes 2026-07-09 21:24:24 +00:00
developer merged commit 68c937f3b6 into development 2026-07-09 21:26:33 +00:00
developer deleted branch feature/payment-intake-refunds 2026-07-09 21:26:33 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: developer/scrabble-game#227