3b744b7d2f
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s
There was no way to see the money as a whole: the ledger was only ever rendered inside one account's card, so "what came in last month" meant exporting the entire CSV and reading it elsewhere. /_gm/ledger lists every operation, newest first, filtered by date range (defaulting to the last 30 days), by wallet, by rail, by kind and by account. Wallet and rail are deliberately separate axes because they answer different questions — "what happened on VK" matches the funded segment or the benefit origin, while "what came through YooKassa" matches the settling provider, and a chip spend has no rail at all, so it drops out of that filter by construction. Above the table sit the totals for everything the filter matches, not merely the page on screen, which is the point of showing them. Money is listed per currency because the rails settle in roubles, Votes and Stars and one sum across them would mean nothing. Row amounts come from the operation snapshot, since the ledger's own columns count chips rather than money. Paging, the CSV export and a refund's way back are all built from the same encoded filter, so none of them can quietly show a different slice than the screen. The export moves here from the user card and gains the filter along with money columns; it is capped, because an append-only ledger grows forever and an unbounded export would eventually time out. The refund action moves onto the funded rows here — an operator can now find a payment by filter without knowing whose it is first — and returns to the same filtered view. The destination travels with the form but is only honoured when it is a console path, so a crafted form cannot turn it into an open redirect. The user card keeps the account's standing rather than its history: balances, benefits, the risk flag and a lifetime summary (money paid and refunded per currency, chips credited and spent), plus a link into the ledger scoped to that account. Operations are rendered in one place, with filters and paging, instead of two. Tests: the filter axes, paging that neither repeats nor drops a row, totals that describe the range rather than the page, money recovered from a snapshot and absent on a spend, and the console page, the export and the card hand-off. The existing finance-panel test now asserts the summary and that the card no longer re-renders the rows.
264 lines
9.4 KiB
Go
264 lines
9.4 KiB
Go
//go:build integration
|
|
|
|
package inttest
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/payments"
|
|
)
|
|
|
|
// seedLedgerRow writes one ledger row directly, at a chosen age, so a test can stand up a history
|
|
// that spans a date range without waiting for one.
|
|
func seedLedgerRow(t *testing.T, acc uuid.UUID, kind, source, provider string, chips int, snapshot string, age time.Duration) {
|
|
t.Helper()
|
|
var providerPaymentID any
|
|
if provider != "" {
|
|
providerPaymentID = "pp-" + uuid.NewString()
|
|
}
|
|
if _, err := testDB.ExecContext(context.Background(), `
|
|
INSERT INTO payments.ledger (ledger_id, account_id, kind, source, origin, chips_delta,
|
|
provider, provider_payment_id, snapshot, created_at)
|
|
VALUES ($1,$2,$3,$4,$4,$5,NULLIF($6,''),$7,$8::jsonb, now() - $9::interval)`,
|
|
uuid.New(), acc, kind, source, chips, provider, providerPaymentID, snapshot,
|
|
fmt.Sprintf("%d seconds", int(age.Seconds()))); err != nil {
|
|
t.Fatalf("seed ledger row: %v", err)
|
|
}
|
|
}
|
|
|
|
// ledgerReport reads a filtered page through the service, the way the console does.
|
|
func ledgerReport(t *testing.T, pay *payments.Service, f payments.LedgerFilter, limit, offset int) payments.LedgerReport {
|
|
t.Helper()
|
|
rep, err := pay.LedgerReportPage(context.Background(), f, limit, offset)
|
|
if err != nil {
|
|
t.Fatalf("ledger report: %v", err)
|
|
}
|
|
return rep
|
|
}
|
|
|
|
// TestLedgerReportFilters covers each filter axis over one seeded history, including the property
|
|
// that matters most for an operator: the totals describe the whole filtered range, not the page.
|
|
func TestLedgerReportFilters(t *testing.T) {
|
|
acc, other := provisionAccount(t), provisionAccount(t)
|
|
// Two recent purchases on different rails, one older purchase outside a narrow range, a spend
|
|
// (no rail at all) and a refund.
|
|
seedLedgerRow(t, acc, "fund", "direct", "yookassa", 100, `{"amount_minor":14900,"currency":"RUB","title":"100 chips"}`, time.Hour)
|
|
seedLedgerRow(t, acc, "fund", "vk", "vk", 50, `{"amount_minor":50,"currency":"VOTE","title":"50 chips"}`, 2*time.Hour)
|
|
seedLedgerRow(t, other, "fund", "direct", "yookassa", 30, `{"amount_minor":4900,"currency":"RUB","title":"30 chips"}`, 40*24*time.Hour)
|
|
seedLedgerRow(t, acc, "spend", "direct", "", -20, `{"title":"hints"}`, 30*time.Minute)
|
|
seedLedgerRow(t, acc, "refund", "direct", "yookassa", -100, `{"amount_minor":14900,"currency":"RUB"}`, 10*time.Minute)
|
|
|
|
pay := newPaymentsService()
|
|
// Scoped to this test's own account: the suite shares one database and other tests seed recent
|
|
// ledger rows, so an unscoped count would be a coin toss.
|
|
recent := payments.LedgerFilter{From: time.Now().Add(-24 * time.Hour), AccountID: acc}
|
|
|
|
t.Run("date range excludes older rows", func(t *testing.T) {
|
|
rep := ledgerReport(t, pay, recent, 100, 0)
|
|
for _, r := range rep.Rows {
|
|
if r.AccountID == other {
|
|
t.Error("a row from outside the range was returned")
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("account", func(t *testing.T) {
|
|
rep := ledgerReport(t, pay, payments.LedgerFilter{AccountID: other}, 100, 0)
|
|
if rep.Total != 1 {
|
|
t.Fatalf("rows for the other account = %d, want 1", rep.Total)
|
|
}
|
|
rep = ledgerReport(t, pay, payments.LedgerFilter{AccountID: acc}, 100, 0)
|
|
if rep.Total != 4 {
|
|
t.Fatalf("rows for the account = %d, want 4", rep.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("kind", func(t *testing.T) {
|
|
f := recent
|
|
f.Kind = "spend"
|
|
rep := ledgerReport(t, pay, f, 100, 0)
|
|
for _, r := range rep.Rows {
|
|
if r.Kind != "spend" {
|
|
t.Errorf("kind filter returned a %q row", r.Kind)
|
|
}
|
|
}
|
|
if rep.Total == 0 {
|
|
t.Error("kind filter matched nothing")
|
|
}
|
|
})
|
|
|
|
t.Run("wallet", func(t *testing.T) {
|
|
f := recent
|
|
f.Wallet = "vk"
|
|
rep := ledgerReport(t, pay, f, 100, 0)
|
|
if rep.Total != 1 {
|
|
t.Fatalf("vk wallet rows = %d, want 1", rep.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("rail excludes spends, which have none", func(t *testing.T) {
|
|
f := recent
|
|
f.Provider = "yookassa"
|
|
rep := ledgerReport(t, pay, f, 100, 0)
|
|
for _, r := range rep.Rows {
|
|
if r.Kind == "spend" {
|
|
t.Error("a chip spend matched a rail filter, but a spend has no rail")
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("totals cover the range, not the page", func(t *testing.T) {
|
|
f := recent
|
|
full := ledgerReport(t, pay, f, 100, 0)
|
|
firstPage := ledgerReport(t, pay, f, 1, 0)
|
|
if len(firstPage.Rows) != 1 {
|
|
t.Fatalf("page rows = %d, want 1", len(firstPage.Rows))
|
|
}
|
|
if firstPage.Total != full.Total {
|
|
t.Errorf("page total = %d, want the full match count %d", firstPage.Total, full.Total)
|
|
}
|
|
if firstPage.Totals.ChipsIn != full.Totals.ChipsIn || firstPage.Totals.ChipsOut != full.Totals.ChipsOut {
|
|
t.Errorf("page totals = %+v, want the whole range's %+v", firstPage.Totals, full.Totals)
|
|
}
|
|
// 100 + 50 credited, 20 spent + 100 refunded revoked.
|
|
if full.Totals.ChipsIn != 150 || full.Totals.ChipsOut != 120 {
|
|
t.Errorf("chip totals = in %d / out %d, want 150 / 120", full.Totals.ChipsIn, full.Totals.ChipsOut)
|
|
}
|
|
// Money is kept per currency; summing roubles and Votes together would be meaningless.
|
|
var rub, vote bool
|
|
for _, m := range full.Totals.MoneyIn {
|
|
switch m.Currency() {
|
|
case payments.CurrencyRUB:
|
|
rub = true
|
|
if m.Minor() != 14900 {
|
|
t.Errorf("RUB in = %d, want 14900", m.Minor())
|
|
}
|
|
case payments.CurrencyVote:
|
|
vote = true
|
|
}
|
|
}
|
|
if !rub || !vote {
|
|
t.Errorf("money in = %v, want both a RUB and a VOTE total", full.Totals.MoneyIn)
|
|
}
|
|
if len(full.Totals.MoneyRefunded) != 1 || full.Totals.MoneyRefunded[0].Minor() != 14900 {
|
|
t.Errorf("money refunded = %v, want one 149.00 RUB total", full.Totals.MoneyRefunded)
|
|
}
|
|
})
|
|
|
|
t.Run("money is recovered from the snapshot", func(t *testing.T) {
|
|
f := recent
|
|
f.Kind = "fund"
|
|
f.Wallet = "direct"
|
|
rep := ledgerReport(t, pay, f, 100, 0)
|
|
if len(rep.Rows) != 1 {
|
|
t.Fatalf("rows = %d, want 1", len(rep.Rows))
|
|
}
|
|
r := rep.Rows[0]
|
|
if !r.HasMoney() || r.Money.Minor() != 14900 || r.Money.Currency() != payments.CurrencyRUB {
|
|
t.Errorf("row money = %v, want 149.00 RUB", r.Money)
|
|
}
|
|
})
|
|
|
|
t.Run("a spend carries no money", func(t *testing.T) {
|
|
f := recent
|
|
f.Kind = "spend"
|
|
rep := ledgerReport(t, pay, f, 100, 0)
|
|
if len(rep.Rows) != 1 {
|
|
t.Fatalf("rows = %d, want 1", len(rep.Rows))
|
|
}
|
|
if rep.Rows[0].HasMoney() {
|
|
t.Errorf("a chip spend reported money %v", rep.Rows[0].Money)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestLedgerReportPaging checks paging walks the range without repeating or dropping a row.
|
|
func TestLedgerReportPaging(t *testing.T) {
|
|
acc := provisionAccount(t)
|
|
for i := range 5 {
|
|
seedLedgerRow(t, acc, "fund", "direct", "yookassa", 10,
|
|
`{"amount_minor":1000,"currency":"RUB"}`, time.Duration(i+1)*time.Minute)
|
|
}
|
|
pay := newPaymentsService()
|
|
f := payments.LedgerFilter{AccountID: acc}
|
|
|
|
seen := map[string]bool{}
|
|
for offset := 0; offset < 5; offset += 2 {
|
|
rep := ledgerReport(t, pay, f, 2, offset)
|
|
if rep.Total != 5 {
|
|
t.Fatalf("total = %d, want 5 at every offset", rep.Total)
|
|
}
|
|
for _, r := range rep.Rows {
|
|
key := r.ProviderPaymentID
|
|
if seen[key] {
|
|
t.Errorf("row %s appeared on two pages", key)
|
|
}
|
|
seen[key] = true
|
|
}
|
|
}
|
|
if len(seen) != 5 {
|
|
t.Errorf("walked %d rows across the pages, want 5", len(seen))
|
|
}
|
|
}
|
|
|
|
// TestLedgerConsolePageAndExport drives the console: the page renders under a filter, the CSV export
|
|
// honours that same filter, and the user card links to the ledger scoped to the account instead of
|
|
// listing the rows itself.
|
|
func TestLedgerConsolePageAndExport(t *testing.T) {
|
|
srv, _, _ := bannerServer(t)
|
|
acc, other := provisionAccount(t), provisionAccount(t)
|
|
seedLedgerRow(t, acc, "fund", "direct", "yookassa", 100, `{"amount_minor":14900,"currency":"RUB","title":"Pack A"}`, time.Hour)
|
|
seedLedgerRow(t, other, "fund", "vk", "vk", 50, `{"amount_minor":50,"currency":"VOTE","title":"Pack B"}`, time.Hour)
|
|
|
|
rec := consoleGet(t, srv, "/_gm/ledger")
|
|
if rec.Code != 200 {
|
|
t.Fatalf("ledger page = %d, want 200", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{"Pack A", "Pack B", "149.00 RUB", "Totals for the filtered range"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("ledger page does not show %q", want)
|
|
}
|
|
}
|
|
|
|
// Filtering to one account must drop the other's operation from the page.
|
|
rec = consoleGet(t, srv, "/_gm/ledger?user="+acc.String())
|
|
if body := rec.Body.String(); !strings.Contains(body, "Pack A") || strings.Contains(body, "Pack B") {
|
|
t.Error("the user filter did not scope the ledger page to one account")
|
|
}
|
|
|
|
// The pager links must carry the filters, or paging would silently widen the view.
|
|
if body := rec.Body.String(); strings.Contains(body, "page=") && !strings.Contains(body, "user="+acc.String()+"&page=") {
|
|
t.Error("a pager link dropped the active filters")
|
|
}
|
|
|
|
// The export honours the same filter as the screen it is linked from.
|
|
rec = consoleGet(t, srv, "/_gm/ledger.csv?user="+acc.String())
|
|
if rec.Code != 200 {
|
|
t.Fatalf("ledger export = %d, want 200", rec.Code)
|
|
}
|
|
csv := rec.Body.String()
|
|
if !strings.Contains(csv, "Pack A") || strings.Contains(csv, "Pack B") {
|
|
t.Error("the CSV export ignored the filter")
|
|
}
|
|
if !strings.Contains(csv, "amount_minor,currency") {
|
|
t.Error("the CSV export has no money columns")
|
|
}
|
|
|
|
// The user card is a summary now: totals and a link into the ledger, not the rows themselves.
|
|
rec = consoleGet(t, srv, "/_gm/users/"+acc.String())
|
|
card := rec.Body.String()
|
|
if !strings.Contains(card, "/_gm/ledger?user="+acc.String()) {
|
|
t.Error("the user card does not link to its ledger slice")
|
|
}
|
|
if !strings.Contains(card, "Chips credited") {
|
|
t.Error("the user card shows no totals summary")
|
|
}
|
|
}
|