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:
@@ -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