internal/store/postgres/bot_stars_wallet.go
Rama main · réplica de lectura del repositorio original.
package postgres
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
)
// Bot Stars revenue wallet.
//
// A bot's wallet is a distinct bucket from the bot user identity's personal
// Stars balance (`stars_balances`). The split is deliberate and mirrors the
// channel ledger: `payments.getStarsStatus` reports what a principal may spend,
// while `payments.getStarsRevenueStats` reports what a peer has earned. Mixing
// them would show an operator their own spending as revenue.
//
// Balance rows are never seeded with a starting grant. A bot earns stars only
// by settling an actual invoice, which is what CreditBotStarsWallet records.
// BotStarsBalance reads the current wallet balance. An unknown bot reads zero:
// absence of a row means the bot has never been paid.
func (s *StarGiftLifecycleStore) BotStarsBalance(ctx context.Context, botUserID int64) (int64, error) {
if botUserID <= 0 {
return 0, domain.ErrStarGiftOwnerInvalid
}
var balance int64
err := s.db.QueryRow(ctx, `SELECT COALESCE((SELECT balance FROM bot_stars_balances WHERE bot_user_id=$1),0)`, botUserID).Scan(&balance)
return balance, err
}
// BotStarsOverallRevenue sums only positive entries so lifetime revenue stays
// monotonic: a refund or a later spend must never lower it.
func (s *StarGiftLifecycleStore) BotStarsOverallRevenue(ctx context.Context, botUserID int64) (int64, error) {
if botUserID <= 0 {
return 0, domain.ErrStarGiftOwnerInvalid
}
var revenue int64
err := s.db.QueryRow(ctx, `SELECT COALESCE(sum(amount) FILTER (WHERE amount>0),0)
FROM bot_stars_transactions WHERE bot_user_id=$1`, botUserID).Scan(&revenue)
return revenue, err
}
// BotStarsTransactions returns one bounded page of wallet history together with
// the current balance, using the same keyset cursor as every other ledger.
func (s *StarGiftLifecycleStore) BotStarsTransactions(ctx context.Context, botUserID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
if botUserID <= 0 {
return domain.StarsTransactionPage{}, domain.ErrStarGiftOwnerInvalid
}
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.StarsTransactionPage{}, err
}
where, order, args := starsTransactionQueryParts("bot_user_id", "amount", botUserID, query)
rows, err := s.db.Query(ctx, `SELECT id,bot_user_id,COALESCE(peer_type,''),COALESCE(peer_id,0),amount,date,reason
FROM bot_stars_transactions WHERE `+where+` ORDER BY id `+order+` LIMIT $2`, args...)
if err != nil {
return domain.StarsTransactionPage{}, err
}
defer rows.Close()
items := make([]domain.StarsTransaction, 0, query.Limit+1)
for rows.Next() {
var item domain.StarsTransaction
var peerType string
if err := rows.Scan(&item.ID, &item.UserID, &peerType, &item.Peer.ID, &item.Amount, &item.Date, &item.Reason); err != nil {
return domain.StarsTransactionPage{}, err
}
item.Peer.Type = domain.PeerType(peerType)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return domain.StarsTransactionPage{}, err
}
page := domain.StarsTransactionPage{Transactions: items}
if len(items) > query.Limit {
items = items[:query.Limit]
page.NextOffset = domain.EncodeStarsCursor(items[len(items)-1].ID)
}
page.Transactions = items
page.Balance, err = s.BotStarsBalance(ctx, botUserID)
return page, err
}
// CreditBotStarsWallet applies one settled invoice atomically: the balance row,
// the transaction entry and the idempotency receipt are written in one
// transaction, so a crash between them can never mint a partial credit.
//
// A replayed InvoiceKey is not an error. The stored receipt is returned with
// credited=false so payments.sendPaymentForm stays safe to retry after a
// network timeout.
func (s *StarGiftLifecycleStore) CreditBotStarsWallet(ctx context.Context, credit domain.BotStarsCredit) (int64, bool, error) {
if !credit.Valid() {
return 0, false, domain.ErrStarGiftLifecycleInvalid
}
var balance int64
credited := false
err := withTx(ctx, s.db, "credit bot stars wallet", func(tx pgx.Tx) error {
stored, ok, err := botStarsReceipt(ctx, tx, credit.InvoiceKey)
if err != nil {
return err
}
if ok {
balance = stored
return nil
}
balance, err = s.applyBotStarsWalletTx(ctx, tx, credit)
if err != nil {
return err
}
credited = true
return nil
})
if err != nil {
return 0, false, err
}
return balance, credited, nil
}
// applyBotStarsWalletTx moves the wallet by the signed amount, appends the
// transaction entry and stores the idempotency receipt.
//
// The credit path upserts, and the debit path locks the row with FOR UPDATE, so
// two concurrent debits serialize and the balance>=0 CHECK rejects an overdraft
// instead of creating negative funds.
func (s *StarGiftLifecycleStore) applyBotStarsWalletTx(ctx context.Context, tx pgx.Tx, credit domain.BotStarsCredit) (int64, error) {
var balance int64
var err error
if credit.Amount > 0 {
balance, err = botStarsUpsertBalance(ctx, tx, credit.BotUserID, credit.Amount)
} else {
if err = tx.QueryRow(ctx, `SELECT balance FROM bot_stars_balances WHERE bot_user_id=$1 FOR UPDATE`,
credit.BotUserID).Scan(&balance); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, domain.ErrStarsInsufficient
}
return 0, err
}
balance += credit.Amount
if balance < 0 {
return 0, domain.ErrStarsInsufficient
}
if _, err = tx.Exec(ctx, `UPDATE bot_stars_balances SET balance=$2,updated_at=now() WHERE bot_user_id=$1`,
credit.BotUserID, balance); err != nil {
return 0, err
}
}
if _, err := tx.Exec(ctx, `INSERT INTO bot_stars_transactions
(bot_user_id,actor_user_id,amount,reason,peer_type,peer_id,invoice_key,date)
VALUES($1,$2,$3,$4,'user',$5,$6,$7)`,
credit.BotUserID, credit.PayerUserID, credit.Amount, string(credit.Reason),
credit.PayerUserID, credit.InvoiceKey, credit.Date); err != nil {
return 0, err
}
// The receipt stores the absolute moved amount: the sign belongs to the
// transaction row, and the CHECK requires a positive magnitude.
moved := credit.Amount
if moved < 0 {
moved = -moved
}
if _, err := tx.Exec(ctx, `INSERT INTO bot_stars_payments
(invoice_key,bot_user_id,payer_user_id,amount,balance_after,settled_at)
VALUES($1,$2,$3,$4,$5,$6)`,
credit.InvoiceKey, credit.BotUserID, credit.PayerUserID, moved, balance, credit.Date); err != nil {
return 0, err
}
return balance, nil
}
// botStarsReceipt returns the stored post-credit balance for an
// already-settled invoice key, so a retry can be answered from the receipt
// without touching the mutable balance row.
// BotStarsRevenueSeries groups the wallet journal into UTC days over the
// inclusive window. Credits and debits are reported separately so the chart
// can stack income against spend the way the official earn screen does,
// instead of netting the two and hiding gross revenue.
func (s *StarGiftLifecycleStore) BotStarsRevenueSeries(ctx context.Context, botUserID int64, fromUnix, toUnix int) ([]domain.BotStarsRevenuePoint, error) {
if botUserID <= 0 {
return nil, domain.ErrStarGiftOwnerInvalid
}
if fromUnix <= 0 || toUnix < fromUnix {
return nil, domain.ErrStarGiftLifecycleInvalid
}
rows, err := s.db.Query(ctx, `SELECT (date/86400)*86400 AS day_start,
COALESCE(sum(amount) FILTER (WHERE amount>0),0) AS earned,
COALESCE(-sum(amount) FILTER (WHERE amount<0),0) AS spent
FROM bot_stars_transactions
WHERE bot_user_id=$1 AND date>=$2 AND date<=$3
GROUP BY day_start ORDER BY day_start`, botUserID, fromUnix, toUnix)
if err != nil {
return nil, err
}
defer rows.Close()
points := make([]domain.BotStarsRevenuePoint, 0, 32)
for rows.Next() {
var point domain.BotStarsRevenuePoint
if err := rows.Scan(&point.DayStart, &point.Amount, &point.Spent); err != nil {
return nil, err
}
if point.Valid() {
points = append(points, point)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return points, nil
}
func botStarsReceipt(ctx context.Context, tx pgx.Tx, invoiceKey string) (balance int64, found bool, err error) {
err = tx.QueryRow(ctx, `SELECT balance_after FROM bot_stars_payments WHERE invoice_key=$1`,
invoiceKey).Scan(&balance)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, false, nil
}
return 0, false, err
}
return balance, true, nil
}
// botStarsUpsertBalance applies a signed delta and returns the new balance.
// The CHECK constraint is the final guard against a negative wallet.
func botStarsUpsertBalance(ctx context.Context, tx pgx.Tx, botUserID, delta int64) (int64, error) {
var balance int64
err := tx.QueryRow(ctx, `INSERT INTO bot_stars_balances(bot_user_id,balance) VALUES($1,$2)
ON CONFLICT(bot_user_id) DO UPDATE SET balance=bot_stars_balances.balance+EXCLUDED.balance,updated_at=now()
RETURNING balance`, botUserID, delta).Scan(&balance)
return balance, err
}