7860efce48
Add the payment-intake write path (provider-agnostic) and the Robokassa direct-rail glue, both unit-tested; transport, wire and UI follow. - payments: extend the ledger insert to thread order_id/provider/ provider_payment_id (spend/grant pass nil); add the order store (create/read/expire + a pack-price loader) and the fund credit — a fund ledger row + a guarded balance upsert + mark-paid in one tx, idempotent on the (provider, provider_payment_id) unique index, cache invalidated after commit. A valid callback is honoured even on an expired order. Service CreateOrder/Fund/ExpireOrders; Money.Major for the provider amount field. - robokassa: build the signed hosted-payment URL (SHA-256, order id via Shp_order, InvId unused) and verify the Result callback signature (Password2), extracting the order and amount. Receipt/fiscalisation is configured shop-side, so no Receipt parameter is sent.
435 lines
17 KiB
Go
435 lines
17 KiB
Go
package payments
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/go-jet/jet/v2/postgres"
|
|
"github.com/go-jet/jet/v2/qrm"
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/postgres/jet/payments/model"
|
|
"scrabble/backend/internal/postgres/jet/payments/table"
|
|
)
|
|
|
|
// Domain errors surfaced by the payments store and service.
|
|
var (
|
|
// ErrInsufficientChips means the spendable segments held fewer chips than the price.
|
|
ErrInsufficientChips = errors.New("payments: insufficient chips")
|
|
// ErrUntrusted means the platform context is untrusted, so the gate is fail-closed.
|
|
ErrUntrusted = errors.New("payments: untrusted platform")
|
|
// ErrProductNotFound means the product is absent or deactivated.
|
|
ErrProductNotFound = errors.New("payments: product not found")
|
|
// ErrNotAValue means the product has no chip price (it is a chip pack or unpriced), so it
|
|
// cannot be bought with chips.
|
|
ErrNotAValue = errors.New("payments: product is not a chip-priced value")
|
|
)
|
|
|
|
// withTx runs fn inside a transaction on db, rolling back on error or panic.
|
|
func withTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) (err error) {
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("payments: begin tx: %w", err)
|
|
}
|
|
defer func() {
|
|
if p := recover(); p != nil {
|
|
_ = tx.Rollback()
|
|
panic(p)
|
|
}
|
|
}()
|
|
if err := fn(tx); err != nil {
|
|
_ = tx.Rollback()
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("payments: commit tx: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sourceAmount is one chip draw from a single segment during a spend.
|
|
type sourceAmount struct {
|
|
source Source
|
|
amount int
|
|
}
|
|
|
|
// catalogProduct is a product resolved for a chip spend: its chip price, the benefit its atoms
|
|
// fold into, and the raw atom composition for the purchase snapshot.
|
|
type catalogProduct struct {
|
|
id uuid.UUID
|
|
title string
|
|
priceChips int
|
|
delta benefitDelta
|
|
atoms map[string]int
|
|
}
|
|
|
|
// loadState reads an account's balances and benefits straight from the materialized tables.
|
|
func (s *Store) loadState(ctx context.Context, accountID uuid.UUID) (walletState, error) {
|
|
st := walletState{chips: map[Source]int{}, benefits: map[Source]benefitState{}}
|
|
var brows []model.Balances
|
|
if err := postgres.SELECT(table.Balances.AllColumns).
|
|
FROM(table.Balances).
|
|
WHERE(table.Balances.AccountID.EQ(postgres.UUID(accountID))).
|
|
QueryContext(ctx, s.db, &brows); err != nil {
|
|
return walletState{}, fmt.Errorf("payments: load balances %s: %w", accountID, err)
|
|
}
|
|
for _, r := range brows {
|
|
st.chips[Source(r.Source)] = int(r.Chips)
|
|
}
|
|
var frows []model.Benefits
|
|
if err := postgres.SELECT(table.Benefits.AllColumns).
|
|
FROM(table.Benefits).
|
|
WHERE(table.Benefits.AccountID.EQ(postgres.UUID(accountID))).
|
|
QueryContext(ctx, s.db, &frows); err != nil {
|
|
return walletState{}, fmt.Errorf("payments: load benefits %s: %w", accountID, err)
|
|
}
|
|
for _, r := range frows {
|
|
st.benefits[Source(r.Origin)] = benefitState{adsPaidUntil: r.AdsPaidUntil, adsForever: r.AdsForever, hints: int(r.Hints)}
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
// state returns an account's payments state, served from the read cache when warm, otherwise
|
|
// loaded from the materialized tables and cached. The returned maps are read-only.
|
|
func (s *Store) state(ctx context.Context, accountID uuid.UUID) (walletState, error) {
|
|
if st, ok := s.cache.get(accountID); ok {
|
|
return st, nil
|
|
}
|
|
st, err := s.loadState(ctx, accountID)
|
|
if err != nil {
|
|
return walletState{}, err
|
|
}
|
|
s.cache.put(accountID, st)
|
|
return st, nil
|
|
}
|
|
|
|
// Invalidate drops the cached state of every listed account so the next read reloads it. It is
|
|
// called after a committed mutation whose transaction the payments package does not own — the
|
|
// account-merge flow (after its own commit) and, later, external fund/refund intake.
|
|
func (s *Store) Invalidate(ids ...uuid.UUID) {
|
|
for _, id := range ids {
|
|
s.cache.invalidate(id)
|
|
}
|
|
}
|
|
|
|
// loadProduct resolves a chip-priced value: an active product with a CHIP price row
|
|
// (method NULL) whose atoms are benefits (hints / no-ads days). It rejects a missing or
|
|
// deactivated product (ErrProductNotFound), a product with no chip price (ErrNotAValue), and a
|
|
// product carrying the chips atom (a chip pack is funded, never bought with chips — ErrNotAValue).
|
|
func (s *Store) loadProduct(ctx context.Context, productID uuid.UUID) (catalogProduct, error) {
|
|
var p model.Product
|
|
err := postgres.SELECT(table.Product.AllColumns).
|
|
FROM(table.Product).
|
|
WHERE(table.Product.ProductID.EQ(postgres.UUID(productID))).
|
|
LIMIT(1).
|
|
QueryContext(ctx, s.db, &p)
|
|
if errors.Is(err, qrm.ErrNoRows) || (err == nil && !p.Active) {
|
|
return catalogProduct{}, ErrProductNotFound
|
|
}
|
|
if err != nil {
|
|
return catalogProduct{}, fmt.Errorf("payments: load product %s: %w", productID, err)
|
|
}
|
|
|
|
var price model.ProductPrice
|
|
err = postgres.SELECT(table.ProductPrice.AllColumns).
|
|
FROM(table.ProductPrice).
|
|
WHERE(table.ProductPrice.ProductID.EQ(postgres.UUID(productID)).
|
|
AND(table.ProductPrice.Method.IS_NULL()).
|
|
AND(table.ProductPrice.Currency.EQ(postgres.String(string(CurrencyChip))))).
|
|
LIMIT(1).
|
|
QueryContext(ctx, s.db, &price)
|
|
if errors.Is(err, qrm.ErrNoRows) {
|
|
return catalogProduct{}, ErrNotAValue
|
|
}
|
|
if err != nil {
|
|
return catalogProduct{}, fmt.Errorf("payments: load price %s: %w", productID, err)
|
|
}
|
|
|
|
var items []model.ProductItem
|
|
if err := postgres.SELECT(table.ProductItem.AllColumns).
|
|
FROM(table.ProductItem).
|
|
WHERE(table.ProductItem.ProductID.EQ(postgres.UUID(productID))).
|
|
QueryContext(ctx, s.db, &items); err != nil {
|
|
return catalogProduct{}, fmt.Errorf("payments: load items %s: %w", productID, err)
|
|
}
|
|
|
|
cp := catalogProduct{id: productID, title: p.Title, priceChips: int(price.Amount), atoms: map[string]int{}}
|
|
for _, it := range items {
|
|
cp.atoms[it.AtomType] = int(it.Quantity)
|
|
switch it.AtomType {
|
|
case "hints":
|
|
cp.delta.hintsAdd += int(it.Quantity)
|
|
case "noads_days":
|
|
cp.delta.noAdsDays += int(it.Quantity)
|
|
case "chips":
|
|
return catalogProduct{}, ErrNotAValue // a value never grants chips
|
|
}
|
|
}
|
|
return cp, nil
|
|
}
|
|
|
|
// stackNoAds returns the new no-ads term end after adding addDays whole days from
|
|
// max(now, current end) — terms add up, the remainder is never lost (§5/D33). A non-positive
|
|
// addDays leaves the term unchanged; a nil current means no term yet.
|
|
func stackNoAds(current *time.Time, addDays int, now time.Time) *time.Time {
|
|
if addDays <= 0 {
|
|
return current
|
|
}
|
|
base := now
|
|
if current != nil && current.After(now) {
|
|
base = *current
|
|
}
|
|
end := base.Add(time.Duration(addDays) * 24 * time.Hour)
|
|
return &end
|
|
}
|
|
|
|
// combineNoAds folds secondary's remaining no-ads term onto primary's during a merge: the
|
|
// remaining duration of each is preserved (§6/D15 "terms extend per origin").
|
|
func combineNoAds(primary, secondary *time.Time, now time.Time) *time.Time {
|
|
if secondary == nil || !secondary.After(now) {
|
|
return primary
|
|
}
|
|
base := now
|
|
if primary != nil && primary.After(now) {
|
|
base = *primary
|
|
}
|
|
end := base.Add(secondary.Sub(now))
|
|
return &end
|
|
}
|
|
|
|
// applyBenefitTx applies a benefit delta to one origin inside tx, stacking the no-ads term,
|
|
// OR-ing the forever flag and adding hints. It ensures the (account, origin) row exists, locks
|
|
// it, then writes the recomputed values — so concurrent applies serialise on the row lock.
|
|
func applyBenefitTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, origin Source, d benefitDelta, now time.Time) error {
|
|
if d.zero() {
|
|
return nil
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO payments.benefits (account_id, origin) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
|
accountID, string(origin)); err != nil {
|
|
return fmt.Errorf("payments: ensure benefit row %s: %w", origin, err)
|
|
}
|
|
var untilCur *time.Time
|
|
var foreverCur bool
|
|
var hintsCur int32
|
|
if err := tx.QueryRowContext(ctx,
|
|
`SELECT ads_paid_until, ads_forever, hints FROM payments.benefits
|
|
WHERE account_id = $1 AND origin = $2 FOR UPDATE`, accountID, string(origin)).
|
|
Scan(&untilCur, &foreverCur, &hintsCur); err != nil {
|
|
return fmt.Errorf("payments: lock benefit %s: %w", origin, err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE payments.benefits SET ads_paid_until = $3, ads_forever = $4, hints = $5, updated_at = now()
|
|
WHERE account_id = $1 AND origin = $2`,
|
|
accountID, string(origin), stackNoAds(untilCur, d.noAdsDays, now), foreverCur || d.forever, int(hintsCur)+d.hintsAdd); err != nil {
|
|
return fmt.Errorf("payments: apply benefit %s: %w", origin, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// insertLedgerTx appends one append-only ledger row inside tx. orderID, provider and
|
|
// providerPaymentID are set only on an intake credit (fund/refund) and are nil for a
|
|
// spend/admin_grant; a non-nil (provider, providerPaymentID) pair is guarded by the partial
|
|
// unique index, so a duplicate provider callback fails here (the idempotency key).
|
|
func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind string, source, origin *Source, chipsDelta int, productID, orderID *uuid.UUID, provider, providerPaymentID *string, snapshot []byte, now time.Time) error {
|
|
id, err := uuid.NewV7()
|
|
if err != nil {
|
|
return fmt.Errorf("payments: ledger id: %w", err)
|
|
}
|
|
// The snapshot is a bare value, not a postgres.String literal: a jsonb column infers its
|
|
// type from an untyped parameter (the game-moves payload idiom), whereas a text-typed
|
|
// literal is rejected against jsonb.
|
|
var snap any = postgres.NULL
|
|
if snapshot != nil {
|
|
snap = string(snapshot)
|
|
}
|
|
stmt := table.Ledger.INSERT(
|
|
table.Ledger.LedgerID, table.Ledger.AccountID, table.Ledger.Kind,
|
|
table.Ledger.Source, table.Ledger.Origin, table.Ledger.ChipsDelta,
|
|
table.Ledger.ProductID, table.Ledger.OrderID, table.Ledger.Provider,
|
|
table.Ledger.ProviderPaymentID, table.Ledger.Snapshot, table.Ledger.CreatedAt,
|
|
).VALUES(
|
|
id, accountID, kind,
|
|
sourceOrNull(source), sourceOrNull(origin), int32(chipsDelta),
|
|
uuidOrNull(productID), uuidOrNull(orderID), stringOrNull(provider),
|
|
stringOrNull(providerPaymentID), snap, now,
|
|
)
|
|
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
|
return fmt.Errorf("payments: insert %s ledger: %w", kind, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// spend draws the chips across the given segments, appends a spend ledger row per draw (carrying
|
|
// the purchase snapshot), and applies the benefit — all in one transaction. It fails closed on
|
|
// an insufficient balance (a guarded decrement), rolling everything back.
|
|
func (s *Store) spend(ctx context.Context, accountID uuid.UUID, draws []sourceAmount, origin Source, productID uuid.UUID, d benefitDelta, snapshot []byte, now time.Time) error {
|
|
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
|
for _, dr := range draws {
|
|
res, err := table.Balances.
|
|
UPDATE(table.Balances.Chips, table.Balances.UpdatedAt).
|
|
SET(table.Balances.Chips.SUB(postgres.Int(int64(dr.amount))), postgres.TimestampzT(now)).
|
|
WHERE(table.Balances.AccountID.EQ(postgres.UUID(accountID)).
|
|
AND(table.Balances.Source.EQ(postgres.String(string(dr.source)))).
|
|
AND(table.Balances.Chips.GT_EQ(postgres.Int(int64(dr.amount))))).
|
|
ExecContext(ctx, tx)
|
|
if err != nil {
|
|
return fmt.Errorf("payments: decrement %s: %w", dr.source, err)
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrInsufficientChips
|
|
}
|
|
src := dr.source
|
|
if err := insertLedgerTx(ctx, tx, accountID, "spend", &src, &origin, -dr.amount, &productID, nil, nil, nil, snapshot, now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return applyBenefitTx(ctx, tx, accountID, origin, d, now)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.cache.invalidate(accountID)
|
|
return nil
|
|
}
|
|
|
|
// grant records an admin_grant ledger row (price 0, no chips) and applies the granted benefit to
|
|
// the chosen origin, in one transaction — a zero-price sale of a value.
|
|
func (s *Store) grant(ctx context.Context, accountID uuid.UUID, origin Source, d benefitDelta, snapshot []byte, now time.Time) error {
|
|
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
|
if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, nil, nil, nil, nil, snapshot, now); err != nil {
|
|
return err
|
|
}
|
|
return applyBenefitTx(ctx, tx, accountID, origin, d, now)
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.cache.invalidate(accountID)
|
|
return nil
|
|
}
|
|
|
|
// consumeHint decrements one hint from the first applicable origin (in the given priority order)
|
|
// that has one, with a guarded update. It returns whether a hint was spent.
|
|
func (s *Store) consumeHint(ctx context.Context, accountID uuid.UUID, origins []Source, now time.Time) (bool, error) {
|
|
for _, o := range origins {
|
|
res, err := table.Benefits.
|
|
UPDATE(table.Benefits.Hints, table.Benefits.UpdatedAt).
|
|
SET(table.Benefits.Hints.SUB(postgres.Int(1)), postgres.TimestampzT(now)).
|
|
WHERE(table.Benefits.AccountID.EQ(postgres.UUID(accountID)).
|
|
AND(table.Benefits.Origin.EQ(postgres.String(string(o)))).
|
|
AND(table.Benefits.Hints.GT(postgres.Int(0)))).
|
|
ExecContext(ctx, s.db)
|
|
if err != nil {
|
|
return false, fmt.Errorf("payments: consume hint %s: %w", o, err)
|
|
}
|
|
if n, _ := res.RowsAffected(); n > 0 {
|
|
s.cache.invalidate(accountID)
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// MergeTx folds the secondary account's segments and benefits into the primary, by source and by
|
|
// origin, inside the caller's transaction (the account-merge flow): chips sum, no-ads terms
|
|
// extend per origin, forever OR-s, hints add. The secondary's payments rows are removed. The
|
|
// caller invalidates the primary's cache after its own commit (Invalidate). It does not touch
|
|
// the account schema — the JET import boundary stays intact, only the shared connection is used.
|
|
func (s *Store) MergeTx(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO payments.balances (account_id, source, chips, updated_at)
|
|
SELECT $1, source, chips, now() FROM payments.balances WHERE account_id = $2
|
|
ON CONFLICT (account_id, source) DO UPDATE
|
|
SET chips = payments.balances.chips + EXCLUDED.chips, updated_at = now()`,
|
|
primary, secondary); err != nil {
|
|
return fmt.Errorf("payments: merge balances: %w", err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM payments.balances WHERE account_id = $1`, secondary); err != nil {
|
|
return fmt.Errorf("payments: clear secondary balances: %w", err)
|
|
}
|
|
|
|
rows, err := tx.QueryContext(ctx,
|
|
`SELECT origin, ads_paid_until, ads_forever, hints FROM payments.benefits WHERE account_id = $1`, secondary)
|
|
if err != nil {
|
|
return fmt.Errorf("payments: read secondary benefits: %w", err)
|
|
}
|
|
type secBenefit struct {
|
|
origin Source
|
|
until *time.Time
|
|
forever bool
|
|
hints int
|
|
}
|
|
var secs []secBenefit
|
|
for rows.Next() {
|
|
var b secBenefit
|
|
var o string
|
|
var h int32
|
|
if err := rows.Scan(&o, &b.until, &b.forever, &h); err != nil {
|
|
rows.Close()
|
|
return fmt.Errorf("payments: scan secondary benefit: %w", err)
|
|
}
|
|
b.origin, b.hints = Source(o), int(h)
|
|
secs = append(secs, b)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return fmt.Errorf("payments: iterate secondary benefits: %w", err)
|
|
}
|
|
rows.Close()
|
|
|
|
for _, b := range secs {
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO payments.benefits (account_id, origin) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
|
primary, string(b.origin)); err != nil {
|
|
return fmt.Errorf("payments: ensure primary benefit %s: %w", b.origin, err)
|
|
}
|
|
var untilCur *time.Time
|
|
var foreverCur bool
|
|
var hintsCur int32
|
|
if err := tx.QueryRowContext(ctx,
|
|
`SELECT ads_paid_until, ads_forever, hints FROM payments.benefits
|
|
WHERE account_id = $1 AND origin = $2 FOR UPDATE`, primary, string(b.origin)).
|
|
Scan(&untilCur, &foreverCur, &hintsCur); err != nil {
|
|
return fmt.Errorf("payments: lock primary benefit %s: %w", b.origin, err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE payments.benefits SET ads_paid_until = $3, ads_forever = $4, hints = $5, updated_at = now()
|
|
WHERE account_id = $1 AND origin = $2`,
|
|
primary, string(b.origin), combineNoAds(untilCur, b.until, now), foreverCur || b.forever, int(hintsCur)+b.hints); err != nil {
|
|
return fmt.Errorf("payments: merge benefit %s: %w", b.origin, err)
|
|
}
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM payments.benefits WHERE account_id = $1`, secondary); err != nil {
|
|
return fmt.Errorf("payments: clear secondary benefits: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sourceOrNull renders an optional source as a SQL string or NULL.
|
|
func sourceOrNull(s *Source) postgres.Expression {
|
|
if s == nil {
|
|
return postgres.NULL
|
|
}
|
|
return postgres.String(string(*s))
|
|
}
|
|
|
|
// uuidOrNull renders an optional id as a SQL uuid or NULL.
|
|
func uuidOrNull(id *uuid.UUID) postgres.Expression {
|
|
if id == nil {
|
|
return postgres.NULL
|
|
}
|
|
return postgres.UUID(*id)
|
|
}
|
|
|
|
// stringOrNull renders an optional string as a SQL string or NULL.
|
|
func stringOrNull(s *string) postgres.Expression {
|
|
if s == nil {
|
|
return postgres.NULL
|
|
}
|
|
return postgres.String(*s)
|
|
}
|