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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
// 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)
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user