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:
@@ -81,6 +81,17 @@ func (s *Service) Fund(ctx context.Context, orderID uuid.UUID, provider, provide
|
||||
return s.store.fund(ctx, orderID, provider, providerPaymentID, paid, s.clock())
|
||||
}
|
||||
|
||||
// Refund reverses a paid order's credit best-effort, exactly once — for an external refund or an
|
||||
// admin-initiated one (E7). It revokes the funded chips floored at 0 (never negative, D27), records
|
||||
// any unrecoverable remainder as a per-account loss and abuse flag, and appends a refund ledger row
|
||||
// idempotent on (provider, providerRefundID) — distinct from the fund's payment id. A duplicate
|
||||
// refund returns AlreadyRefunded. The caller records the refunded payment event and performs any
|
||||
// provider-side money-back (the rails have no unsolicited refund push: Robokassa via its refund API
|
||||
// / cabinet, VK via support, Telegram via refundStarPayment — all admin-triggered).
|
||||
func (s *Service) Refund(ctx context.Context, orderID uuid.UUID, provider, providerRefundID string, refunded Money) (RefundOutcome, error) {
|
||||
return s.store.refund(ctx, orderID, provider, providerRefundID, refunded, s.clock())
|
||||
}
|
||||
|
||||
// Pre-checkout decline reason codes. They are language-neutral: the transport layer localises them
|
||||
// to the order account's preferred language before showing the payer (the reason is displayed in the
|
||||
// Telegram payment sheet).
|
||||
|
||||
Reference in New Issue
Block a user