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:
@@ -91,6 +91,16 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
|
||||
the seeded Mini App rather than the bot profile.
|
||||
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
|
||||
default 25) to respect the Bot API flood limits.
|
||||
- **Payments (Telegram Stars).** When `TELEGRAM_STARS_OUTBOX_DIR` is set (default `/data`), the
|
||||
bot handles the Stars rail. Only the bot reaches Telegram, so it mints the invoice on a
|
||||
`CreateInvoice` bot-link command (`createInvoiceLink`, XTR — the link goes back to the Mini App
|
||||
for `WebApp.openInvoice`); it gates each `pre_checkout_query` through the bot-link
|
||||
(`ValidatePreCheckout`, backed by the backend intake — declining an already-paid reusable
|
||||
invoice before the charge); and it records each `successful_payment` in a durable **SQLite
|
||||
outbox** (`internal/outbox`, `stars.db` on the writable volume) before forwarding it over the
|
||||
bot-link (`ForwardPayment`). The outbox is re-driven on startup and every 30 s, so a gateway or
|
||||
backend outage never loses a paid order; crediting is idempotent on `telegram_payment_charge_id`.
|
||||
The rail stays inert until a chip pack carries an XTR price (seeded in the admin).
|
||||
|
||||
The send commands address a recipient by the identity `external_id` (as in the backend
|
||||
`identities` table), so a future VK / MAX bot reuses them; only the validator's initData
|
||||
@@ -104,9 +114,11 @@ parsing is Telegram-specific.
|
||||
gateway also implements `SendToUser` / `SendToGameChannel` as the backend's admin
|
||||
relay.
|
||||
- `pkg/proto/botlink/v1`, service `BotLink` — the reverse bidi stream the **bot** dials
|
||||
on the gateway (`Hello` / `Command` / `Ack`), now also carrying a `ChatGateCommand` (set
|
||||
a user's chat write access) and a unary `ResolveChatEligibility` (the bot's join-time
|
||||
query) over the same mTLS channel. Generated Go is committed under `pkg`.
|
||||
on the gateway (`Hello` / `Command` / `Ack`), carrying a `ChatGateCommand` (set a user's
|
||||
chat write access) and a `CreateInvoiceCommand` (mint a Stars invoice link, returned in the
|
||||
Ack result), plus unary `ResolveChatEligibility` (the bot's join-time query),
|
||||
`ValidatePreCheckout` and `ForwardPayment` (the Stars rail) over the same mTLS channel.
|
||||
Generated Go is committed under `pkg`.
|
||||
|
||||
## Deep-link scheme
|
||||
|
||||
@@ -153,6 +165,7 @@ Bot (`cmd/bot`):
|
||||
| `TELEGRAM_CHAT_ID` | — | the moderated discussion chat id (a channel's linked group); empty disables chat gating |
|
||||
| `TELEGRAM_SUPPORT_CHAT_ID` | — | the support relay's forum supergroup id (topic per user); empty disables the relay |
|
||||
| `TELEGRAM_SUPPORT_STATE_DIR` | `/data` | directory for the support relay's JSON state (a writable volume) |
|
||||
| `TELEGRAM_STARS_OUTBOX_DIR` | `/data` | directory for the Telegram Stars payment outbox (`stars.db`, a writable volume); empty disables the Stars rail |
|
||||
| `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it |
|
||||
| `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs |
|
||||
| `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs |
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"scrabble/platform/telegram/internal/bot"
|
||||
"scrabble/platform/telegram/internal/botlink"
|
||||
"scrabble/platform/telegram/internal/config"
|
||||
"scrabble/platform/telegram/internal/outbox"
|
||||
"scrabble/platform/telegram/internal/promobot"
|
||||
"scrabble/platform/telegram/internal/support"
|
||||
)
|
||||
@@ -90,11 +91,24 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
|
||||
GameChannelID: cfg.GameChannelID,
|
||||
SupportChatID: cfg.SupportChatID,
|
||||
SupportStore: supportStore,
|
||||
AcceptPayments: cfg.StarsOutboxDir != "",
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The Telegram Stars payment outbox: a durable SQLite store on the bot host's writable volume.
|
||||
// Opened when the rail is enabled; a failure to open is fatal, since accepting Stars without a
|
||||
// durable outbox would risk losing a paid-for order.
|
||||
var paymentOutbox *outbox.Store
|
||||
if cfg.StarsOutboxDir != "" {
|
||||
paymentOutbox, err = outbox.Open(filepath.Join(cfg.StarsOutboxDir, "stars.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = paymentOutbox.Close() }()
|
||||
}
|
||||
|
||||
tlsCfg, err := mtls.ClientConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile, cfg.BotLink.ServerName)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -115,6 +129,12 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
|
||||
// the bot after the client is built — the late binding that breaks the bot <->
|
||||
// client construction cycle.
|
||||
b.SetEligibilityResolver(client.ResolveChatEligibility)
|
||||
// The Telegram Stars payment path rides the same bot-link: pre_checkout validation and
|
||||
// completed-payment forwarding, backed by the durable outbox — wired here for the same
|
||||
// late-binding reason.
|
||||
if paymentOutbox != nil {
|
||||
b.SetPaymentHandlers(client.ValidatePreCheckout, client.ForwardPayment, paymentOutbox)
|
||||
}
|
||||
|
||||
// The optional standalone promo bot: a second bot (its own token) that only answers
|
||||
// /start with a button opening the main bot's Mini App. It is self-contained — no
|
||||
@@ -160,6 +180,11 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
|
||||
logger.Error("bot-link client stopped", zap.Error(err))
|
||||
}
|
||||
})
|
||||
// Re-drive the Stars payment outbox: at startup (recovering payments left by a restart or a past
|
||||
// outage) and periodically thereafter.
|
||||
if paymentOutbox != nil {
|
||||
wg.Go(func() { b.RunPaymentDrainer(ctx) })
|
||||
}
|
||||
// The promo bot runs its own getUpdates long-poll on its own token (no 409 with
|
||||
// the main bot's lease).
|
||||
if promo != nil {
|
||||
|
||||
@@ -12,3 +12,16 @@ require (
|
||||
google.golang.org/protobuf v1.36.11
|
||||
scrabble/pkg v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
modernc.org/libc v1.72.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.49.1 // indirect
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"scrabble/platform/telegram/internal/outbox"
|
||||
"scrabble/platform/telegram/internal/support"
|
||||
)
|
||||
|
||||
@@ -49,6 +50,10 @@ type Config struct {
|
||||
// SupportStore persists the support relay's state (topic mapping, block list,
|
||||
// relayed message ids); required when SupportChatID is set, ignored otherwise.
|
||||
SupportStore *support.Store
|
||||
// AcceptPayments enables the Telegram Stars rail: the bot then subscribes to
|
||||
// pre_checkout_query updates and handles pre_checkout / successful_payment. The runtime
|
||||
// dependencies (the validator, forwarder and outbox) are wired with SetPaymentHandlers.
|
||||
AcceptPayments bool
|
||||
}
|
||||
|
||||
// EligibilityResolver answers whether the Telegram user identified by externalID
|
||||
@@ -91,6 +96,12 @@ type Bot struct {
|
||||
supportLocks *keyedMutex
|
||||
// admins caches the support chat's administrator ids (who may reply and act).
|
||||
admins *adminCache
|
||||
// precheck validates a Stars pre_checkout order and forward delivers a completed payment; both
|
||||
// are late-bound (SetPaymentHandlers) over the bot-link, which is built after the bot. outbox
|
||||
// durably records completed payments before they are forwarded. All nil when the Stars rail is off.
|
||||
precheck PreCheckoutValidator
|
||||
forward PaymentForwarder
|
||||
outbox *outbox.Store
|
||||
}
|
||||
|
||||
// New builds the bot wrapper, registering the /start handler and a default handler
|
||||
@@ -123,9 +134,10 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
// callback handler by their "sup:" data prefix.
|
||||
opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback))
|
||||
}
|
||||
// Allowed updates default to "all except chat_member". Specify an explicit set only
|
||||
// when we need chat_member (moderated chat) — and then re-add callback_query (which
|
||||
// the explicit set would otherwise drop) when the support relay needs it.
|
||||
// Allowed updates default to "all except chat_member" (which already includes
|
||||
// pre_checkout_query and message-borne successful_payment). Specify an explicit set only when we
|
||||
// need chat_member (moderated chat) — and then re-add callback_query and, for the Stars rail,
|
||||
// pre_checkout_query, which the explicit set would otherwise drop.
|
||||
if cfg.ChatID != 0 {
|
||||
allowed := tgbot.AllowedUpdates{
|
||||
models.AllowedUpdateMessage,
|
||||
@@ -135,6 +147,9 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
if t.supportEnabled() {
|
||||
allowed = append(allowed, models.AllowedUpdateCallbackQuery)
|
||||
}
|
||||
if cfg.AcceptPayments {
|
||||
allowed = append(allowed, models.AllowedUpdatePreCheckoutQuery)
|
||||
}
|
||||
opts = append(opts, tgbot.WithAllowedUpdates(allowed))
|
||||
}
|
||||
if cfg.TestEnv {
|
||||
@@ -346,6 +361,17 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
|
||||
t.handleChatMember(ctx, update.ChatMember)
|
||||
return
|
||||
}
|
||||
// Telegram Stars: the pre_checkout gate (validated against the backend) and the completed
|
||||
// payment (persisted to the outbox and forwarded) — before the support relay, so a payment
|
||||
// message is never mistaken for a support DM or given a launch reply.
|
||||
if update.PreCheckoutQuery != nil {
|
||||
t.handlePreCheckout(ctx, update.PreCheckoutQuery)
|
||||
return
|
||||
}
|
||||
if update.Message != nil && update.Message.SuccessfulPayment != nil {
|
||||
t.handleSuccessfulPayment(ctx, update.Message)
|
||||
return
|
||||
}
|
||||
// Support relay (when enabled): a non-/start message — /start has its own handler —
|
||||
// is either an operator's reply in the support chat or a user's direct message to
|
||||
// relay. Everything else falls through to the Mini App launch reply.
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/platform/telegram/internal/outbox"
|
||||
)
|
||||
|
||||
// starsCurrency is the Telegram Stars currency code for createInvoiceLink and the invoice line.
|
||||
const starsCurrency = "XTR"
|
||||
|
||||
// paymentDrainInterval is how often the bot re-drives undelivered outbox payments (the backstop for a
|
||||
// gateway or backend outage, and the restart re-drive); the happy path forwards immediately on receipt.
|
||||
const paymentDrainInterval = 30 * time.Second
|
||||
|
||||
// PreCheckoutValidator validates a Stars pre_checkout order over the bot-link and returns whether it
|
||||
// may be charged plus a short, already-localised decline reason for the payer. The bot-link client
|
||||
// backs it.
|
||||
type PreCheckoutValidator func(ctx context.Context, orderID string, amount int64, currency string) (ok bool, reason string, err error)
|
||||
|
||||
// PaymentForwarder delivers a completed Stars payment over the bot-link for crediting and reports
|
||||
// whether it was credited (or already had been). A non-nil error is transient and retried. The
|
||||
// bot-link client backs it.
|
||||
type PaymentForwarder func(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (credited bool, err error)
|
||||
|
||||
// SetPaymentHandlers wires the Telegram Stars runtime dependencies after construction: the
|
||||
// pre_checkout validator and payment forwarder (both over the bot-link, built after the bot) and the
|
||||
// durable outbox. It is the late binding that breaks the bot <-> bot-link construction cycle.
|
||||
func (t *Bot) SetPaymentHandlers(precheck PreCheckoutValidator, forward PaymentForwarder, store *outbox.Store) {
|
||||
t.precheck = precheck
|
||||
t.forward = forward
|
||||
t.outbox = store
|
||||
}
|
||||
|
||||
// CreateInvoiceLink mints a Telegram Stars invoice link (XTR) for amountStars, tagged with payload
|
||||
// (the order id, echoed back in pre_checkout and successful_payment), and returns the link. The
|
||||
// provider token is empty for Stars. Only the bot reaches Telegram, so the gateway calls this over
|
||||
// the bot-link on the wallet-order path.
|
||||
func (t *Bot) CreateInvoiceLink(ctx context.Context, title, description, payload string, amountStars int64) (string, error) {
|
||||
return t.api.CreateInvoiceLink(ctx, &tgbot.CreateInvoiceLinkParams{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Payload: payload,
|
||||
Currency: starsCurrency,
|
||||
Prices: []models.LabeledPrice{{Label: title, Amount: int(amountStars)}},
|
||||
})
|
||||
}
|
||||
|
||||
// handlePreCheckout answers a Stars pre_checkout_query. It validates the order against the backend
|
||||
// (over the bot-link) and approves only on a positive answer; a not-ok answer or a validation
|
||||
// failure declines the charge (fail-closed), before any star moves. The decline reason from the
|
||||
// backend is already localised to the payer's account language.
|
||||
func (t *Bot) handlePreCheckout(ctx context.Context, q *models.PreCheckoutQuery) {
|
||||
ok, reason := false, ""
|
||||
if t.precheck == nil {
|
||||
t.log.Warn("pre_checkout received but the validator is not wired; declining", zap.String("order", q.InvoicePayload))
|
||||
reason = fallbackDeclineText(q.From)
|
||||
} else if v, r, err := t.precheck(ctx, q.InvoicePayload, int64(q.TotalAmount), q.Currency); err != nil {
|
||||
t.log.Warn("pre_checkout validation failed; declining", zap.String("order", q.InvoicePayload), zap.Error(err))
|
||||
reason = fallbackDeclineText(q.From)
|
||||
} else {
|
||||
ok, reason = v, r
|
||||
}
|
||||
params := &tgbot.AnswerPreCheckoutQueryParams{PreCheckoutQueryID: q.ID, OK: ok}
|
||||
if !ok {
|
||||
params.ErrorMessage = reason
|
||||
}
|
||||
if _, err := t.api.AnswerPreCheckoutQuery(ctx, params); err != nil {
|
||||
t.log.Warn("answer pre_checkout failed", zap.String("order", q.InvoicePayload), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// handleSuccessfulPayment records a completed Stars payment in the durable outbox and forwards it to
|
||||
// the gateway. Persisting first is the durability point: a crash before forwarding still re-drives
|
||||
// the payment on restart. The immediate forward is best-effort; the periodic drainer covers a
|
||||
// gateway or backend outage.
|
||||
func (t *Bot) handleSuccessfulPayment(ctx context.Context, msg *models.Message) {
|
||||
sp := msg.SuccessfulPayment
|
||||
if t.outbox == nil {
|
||||
t.log.Error("successful_payment received but the outbox is not wired; the payment is at risk",
|
||||
zap.String("charge", sp.TelegramPaymentChargeID))
|
||||
return
|
||||
}
|
||||
var tgUserID int64
|
||||
if msg.From != nil {
|
||||
tgUserID = msg.From.ID
|
||||
}
|
||||
rec := outbox.Record{
|
||||
ChargeID: sp.TelegramPaymentChargeID,
|
||||
OrderID: sp.InvoicePayload,
|
||||
Amount: int64(sp.TotalAmount),
|
||||
UserID: tgUserID,
|
||||
}
|
||||
if err := t.outbox.Add(ctx, rec); err != nil {
|
||||
t.log.Error("outbox persist failed; the drainer cannot recover this payment",
|
||||
zap.String("charge", rec.ChargeID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
t.log.Info("stars payment received", zap.String("charge", rec.ChargeID), zap.String("order", rec.OrderID))
|
||||
t.forwardOne(ctx, rec)
|
||||
}
|
||||
|
||||
// RunPaymentDrainer re-drives undelivered outbox payments: once at startup (recovering any left by a
|
||||
// restart or a past outage) and then on paymentDrainInterval, until ctx is cancelled. It is a no-op
|
||||
// when the Stars rail is not wired.
|
||||
func (t *Bot) RunPaymentDrainer(ctx context.Context) {
|
||||
if t.outbox == nil || t.forward == nil {
|
||||
return
|
||||
}
|
||||
t.drainOutbox(ctx)
|
||||
ticker := time.NewTicker(paymentDrainInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
t.drainOutbox(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drainOutbox forwards a batch of pending payments to the gateway.
|
||||
func (t *Bot) drainOutbox(ctx context.Context) {
|
||||
recs, err := t.outbox.Pending(ctx, 50)
|
||||
if err != nil {
|
||||
t.log.Warn("outbox drain read failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, rec := range recs {
|
||||
t.forwardOne(ctx, rec)
|
||||
}
|
||||
}
|
||||
|
||||
// forwardOne delivers one payment to the gateway and, on a durable response (credited or not), marks
|
||||
// it forwarded so it is not re-sent. A transport error leaves the row for the next drain. Crediting is
|
||||
// idempotent at the backend, so a re-forward after a lost ack never double-credits.
|
||||
func (t *Bot) forwardOne(ctx context.Context, rec outbox.Record) {
|
||||
if t.forward == nil {
|
||||
return
|
||||
}
|
||||
credited, err := t.forward(ctx, rec.OrderID, rec.ChargeID, rec.Amount, rec.UserID)
|
||||
if err != nil {
|
||||
t.log.Warn("forward payment failed; will retry", zap.String("charge", rec.ChargeID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if err := t.outbox.MarkForwarded(ctx, rec.ChargeID); err != nil {
|
||||
t.log.Error("mark forwarded failed", zap.String("charge", rec.ChargeID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
t.log.Info("stars payment forwarded", zap.String("charge", rec.ChargeID), zap.String("order", rec.OrderID), zap.Bool("credited", credited))
|
||||
}
|
||||
|
||||
// fallbackDeclineText is the pre_checkout decline message used only when the backend cannot be
|
||||
// reached to form a localised one; it falls back to the payer's Telegram client language.
|
||||
func fallbackDeclineText(from *models.User) string {
|
||||
if from != nil && from.LanguageCode == "ru" {
|
||||
return "Оплата временно недоступна. Попробуйте позже."
|
||||
}
|
||||
return "Payment is temporarily unavailable. Please try again later."
|
||||
}
|
||||
@@ -83,6 +83,34 @@ func (c *Client) ResolveChatEligibility(ctx context.Context, externalID string)
|
||||
return resp.GetEligible(), nil
|
||||
}
|
||||
|
||||
// ValidatePreCheckout asks the gateway whether a Telegram Stars pre_checkout_query for orderID
|
||||
// paying amount in currency may be approved, before the charge. The bot calls it on every
|
||||
// pre_checkout_query over the same mTLS connection and approves only on ok; the reason is a short
|
||||
// decline message (already localised by the backend) to show the payer.
|
||||
func (c *Client) ValidatePreCheckout(ctx context.Context, orderID string, amount int64, currency string) (ok bool, reason string, err error) {
|
||||
resp, err := c.client.ValidatePreCheckout(ctx, &botlinkv1.PreCheckoutRequest{OrderId: orderID, Amount: amount, Currency: currency})
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
return resp.GetOk(), resp.GetReason(), nil
|
||||
}
|
||||
|
||||
// ForwardPayment delivers a completed Stars payment to the gateway for crediting. The bot calls it
|
||||
// from the outbox drain; it reports whether the order was credited (or already had been). A non-nil
|
||||
// error is a transient failure the bot retries.
|
||||
func (c *Client) ForwardPayment(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (credited bool, err error) {
|
||||
resp, err := c.client.ForwardPayment(ctx, &botlinkv1.ForwardPaymentRequest{
|
||||
OrderId: orderID,
|
||||
TelegramPaymentChargeId: chargeID,
|
||||
Amount: amount,
|
||||
TelegramUserId: telegramUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return resp.GetCredited(), nil
|
||||
}
|
||||
|
||||
// Run keeps the bot-link command stream open, re-opening it after each break, until
|
||||
// ctx is cancelled. The gRPC connection auto-reconnects the transport underneath.
|
||||
func (c *Client) Run(ctx context.Context) error {
|
||||
@@ -121,8 +149,8 @@ func (c *Client) serve(ctx context.Context, client botlinkv1.BotLinkClient) erro
|
||||
if cmd == nil {
|
||||
continue
|
||||
}
|
||||
delivered, herr := c.exec.Handle(ctx, cmd)
|
||||
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered}
|
||||
delivered, result, herr := c.exec.Handle(ctx, cmd)
|
||||
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result}
|
||||
if herr != nil {
|
||||
ack.Error = herr.Error()
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ type Sender interface {
|
||||
// chat, but only when they are currently in it; it reports whether a restriction
|
||||
// was applied.
|
||||
ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool, error)
|
||||
// CreateInvoiceLink mints a Telegram Stars invoice link (XTR) for amountStars, tagged
|
||||
// with payload (the order id, echoed back in pre_checkout and successful_payment), and
|
||||
// returns the link.
|
||||
CreateInvoiceLink(ctx context.Context, title, description, payload string, amountStars int64) (string, error)
|
||||
}
|
||||
|
||||
// Executor turns a bot-link Command into a Bot API send. The delivered flag mirrors
|
||||
@@ -48,22 +52,42 @@ func NewExecutor(sender Sender, channelID int64, log *zap.Logger) *Executor {
|
||||
return &Executor{sender: sender, channelID: channelID, log: log}
|
||||
}
|
||||
|
||||
// Handle dispatches one command to the matching Bot API send.
|
||||
func (e *Executor) Handle(ctx context.Context, cmd *botlinkv1.Command) (bool, error) {
|
||||
// Handle dispatches one command to the matching Bot API call. It returns whether the command was
|
||||
// delivered, an optional result string (the created invoice link for a create_invoice command; empty
|
||||
// otherwise), and an error for an unexpected or malformed failure.
|
||||
func (e *Executor) Handle(ctx context.Context, cmd *botlinkv1.Command) (bool, string, error) {
|
||||
switch p := cmd.GetPayload().(type) {
|
||||
case *botlinkv1.Command_Notify:
|
||||
return e.notify(ctx, p.Notify)
|
||||
d, err := e.notify(ctx, p.Notify)
|
||||
return d, "", err
|
||||
case *botlinkv1.Command_SendToUser:
|
||||
return e.sendToUser(ctx, p.SendToUser)
|
||||
d, err := e.sendToUser(ctx, p.SendToUser)
|
||||
return d, "", err
|
||||
case *botlinkv1.Command_SendToChannel:
|
||||
return e.sendToChannel(ctx, p.SendToChannel)
|
||||
d, err := e.sendToChannel(ctx, p.SendToChannel)
|
||||
return d, "", err
|
||||
case *botlinkv1.Command_ChatGate:
|
||||
return e.chatGate(ctx, p.ChatGate)
|
||||
d, err := e.chatGate(ctx, p.ChatGate)
|
||||
return d, "", err
|
||||
case *botlinkv1.Command_CreateInvoice:
|
||||
return e.createInvoice(ctx, p.CreateInvoice)
|
||||
default:
|
||||
return false, fmt.Errorf("botlink: empty command")
|
||||
return false, "", fmt.Errorf("botlink: empty command")
|
||||
}
|
||||
}
|
||||
|
||||
// createInvoice mints a Telegram Stars invoice link for the order and returns it in the Ack result.
|
||||
// A Bot API failure is a hard error carried back in the Ack, so the gateway's synchronous mint fails
|
||||
// (rather than returning an empty link).
|
||||
func (e *Executor) createInvoice(ctx context.Context, req *botlinkv1.CreateInvoiceCommand) (bool, string, error) {
|
||||
link, err := e.sender.CreateInvoiceLink(ctx, req.GetTitle(), req.GetDescription(), req.GetPayload(), req.GetAmount())
|
||||
if err != nil {
|
||||
e.log.Warn("create invoice link failed", zap.String("order", req.GetPayload()), zap.Error(err))
|
||||
return false, "", err
|
||||
}
|
||||
return true, link, nil
|
||||
}
|
||||
|
||||
// chatGate applies a chat-gate command: it parses the target Telegram user id and
|
||||
// sets their write access in the moderated chat (a no-op when they are not in it). A
|
||||
// Bot API failure is logged and reported as not-delivered, not a hard error.
|
||||
|
||||
@@ -13,11 +13,13 @@ import (
|
||||
|
||||
// fakeSender records the delivery calls the executor makes.
|
||||
type fakeSender struct {
|
||||
notify []notifyCall
|
||||
text []textCall
|
||||
gate []gateCall
|
||||
applied bool // ApplyChatGate's reported result
|
||||
err error
|
||||
notify []notifyCall
|
||||
text []textCall
|
||||
gate []gateCall
|
||||
invoice []invoiceCall
|
||||
invoiceLink string // CreateInvoiceLink's returned link
|
||||
applied bool // ApplyChatGate's reported result
|
||||
err error
|
||||
}
|
||||
|
||||
type notifyCall struct {
|
||||
@@ -32,6 +34,10 @@ type gateCall struct {
|
||||
userID int64
|
||||
allow bool
|
||||
}
|
||||
type invoiceCall struct {
|
||||
title, description, payload string
|
||||
amount int64
|
||||
}
|
||||
|
||||
func (f *fakeSender) Notify(_ context.Context, chatID int64, text, buttonText, startParam string) error {
|
||||
f.notify = append(f.notify, notifyCall{chatID, text, buttonText, startParam})
|
||||
@@ -48,6 +54,11 @@ func (f *fakeSender) ApplyChatGate(_ context.Context, userID int64, allow bool)
|
||||
return f.applied, f.err
|
||||
}
|
||||
|
||||
func (f *fakeSender) CreateInvoiceLink(_ context.Context, title, description, payload string, amountStars int64) (string, error) {
|
||||
f.invoice = append(f.invoice, invoiceCall{title, description, payload, amountStars})
|
||||
return f.invoiceLink, f.err
|
||||
}
|
||||
|
||||
func yourTurnPayload(gameID string) []byte {
|
||||
b := flatbuffers.NewBuilder(0)
|
||||
gid := b.CreateString(gameID)
|
||||
@@ -67,7 +78,7 @@ func TestExecutorNotifyDelivers(t *testing.T) {
|
||||
const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||
sender := &fakeSender{}
|
||||
exec := NewExecutor(sender, 0, nil)
|
||||
delivered, err := exec.Handle(context.Background(), notifyCmd("12345", "your_turn", yourTurnPayload(gameID), "en"))
|
||||
delivered, _, err := exec.Handle(context.Background(), notifyCmd("12345", "your_turn", yourTurnPayload(gameID), "en"))
|
||||
if err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
@@ -85,7 +96,7 @@ func TestExecutorNotifyDelivers(t *testing.T) {
|
||||
func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
exec := NewExecutor(sender, 0, nil)
|
||||
delivered, err := exec.Handle(context.Background(), notifyCmd("12345", "opponent_moved", nil, "en"))
|
||||
delivered, _, err := exec.Handle(context.Background(), notifyCmd("12345", "opponent_moved", nil, "en"))
|
||||
if err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
@@ -99,7 +110,7 @@ func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) {
|
||||
|
||||
func TestExecutorNotifyInvalidExternalID(t *testing.T) {
|
||||
exec := NewExecutor(&fakeSender{}, 0, nil)
|
||||
if _, err := exec.Handle(context.Background(), notifyCmd("not-a-number", "your_turn", yourTurnPayload("g"), "en")); err == nil {
|
||||
if _, _, err := exec.Handle(context.Background(), notifyCmd("not-a-number", "your_turn", yourTurnPayload("g"), "en")); err == nil {
|
||||
t.Error("expected an error for a non-numeric external_id")
|
||||
}
|
||||
}
|
||||
@@ -108,7 +119,7 @@ func TestExecutorSendToUser(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
exec := NewExecutor(sender, 0, nil)
|
||||
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToUser{SendToUser: &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi"}}}
|
||||
delivered, err := exec.Handle(context.Background(), cmd)
|
||||
delivered, _, err := exec.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
@@ -126,7 +137,7 @@ func chatGateCmd(externalID string, allow bool) *botlinkv1.Command {
|
||||
func TestExecutorChatGateApplied(t *testing.T) {
|
||||
sender := &fakeSender{applied: true}
|
||||
exec := NewExecutor(sender, 0, nil)
|
||||
delivered, err := exec.Handle(context.Background(), chatGateCmd("777", true))
|
||||
delivered, _, err := exec.Handle(context.Background(), chatGateCmd("777", true))
|
||||
if err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
@@ -138,7 +149,7 @@ func TestExecutorChatGateApplied(t *testing.T) {
|
||||
func TestExecutorChatGateNotInChat(t *testing.T) {
|
||||
sender := &fakeSender{applied: false} // user not in the chat
|
||||
exec := NewExecutor(sender, 0, nil)
|
||||
delivered, err := exec.Handle(context.Background(), chatGateCmd("888", false))
|
||||
delivered, _, err := exec.Handle(context.Background(), chatGateCmd("888", false))
|
||||
if err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
@@ -152,24 +163,53 @@ func TestExecutorChatGateNotInChat(t *testing.T) {
|
||||
|
||||
func TestExecutorChatGateInvalidExternalID(t *testing.T) {
|
||||
exec := NewExecutor(&fakeSender{}, 0, nil)
|
||||
if _, err := exec.Handle(context.Background(), chatGateCmd("not-a-number", true)); err == nil {
|
||||
if _, _, err := exec.Handle(context.Background(), chatGateCmd("not-a-number", true)); err == nil {
|
||||
t.Error("expected an error for a non-numeric external_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorCreateInvoice(t *testing.T) {
|
||||
sender := &fakeSender{invoiceLink: "https://t.me/$abc"}
|
||||
exec := NewExecutor(sender, 0, nil)
|
||||
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
|
||||
Title: "50 chips", Description: "50 chips", Payload: "order-1", Amount: 40,
|
||||
}}}
|
||||
delivered, result, err := exec.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
if !delivered || result != "https://t.me/$abc" {
|
||||
t.Errorf("create invoice = %v / %q, want true / the link", delivered, result)
|
||||
}
|
||||
if len(sender.invoice) != 1 || sender.invoice[0].payload != "order-1" || sender.invoice[0].amount != 40 {
|
||||
t.Errorf("invoice calls = %+v", sender.invoice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorCreateInvoiceError(t *testing.T) {
|
||||
sender := &fakeSender{err: context.DeadlineExceeded}
|
||||
exec := NewExecutor(sender, 0, nil)
|
||||
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
|
||||
Title: "x", Description: "x", Payload: "order-2", Amount: 10,
|
||||
}}}
|
||||
if _, _, err := exec.Handle(context.Background(), cmd); err == nil {
|
||||
t.Error("expected an error when minting the invoice fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorSendToChannel(t *testing.T) {
|
||||
channelCmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToChannel{SendToChannel: &telegramv1.SendToGameChannelRequest{Text: "news"}}}
|
||||
|
||||
t.Run("unconfigured", func(t *testing.T) {
|
||||
exec := NewExecutor(&fakeSender{}, 0, nil)
|
||||
if _, err := exec.Handle(context.Background(), channelCmd); err == nil {
|
||||
if _, _, err := exec.Handle(context.Background(), channelCmd); err == nil {
|
||||
t.Error("expected an error when no channel is configured")
|
||||
}
|
||||
})
|
||||
t.Run("configured", func(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
exec := NewExecutor(sender, 555, nil)
|
||||
delivered, err := exec.Handle(context.Background(), channelCmd)
|
||||
delivered, _, err := exec.Handle(context.Background(), channelCmd)
|
||||
if err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,11 @@ type BotConfig struct {
|
||||
// (TELEGRAM_SUPPORT_STATE_DIR, default /data). It must be writable by the
|
||||
// container user (UID 65532) and backed by a persistent volume.
|
||||
SupportStateDir string
|
||||
// StarsOutboxDir is the directory holding the Telegram Stars payment outbox SQLite file
|
||||
// (TELEGRAM_STARS_OUTBOX_DIR, optional; empty disables the Stars rail). It must be writable by
|
||||
// the container user (UID 65532) and backed by a persistent volume — a lost outbox loses any
|
||||
// payment not yet forwarded to the gateway.
|
||||
StarsOutboxDir string
|
||||
// PromoBotToken is the API token of the optional standalone promo bot run in this
|
||||
// container — a second bot whose only job is to answer /start with a button that
|
||||
// opens the main bot's Mini App (TELEGRAM_PROMO_BOT_TOKEN, optional; empty disables
|
||||
@@ -157,6 +162,7 @@ func LoadBot() (BotConfig, error) {
|
||||
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
|
||||
PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam),
|
||||
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
|
||||
StarsOutboxDir: os.Getenv("TELEGRAM_STARS_OUTBOX_DIR"),
|
||||
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
|
||||
BotLink: BotLinkClientConfig{
|
||||
GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"),
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Package outbox is the Telegram Stars payment outbox: a small SQLite store on the bot host's
|
||||
// writable volume that durably records each completed Stars payment the moment Telegram delivers it,
|
||||
// so a gateway or backend outage cannot lose it. The bot forwards pending rows to the gateway and
|
||||
// marks them delivered; rows still undelivered are re-driven on restart. It is pure-Go SQLite
|
||||
// (modernc.org/sqlite, no CGO) so it runs on the distroless image.
|
||||
package outbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Record is one completed Stars payment awaiting delivery to the gateway.
|
||||
type Record struct {
|
||||
// ChargeID is the telegram_payment_charge_id — the primary key here and the credit idempotency
|
||||
// key at the backend, so a re-delivered or retried payment is never credited twice.
|
||||
ChargeID string
|
||||
// OrderID is the invoice payload (our order id) the payment settles.
|
||||
OrderID string
|
||||
// Amount is the stars paid (the XTR minor unit is the whole star).
|
||||
Amount int64
|
||||
// UserID is the payer's Telegram user id.
|
||||
UserID int64
|
||||
}
|
||||
|
||||
// Store is the SQLite-backed payment outbox.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Open opens (creating if absent) the outbox database at path and ensures its schema. The parent
|
||||
// directory must exist and be writable by the container user.
|
||||
func Open(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("outbox: open %s: %w", path, err)
|
||||
}
|
||||
// A single connection serialises writes and sidesteps SQLite's "database is locked" under the
|
||||
// bot's low, bursty payment volume.
|
||||
db.SetMaxOpenConns(1)
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS stars_payments (
|
||||
charge_id TEXT PRIMARY KEY,
|
||||
order_id TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
forwarded INTEGER NOT NULL DEFAULT 0
|
||||
)`); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("outbox: init schema: %w", err)
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// Close closes the database.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// Add records a completed payment. It is idempotent on the charge id: a payment Telegram re-delivers
|
||||
// (or one already recorded and forwarded) is ignored, so it is never forwarded — and credited —
|
||||
// twice.
|
||||
func (s *Store) Add(ctx context.Context, r Record) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO stars_payments (charge_id, order_id, amount, user_id, created_at, forwarded)
|
||||
VALUES (?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(charge_id) DO NOTHING`,
|
||||
r.ChargeID, r.OrderID, r.Amount, r.UserID, time.Now().Unix())
|
||||
if err != nil {
|
||||
return fmt.Errorf("outbox: add %s: %w", r.ChargeID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pending returns up to limit payments not yet forwarded, oldest first.
|
||||
func (s *Store) Pending(ctx context.Context, limit int) ([]Record, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT charge_id, order_id, amount, user_id FROM stars_payments
|
||||
WHERE forwarded = 0 ORDER BY created_at LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("outbox: read pending: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Record
|
||||
for rows.Next() {
|
||||
var r Record
|
||||
if err := rows.Scan(&r.ChargeID, &r.OrderID, &r.Amount, &r.UserID); err != nil {
|
||||
return nil, fmt.Errorf("outbox: scan: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MarkForwarded flags a payment as delivered so it is not forwarded again.
|
||||
func (s *Store) MarkForwarded(ctx context.Context, chargeID string) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE stars_payments SET forwarded = 1 WHERE charge_id = ?`, chargeID); err != nil {
|
||||
return fmt.Errorf("outbox: mark forwarded %s: %w", chargeID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package outbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// openTemp opens a fresh outbox in a temp dir.
|
||||
func openTemp(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := Open(filepath.Join(t.TempDir(), "stars.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestOutboxAddPendingMark(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTemp(t)
|
||||
|
||||
rec := Record{ChargeID: "ch1", OrderID: "ord1", Amount: 40, UserID: 777}
|
||||
if err := s.Add(ctx, rec); err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
pending, err := s.Pending(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0] != rec {
|
||||
t.Fatalf("pending = %+v, want [%+v]", pending, rec)
|
||||
}
|
||||
|
||||
if err := s.MarkForwarded(ctx, "ch1"); err != nil {
|
||||
t.Fatalf("mark: %v", err)
|
||||
}
|
||||
pending, err = s.Pending(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("pending after mark: %v", err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("pending after mark = %+v, want empty", pending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboxAddIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTemp(t)
|
||||
|
||||
rec := Record{ChargeID: "dup", OrderID: "ord1", Amount: 80, UserID: 1}
|
||||
if err := s.Add(ctx, rec); err != nil {
|
||||
t.Fatalf("add 1: %v", err)
|
||||
}
|
||||
// A re-delivered payment (same charge id) must not add a second row, even with different fields.
|
||||
if err := s.Add(ctx, Record{ChargeID: "dup", OrderID: "other", Amount: 999, UserID: 2}); err != nil {
|
||||
t.Fatalf("add 2: %v", err)
|
||||
}
|
||||
pending, err := s.Pending(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0] != rec {
|
||||
t.Fatalf("pending = %+v, want the single original row", pending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboxAddIgnoresForwarded(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTemp(t)
|
||||
|
||||
rec := Record{ChargeID: "ch", OrderID: "o", Amount: 40, UserID: 5}
|
||||
if err := s.Add(ctx, rec); err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
if err := s.MarkForwarded(ctx, "ch"); err != nil {
|
||||
t.Fatalf("mark: %v", err)
|
||||
}
|
||||
// A re-delivery after forwarding must not resurrect the row as pending (no double credit).
|
||||
if err := s.Add(ctx, rec); err != nil {
|
||||
t.Fatalf("re-add: %v", err)
|
||||
}
|
||||
pending, err := s.Pending(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("pending = %+v, want empty (already forwarded)", pending)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutboxReopenReDrives proves a restart re-drives undelivered payments: a payment persisted but
|
||||
// not marked forwarded is still pending after the store is reopened from the same file.
|
||||
func TestOutboxReopenReDrives(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "stars.db")
|
||||
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
rec := Record{ChargeID: "persist", OrderID: "o1", Amount: 40, UserID: 9}
|
||||
if err := s.Add(ctx, rec); err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
reopened, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer func() { _ = reopened.Close() }()
|
||||
pending, err := reopened.Pending(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0] != rec {
|
||||
t.Fatalf("pending after reopen = %+v, want the undelivered payment", pending)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user