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,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())
|
||||
}
|
||||
Reference in New Issue
Block a user