Merge pull request 'feat(payments): refund engine (best-effort revoke, never negative)' (#227) from feature/payment-intake-refunds into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m54s
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m54s
This commit was merged in pull request #227.
This commit is contained in:
@@ -34,7 +34,7 @@ status — without re-deriving decisions.
|
|||||||
| E2 | Currency + benefit core | 1 | DONE |
|
| E2 | Currency + benefit core | 1 | DONE |
|
||||||
| E3 | Wallet UI | 1 | DONE |
|
| E3 | Wallet UI | 1 | DONE |
|
||||||
| E4 | Durability (PITR) | 2 | DONE |
|
| E4 | Durability (PITR) | 2 | DONE |
|
||||||
| E5 | Payment intake | 2 | WIP |
|
| E5 | Payment intake | 2 | DONE |
|
||||||
| E6 | Ads | 2 | TODO |
|
| E6 | Ads | 2 | TODO |
|
||||||
| E7 | Admin & reports | 2 | TODO |
|
| E7 | Admin & reports | 2 | TODO |
|
||||||
| E8 | Guest limits | — | TODO |
|
| E8 | Guest limits | — | TODO |
|
||||||
@@ -504,7 +504,7 @@ maintenance window). Migrations stay expand-contract so image rollback remains D
|
|||||||
|
|
||||||
## E5 — Payment intake
|
## 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.
|
**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`
|
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),
|
`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
|
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
|
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
|
in the admin is the go-live. Finally **refunds** are delivered on `feature/payment-intake-refunds`: a
|
||||||
spend-path `NotifyBanner`, deferred with the owner's agreement).
|
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,
|
**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,
|
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
|
**Receipts (§12).** Robokassa self-employed НПД receipt on payment (provider config); VK
|
||||||
handles Votes tax itself; TG Stars — no receipt.
|
handles Votes tax itself; TG Stars — no receipt.
|
||||||
|
|
||||||
**Refunds (§9).** ToS non-refundable; admin manual refund (ties to `accountdelete`); external
|
**Refunds (§9).** ToS non-refundable. **All refunds are admin-triggered** (E7): no rail pushes an
|
||||||
`refunded` events honoured — best-effort benefit revoke (never negative; record loss + abuse
|
unsolicited refund — Robokassa refund API / cabinet (auto-polling deferred as a low-value worker),
|
||||||
flag if spent), ledger `refund` row. Ledger export-ready (reconciliation not built).
|
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.**
|
**Tests.**
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package inttest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -23,6 +24,21 @@ func orderStatus(t *testing.T, orderID uuid.UUID) string {
|
|||||||
return status
|
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
|
// 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)
|
// funding it credits the funded segment exactly once, and a replayed callback (the same order)
|
||||||
// credits nothing more — the ledger idempotency index holds.
|
// 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))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
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
|
// 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
|
// to the order account's preferred language before showing the payer (the reason is displayed in the
|
||||||
// Telegram payment sheet).
|
// Telegram payment sheet).
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ var (
|
|||||||
// ErrAmountMismatch means the callback's paid amount or currency does not match the order's
|
// ErrAmountMismatch means the callback's paid amount or currency does not match the order's
|
||||||
// expected amount — the credit is refused (§9: verify amount after matching by order id).
|
// expected amount — the credit is refused (§9: verify amount after matching by order id).
|
||||||
ErrAmountMismatch = errors.New("payments: paid amount does not match the order")
|
ErrAmountMismatch = errors.New("payments: paid amount does not match the order")
|
||||||
|
// ErrOrderNotPaid means a refund targets an order that was never funded — there is nothing to
|
||||||
|
// reverse (guards against a spurious loss/abuse record on an unpaid order).
|
||||||
|
ErrOrderNotPaid = errors.New("payments: order is not paid")
|
||||||
)
|
)
|
||||||
|
|
||||||
// errAlreadyCredited is the internal sentinel that unwinds the fund transaction when the ledger's
|
// errAlreadyCredited is the internal sentinel that unwinds the fund transaction when the ledger's
|
||||||
@@ -34,6 +37,10 @@ var (
|
|||||||
// a replayed callback is a success that credits nothing.
|
// a replayed callback is a success that credits nothing.
|
||||||
var errAlreadyCredited = errors.New("payments: already credited")
|
var errAlreadyCredited = errors.New("payments: already credited")
|
||||||
|
|
||||||
|
// errAlreadyRefunded unwinds the refund transaction when the ledger idempotency index rejects a
|
||||||
|
// duplicate refund (same provider refund id). It is not surfaced: a replayed refund reverses nothing.
|
||||||
|
var errAlreadyRefunded = errors.New("payments: already refunded")
|
||||||
|
|
||||||
// packInfo is a chip pack resolved for an order: the product, the chips it funds and its price in
|
// packInfo is a chip pack resolved for an order: the product, the chips it funds and its price in
|
||||||
// the requested payment method's currency.
|
// the requested payment method's currency.
|
||||||
type packInfo struct {
|
type packInfo struct {
|
||||||
@@ -271,6 +278,103 @@ func (s *Store) fund(ctx context.Context, orderID uuid.UUID, provider, providerP
|
|||||||
return outcome, nil
|
return outcome, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefundOutcome reports a refund's result: whose funded segment was reversed, the chips actually
|
||||||
|
// clawed back (floored at 0), the unrecoverable remainder (a loss, when the chips were already
|
||||||
|
// spent), and whether the refund was a duplicate that reversed nothing.
|
||||||
|
type RefundOutcome struct {
|
||||||
|
AccountID uuid.UUID
|
||||||
|
Source Source
|
||||||
|
Revoked int
|
||||||
|
Loss int
|
||||||
|
AlreadyRefunded bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// refund reverses a paid order's credit best-effort, exactly once. It matches the order (which must
|
||||||
|
// be paid), verifies the refunded amount, then in one transaction appends a refund ledger row
|
||||||
|
// (idempotent on the (provider, provider_payment_id) index — the refund id is distinct from the
|
||||||
|
// fund's payment id, so the two rows coexist), revokes the funded chips floored at 0 (never
|
||||||
|
// negative, D27/balances_chips_chk) and, when chips were already spent, records the unrecoverable
|
||||||
|
// remainder as a per-account loss and flips the abuse flag. A duplicate refund returns
|
||||||
|
// AlreadyRefunded with no second reversal. The ledger row's chipsDelta is what is actually
|
||||||
|
// reclaimed; the full reversal (money, original chips, loss) rides in its snapshot for the report.
|
||||||
|
func (s *Store) refund(ctx context.Context, orderID uuid.UUID, provider, providerRefundID string, refunded Money, now time.Time) (RefundOutcome, error) {
|
||||||
|
ord, err := s.orderByID(ctx, orderID)
|
||||||
|
if err != nil {
|
||||||
|
return RefundOutcome{}, err
|
||||||
|
}
|
||||||
|
if ord.status != "paid" {
|
||||||
|
return RefundOutcome{}, ErrOrderNotPaid
|
||||||
|
}
|
||||||
|
if refunded.Currency() != Currency(ord.currency) || refunded.Minor() != ord.expectedAmount {
|
||||||
|
return RefundOutcome{}, ErrAmountMismatch
|
||||||
|
}
|
||||||
|
chips, title, err := s.packForCredit(ctx, ord.productID)
|
||||||
|
if err != nil {
|
||||||
|
return RefundOutcome{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
src := Source(ord.origin)
|
||||||
|
outcome := RefundOutcome{AccountID: ord.accountID, Source: src}
|
||||||
|
pv, pr := provider, providerRefundID
|
||||||
|
productID := ord.productID
|
||||||
|
oid := orderID
|
||||||
|
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
// Lock the funded segment and read what is left; a spent balance floors the reversal at 0.
|
||||||
|
var avail int
|
||||||
|
e := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT chips FROM payments.balances WHERE account_id = $1 AND source = $2 FOR UPDATE`,
|
||||||
|
ord.accountID, string(src)).Scan(&avail)
|
||||||
|
switch {
|
||||||
|
case errors.Is(e, sql.ErrNoRows):
|
||||||
|
avail = 0
|
||||||
|
case e != nil:
|
||||||
|
return fmt.Errorf("payments: read balance for refund: %w", e)
|
||||||
|
}
|
||||||
|
revoked := min(chips, avail)
|
||||||
|
loss := chips - revoked
|
||||||
|
outcome.Revoked, outcome.Loss = revoked, loss
|
||||||
|
|
||||||
|
snapshot, e := marshalRefundSnapshot(ord.productID, title, chips, revoked, loss, refunded, providerRefundID)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e := insertLedgerTx(ctx, tx, ord.accountID, "refund", &src, &src, -revoked, &productID, &oid, &pv, &pr, snapshot, now); e != nil {
|
||||||
|
if isUniqueViolation(e) {
|
||||||
|
outcome.AlreadyRefunded = true
|
||||||
|
return errAlreadyRefunded
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if revoked > 0 {
|
||||||
|
if _, e := tx.ExecContext(ctx,
|
||||||
|
`UPDATE payments.balances SET chips = chips - $3, updated_at = now()
|
||||||
|
WHERE account_id = $1 AND source = $2`,
|
||||||
|
ord.accountID, string(src), revoked); e != nil {
|
||||||
|
return fmt.Errorf("payments: revoke chips %s: %w", src, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if loss > 0 {
|
||||||
|
if _, e := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO payments.account_risk (account_id, abuse, loss_chips, updated_at)
|
||||||
|
VALUES ($1, true, $2, now())
|
||||||
|
ON CONFLICT (account_id) DO UPDATE
|
||||||
|
SET abuse = true, loss_chips = payments.account_risk.loss_chips + EXCLUDED.loss_chips, updated_at = now()`,
|
||||||
|
ord.accountID, int64(loss)); e != nil {
|
||||||
|
return fmt.Errorf("payments: record refund loss: %w", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errAlreadyRefunded) {
|
||||||
|
return outcome, nil
|
||||||
|
}
|
||||||
|
return RefundOutcome{}, err
|
||||||
|
}
|
||||||
|
s.cache.invalidate(ord.accountID)
|
||||||
|
return outcome, nil
|
||||||
|
}
|
||||||
|
|
||||||
// insertPaymentEvent appends an undispatched lifecycle event (succeeded/failed/refunded) for the
|
// insertPaymentEvent appends an undispatched lifecycle event (succeeded/failed/refunded) for the
|
||||||
// dispatcher to deliver. orderID and payload (a jsonb detail blob) are optional.
|
// dispatcher to deliver. orderID and payload (a jsonb detail blob) are optional.
|
||||||
func (s *Store) insertPaymentEvent(ctx context.Context, accountID uuid.UUID, orderID *uuid.UUID, eventType string, payload []byte, now time.Time) error {
|
func (s *Store) insertPaymentEvent(ctx context.Context, accountID uuid.UUID, orderID *uuid.UUID, eventType string, payload []byte, now time.Time) error {
|
||||||
@@ -373,6 +477,27 @@ func marshalFundSnapshot(productID uuid.UUID, title string, chips int, paid Mone
|
|||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// marshalRefundSnapshot records the full reversal on the refund ledger row: the pack, the original
|
||||||
|
// funded chips, how many were actually reclaimed, the unrecoverable loss (already spent), the money
|
||||||
|
// refunded and the provider refund id — so the ledger stays reconcilable against the balance
|
||||||
|
// (chipsDelta = revoked) while the report still sees the whole reversal (§7/D27/D34).
|
||||||
|
func marshalRefundSnapshot(productID uuid.UUID, title string, chips, revoked, loss int, refunded Money, refundID string) ([]byte, error) {
|
||||||
|
b, err := json.Marshal(struct {
|
||||||
|
ProductID string `json:"product_id"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Chips int `json:"chips"`
|
||||||
|
Revoked int `json:"revoked"`
|
||||||
|
Loss int `json:"loss"`
|
||||||
|
Amount int64 `json:"amount_minor"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
RefundID string `json:"refund_id"`
|
||||||
|
}{productID.String(), title, chips, revoked, loss, refunded.Minor(), string(refunded.Currency()), refundID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("payments: marshal refund snapshot: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
// isUniqueViolation reports whether err is a PostgreSQL unique-constraint violation (SQLSTATE
|
// isUniqueViolation reports whether err is a PostgreSQL unique-constraint violation (SQLSTATE
|
||||||
// 23505) — here, a duplicate provider callback hitting the ledger idempotency index.
|
// 23505) — here, a duplicate provider callback hitting the ledger idempotency index.
|
||||||
func isUniqueViolation(err error) bool {
|
func isUniqueViolation(err error) bool {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccountRisk struct {
|
||||||
|
AccountID uuid.UUID `sql:"primary_key"`
|
||||||
|
Abuse bool
|
||||||
|
LossChips int64
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var AccountRisk = newAccountRiskTable("payments", "account_risk", "")
|
||||||
|
|
||||||
|
type accountRiskTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
AccountID postgres.ColumnString
|
||||||
|
Abuse postgres.ColumnBool
|
||||||
|
LossChips postgres.ColumnInteger
|
||||||
|
UpdatedAt postgres.ColumnTimestampz
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountRiskTable struct {
|
||||||
|
accountRiskTable
|
||||||
|
|
||||||
|
EXCLUDED accountRiskTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new AccountRiskTable with assigned alias
|
||||||
|
func (a AccountRiskTable) AS(alias string) *AccountRiskTable {
|
||||||
|
return newAccountRiskTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new AccountRiskTable with assigned schema name
|
||||||
|
func (a AccountRiskTable) FromSchema(schemaName string) *AccountRiskTable {
|
||||||
|
return newAccountRiskTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new AccountRiskTable with assigned table prefix
|
||||||
|
func (a AccountRiskTable) WithPrefix(prefix string) *AccountRiskTable {
|
||||||
|
return newAccountRiskTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new AccountRiskTable with assigned table suffix
|
||||||
|
func (a AccountRiskTable) WithSuffix(suffix string) *AccountRiskTable {
|
||||||
|
return newAccountRiskTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAccountRiskTable(schemaName, tableName, alias string) *AccountRiskTable {
|
||||||
|
return &AccountRiskTable{
|
||||||
|
accountRiskTable: newAccountRiskTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newAccountRiskTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAccountRiskTableImpl(schemaName, tableName, alias string) accountRiskTable {
|
||||||
|
var (
|
||||||
|
AccountIDColumn = postgres.StringColumn("account_id")
|
||||||
|
AbuseColumn = postgres.BoolColumn("abuse")
|
||||||
|
LossChipsColumn = postgres.IntegerColumn("loss_chips")
|
||||||
|
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||||
|
allColumns = postgres.ColumnList{AccountIDColumn, AbuseColumn, LossChipsColumn, UpdatedAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{AbuseColumn, LossChipsColumn, UpdatedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{AbuseColumn, LossChipsColumn, UpdatedAtColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return accountRiskTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
AccountID: AccountIDColumn,
|
||||||
|
Abuse: AbuseColumn,
|
||||||
|
LossChips: LossChipsColumn,
|
||||||
|
UpdatedAt: UpdatedAtColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ package table
|
|||||||
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
|
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
|
||||||
// this method only once at the beginning of the program.
|
// this method only once at the beginning of the program.
|
||||||
func UseSchema(schema string) {
|
func UseSchema(schema string) {
|
||||||
|
AccountRisk = AccountRisk.FromSchema(schema)
|
||||||
Balances = Balances.FromSchema(schema)
|
Balances = Balances.FromSchema(schema)
|
||||||
Benefits = Benefits.FromSchema(schema)
|
Benefits = Benefits.FromSchema(schema)
|
||||||
CatalogAtom = CatalogAtom.FromSchema(schema)
|
CatalogAtom = CatalogAtom.FromSchema(schema)
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Per-account payment risk: the loss and abuse signal an external/admin refund leaves
|
||||||
|
-- behind when the refunded chips were already spent. A refund revokes chips best-effort
|
||||||
|
-- and never drives a balance negative (D27, balances_chips_chk); the unrecoverable
|
||||||
|
-- remainder is a recorded loss and flips an abuse flag the /_gm financial report reads
|
||||||
|
-- (D40, E7). Mutable per-account state (upserted on each such refund), so — unlike the
|
||||||
|
-- ledger — it carries no append-only trigger. Additive: a new table only, so goose
|
||||||
|
-- applies it forward with no rewrite of existing data (the contour is not wiped). The
|
||||||
|
-- payments role inherits ALL on it via the schema default privileges set in 00010.
|
||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
CREATE TABLE payments.account_risk (
|
||||||
|
account_id uuid NOT NULL,
|
||||||
|
-- abuse flips true the first time a refund cannot fully reclaim its chips (spent).
|
||||||
|
abuse boolean DEFAULT false NOT NULL,
|
||||||
|
-- loss_chips accumulates the unrecoverable chips across such refunds (bigint: an
|
||||||
|
-- accumulator, mapped to int64 by go-jet — not the numeric->float64 trap).
|
||||||
|
loss_chips bigint DEFAULT 0 NOT NULL,
|
||||||
|
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT account_risk_pkey PRIMARY KEY (account_id),
|
||||||
|
CONSTRAINT account_risk_loss_chips_chk CHECK ((loss_chips >= 0))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS payments.account_risk;
|
||||||
+16
-8
@@ -241,14 +241,22 @@ existing `botlink` push / email relay. "Payment failed" (an **active** provider
|
|||||||
an abandoned pending) is surfaced to the user; "payment succeeded" is a hook (email / bot
|
an abandoned pending) is surfaced to the user; "payment succeeded" is a hook (email / bot
|
||||||
message).
|
message).
|
||||||
|
|
||||||
**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. An admin may
|
**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. Refunds are
|
||||||
issue a **manual** refund (edge case: a user demands one shortly after paying / closes their
|
**admin-triggered** (the E7 console), since no rail pushes an unsolicited refund: Robokassa
|
||||||
account — tie into `accountdelete`, which already preserves messages). **External** refunds
|
refunds run through its refund API / merchant cabinet (auto-polling a rail's refund status is a
|
||||||
(chargeback / store decision / TG / VK) are honoured: the system takes the `refunded` event,
|
deferred worker, not worth it at low chargeback volume), VK refunds are handled by support, and
|
||||||
**best-effort** revokes the benefit (never going negative; if chips were already spent, it
|
Telegram Stars refunds are issued with `refundStarPayment`. All of them converge on one engine —
|
||||||
records the loss + an abuse flag), and writes to the ledger. The ledger is **export-ready**
|
the `Refund` method (`internal/payments`): it matches the paid order, appends a **refund** ledger
|
||||||
for future tax reporting and Robokassa reconciliation (reconciliation itself is not built
|
row (idempotent on `(provider, provider_refund_id)` — the refund id is distinct from the fund's
|
||||||
yet; the schema stays compatible).
|
payment id, so the two rows coexist under the same partial-unique index), and **best-effort 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**
|
||||||
|
(`payments.account_risk`, read by the E7 report). The refund ledger row's chip delta is what was
|
||||||
|
actually reclaimed, so the ledger stays reconcilable against the balance; the **full** reversal
|
||||||
|
(money, original chips, loss) rides in the row's snapshot. The order stays `paid` — the refund lives
|
||||||
|
in the ledger + a `refunded` payment event, not in the order status. A duplicate refund reverses
|
||||||
|
nothing. The ledger is **export-ready** for future tax reporting and reconciliation (the reconciler
|
||||||
|
itself is not built yet; the schema stays compatible).
|
||||||
|
|
||||||
## 10. Ads
|
## 10. Ads
|
||||||
|
|
||||||
|
|||||||
+14
-8
@@ -243,14 +243,20 @@ at-least-once + идемпотентный приём (дедуп по `telegram
|
|||||||
брошенный pending) доводится до пользователя; «оплата прошла» — хук (письмо / сообщение в
|
брошенный pending) доводится до пользователя; «оплата прошла» — хук (письмо / сообщение в
|
||||||
бота).
|
бота).
|
||||||
|
|
||||||
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Админ может сделать
|
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Возвраты **инициирует
|
||||||
**ручной** возврат (крайний случай: пользователь требует вскоре после оплаты / закрывает
|
админ** (консоль E7): ни один рельс не шлёт непрошеный возврат — Robokassa через refund-API / ЛК
|
||||||
аккаунт — связано с `accountdelete`, где уже сохраняются сообщения). **Внешние** возвраты
|
(авто-опрос статуса — отложенный воркер, при низком объёме чарджбеков не оправдан), VK — через
|
||||||
(чарджбек / решение стора / TG / VK) обрабатываются: система принимает событие `refunded`,
|
поддержку, TG Stars — вызовом `refundStarPayment`. Все сходятся на одном движке — метод `Refund`
|
||||||
**по возможности** отзывает бенефит (в минус не уходим; если Фишки уже потрачены —
|
(`internal/payments`): матчит оплаченный заказ, пишет **refund**-строку журнала (идемпотентно по
|
||||||
фиксирует убыток + флаг защиты от злоупотреблений), пишет в журнал. Журнал операций
|
`(provider, provider_refund_id)` — refund-id отличается от payment-id fund'а, поэтому строки
|
||||||
**спроектирован экспортопригодным** для будущей налоговой отчётности и сверки с Robokassa
|
сосуществуют под тем же partial-unique индексом) и **по возможности отзывает начисленные Фишки с
|
||||||
(саму сверку пока не строим; схема остаётся совместимой).
|
полом 0** (в минус не уходим — D27, `balances_chips_chk`). Если Фишки уже потрачены, невозвратный
|
||||||
|
остаток фиксируется как **убыток + флаг злоупотребления** per-account (`payments.account_risk`, читает
|
||||||
|
отчёт E7). Дельта Фишек в refund-строке — то, что реально отозвано, поэтому журнал остаётся сверяемым
|
||||||
|
с балансом; **полный** реверс (деньги, исходные Фишки, убыток) лежит в snapshot строки. Заказ остаётся
|
||||||
|
`paid` — возврат живёт в журнале + событии `refunded`, не в статусе заказа. Повторный возврат не
|
||||||
|
отзывает ничего. Журнал операций **спроектирован экспортопригодным** для будущей налоговой отчётности
|
||||||
|
и сверки (саму сверку пока не строим; схема остаётся совместимой).
|
||||||
|
|
||||||
## 10. Реклама
|
## 10. Реклама
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user