//go:build integration package inttest import ( "context" "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 } // 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") } } // 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") } } // 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)) } }