dbd76d53e8
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
The first ads slice: a voluntary rewarded video credits chips. VK Mini App ads (VKWebAppShowNativeAds) expose only a client-side watch result — no server-to-server verify — so the credit is client-attested, guarded by a server daily + hourly cap (config reward_daily_cap / reward_hourly_cap, default 50 / 10). The caps are both anti-abuse (bounding a forger who skips the ad and calls the endpoint directly) and an economic conversion lever (limiting free chips so a player who wants more buys). D29 amended to VK's reality. Backend: CreditReward (VK-only, order-less, idempotent on a client nonce, floored by the caps; payout from config rewarded_payout_chips, default 0 = off) + the wallet.reward edge op returning the updated wallet (reward_chips gates the "watch for chips" CTA). Additive migration (two config columns). Client: the ads-network abstraction (lib/ads.ts, VK impl) + the VK bridge (vkRewardedReady / vkShowRewarded) + the Wallet CTA + i18n. A contour test stub (VITE_ADS_STUB -> a toast instead of a real ad; prod always real) and a temporary diagnostic that logs the raw VK data, so we confirm on the contour exactly what VK returns (harden to signature-verify if it carries one). Tests: backend integration (credit, nonce idempotency, hourly cap, disabled, non-VK refusal) + codec unit (reward wire). Docs: PAYMENTS(+ru) §10, D29 amend, PLAN E6. Bundle: shared budget 30->31 (reward i18n strings).
195 lines
9.3 KiB
Go
195 lines
9.3 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,
|
|
}
|
|
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"
|
|
|
|
// 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())
|
|
}
|