feat(payments): order-flow and fund credit engine + the Robokassa adapter
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.
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/payments/model"
|
||||
"scrabble/backend/internal/postgres/jet/payments/table"
|
||||
)
|
||||
|
||||
// Intake errors surfaced by the order-flow and external-credit (fund) path.
|
||||
var (
|
||||
// ErrNotAPack means the product is not a fundable chip pack in the requested method: it
|
||||
// carries no chips atom, or no price for that payment method.
|
||||
ErrNotAPack = errors.New("payments: product is not a chip pack for this method")
|
||||
// ErrOrderNotFound means no order matches the id (an unknown or forged callback reference).
|
||||
ErrOrderNotFound = errors.New("payments: order not found")
|
||||
// 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")
|
||||
)
|
||||
|
||||
// errAlreadyCredited is the internal sentinel that unwinds the fund transaction when the ledger's
|
||||
// (provider, provider_payment_id) unique index rejects a duplicate callback. It is not surfaced:
|
||||
// a replayed callback is a success that credits nothing.
|
||||
var errAlreadyCredited = errors.New("payments: already credited")
|
||||
|
||||
// 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 {
|
||||
productID uuid.UUID
|
||||
title string
|
||||
chips int
|
||||
price Money
|
||||
}
|
||||
|
||||
// packChips returns the quantity of the chips atom a product carries, or 0 if it has none (which
|
||||
// marks it as a value, not a fundable pack).
|
||||
func (s *Store) packChips(ctx context.Context, productID uuid.UUID) (int, error) {
|
||||
var item model.ProductItem
|
||||
err := postgres.SELECT(table.ProductItem.AllColumns).
|
||||
FROM(table.ProductItem).
|
||||
WHERE(table.ProductItem.ProductID.EQ(postgres.UUID(productID)).
|
||||
AND(table.ProductItem.AtomType.EQ(postgres.String(atomChips)))).
|
||||
LIMIT(1).
|
||||
QueryContext(ctx, s.db, &item)
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("payments: load pack chips %s: %w", productID, err)
|
||||
}
|
||||
return int(item.Quantity), nil
|
||||
}
|
||||
|
||||
// loadPackForOrder resolves an active chip pack for a new order in the given payment method: an
|
||||
// active product carrying a chips atom and a price row for the method (its currency and amount are
|
||||
// the order's expected amount). It rejects a missing/deactivated product (ErrProductNotFound) and
|
||||
// a product that is not a pack for the method (ErrNotAPack).
|
||||
func (s *Store) loadPackForOrder(ctx context.Context, productID uuid.UUID, method Source) (packInfo, 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 packInfo{}, ErrProductNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return packInfo{}, fmt.Errorf("payments: load product %s: %w", productID, err)
|
||||
}
|
||||
|
||||
chips, err := s.packChips(ctx, productID)
|
||||
if err != nil {
|
||||
return packInfo{}, err
|
||||
}
|
||||
if chips <= 0 {
|
||||
return packInfo{}, ErrNotAPack
|
||||
}
|
||||
|
||||
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.EQ(postgres.String(string(method))))).
|
||||
LIMIT(1).
|
||||
QueryContext(ctx, s.db, &price)
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return packInfo{}, ErrNotAPack
|
||||
}
|
||||
if err != nil {
|
||||
return packInfo{}, fmt.Errorf("payments: load pack price %s: %w", productID, err)
|
||||
}
|
||||
money, err := MoneyFromMinor(price.Amount, Currency(price.Currency))
|
||||
if err != nil {
|
||||
return packInfo{}, err
|
||||
}
|
||||
return packInfo{productID: productID, title: p.Title, chips: chips, price: money}, nil
|
||||
}
|
||||
|
||||
// packForCredit resolves the chips and title of an ordered pack at credit time, ignoring the
|
||||
// product's active flag: the money is real, so an order is honoured even if the pack was
|
||||
// deactivated after it was placed (§9/D23).
|
||||
func (s *Store) packForCredit(ctx context.Context, productID uuid.UUID) (chips int, title string, err error) {
|
||||
var p model.Product
|
||||
e := postgres.SELECT(table.Product.Title).
|
||||
FROM(table.Product).
|
||||
WHERE(table.Product.ProductID.EQ(postgres.UUID(productID))).
|
||||
LIMIT(1).
|
||||
QueryContext(ctx, s.db, &p)
|
||||
if errors.Is(e, qrm.ErrNoRows) {
|
||||
return 0, "", ErrProductNotFound
|
||||
}
|
||||
if e != nil {
|
||||
return 0, "", fmt.Errorf("payments: load product %s: %w", productID, e)
|
||||
}
|
||||
chips, err = s.packChips(ctx, productID)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
if chips <= 0 {
|
||||
return 0, "", ErrNotAPack
|
||||
}
|
||||
return chips, p.Title, nil
|
||||
}
|
||||
|
||||
// newOrder is the intent a CreateOrder writes: a pending order for a pack, priced in the method's
|
||||
// currency, tagged with the provider that will settle it.
|
||||
type newOrder struct {
|
||||
orderID uuid.UUID
|
||||
accountID uuid.UUID
|
||||
platform string
|
||||
productID uuid.UUID
|
||||
amount Money
|
||||
origin Source
|
||||
provider string
|
||||
}
|
||||
|
||||
// createOrder inserts a pending order.
|
||||
func (s *Store) createOrder(ctx context.Context, o newOrder, now time.Time) error {
|
||||
stmt := table.Orders.INSERT(
|
||||
table.Orders.OrderID, table.Orders.AccountID, table.Orders.Platform,
|
||||
table.Orders.ProductID, table.Orders.ExpectedAmount, table.Orders.Currency,
|
||||
table.Orders.Origin, table.Orders.Status, table.Orders.Provider,
|
||||
table.Orders.CreatedAt, table.Orders.UpdatedAt,
|
||||
).VALUES(
|
||||
o.orderID, o.accountID, o.platform,
|
||||
o.productID, o.amount.Minor(), string(o.amount.Currency()),
|
||||
string(o.origin), "pending", o.provider,
|
||||
now, now,
|
||||
)
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("payments: create order: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// orderRow is a stored order read back for the intake path.
|
||||
type orderRow struct {
|
||||
orderID uuid.UUID
|
||||
accountID uuid.UUID
|
||||
productID uuid.UUID
|
||||
expectedAmount int64
|
||||
currency string
|
||||
origin string
|
||||
status string
|
||||
}
|
||||
|
||||
// orderByID reads an order, or ErrOrderNotFound.
|
||||
func (s *Store) orderByID(ctx context.Context, orderID uuid.UUID) (orderRow, error) {
|
||||
var o model.Orders
|
||||
err := postgres.SELECT(table.Orders.AllColumns).
|
||||
FROM(table.Orders).
|
||||
WHERE(table.Orders.OrderID.EQ(postgres.UUID(orderID))).
|
||||
LIMIT(1).
|
||||
QueryContext(ctx, s.db, &o)
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return orderRow{}, ErrOrderNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return orderRow{}, fmt.Errorf("payments: load order %s: %w", orderID, err)
|
||||
}
|
||||
return orderRow{
|
||||
orderID: o.OrderID,
|
||||
accountID: o.AccountID,
|
||||
productID: o.ProductID,
|
||||
expectedAmount: o.ExpectedAmount,
|
||||
currency: o.Currency,
|
||||
origin: o.Origin,
|
||||
status: o.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FundOutcome reports the result of an intake credit: whose balance, which segment and how many
|
||||
// chips were credited, and whether the callback was a duplicate that credited nothing.
|
||||
type FundOutcome struct {
|
||||
AccountID uuid.UUID
|
||||
Source Source
|
||||
Chips int
|
||||
AlreadyCredited bool
|
||||
}
|
||||
|
||||
// fund credits a paid order exactly once: it matches the order, verifies the amount, then in one
|
||||
// transaction appends a fund ledger row (idempotent on the (provider, provider_payment_id) unique
|
||||
// index), credits the funded segment's balance and marks the order paid. A duplicate callback is
|
||||
// rejected by the unique index and returns AlreadyCredited with no error and no second credit. A
|
||||
// valid callback is honoured even on an expired order (§9/D23). The read cache is invalidated after
|
||||
// the commit, since the credit runs outside any request the payments package owns.
|
||||
func (s *Store) fund(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string, paid Money, now time.Time) (FundOutcome, error) {
|
||||
ord, err := s.orderByID(ctx, orderID)
|
||||
if err != nil {
|
||||
return FundOutcome{}, err
|
||||
}
|
||||
if paid.Currency() != Currency(ord.currency) || paid.Minor() != ord.expectedAmount {
|
||||
return FundOutcome{}, ErrAmountMismatch
|
||||
}
|
||||
chips, title, err := s.packForCredit(ctx, ord.productID)
|
||||
if err != nil {
|
||||
return FundOutcome{}, err
|
||||
}
|
||||
snapshot, err := marshalFundSnapshot(ord.productID, title, chips, paid)
|
||||
if err != nil {
|
||||
return FundOutcome{}, err
|
||||
}
|
||||
|
||||
src := Source(ord.origin)
|
||||
outcome := FundOutcome{AccountID: ord.accountID, Source: src, Chips: chips}
|
||||
pv, pp := provider, providerPaymentID
|
||||
productID := ord.productID
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if e := insertLedgerTx(ctx, tx, ord.accountID, "fund", &src, &src, chips, &productID, &orderID, &pv, &pp, snapshot, now); e != nil {
|
||||
if isUniqueViolation(e) {
|
||||
outcome.AlreadyCredited = true
|
||||
return errAlreadyCredited
|
||||
}
|
||||
return e
|
||||
}
|
||||
if _, e := tx.ExecContext(ctx,
|
||||
`INSERT INTO payments.balances (account_id, source, chips, updated_at)
|
||||
VALUES ($1, $2, $3, now())
|
||||
ON CONFLICT (account_id, source) DO UPDATE
|
||||
SET chips = payments.balances.chips + EXCLUDED.chips, updated_at = now()`,
|
||||
ord.accountID, string(src), chips); e != nil {
|
||||
return fmt.Errorf("payments: credit balance %s: %w", src, e)
|
||||
}
|
||||
if _, e := tx.ExecContext(ctx,
|
||||
`UPDATE payments.orders SET status = 'paid', provider = $2, provider_payment_id = $3, updated_at = now()
|
||||
WHERE order_id = $1`,
|
||||
orderID, provider, providerPaymentID); e != nil {
|
||||
return fmt.Errorf("payments: mark order paid: %w", e)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, errAlreadyCredited) {
|
||||
return outcome, nil
|
||||
}
|
||||
return FundOutcome{}, err
|
||||
}
|
||||
s.cache.invalidate(ord.accountID)
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// orderTTL reads the configured pending-order lifetime in whole seconds.
|
||||
func (s *Store) orderTTL(ctx context.Context) (int, error) {
|
||||
var cfg model.Config
|
||||
if err := postgres.SELECT(table.Config.OrderTTLSeconds).
|
||||
FROM(table.Config).
|
||||
LIMIT(1).
|
||||
QueryContext(ctx, s.db, &cfg); err != nil {
|
||||
return 0, fmt.Errorf("payments: read order ttl: %w", err)
|
||||
}
|
||||
return int(cfg.OrderTTLSeconds), nil
|
||||
}
|
||||
|
||||
// expirePending marks every pending order older than ttlSeconds as expired, returning how many.
|
||||
// Expiry is cosmetic DB hygiene: a later valid callback still credits an expired order (§9/D23).
|
||||
func (s *Store) expirePending(ctx context.Context, ttlSeconds int, now time.Time) (int, error) {
|
||||
cutoff := now.Add(-time.Duration(ttlSeconds) * time.Second)
|
||||
res, err := table.Orders.
|
||||
UPDATE(table.Orders.Status, table.Orders.UpdatedAt).
|
||||
SET(postgres.String("expired"), postgres.TimestampzT(now)).
|
||||
WHERE(table.Orders.Status.EQ(postgres.String("pending")).
|
||||
AND(table.Orders.CreatedAt.LT(postgres.TimestampzT(cutoff)))).
|
||||
ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("payments: expire pending orders: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// marshalFundSnapshot records what a fund credited (the pack, chips and paid amount) on the ledger
|
||||
// row, so history stays independent of later catalog edits (§7/D34).
|
||||
func marshalFundSnapshot(productID uuid.UUID, title string, chips int, paid Money) ([]byte, error) {
|
||||
b, err := json.Marshal(struct {
|
||||
ProductID string `json:"product_id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Chips int `json:"chips"`
|
||||
Amount int64 `json:"amount_minor"`
|
||||
Currency string `json:"currency"`
|
||||
}{productID.String(), title, chips, paid.Minor(), string(paid.Currency())})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payments: marshal fund 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 {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
Reference in New Issue
Block a user