From 21fb14facf3e4cc4845d2b802aefb3f64cc555ac Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 23:21:10 +0200 Subject: [PATCH] feat(payments): refund engine (best-effort revoke, never negative) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- PLAN.md | 28 +++- .../internal/inttest/payments_intake_test.go | 141 ++++++++++++++++++ backend/internal/payments/service_intake.go | 11 ++ backend/internal/payments/store_intake.go | 125 ++++++++++++++++ .../jet/payments/model/account_risk.go | 20 +++ .../jet/payments/table/account_risk.go | 87 +++++++++++ .../jet/payments/table/table_use_schema.go | 1 + .../migrations/00012_account_risk.sql | 25 ++++ docs/PAYMENTS.md | 24 ++- docs/PAYMENTS_ru.md | 22 ++- 10 files changed, 461 insertions(+), 23 deletions(-) create mode 100644 backend/internal/postgres/jet/payments/model/account_risk.go create mode 100644 backend/internal/postgres/jet/payments/table/account_risk.go create mode 100644 backend/internal/postgres/migrations/00012_account_risk.sql diff --git a/PLAN.md b/PLAN.md index 744990e..8fc8e53 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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.** diff --git a/backend/internal/inttest/payments_intake_test.go b/backend/internal/inttest/payments_intake_test.go index 46206e8..7eeaafc 100644 --- a/backend/internal/inttest/payments_intake_test.go +++ b/backend/internal/inttest/payments_intake_test.go @@ -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) + } +} diff --git a/backend/internal/payments/service_intake.go b/backend/internal/payments/service_intake.go index 631228c..df18165 100644 --- a/backend/internal/payments/service_intake.go +++ b/backend/internal/payments/service_intake.go @@ -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). diff --git a/backend/internal/payments/store_intake.go b/backend/internal/payments/store_intake.go index 605a6d2..ff15680 100644 --- a/backend/internal/payments/store_intake.go +++ b/backend/internal/payments/store_intake.go @@ -27,6 +27,9 @@ var ( // 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). 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 @@ -34,6 +37,10 @@ var ( // a replayed callback is a success that credits nothing. 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 // the requested payment method's currency. type packInfo struct { @@ -271,6 +278,103 @@ func (s *Store) fund(ctx context.Context, orderID uuid.UUID, provider, providerP 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 // 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 { @@ -373,6 +477,27 @@ func marshalFundSnapshot(productID uuid.UUID, title string, chips int, paid Mone 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 // 23505) — here, a duplicate provider callback hitting the ledger idempotency index. func isUniqueViolation(err error) bool { diff --git a/backend/internal/postgres/jet/payments/model/account_risk.go b/backend/internal/postgres/jet/payments/model/account_risk.go new file mode 100644 index 0000000..9c5be59 --- /dev/null +++ b/backend/internal/postgres/jet/payments/model/account_risk.go @@ -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 +} diff --git a/backend/internal/postgres/jet/payments/table/account_risk.go b/backend/internal/postgres/jet/payments/table/account_risk.go new file mode 100644 index 0000000..7bdc0ca --- /dev/null +++ b/backend/internal/postgres/jet/payments/table/account_risk.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/payments/table/table_use_schema.go b/backend/internal/postgres/jet/payments/table/table_use_schema.go index 7c9d881..27e039b 100644 --- a/backend/internal/postgres/jet/payments/table/table_use_schema.go +++ b/backend/internal/postgres/jet/payments/table/table_use_schema.go @@ -10,6 +10,7 @@ package table // 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. func UseSchema(schema string) { + AccountRisk = AccountRisk.FromSchema(schema) Balances = Balances.FromSchema(schema) Benefits = Benefits.FromSchema(schema) CatalogAtom = CatalogAtom.FromSchema(schema) diff --git a/backend/internal/postgres/migrations/00012_account_risk.sql b/backend/internal/postgres/migrations/00012_account_risk.sql new file mode 100644 index 0000000..ba1296c --- /dev/null +++ b/backend/internal/postgres/migrations/00012_account_risk.sql @@ -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; diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index 14f62b8..560cdf9 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -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 message). -**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. An admin may -issue a **manual** refund (edge case: a user demands one shortly after paying / closes their -account — tie into `accountdelete`, which already preserves messages). **External** refunds -(chargeback / store decision / TG / VK) are honoured: the system takes the `refunded` event, -**best-effort** revokes the benefit (never going negative; if chips were already spent, it -records the loss + an abuse flag), and writes to the ledger. The ledger is **export-ready** -for future tax reporting and Robokassa reconciliation (reconciliation itself is not built -yet; the schema stays compatible). +**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. Refunds are +**admin-triggered** (the E7 console), since no rail pushes an unsolicited refund: Robokassa +refunds run through its refund API / merchant cabinet (auto-polling a rail's refund status is a +deferred worker, not worth it at low chargeback volume), VK refunds are handled by support, and +Telegram Stars refunds are issued with `refundStarPayment`. All of them converge on one engine — +the `Refund` method (`internal/payments`): it matches the paid order, appends a **refund** ledger +row (idempotent on `(provider, provider_refund_id)` — the refund id is distinct from the fund's +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 diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index 84cd43b..f917236 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -243,14 +243,20 @@ at-least-once + идемпотентный приём (дедуп по `telegram брошенный pending) доводится до пользователя; «оплата прошла» — хук (письмо / сообщение в бота). -**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Админ может сделать -**ручной** возврат (крайний случай: пользователь требует вскоре после оплаты / закрывает -аккаунт — связано с `accountdelete`, где уже сохраняются сообщения). **Внешние** возвраты -(чарджбек / решение стора / TG / VK) обрабатываются: система принимает событие `refunded`, -**по возможности** отзывает бенефит (в минус не уходим; если Фишки уже потрачены — -фиксирует убыток + флаг защиты от злоупотреблений), пишет в журнал. Журнал операций -**спроектирован экспортопригодным** для будущей налоговой отчётности и сверки с Robokassa -(саму сверку пока не строим; схема остаётся совместимой). +**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Возвраты **инициирует +админ** (консоль E7): ни один рельс не шлёт непрошеный возврат — Robokassa через refund-API / ЛК +(авто-опрос статуса — отложенный воркер, при низком объёме чарджбеков не оправдан), VK — через +поддержку, TG Stars — вызовом `refundStarPayment`. Все сходятся на одном движке — метод `Refund` +(`internal/payments`): матчит оплаченный заказ, пишет **refund**-строку журнала (идемпотентно по +`(provider, provider_refund_id)` — refund-id отличается от payment-id fund'а, поэтому строки +сосуществуют под тем же partial-unique индексом) и **по возможности отзывает начисленные Фишки с +полом 0** (в минус не уходим — D27, `balances_chips_chk`). Если Фишки уже потрачены, невозвратный +остаток фиксируется как **убыток + флаг злоупотребления** per-account (`payments.account_risk`, читает +отчёт E7). Дельта Фишек в refund-строке — то, что реально отозвано, поэтому журнал остаётся сверяемым +с балансом; **полный** реверс (деньги, исходные Фишки, убыток) лежит в snapshot строки. Заказ остаётся +`paid` — возврат живёт в журнале + событии `refunded`, не в статусе заказа. Повторный возврат не +отзывает ничего. Журнал операций **спроектирован экспортопригодным** для будущей налоговой отчётности +и сверки (саму сверку пока не строим; схема остаётся совместимой). ## 10. Реклама