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:
@@ -4,6 +4,7 @@ package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
@@ -23,6 +24,21 @@ func orderStatus(t *testing.T, orderID uuid.UUID) string {
|
||||
return status
|
||||
}
|
||||
|
||||
// readRisk reads an account's payment-risk row (abuse flag + accumulated loss), or (false, 0) when
|
||||
// none exists.
|
||||
func readRisk(t *testing.T, acc uuid.UUID) (abuse bool, loss int64) {
|
||||
t.Helper()
|
||||
err := testDB.QueryRowContext(context.Background(),
|
||||
`SELECT abuse, loss_chips FROM payments.account_risk WHERE account_id=$1`, acc).Scan(&abuse, &loss)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, 0
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read risk: %v", err)
|
||||
}
|
||||
return abuse, loss
|
||||
}
|
||||
|
||||
// TestPaymentsOrderFundCreditsOnce verifies the intake path over Postgres: creating an order then
|
||||
// funding it credits the funded segment exactly once, and a replayed callback (the same order)
|
||||
// credits nothing more — the ledger idempotency index holds.
|
||||
@@ -326,3 +342,128 @@ func TestPaymentsExpiredOrderStillCredits(t *testing.T) {
|
||||
t.Errorf("order status = %s, want paid after the honoured callback", orderStatus(t, res.OrderID))
|
||||
}
|
||||
}
|
||||
|
||||
// fundedOrder creates and funds an order for chips in the direct rail, returning its id and account.
|
||||
func fundedOrder(t *testing.T, svc *payments.Service, chips int, priceMinor int64) (uuid.UUID, uuid.UUID) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, chips, methodPrice{method: "direct", currency: "RUB", amount: priceMinor})
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
paid, _ := payments.MoneyFromMinor(priceMinor, payments.CurrencyRUB)
|
||||
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
|
||||
t.Fatalf("fund: %v", err)
|
||||
}
|
||||
return res.OrderID, acc
|
||||
}
|
||||
|
||||
// TestPaymentsRefundFull reverses a fully-unspent order: all chips are clawed back, no loss/abuse.
|
||||
func TestPaymentsRefundFull(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
orderID, acc := fundedOrder(t, svc, 100, 14900)
|
||||
|
||||
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
out, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-1", refunded)
|
||||
if err != nil {
|
||||
t.Fatalf("refund: %v", err)
|
||||
}
|
||||
if out.AlreadyRefunded || out.Revoked != 100 || out.Loss != 0 || out.Source != payments.SourceDirect {
|
||||
t.Fatalf("refund outcome = %+v, want 100 revoked, 0 loss", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance after refund = %d, want 0", got)
|
||||
}
|
||||
if ledgerRows(t, acc, "refund") != 1 {
|
||||
t.Errorf("refund ledger rows = %d, want 1", ledgerRows(t, acc, "refund"))
|
||||
}
|
||||
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
|
||||
t.Errorf("risk = (%v, %d), want (false, 0) — nothing was spent", abuse, loss)
|
||||
}
|
||||
// The order stays 'paid' — the refund lives in the ledger, not in the order status.
|
||||
if orderStatus(t, orderID) != "paid" {
|
||||
t.Errorf("order status = %s, want paid (refund is ledger-only)", orderStatus(t, orderID))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsRefundAfterSpend reverses an order whose chips were partly spent: the reversal floors
|
||||
// at 0 (never negative), and the unrecoverable remainder is recorded as a loss + abuse flag.
|
||||
func TestPaymentsRefundAfterSpend(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
orderID, acc := fundedOrder(t, svc, 100, 14900)
|
||||
|
||||
// Simulate 70 chips already spent, leaving 30 in the funded segment.
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE payments.balances SET chips = 30 WHERE account_id = $1 AND source = 'direct'`, acc); err != nil {
|
||||
t.Fatalf("simulate spend: %v", err)
|
||||
}
|
||||
|
||||
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
out, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-2", refunded)
|
||||
if err != nil {
|
||||
t.Fatalf("refund: %v", err)
|
||||
}
|
||||
if out.Revoked != 30 || out.Loss != 70 {
|
||||
t.Fatalf("refund outcome = %+v, want 30 revoked / 70 loss", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 0 {
|
||||
t.Errorf("balance after refund = %d, want 0 (floored, never negative)", got)
|
||||
}
|
||||
if abuse, loss := readRisk(t, acc); !abuse || loss != 70 {
|
||||
t.Errorf("risk = (%v, %d), want (true, 70)", abuse, loss)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsRefundIdempotent verifies a replayed refund (same provider refund id) reverses nothing
|
||||
// more — the ledger idempotency index holds.
|
||||
func TestPaymentsRefundIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
orderID, acc := fundedOrder(t, svc, 100, 14900)
|
||||
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
|
||||
if _, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-dup", refunded); err != nil {
|
||||
t.Fatalf("first refund: %v", err)
|
||||
}
|
||||
// Re-credit the segment to prove the duplicate does not revoke again.
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE payments.balances SET chips = 100 WHERE account_id = $1 AND source = 'direct'`, acc); err != nil {
|
||||
t.Fatalf("re-credit: %v", err)
|
||||
}
|
||||
out2, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-dup", refunded)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate refund: %v", err)
|
||||
}
|
||||
if !out2.AlreadyRefunded {
|
||||
t.Error("duplicate refund not flagged AlreadyRefunded")
|
||||
}
|
||||
if got := readBalance(t, acc, "direct"); got != 100 {
|
||||
t.Errorf("balance after duplicate refund = %d, want 100 (not revoked twice)", got)
|
||||
}
|
||||
if ledgerRows(t, acc, "refund") != 1 {
|
||||
t.Errorf("refund ledger rows = %d, want 1 (duplicate wrote none)", ledgerRows(t, acc, "refund"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsRefundUnpaidOrder refuses to refund an order that was never funded.
|
||||
func TestPaymentsRefundUnpaidOrder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
|
||||
if _, err := svc.Refund(ctx, res.OrderID, "robokassa", "rk-refund-x", refunded); !errors.Is(err, payments.ErrOrderNotPaid) {
|
||||
t.Fatalf("refund of a pending order = %v, want ErrOrderNotPaid", err)
|
||||
}
|
||||
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
|
||||
t.Errorf("risk = (%v, %d), want (false, 0) — no reversal on an unpaid order", abuse, loss)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user