feat(payments): chip wallet, store-compliance gate and benefit application
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
Stand up the internal chip/benefit mechanic behind the narrow payments interface: context-aware balances and benefits, an atomic chip spend, admin grants as zero-price value sales, the one-directional store-compliance gate (VK/TG same- origin only, web draws direct→vk→tg, VK-iOS frozen, untrusted fail-closed), and per-origin hint and no-ads application with term stacking. Reads are served from an in-process, account-keyed write-through cache (mirroring the suspension gate), so hot paths issue no query to the payments schema. Flip the online-game hint wallet and the ad-banner suppression from the deprecated accounts.hint_balance / paid_account columns to the payments benefit (a hint balance no longer suppresses the banner — only a no-ads benefit does), and fold chip segments and benefits by origin on account merge, inside the merge tx. Add the GET/POST /api/v1/user/wallet edge chain (REST → Connect → FlatBuffers) plus its codec unit test; no wallet UI yet. Bring the frozen owner decisions log into the repo at docs/PAYMENTS_DECISIONS_ru.md (it was untracked under .vscode) and reference it from PLAN.md; record the read-cache design and the present-sources interface in PLAN.md and docs/PAYMENTS.md (+ RU mirror).
This commit is contained in:
@@ -51,8 +51,24 @@ var ErrSameAccount = errors.New("accountmerge: primary and secondary are the sam
|
||||
type Merger struct {
|
||||
db *sql.DB
|
||||
now func() time.Time
|
||||
// payments, when set, merges the two accounts' chip segments and benefits by origin inside
|
||||
// the merge transaction (SetPayments). Nil leaves payments untouched (tests that do not
|
||||
// exercise the wallet).
|
||||
payments PaymentsMerger
|
||||
}
|
||||
|
||||
// PaymentsMerger is the payments surface the account merge enlists: fold the secondary's
|
||||
// segments and benefits into the primary within the merge transaction, then invalidate the
|
||||
// affected read caches after the commit. *payments.Service satisfies it.
|
||||
type PaymentsMerger interface {
|
||||
MergeTx(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error
|
||||
Invalidate(ids ...uuid.UUID)
|
||||
}
|
||||
|
||||
// SetPayments installs the payments merge hook. It must be called during startup wiring; the
|
||||
// default (nil) merges no wallet state.
|
||||
func (m *Merger) SetPayments(p PaymentsMerger) { m.payments = p }
|
||||
|
||||
// NewMerger constructs a Merger over db.
|
||||
func NewMerger(db *sql.DB) *Merger {
|
||||
return &Merger{db: db, now: func() time.Time { return time.Now().UTC() }}
|
||||
@@ -67,7 +83,7 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
|
||||
return ErrSameAccount
|
||||
}
|
||||
now := m.now()
|
||||
return withTx(ctx, m.db, func(tx *sql.Tx) error {
|
||||
if err := withTx(ctx, m.db, func(tx *sql.Tx) error {
|
||||
if err := guardActiveSharedGame(ctx, tx, primary, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -107,8 +123,21 @@ func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error
|
||||
if err := deleteEphemerals(ctx, tx, secondary); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.payments != nil {
|
||||
if err := m.payments.MergeTx(ctx, tx, primary, secondary); err != nil {
|
||||
return fmt.Errorf("accountmerge: payments: %w", err)
|
||||
}
|
||||
}
|
||||
return tombstone(ctx, tx, primary, secondary, now)
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// The payments read cache is invalidated only after the merge commits, so a read racing the
|
||||
// transaction cannot re-cache pre-merge state (both accounts' rows are moved or dropped).
|
||||
if m.payments != nil {
|
||||
m.payments.Invalidate(primary, secondary)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// guardActiveSharedGame returns ErrActiveGameConflict when primary and secondary
|
||||
@@ -248,25 +277,16 @@ func mergeBestMoves(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUI
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
|
||||
// all other profile fields stay the primary's.
|
||||
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
||||
var sec model.Accounts
|
||||
if err := postgres.SELECT(table.Accounts.AllColumns).
|
||||
FROM(table.Accounts).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary))).
|
||||
QueryContext(ctx, tx, &sec); err != nil {
|
||||
return fmt.Errorf("accountmerge: load secondary account: %w", err)
|
||||
}
|
||||
upd := table.Accounts.UPDATE(
|
||||
table.Accounts.HintBalance, table.Accounts.PaidAccount, table.Accounts.UpdatedAt,
|
||||
).SET(
|
||||
table.Accounts.HintBalance.ADD(postgres.Int(int64(sec.HintBalance))),
|
||||
table.Accounts.PaidAccount.OR(postgres.Bool(sec.PaidAccount)),
|
||||
postgres.TimestampzT(now),
|
||||
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(primary)))
|
||||
// mergeAccountFields bumps the primary account's updated_at to reflect the merge. The former
|
||||
// hint-wallet and paid-flag merge moved to the payments domain, where segments and benefits
|
||||
// merge by origin (see the payments MergeTx step and docs/PAYMENTS.md §6); the legacy
|
||||
// accounts.hint_balance / paid_account columns are deprecated and no longer read or written.
|
||||
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, _ uuid.UUID, now time.Time) error {
|
||||
upd := table.Accounts.UPDATE(table.Accounts.UpdatedAt).
|
||||
SET(postgres.TimestampzT(now)).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(primary)))
|
||||
if _, err := upd.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("accountmerge: update primary account: %w", err)
|
||||
return fmt.Errorf("accountmerge: touch primary account: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user