eef90a152e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
Split the single Robokassa direct rail into one merchant shop per channel (web / android; ios later), chosen by the trusted X-Platform subtype, while every shop still credits the one `direct` wallet — merchant-account separation for accounting and receipts, not a new wallet. - config: a channel-keyed shops registry (web seeds from the legacy vars or _WEB_*, android from _ANDROID_*); an empty shop leaves the rail dormant; per-shop validation. - intake: the order picks its shop by subtype (unknown falls back to web); the per-shop Result callback is verified by that shop's own Password2 at /pay/robokassa/result/<channel> (the gateway extracts the channel; Caddy's /pay/* glob already forwards it — no Caddyfile change). - persistence: an additive `shop` column on the order (migration 00015), recorded from the payment context, surfaced per entry in the admin report. - standalone apps sign in by email only, so a direct purchase keeps its email anchor. - docs: PAYMENTS (+ru) topology, deploy env vars + compose mapping, the decisions log (D41 revised for the ИП / 54-ФЗ move; D42-D44), the plan. Contour-safe: dormant until shops are configured; the migration is additive (no wipe); no client wire change. Fiscalization (Receipt/Email) and the gateway `direct/android` subtype follow when the ИП / RuStore are live.
228 lines
11 KiB
Go
228 lines
11 KiB
Go
package payments
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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,
|
|
shop: directShop(cxt),
|
|
}
|
|
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
|
|
}
|
|
|
|
// directShop returns the merchant shop (channel) a direct order is issued through — the trusted
|
|
// platform subtype for the direct rail ("web"/"android"), or "" for any other rail (the per-shop
|
|
// split is direct-only; D42/D44). Recorded on the order for the admin per-channel breakdown.
|
|
func directShop(cxt Context) string {
|
|
if cxt.Kind == SourceDirect {
|
|
return cxt.Subtype
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// OrderItem returns a pending order's human title and the amount it charges, in the order's own
|
|
// currency — the details a provider's item-lookup phase needs (VK's get_item). It reads the order
|
|
// and the pack title, honouring the pack even if it was later deactivated (mirrors Fund).
|
|
func (s *Service) OrderItem(ctx context.Context, orderID uuid.UUID) (title string, amount Money, err error) {
|
|
ord, err := s.store.orderByID(ctx, orderID)
|
|
if err != nil {
|
|
return "", Money{}, err
|
|
}
|
|
_, title, err = s.store.packForCredit(ctx, ord.productID)
|
|
if err != nil {
|
|
return "", Money{}, err
|
|
}
|
|
amount, err = MoneyFromMinor(ord.expectedAmount, Currency(ord.currency))
|
|
if err != nil {
|
|
return "", Money{}, err
|
|
}
|
|
return title, amount, 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())
|
|
}
|
|
|
|
// Refund reverses a paid order's credit best-effort, exactly once — for an external refund or an
|
|
// admin-initiated one (E7). It revokes the funded chips floored at 0 (never negative, D27), records
|
|
// any unrecoverable remainder as a per-account loss and abuse flag, and appends a refund ledger row
|
|
// idempotent on (provider, providerRefundID) — distinct from the fund's payment id. A duplicate
|
|
// refund returns AlreadyRefunded. The caller records the refunded payment event and performs any
|
|
// provider-side money-back (the rails have no unsolicited refund push: Robokassa via its refund API
|
|
// / cabinet, VK via support, Telegram via refundStarPayment — all admin-triggered).
|
|
func (s *Service) Refund(ctx context.Context, orderID uuid.UUID, provider, providerRefundID string, refunded Money) (RefundOutcome, error) {
|
|
return s.store.refund(ctx, orderID, provider, providerRefundID, refunded, s.clock())
|
|
}
|
|
|
|
// Pre-checkout decline reason codes. They are language-neutral: the transport layer localises them
|
|
// to the order account's preferred language before showing the payer (the reason is displayed in the
|
|
// Telegram payment sheet).
|
|
const (
|
|
// PreCheckoutGone means no order matches — an unknown or stale invoice payload.
|
|
PreCheckoutGone = "order_gone"
|
|
// PreCheckoutAlreadyPaid means the order is already paid (a reusable invoice link paid twice).
|
|
PreCheckoutAlreadyPaid = "already_paid"
|
|
// PreCheckoutPriceChanged means the amount or currency no longer matches the order.
|
|
PreCheckoutPriceChanged = "price_changed"
|
|
)
|
|
|
|
// PreCheckoutOutcome is the pre-charge validation of a Telegram Stars order. OK approves the charge;
|
|
// otherwise Reason is a decline reason code the transport localises. AccountID is the order's account
|
|
// (for localising the reason to its preferred language); it is the zero UUID when the order is unknown.
|
|
type PreCheckoutOutcome struct {
|
|
OK bool
|
|
Reason string
|
|
AccountID uuid.UUID
|
|
}
|
|
|
|
// ValidatePreCheckout answers whether a Stars pre_checkout_query for orderID paying amount may be
|
|
// approved, before any star is charged. It approves an order that exists, is not already paid (a
|
|
// reusable invoice link paid a second time is refused here) and whose expected amount and currency
|
|
// match the invoice. A pending or honoured-expired order is approved — a late credit is honoured
|
|
// (§9/D23). A missing order or a mismatch is a clean decline with a reason code, not an error.
|
|
func (s *Service) ValidatePreCheckout(ctx context.Context, orderID uuid.UUID, amount Money) (PreCheckoutOutcome, error) {
|
|
ord, err := s.store.orderByID(ctx, orderID)
|
|
if errors.Is(err, ErrOrderNotFound) {
|
|
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutGone}, nil
|
|
}
|
|
if err != nil {
|
|
return PreCheckoutOutcome{}, err
|
|
}
|
|
if ord.status == "paid" {
|
|
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutAlreadyPaid, AccountID: ord.accountID}, nil
|
|
}
|
|
if amount.Currency() != Currency(ord.currency) || amount.Minor() != ord.expectedAmount {
|
|
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutPriceChanged, AccountID: ord.accountID}, nil
|
|
}
|
|
return PreCheckoutOutcome{OK: true, AccountID: ord.accountID}, nil
|
|
}
|
|
|
|
// providerVKAds tags a rewarded-video credit from the VK ads network in the ledger (distinct from
|
|
// the "vk" Votes-purchase provider), so the daily cap counts only ad credits and the report separates
|
|
// them.
|
|
const providerVKAds = "vk_ads"
|
|
|
|
// InterstitialCooldowns reports the post-move interstitial-ad cooldowns (seconds): global, vs_ai and
|
|
// the independent hint-triggered one. The client mirrors them and self-gates (client-mirrored, D30).
|
|
func (s *Service) InterstitialCooldowns(ctx context.Context) (global, vsAi, hint int, err error) {
|
|
return s.store.interstitialCooldowns(ctx)
|
|
}
|
|
|
|
// RewardPayout reports the chips a rewarded-video view earns in the caller's context — the config
|
|
// payout in a trusted VK context with the VK segment attached, and 0 everywhere else (rewarded is
|
|
// VK-only, D28). The client uses it to gate the "watch for chips" button.
|
|
func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Source) (int, error) {
|
|
if cxt.Kind != SourceVK || !cxt.Trusted() || !has(present, SourceVK) {
|
|
return 0, nil
|
|
}
|
|
payout, _, _, err := s.store.rewardConfig(ctx)
|
|
return payout, err
|
|
}
|
|
|
|
// RewardConfig reads the rewarded-video config for the admin editor: the payout (chips per view) and
|
|
// the per-day and per-hour caps.
|
|
func (s *Service) RewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap int, err error) {
|
|
return s.store.rewardConfig(ctx)
|
|
}
|
|
|
|
// SetRewardConfig updates the rewarded-video config (the payout and the two caps). All three must be
|
|
// non-negative; a 0 payout leaves rewarded inert. It is not a catalog product, so it does not mark the
|
|
// offer stale.
|
|
func (s *Service) SetRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
|
|
if payout < 0 || dailyCap < 0 || hourlyCap < 0 {
|
|
return errors.New("payments: reward payout and caps must be non-negative")
|
|
}
|
|
return s.store.setRewardConfig(ctx, payout, dailyCap, hourlyCap)
|
|
}
|
|
|
|
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
|
|
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
|
|
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
|
|
// client nonce and order-less. It credits nothing when rewarded is unconfigured (0 payout) or the
|
|
// daily cap is reached. Rewarded video is VK-only (D28) and is an ad view — not a purchase — so the
|
|
// VK-iOS purchase freeze does not apply; it requires a trusted VK context with the VK segment
|
|
// attached.
|
|
func (s *Service) CreditReward(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source, nonce string) (RewardOutcome, error) {
|
|
if cxt.Kind != SourceVK || !cxt.Trusted() || !has(present, SourceVK) {
|
|
return RewardOutcome{}, ErrUntrusted
|
|
}
|
|
return s.store.creditReward(ctx, accountID, SourceVK, providerVKAds, nonce, 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())
|
|
}
|
|
|
|
// UndispatchedEvents returns up to limit payment events awaiting delivery. The dispatcher drains
|
|
// them and marks each delivered via MarkEventDispatched.
|
|
func (s *Service) UndispatchedEvents(ctx context.Context, limit int) ([]PaymentEvent, error) {
|
|
return s.store.undispatchedEvents(ctx, limit)
|
|
}
|
|
|
|
// MarkEventDispatched stamps a payment event as delivered so it is not re-sent.
|
|
func (s *Service) MarkEventDispatched(ctx context.Context, eventID uuid.UUID) error {
|
|
return s.store.markEventDispatched(ctx, eventID, s.clock())
|
|
}
|