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) }