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

// setRewardConfig sets the rewarded payout and caps on the shared config row, restoring the defaults
// after the test (the row is a singleton shared across the sequential suite).
func setRewardConfig(t *testing.T, payout, dailyCap, hourlyCap int) {
	t.Helper()
	if _, err := testDB.ExecContext(context.Background(),
		`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
		payout, dailyCap, hourlyCap); err != nil {
		t.Fatalf("set reward config: %v", err)
	}
	t.Cleanup(func() {
		_, _ = testDB.ExecContext(context.Background(),
			`UPDATE payments.config SET rewarded_payout_chips=0, reward_daily_cap=50, reward_hourly_cap=10`)
	})
}

// TestPaymentsRewardCredit exercises the rewarded-video credit: a watched view credits the VK segment
// the configured payout, a retried view (same nonce) credits once, and the hourly cap blocks the
// third distinct view.
func TestPaymentsRewardCredit(t *testing.T) {
	ctx := context.Background()
	svc := newPaymentsService()
	acc := uuid.New()
	setRewardConfig(t, 5, 5, 2) // 5 chips/view, daily 5, hourly 2
	vk := payments.NewContext("vk", "web")
	present := []payments.Source{payments.SourceVK}

	out, err := svc.CreditReward(ctx, acc, vk, present, "n1")
	if err != nil {
		t.Fatalf("credit: %v", err)
	}
	if out.Chips != 5 || out.Capped {
		t.Fatalf("reward outcome = %+v, want 5 chips, not capped", out)
	}
	if got := readBalance(t, acc, "vk"); got != 5 {
		t.Errorf("vk balance = %d, want 5", got)
	}

	// A retried view (same nonce) credits nothing more.
	out2, err := svc.CreditReward(ctx, acc, vk, present, "n1")
	if err != nil {
		t.Fatalf("retry: %v", err)
	}
	if !out2.AlreadyCredited {
		t.Error("retried view not flagged AlreadyCredited")
	}
	if got := readBalance(t, acc, "vk"); got != 5 {
		t.Errorf("vk balance after retry = %d, want 5 (credited once)", got)
	}

	// A second distinct view credits again (2 total).
	if _, err := svc.CreditReward(ctx, acc, vk, present, "n2"); err != nil {
		t.Fatalf("credit 2: %v", err)
	}
	if got := readBalance(t, acc, "vk"); got != 10 {
		t.Errorf("vk balance = %d, want 10", got)
	}

	// The third distinct view hits the hourly cap (2) — capped, no credit.
	out3, err := svc.CreditReward(ctx, acc, vk, present, "n3")
	if err != nil {
		t.Fatalf("credit 3: %v", err)
	}
	if !out3.Capped {
		t.Error("third view not capped (hourly cap 2)")
	}
	if got := readBalance(t, acc, "vk"); got != 10 {
		t.Errorf("vk balance after cap = %d, want 10 (capped view credited nothing)", got)
	}
}

// TestPaymentsRewardDisabledAndContext verifies rewarded is inert when unconfigured (0 payout) and
// refused outside a VK context (rewarded is VK-only, D28).
func TestPaymentsRewardDisabledAndContext(t *testing.T) {
	ctx := context.Background()
	svc := newPaymentsService()
	acc := uuid.New()
	setRewardConfig(t, 0, 50, 10) // payout 0 = disabled

	vk := payments.NewContext("vk", "web")
	out, err := svc.CreditReward(ctx, acc, vk, []payments.Source{payments.SourceVK}, "d1")
	if err != nil {
		t.Fatalf("credit (disabled): %v", err)
	}
	if out.Chips != 0 || out.Capped || out.AlreadyCredited {
		t.Fatalf("disabled reward outcome = %+v, want 0 chips, not capped", out)
	}

	// A direct (non-VK) context is refused — rewarded is VK-only.
	direct := payments.NewContext("direct", "web")
	if _, err := svc.CreditReward(ctx, acc, direct, []payments.Source{payments.SourceDirect}, "d2"); !errors.Is(err, payments.ErrUntrusted) {
		t.Fatalf("direct-context reward = %v, want ErrUntrusted", err)
	}
}

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