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, } 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 } // 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 } // 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()) }