// 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 }