//go:build integration package inttest import ( "context" "database/sql" "errors" "testing" "github.com/google/uuid" "scrabble/backend/internal/payments" ) // orderStatus reads an order's status. func orderStatus(t *testing.T, orderID uuid.UUID) string { t.Helper() var status string if err := testDB.QueryRowContext(context.Background(), `SELECT status FROM payments.orders WHERE order_id=$1`, orderID).Scan(&status); err != nil { t.Fatalf("read order status: %v", err) } return status } // readRisk reads an account's payment-risk row (abuse flag + accumulated loss), or (false, 0) when // none exists. func readRisk(t *testing.T, acc uuid.UUID) (abuse bool, loss int64) { t.Helper() err := testDB.QueryRowContext(context.Background(), `SELECT abuse, loss_chips FROM payments.account_risk WHERE account_id=$1`, acc).Scan(&abuse, &loss) if errors.Is(err, sql.ErrNoRows) { return false, 0 } if err != nil { t.Fatalf("read risk: %v", err) } return abuse, loss } // TestPaymentsOrderFundCreditsOnce verifies the intake path over Postgres: creating an order then // funding it credits the funded segment exactly once, and a replayed callback (the same order) // credits nothing more — the ledger idempotency index holds. func TestPaymentsOrderFundCreditsOnce(t *testing.T) { ctx := context.Background() svc := newPaymentsService() acc := uuid.New() prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) // 149.00 RUB funds 100 chips res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa") if err != nil { t.Fatalf("create order: %v", err) } if res.Amount.Minor() != 14900 || res.Amount.Currency() != payments.CurrencyRUB { t.Fatalf("order amount = %s, want 149.00 RUB", res.Amount) } if orderStatus(t, res.OrderID) != "pending" { t.Errorf("new order status = %s, want pending", orderStatus(t, res.OrderID)) } paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB) out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid) if err != nil { t.Fatalf("fund: %v", err) } if out.AlreadyCredited || out.Chips != 100 || out.Source != payments.SourceDirect { t.Fatalf("fund outcome = %+v, want 100 chips to direct, not already-credited", out) } if got := readBalance(t, acc, "direct"); got != 100 { t.Errorf("balance after fund = %d, want 100", got) } if ledgerRows(t, acc, "fund") != 1 { t.Errorf("fund ledger rows = %d, want 1", ledgerRows(t, acc, "fund")) } if orderStatus(t, res.OrderID) != "paid" { t.Errorf("order status after fund = %s, want paid", orderStatus(t, res.OrderID)) } // A replayed callback for the same order is rejected by the unique index: no second credit. out2, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid) if err != nil { t.Fatalf("duplicate fund: %v", err) } if !out2.AlreadyCredited { t.Error("duplicate callback not flagged AlreadyCredited") } if got := readBalance(t, acc, "direct"); got != 100 { t.Errorf("balance after duplicate = %d, want 100 (credited once)", got) } if ledgerRows(t, acc, "fund") != 1 { t.Error("duplicate callback wrote a second fund ledger row") } } // TestPaymentsVKOrderFundCredits exercises the VK Votes rail over Postgres: a VK order prices the // pack in votes; the get_item lookup returns its title and vote price; a chargeable // order_status_change credits the vk segment exactly once (idempotent on VK's own order id). func TestPaymentsVKOrderFundCredits(t *testing.T) { ctx := context.Background() svc := newPaymentsService() acc := uuid.New() prod := seedPackProduct(t, 200, methodPrice{method: "vk", currency: "VOTE", amount: 30}) res, err := svc.CreateOrder(ctx, acc, payments.NewContext("vk", "web"), []payments.Source{payments.SourceVK}, prod, "vk") if err != nil { t.Fatalf("create vk order: %v", err) } if res.Amount.Currency() != payments.CurrencyVote || res.Amount.Minor() != 30 { t.Fatalf("vk order amount = %s, want 30 VOTE", res.Amount) } // get_item phase: title + vote price. title, amount, err := svc.OrderItem(ctx, res.OrderID) if err != nil { t.Fatalf("order item: %v", err) } if title == "" || amount.Minor() != 30 || amount.Currency() != payments.CurrencyVote { t.Errorf("order item = %q / %s, want a title + 30 VOTE", title, amount) } // order_status_change phase: VK's own order id is the idempotency key. paid, _ := payments.MoneyFromMinor(30, payments.CurrencyVote) out, err := svc.Fund(ctx, res.OrderID, "vk", "vk-order-777", paid) if err != nil { t.Fatalf("vk fund: %v", err) } if out.AlreadyCredited || out.Chips != 200 || out.Source != payments.SourceVK { t.Fatalf("vk fund outcome = %+v, want 200 chips credited to vk", out) } if got := readBalance(t, acc, "vk"); got != 200 { t.Errorf("vk balance after fund = %d, want 200", got) } // A duplicate VK callback (same VK order id) credits nothing more. out2, err := svc.Fund(ctx, res.OrderID, "vk", "vk-order-777", paid) if err != nil { t.Fatalf("duplicate vk fund: %v", err) } if !out2.AlreadyCredited { t.Error("duplicate VK callback not flagged AlreadyCredited") } if got := readBalance(t, acc, "vk"); got != 200 { t.Errorf("vk balance after duplicate = %d, want 200 (credited once)", got) } } // 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) { ctx := context.Background() svc := newPaymentsService() acc := uuid.New() prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa") if err != nil { t.Fatalf("create order: %v", err) } underpaid, _ := payments.MoneyFromMinor(100, payments.CurrencyRUB) if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), underpaid); !errors.Is(err, payments.ErrAmountMismatch) { t.Fatalf("fund = %v, want ErrAmountMismatch", err) } if got := readBalance(t, acc, "direct"); got != 0 { t.Errorf("balance = %d, want 0 (nothing credited on mismatch)", got) } if ledgerRows(t, acc, "fund") != 0 { t.Error("fund ledger row written on an amount mismatch") } } // TestPaymentsEventDispatchDrain verifies the payment_events dispatcher queue: a recorded event is // returned as undispatched until marked, then drops out (so the dispatcher delivers it once). func TestPaymentsEventDispatchDrain(t *testing.T) { ctx := context.Background() svc := newPaymentsService() acc := uuid.New() if err := svc.RecordPaymentEvent(ctx, acc, nil, "succeeded", []byte(`{"chips":10,"source":"direct"}`)); err != nil { t.Fatalf("record event: %v", err) } // testDB is shared, so filter the queue to our account. find := func() *payments.PaymentEvent { evs, err := svc.UndispatchedEvents(ctx, 100) if err != nil { t.Fatalf("undispatched: %v", err) } for i := range evs { if evs[i].AccountID == acc { return &evs[i] } } return nil } mine := find() if mine == nil { t.Fatal("recorded event not in the undispatched queue") } if mine.Type != "succeeded" { t.Errorf("event type = %s, want succeeded", mine.Type) } if err := svc.MarkEventDispatched(ctx, mine.EventID); err != nil { t.Fatalf("mark dispatched: %v", err) } if find() != nil { t.Error("event still undispatched after MarkEventDispatched") } } // TestPaymentsExpiredOrderStillCredits verifies an expired pending order is still honoured by a // later valid callback (§9/D23: expiry is cosmetic, the money is real). func TestPaymentsExpiredOrderStillCredits(t *testing.T) { ctx := context.Background() svc := newPaymentsService() acc := uuid.New() prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa") if err != nil { t.Fatalf("create order: %v", err) } // Age the order well past the configured TTL, then sweep it to expired. if _, err := testDB.ExecContext(ctx, `UPDATE payments.orders SET created_at = now() - interval '1 day' WHERE order_id=$1`, res.OrderID); err != nil { t.Fatalf("age order: %v", err) } if n, err := svc.ExpireOrders(ctx); err != nil || n < 1 { t.Fatalf("expire orders = %d (err %v), want at least 1", n, err) } if orderStatus(t, res.OrderID) != "expired" { t.Fatalf("order status = %s, want expired before the late callback", orderStatus(t, res.OrderID)) } paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB) out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid) if err != nil { t.Fatalf("fund after expiry: %v", err) } if out.AlreadyCredited || out.Chips != 100 { t.Fatalf("fund outcome = %+v, want a fresh 100-chip credit", out) } if got := readBalance(t, acc, "direct"); got != 100 { t.Errorf("balance = %d, want 100 (expired order honoured)", got) } if orderStatus(t, res.OrderID) != "paid" { t.Errorf("order status = %s, want paid after the honoured callback", orderStatus(t, res.OrderID)) } } // fundedOrder creates and funds an order for chips in the direct rail, returning its id and account. func fundedOrder(t *testing.T, svc *payments.Service, chips int, priceMinor int64) (uuid.UUID, uuid.UUID) { t.Helper() ctx := context.Background() acc := uuid.New() prod := seedPackProduct(t, chips, methodPrice{method: "direct", currency: "RUB", amount: priceMinor}) res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa") if err != nil { t.Fatalf("create order: %v", err) } paid, _ := payments.MoneyFromMinor(priceMinor, payments.CurrencyRUB) if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil { t.Fatalf("fund: %v", err) } return res.OrderID, acc } // TestPaymentsRefundFull reverses a fully-unspent order: all chips are clawed back, no loss/abuse. func TestPaymentsRefundFull(t *testing.T) { ctx := context.Background() svc := newPaymentsService() orderID, acc := fundedOrder(t, svc, 100, 14900) refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB) out, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-1", refunded) if err != nil { t.Fatalf("refund: %v", err) } if out.AlreadyRefunded || out.Revoked != 100 || out.Loss != 0 || out.Source != payments.SourceDirect { t.Fatalf("refund outcome = %+v, want 100 revoked, 0 loss", out) } if got := readBalance(t, acc, "direct"); got != 0 { t.Errorf("balance after refund = %d, want 0", got) } if ledgerRows(t, acc, "refund") != 1 { t.Errorf("refund ledger rows = %d, want 1", ledgerRows(t, acc, "refund")) } if abuse, loss := readRisk(t, acc); abuse || loss != 0 { t.Errorf("risk = (%v, %d), want (false, 0) — nothing was spent", abuse, loss) } // The order stays 'paid' — the refund lives in the ledger, not in the order status. if orderStatus(t, orderID) != "paid" { t.Errorf("order status = %s, want paid (refund is ledger-only)", orderStatus(t, orderID)) } } // TestPaymentsRefundAfterSpend reverses an order whose chips were partly spent: the reversal floors // at 0 (never negative), and the unrecoverable remainder is recorded as a loss + abuse flag. func TestPaymentsRefundAfterSpend(t *testing.T) { ctx := context.Background() svc := newPaymentsService() orderID, acc := fundedOrder(t, svc, 100, 14900) // Simulate 70 chips already spent, leaving 30 in the funded segment. if _, err := testDB.ExecContext(ctx, `UPDATE payments.balances SET chips = 30 WHERE account_id = $1 AND source = 'direct'`, acc); err != nil { t.Fatalf("simulate spend: %v", err) } refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB) out, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-2", refunded) if err != nil { t.Fatalf("refund: %v", err) } if out.Revoked != 30 || out.Loss != 70 { t.Fatalf("refund outcome = %+v, want 30 revoked / 70 loss", out) } if got := readBalance(t, acc, "direct"); got != 0 { t.Errorf("balance after refund = %d, want 0 (floored, never negative)", got) } if abuse, loss := readRisk(t, acc); !abuse || loss != 70 { t.Errorf("risk = (%v, %d), want (true, 70)", abuse, loss) } } // TestPaymentsRefundIdempotent verifies a replayed refund (same provider refund id) reverses nothing // more — the ledger idempotency index holds. func TestPaymentsRefundIdempotent(t *testing.T) { ctx := context.Background() svc := newPaymentsService() orderID, acc := fundedOrder(t, svc, 100, 14900) refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB) if _, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-dup", refunded); err != nil { t.Fatalf("first refund: %v", err) } // Re-credit the segment to prove the duplicate does not revoke again. if _, err := testDB.ExecContext(ctx, `UPDATE payments.balances SET chips = 100 WHERE account_id = $1 AND source = 'direct'`, acc); err != nil { t.Fatalf("re-credit: %v", err) } out2, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-dup", refunded) if err != nil { t.Fatalf("duplicate refund: %v", err) } if !out2.AlreadyRefunded { t.Error("duplicate refund not flagged AlreadyRefunded") } if got := readBalance(t, acc, "direct"); got != 100 { t.Errorf("balance after duplicate refund = %d, want 100 (not revoked twice)", got) } if ledgerRows(t, acc, "refund") != 1 { t.Errorf("refund ledger rows = %d, want 1 (duplicate wrote none)", ledgerRows(t, acc, "refund")) } } // TestPaymentsRefundUnpaidOrder refuses to refund an order that was never funded. func TestPaymentsRefundUnpaidOrder(t *testing.T) { ctx := context.Background() svc := newPaymentsService() acc := uuid.New() prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa") if err != nil { t.Fatalf("create order: %v", err) } refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB) if _, err := svc.Refund(ctx, res.OrderID, "robokassa", "rk-refund-x", refunded); !errors.Is(err, payments.ErrOrderNotPaid) { t.Fatalf("refund of a pending order = %v, want ErrOrderNotPaid", err) } if abuse, loss := readRisk(t, acc); abuse || loss != 0 { t.Errorf("risk = (%v, %d), want (false, 0) — no reversal on an unpaid order", abuse, loss) } }