feat(payments): Telegram Stars payment rail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
Accept real money via Telegram Stars (XTR) — the third intake rail alongside Robokassa (direct) and VK Votes. Only the bot reaches Telegram, so the rail funnels through the reverse mTLS bot-link: - the gateway mints the invoice on a CreateInvoice command (the bot calls createInvoiceLink, XTR; the link goes to WebApp.openInvoice); - the bot gates each pre_checkout_query via a ValidatePreCheckout unary (the order must exist, be still creditable and not already paid — the reusable-invoice double-pay guard; the decline reason is localised to the order account's language); - a completed successful_payment is queued in a durable pure-Go SQLite outbox and forwarded via a ForwardPayment unary, credited once (idempotent on telegram_payment_charge_id, honours an expired order), re-driven on restart and every 30s. The rail is wired by TELEGRAM_STARS_OUTBOX_DIR (default /data) but stays inert until a chip pack carries an XTR price, so seeding a Stars price in the admin is the go-live. Tests: backend integration (order->forward->credit once, duplicate, pre_checkout gate) + bot outbox unit (idempotent, restart re-drive) + executor createInvoice. Docs: PAYMENTS(+ru) §9, ARCHITECTURE, the platform/telegram README, PLAN.
This commit is contained in:
@@ -129,6 +129,101 @@ func TestPaymentsVKOrderFundCredits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsTelegramStarsRail exercises the Telegram Stars rail over Postgres: an order prices the
|
||||
// pack in whole stars (XTR); a pre_checkout on the pending order is approved; the forwarded payment
|
||||
// credits the telegram segment exactly once (idempotent on the Telegram charge id); and a
|
||||
// pre_checkout after the order is paid is declined (a reusable invoice link paid twice).
|
||||
func TestPaymentsTelegramStarsRail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 50, methodPrice{method: "telegram", currency: "XTR", amount: 40}) // 40 stars fund 50 chips
|
||||
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("telegram", "android"), []payments.Source{payments.SourceTelegram}, prod, "telegram")
|
||||
if err != nil {
|
||||
t.Fatalf("create telegram order: %v", err)
|
||||
}
|
||||
if res.Amount.Currency() != payments.CurrencyStar || res.Amount.Minor() != 40 {
|
||||
t.Fatalf("telegram order amount = %s, want 40 XTR", res.Amount)
|
||||
}
|
||||
|
||||
// pre_checkout on the pending order: approved, and it reports the account for reason localisation.
|
||||
starAmt, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
|
||||
pc, err := svc.ValidatePreCheckout(ctx, res.OrderID, starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("pre_checkout: %v", err)
|
||||
}
|
||||
if !pc.OK || pc.AccountID != acc {
|
||||
t.Fatalf("pre_checkout = %+v, want approved for account %s", pc, acc)
|
||||
}
|
||||
|
||||
// The forwarded successful_payment credits once, idempotent on the Telegram charge id.
|
||||
out, err := svc.Fund(ctx, res.OrderID, "telegram", "tg-charge-1", starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("telegram fund: %v", err)
|
||||
}
|
||||
if out.AlreadyCredited || out.Chips != 50 || out.Source != payments.SourceTelegram {
|
||||
t.Fatalf("telegram fund outcome = %+v, want 50 chips credited to telegram", out)
|
||||
}
|
||||
if got := readBalance(t, acc, "telegram"); got != 50 {
|
||||
t.Errorf("telegram balance after fund = %d, want 50", got)
|
||||
}
|
||||
|
||||
// A retried forward (same charge id, e.g. a lost ack) credits nothing more.
|
||||
out2, err := svc.Fund(ctx, res.OrderID, "telegram", "tg-charge-1", starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate telegram fund: %v", err)
|
||||
}
|
||||
if !out2.AlreadyCredited {
|
||||
t.Error("retried forward not flagged AlreadyCredited")
|
||||
}
|
||||
if got := readBalance(t, acc, "telegram"); got != 50 {
|
||||
t.Errorf("telegram balance after retry = %d, want 50 (credited once)", got)
|
||||
}
|
||||
|
||||
// pre_checkout after the order is paid: declined (the reusable-link double-pay guard).
|
||||
pc2, err := svc.ValidatePreCheckout(ctx, res.OrderID, starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("pre_checkout after paid: %v", err)
|
||||
}
|
||||
if pc2.OK || pc2.Reason != payments.PreCheckoutAlreadyPaid {
|
||||
t.Errorf("pre_checkout after paid = %+v, want a decline with already_paid", pc2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsTelegramPreCheckoutDeclines covers the pre_checkout decline reasons: an unknown order
|
||||
// and an amount that no longer matches.
|
||||
func TestPaymentsTelegramPreCheckoutDeclines(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
starAmt, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
|
||||
|
||||
// An unknown order id declines as gone, with no account to localise against.
|
||||
pc, err := svc.ValidatePreCheckout(ctx, uuid.New(), starAmt)
|
||||
if err != nil {
|
||||
t.Fatalf("pre_checkout unknown: %v", err)
|
||||
}
|
||||
if pc.OK || pc.Reason != payments.PreCheckoutGone || pc.AccountID != (uuid.UUID{}) {
|
||||
t.Errorf("pre_checkout unknown = %+v, want a gone decline with no account", pc)
|
||||
}
|
||||
|
||||
// A pending order validated at the wrong amount declines as price-changed.
|
||||
acc := uuid.New()
|
||||
prod := seedPackProduct(t, 100, methodPrice{method: "telegram", currency: "XTR", amount: 80})
|
||||
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("telegram", "android"), []payments.Source{payments.SourceTelegram}, prod, "telegram")
|
||||
if err != nil {
|
||||
t.Fatalf("create telegram order: %v", err)
|
||||
}
|
||||
wrong, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
|
||||
pc2, err := svc.ValidatePreCheckout(ctx, res.OrderID, wrong)
|
||||
if err != nil {
|
||||
t.Fatalf("pre_checkout mismatch: %v", err)
|
||||
}
|
||||
if pc2.OK || pc2.Reason != payments.PreCheckoutPriceChanged {
|
||||
t.Errorf("pre_checkout mismatch = %+v, want a price_changed decline", pc2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsFundAmountMismatch verifies a callback whose paid amount does not match the order is
|
||||
// refused and credits nothing (§9: verify the amount after matching by order id).
|
||||
func TestPaymentsFundAmountMismatch(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user