36a5730e52
Wire the Robokassa direct rail into the backend transport. POST /api/v1/user/wallet/order (walletGate + a D36 confirmed-email gate for the direct rail) opens a pending order and returns the signed Robokassa payment URL. The internal, gateway-only /payments/robokassa/result endpoint verifies the Result signature, credits the matched order exactly once via Fund (honoured even if expired), records a succeeded payment event, and answers Robokassa's "OK<InvId>". Add the Robokassa env config, an account HasConfirmedEmail check (D36), the payment_events writer, and a periodic pending-order reaper. The routes register only when a Robokassa merchant login is configured.
80 lines
3.2 KiB
Go
80 lines
3.2 KiB
Go
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())
|
|
}
|
|
|
|
// RecordPaymentEvent appends a payment lifecycle event (succeeded/failed/refunded) for the
|
|
// dispatcher to deliver to the user (live stream, botlink or email).
|
|
func (s *Service) RecordPaymentEvent(ctx context.Context, accountID uuid.UUID, orderID *uuid.UUID, eventType string, payload []byte) error {
|
|
return s.store.insertPaymentEvent(ctx, accountID, orderID, eventType, payload, s.clock())
|
|
}
|