Release v1.14.0 — monetization launch (E5-E8) #237
@@ -147,12 +147,13 @@ func (m Money) Cmp(o Money) (int, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// String renders the amount as "<value> <currency>", with the currency's
|
// Major renders the amount as a decimal string without the currency, with the currency's
|
||||||
// fractional digits and no floating point (e.g. "149.50 RUB", "250 XTR").
|
// fractional digits and no floating point (e.g. "149.50", "250") — the form a provider's amount
|
||||||
func (m Money) String() string {
|
// field (Robokassa OutSum) takes.
|
||||||
|
func (m Money) Major() string {
|
||||||
scale := m.currency.minorPerUnit()
|
scale := m.currency.minorPerUnit()
|
||||||
if scale == 1 {
|
if scale == 1 {
|
||||||
return fmt.Sprintf("%d %s", m.minor, m.currency)
|
return fmt.Sprintf("%d", m.minor)
|
||||||
}
|
}
|
||||||
neg := m.minor < 0
|
neg := m.minor < 0
|
||||||
abs := m.minor
|
abs := m.minor
|
||||||
@@ -167,5 +168,10 @@ func (m Money) String() string {
|
|||||||
if neg {
|
if neg {
|
||||||
sign = "-"
|
sign = "-"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s%d.%0*d %s", sign, abs/scale, width, abs%scale, m.currency)
|
return fmt.Sprintf("%s%d.%0*d", sign, abs/scale, width, abs%scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String renders the amount as "<value> <currency>" (e.g. "149.50 RUB", "250 XTR").
|
||||||
|
func (m Money) String() string {
|
||||||
|
return m.Major() + " " + string(m.currency)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrderResult is what CreateOrder returns to the transport: the created order id and the details a
|
||||||
|
// provider launch payload needs — the amount to charge and a human title for the payment.
|
||||||
|
type OrderResult struct {
|
||||||
|
OrderID uuid.UUID
|
||||||
|
Amount Money
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrder opens a pending order to fund a chip pack in the execution context's payment method,
|
||||||
|
// tagged with the provider that will settle it. It gate-checks the context (trusted, not the
|
||||||
|
// VK-iOS spend freeze) and that the method's funding segment is attached, prices the pack in the
|
||||||
|
// method's currency, then writes the order. The caller enforces any account-level precondition
|
||||||
|
// (e.g. the direct email anchor, D36) before calling — payments holds no cross-schema identity
|
||||||
|
// knowledge.
|
||||||
|
func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source, productID uuid.UUID, provider string) (OrderResult, error) {
|
||||||
|
if !cxt.Trusted() || cxt.vkFrozen() {
|
||||||
|
return OrderResult{}, ErrUntrusted
|
||||||
|
}
|
||||||
|
method := cxt.Kind
|
||||||
|
if !has(present, method) {
|
||||||
|
return OrderResult{}, ErrUntrusted // the funding segment is not attached to the account
|
||||||
|
}
|
||||||
|
pack, err := s.store.loadPackForOrder(ctx, productID, method)
|
||||||
|
if err != nil {
|
||||||
|
return OrderResult{}, err
|
||||||
|
}
|
||||||
|
orderID, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return OrderResult{}, fmt.Errorf("payments: order id: %w", err)
|
||||||
|
}
|
||||||
|
o := newOrder{
|
||||||
|
orderID: orderID,
|
||||||
|
accountID: accountID,
|
||||||
|
platform: string(method),
|
||||||
|
productID: productID,
|
||||||
|
amount: pack.price,
|
||||||
|
origin: method,
|
||||||
|
provider: provider,
|
||||||
|
}
|
||||||
|
if err := s.store.createOrder(ctx, o, s.clock()); err != nil {
|
||||||
|
return OrderResult{}, err
|
||||||
|
}
|
||||||
|
return OrderResult{OrderID: orderID, Amount: pack.price, Title: pack.title}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fund credits a paid order into its funded segment exactly once, from a verified provider callback
|
||||||
|
// — the single writer for every rail. It matches the order, verifies the paid amount, appends the
|
||||||
|
// fund ledger row (idempotent on (provider, provider_payment_id)), credits the balance and marks
|
||||||
|
// the order paid. A duplicate callback returns AlreadyCredited without a second credit; a valid
|
||||||
|
// callback is honoured even on an expired order (§9/D23).
|
||||||
|
func (s *Service) Fund(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string, paid Money) (FundOutcome, error) {
|
||||||
|
return s.store.fund(ctx, orderID, provider, providerPaymentID, paid, s.clock())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpireOrders marks pending orders older than the configured lifetime as expired, returning how
|
||||||
|
// many were swept. It backs the periodic pending reaper; expiry is cosmetic (a late valid callback
|
||||||
|
// still credits — see Fund).
|
||||||
|
func (s *Service) ExpireOrders(ctx context.Context) (int, error) {
|
||||||
|
ttl, err := s.store.orderTTL(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return s.store.expirePending(ctx, ttl, s.clock())
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gateOnlyService builds a Service with a fixed clock and no store, usable only for the CreateOrder
|
||||||
|
// gate rejections that return before any store access.
|
||||||
|
func gateOnlyService() *Service {
|
||||||
|
return &Service{clock: func() time.Time { return time.Unix(0, 0).UTC() }}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateOrderGateRejections checks that CreateOrder fails closed — before touching the store —
|
||||||
|
// on an untrusted platform, the VK-iOS spend freeze, and a method whose funding segment the account
|
||||||
|
// does not hold.
|
||||||
|
func TestCreateOrderGateRejections(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, prod := uuid.New(), uuid.New()
|
||||||
|
present := []Source{SourceDirect, SourceVK}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cxt Context
|
||||||
|
}{
|
||||||
|
{"untrusted context", Context{}},
|
||||||
|
{"vk-ios spend freeze", Context{Kind: SourceVK, Subtype: SubtypeIOS}},
|
||||||
|
{"method segment not attached", Context{Kind: SourceTelegram}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := gateOnlyService().CreateOrder(ctx, acc, tc.cxt, present, prod, "robokassa")
|
||||||
|
if !errors.Is(err, ErrUntrusted) {
|
||||||
|
t.Fatalf("CreateOrder(%s) = %v, want ErrUntrusted", tc.name, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -230,8 +230,11 @@ func applyBenefitTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, origin
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// insertLedgerTx appends one append-only ledger row inside tx.
|
// insertLedgerTx appends one append-only ledger row inside tx. orderID, provider and
|
||||||
func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind string, source, origin *Source, chipsDelta int, productID *uuid.UUID, snapshot []byte, now time.Time) error {
|
// 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()
|
id, err := uuid.NewV7()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("payments: ledger id: %w", err)
|
return fmt.Errorf("payments: ledger id: %w", err)
|
||||||
@@ -246,11 +249,13 @@ func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind s
|
|||||||
stmt := table.Ledger.INSERT(
|
stmt := table.Ledger.INSERT(
|
||||||
table.Ledger.LedgerID, table.Ledger.AccountID, table.Ledger.Kind,
|
table.Ledger.LedgerID, table.Ledger.AccountID, table.Ledger.Kind,
|
||||||
table.Ledger.Source, table.Ledger.Origin, table.Ledger.ChipsDelta,
|
table.Ledger.Source, table.Ledger.Origin, table.Ledger.ChipsDelta,
|
||||||
table.Ledger.ProductID, table.Ledger.Snapshot, table.Ledger.CreatedAt,
|
table.Ledger.ProductID, table.Ledger.OrderID, table.Ledger.Provider,
|
||||||
|
table.Ledger.ProviderPaymentID, table.Ledger.Snapshot, table.Ledger.CreatedAt,
|
||||||
).VALUES(
|
).VALUES(
|
||||||
id, accountID, kind,
|
id, accountID, kind,
|
||||||
sourceOrNull(source), sourceOrNull(origin), int32(chipsDelta),
|
sourceOrNull(source), sourceOrNull(origin), int32(chipsDelta),
|
||||||
uuidOrNull(productID), snap, now,
|
uuidOrNull(productID), uuidOrNull(orderID), stringOrNull(provider),
|
||||||
|
stringOrNull(providerPaymentID), snap, now,
|
||||||
)
|
)
|
||||||
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
||||||
return fmt.Errorf("payments: insert %s ledger: %w", kind, err)
|
return fmt.Errorf("payments: insert %s ledger: %w", kind, err)
|
||||||
@@ -278,7 +283,7 @@ func (s *Store) spend(ctx context.Context, accountID uuid.UUID, draws []sourceAm
|
|||||||
return ErrInsufficientChips
|
return ErrInsufficientChips
|
||||||
}
|
}
|
||||||
src := dr.source
|
src := dr.source
|
||||||
if err := insertLedgerTx(ctx, tx, accountID, "spend", &src, &origin, -dr.amount, &productID, snapshot, now); err != nil {
|
if err := insertLedgerTx(ctx, tx, accountID, "spend", &src, &origin, -dr.amount, &productID, nil, nil, nil, snapshot, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,7 +300,7 @@ func (s *Store) spend(ctx context.Context, accountID uuid.UUID, draws []sourceAm
|
|||||||
// the chosen origin, in one transaction — a zero-price sale of a value.
|
// 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 {
|
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 {
|
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, nil, snapshot, now); err != nil {
|
if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, nil, nil, nil, nil, snapshot, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return applyBenefitTx(ctx, tx, accountID, origin, d, now)
|
return applyBenefitTx(ctx, tx, accountID, origin, d, now)
|
||||||
@@ -419,3 +424,11 @@ func uuidOrNull(id *uuid.UUID) postgres.Expression {
|
|||||||
}
|
}
|
||||||
return postgres.UUID(*id)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Package robokassa builds and verifies Robokassa direct-rail (RUB) payments: it forms the signed
|
||||||
|
// hosted-payment URL a client is sent to, and verifies the Result-URL server callback that credits
|
||||||
|
// an order. It is pure provider glue — no database, no payments-domain coupling — so the payments
|
||||||
|
// domain stays provider-agnostic and this layer is unit-testable in isolation.
|
||||||
|
//
|
||||||
|
// The order is threaded through Robokassa's custom-parameter channel as Shp_order=<order id> (echoed
|
||||||
|
// back in the callback and bound into the signature), not the numeric InvId, because an order id is
|
||||||
|
// a uuid; InvId is sent as 0. Idempotency is therefore keyed on the order id at the credit site.
|
||||||
|
// Signatures use SHA-256 (configured to match the shop's technical settings); the shop's test mode
|
||||||
|
// is carried by IsTest.
|
||||||
|
package robokassa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// payEndpoint is Robokassa's hosted payment page; the signed query sends the client there.
|
||||||
|
const payEndpoint = "https://auth.robokassa.ru/Merchant/Index.aspx"
|
||||||
|
|
||||||
|
// Config is a Robokassa shop's credentials. Password1 signs the outgoing payment request;
|
||||||
|
// Password2 signs (and so verifies) the incoming Result callback. IsTest adds IsTest=1 so the shop's
|
||||||
|
// test mode simulates payments without money movement.
|
||||||
|
type Config struct {
|
||||||
|
MerchantLogin string
|
||||||
|
Password1 string
|
||||||
|
Password2 string
|
||||||
|
IsTest bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentURL builds the signed hosted-payment URL for an order: amount is the OutSum decimal string
|
||||||
|
// (roubles, e.g. "149.00"), description is the human payment purpose. The order id rides as
|
||||||
|
// Shp_order and is bound into the SHA-256 signature; InvId is 0 (unused).
|
||||||
|
func (c Config) PaymentURL(orderID uuid.UUID, amount, description string) string {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("Shp_order", orderID.String())
|
||||||
|
// Signature base: MerchantLogin:OutSum:InvId:Password1[:Shp_key=value...sorted].
|
||||||
|
base := c.MerchantLogin + ":" + amount + ":0:" + c.Password1 + shpSuffix(q)
|
||||||
|
|
||||||
|
q.Set("MerchantLogin", c.MerchantLogin)
|
||||||
|
q.Set("OutSum", amount)
|
||||||
|
q.Set("InvId", "0")
|
||||||
|
q.Set("Description", description)
|
||||||
|
q.Set("SignatureValue", sign(base))
|
||||||
|
if c.IsTest {
|
||||||
|
q.Set("IsTest", "1")
|
||||||
|
}
|
||||||
|
return payEndpoint + "?" + q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyResult verifies a Result-URL callback and extracts the order it credits. It recomputes the
|
||||||
|
// SHA-256 signature OutSum:InvId:Password2[:Shp_...sorted] over the callback's own fields and
|
||||||
|
// compares it (case-insensitively) with SignatureValue. On success it returns the order id (from
|
||||||
|
// Shp_order) and the raw OutSum string the caller re-checks against the order amount; on any
|
||||||
|
// missing field, a signature mismatch or an unparseable order id it returns ok=false.
|
||||||
|
func (c Config) VerifyResult(v url.Values) (orderID uuid.UUID, outSum string, ok bool) {
|
||||||
|
outSum = v.Get("OutSum")
|
||||||
|
sig := v.Get("SignatureValue")
|
||||||
|
order := v.Get("Shp_order")
|
||||||
|
if outSum == "" || sig == "" || order == "" {
|
||||||
|
return uuid.Nil, "", false
|
||||||
|
}
|
||||||
|
// Robokassa signs with the InvId it returns in the callback; recompute with that same value.
|
||||||
|
base := outSum + ":" + v.Get("InvId") + ":" + c.Password2 + shpSuffix(v)
|
||||||
|
if !strings.EqualFold(sign(base), sig) {
|
||||||
|
return uuid.Nil, "", false
|
||||||
|
}
|
||||||
|
id, err := uuid.Parse(order)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, "", false
|
||||||
|
}
|
||||||
|
return id, outSum, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// shpSuffix renders the Shp_ custom parameters of v as Robokassa binds them into a signature:
|
||||||
|
// every Shp_-prefixed key, sorted alphabetically, appended as ":key=value".
|
||||||
|
func shpSuffix(v url.Values) string {
|
||||||
|
var keys []string
|
||||||
|
for k := range v {
|
||||||
|
if strings.HasPrefix(k, "Shp_") {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
var b strings.Builder
|
||||||
|
for _, k := range keys {
|
||||||
|
b.WriteString(":")
|
||||||
|
b.WriteString(k)
|
||||||
|
b.WriteString("=")
|
||||||
|
b.WriteString(v.Get(k))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign returns the lowercase hex SHA-256 of s, the hash Robokassa compares against SignatureValue.
|
||||||
|
func sign(s string) string {
|
||||||
|
sum := sha256.Sum256([]byte(s))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package robokassa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testCfg() Config {
|
||||||
|
return Config{MerchantLogin: "shop", Password1: "p1", Password2: "p2", IsTest: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resultSig computes the Pass2 Result signature Robokassa would send for a callback carrying only
|
||||||
|
// Shp_order — the fixture the verifier must accept.
|
||||||
|
func resultSig(pass2, outSum, invID string, orderID uuid.UUID) string {
|
||||||
|
return sign(outSum + ":" + invID + ":" + pass2 + ":Shp_order=" + orderID.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaymentURL(t *testing.T) {
|
||||||
|
cfg := testCfg()
|
||||||
|
id := uuid.New()
|
||||||
|
u, err := url.Parse(cfg.PaymentURL(id, "149.00", "10 chips"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse payment url: %v", err)
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
if got := q.Get("OutSum"); got != "149.00" {
|
||||||
|
t.Errorf("OutSum = %q, want 149.00", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("InvId"); got != "0" {
|
||||||
|
t.Errorf("InvId = %q, want 0 (unused)", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("Shp_order"); got != id.String() {
|
||||||
|
t.Errorf("Shp_order = %q, want the order id", got)
|
||||||
|
}
|
||||||
|
if got := q.Get("IsTest"); got != "1" {
|
||||||
|
t.Errorf("IsTest = %q, want 1 (test shop)", got)
|
||||||
|
}
|
||||||
|
want := sign("shop:149.00:0:p1:Shp_order=" + id.String())
|
||||||
|
if got := q.Get("SignatureValue"); got != want {
|
||||||
|
t.Errorf("SignatureValue = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyResult(t *testing.T) {
|
||||||
|
cfg := testCfg()
|
||||||
|
id := uuid.New()
|
||||||
|
|
||||||
|
valid := func() url.Values {
|
||||||
|
v := url.Values{}
|
||||||
|
v.Set("OutSum", "149.00")
|
||||||
|
v.Set("InvId", "0")
|
||||||
|
v.Set("Shp_order", id.String())
|
||||||
|
// Robokassa sends the hash uppercase; the verifier must be case-insensitive.
|
||||||
|
v.Set("SignatureValue", strings.ToUpper(resultSig("p2", "149.00", "0", id)))
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
gotID, gotSum, ok := cfg.VerifyResult(valid())
|
||||||
|
if !ok || gotID != id || gotSum != "149.00" {
|
||||||
|
t.Fatalf("VerifyResult(valid) = %v/%q/%v, want %v/149.00/true", gotID, gotSum, ok, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tampered amount breaks the signature.
|
||||||
|
tampered := valid()
|
||||||
|
tampered.Set("OutSum", "1.00")
|
||||||
|
if _, _, ok := cfg.VerifyResult(tampered); ok {
|
||||||
|
t.Error("VerifyResult accepted a tampered amount")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wrong Password2 (a forged callback) does not verify.
|
||||||
|
wrongPass := Config{MerchantLogin: "shop", Password1: "p1", Password2: "other"}
|
||||||
|
if _, _, ok := wrongPass.VerifyResult(valid()); ok {
|
||||||
|
t.Error("VerifyResult accepted a signature under the wrong password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing Shp_order is rejected (no order to credit).
|
||||||
|
noOrder := valid()
|
||||||
|
noOrder.Del("Shp_order")
|
||||||
|
if _, _, ok := cfg.VerifyResult(noOrder); ok {
|
||||||
|
t.Error("VerifyResult accepted a callback with no order")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user