Files
member-console/internal/server/operator_billing_paged_queries_test.go
T
cgalo5758 dd3962990b Adopt entity keys and add invoice numbers
Replace the entity slugs on organizations, workspaces, resource pools,
and
plan ladders with nullable `key` columns and add keys to products,
prices,
and entitlement sets. Rename `providers.slug` to `provider` and add
partial
unique indexes for system and org role names.

Assign invoice numbers per billing account from a gapless transactional
counter; Stripe's number moves to the invoice mapping as an external
reference.

Seeds, fixtures, and the operator lookup address rows by key, and the
returning-login resync no longer blanks a display name when the IdP
sends
no `name` claim.
2026-08-29 20:12:04 -05:00

333 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
// Billing×4 paged/searched/filtered queries (operator-list-scale UX-4;
// operator-billing-views D8; tasks 2.4/3.3/3.4). DB-backed via
// topoTestDB (core schema only, the same helper
// TestLoadBillingData_StripeConfigured already uses in
// operator_billing_empty_state_test.go) inside a rolled-back transaction,
// so fixtures never persist across tests or runs.
import (
"context"
"database/sql"
"fmt"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
)
// truncateBillingPagedTables wipes the tables these tests count rows from.
// topoTestDB's scratch database is private to this lane's tests but may
// carry committed fixture rows from earlier runs or other lanes (the same
// caveat operator_billing_empty_state_test.go's
// TestLoadBillingData_StripeConfigured documents); truncating inside the
// rolled-back tx keeps that cleanup local to this test.
func truncateBillingPagedTables(t *testing.T, tx *sql.Tx) {
t.Helper()
if _, err := tx.ExecContext(context.Background(),
`TRUNCATE core.payments, core.invoices, core.subscriptions, core.accounts CASCADE`); err != nil {
t.Fatalf("truncate billing tables: %v", err)
}
}
// billingPagedTestFixture is one org + one billing account, the base unit
// every paged-query test builds on.
type billingPagedTestFixture struct {
orgID string
orgName string
accountID string
// accountName is what the ba.name ILIKE branch of each paged query
// matches against directly (the view's leading identifier pre-reorder).
accountName string
}
// newBillingPagedOrgAndAccount inserts a user -> person -> organization ->
// billing account chain with the given org and account names, returning
// the fixture. Names are brand-neutral per this repo's test-fixture
// convention.
func newBillingPagedOrgAndAccount(t *testing.T, tx *sql.Tx, orgName, accountName string) billingPagedTestFixture {
t.Helper()
ctx := context.Background()
uniq := fmt.Sprintf("%d", time.Now().UnixNano())
scan := func(query string, args ...any) string {
var id string
if err := tx.QueryRowContext(ctx, query, args...).Scan(&id); err != nil {
t.Fatalf("fixture %q: %v", query, err)
}
return id
}
userID := scan(`INSERT INTO core.users (oidc_subject) VALUES ($1) RETURNING user_id`, "sub-"+uniq)
personID := scan(`INSERT INTO core.persons (user_id, display_name, primary_email) VALUES ($1,$2,$3) RETURNING person_id`,
userID, "Tester "+uniq, uniq+"@example.test")
orgID := scan(`INSERT INTO core.organizations (name, org_type, owner_person_id) VALUES ($1,'personal',$2) RETURNING org_id`,
orgName, personID)
accountID := scan(`INSERT INTO core.accounts (org_id, name, status) VALUES ($1,$2,'active') RETURNING billing_account_id`,
orgID, accountName)
return billingPagedTestFixture{orgID: orgID, orgName: orgName, accountID: accountID, accountName: accountName}
}
// TestListBillingAccountsPage covers pagination (true total, LIMIT/OFFSET)
// and search (account name directly, organization name via the
// pre-resolved org_ids array narg — design D3, since internal/billing
// joins no core.organizations query).
func TestListBillingAccountsPage(t *testing.T) {
database := topoTestDB(t)
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
bq := billing.New(tx)
truncateBillingPagedTables(t, tx)
newBillingPagedOrgAndAccount(t, tx, "Acme Cooperative", "Acme Primary")
newBillingPagedOrgAndAccount(t, tx, "Acme Cooperative Annex", "Acme Marketing")
bramble := newBillingPagedOrgAndAccount(t, tx, "Bramble Collective", "Bramble Primary")
t.Run("pagination reports a true total across pages", func(t *testing.T) {
page1, err := bq.ListBillingAccountsPage(ctx, billing.ListBillingAccountsPageParams{PageLimit: 2, PageOffset: 0})
if err != nil {
t.Fatalf("page 1: %v", err)
}
if len(page1) != 2 {
t.Fatalf("page 1: got %d rows, want 2", len(page1))
}
if page1[0].TotalCount != 3 {
t.Errorf("page 1 total = %d, want 3", page1[0].TotalCount)
}
page2, err := bq.ListBillingAccountsPage(ctx, billing.ListBillingAccountsPageParams{PageLimit: 2, PageOffset: 2})
if err != nil {
t.Fatalf("page 2: %v", err)
}
if len(page2) != 1 {
t.Fatalf("page 2: got %d rows, want 1", len(page2))
}
if page2[0].TotalCount != 3 {
t.Errorf("page 2 total = %d, want 3", page2[0].TotalCount)
}
})
t.Run("search matches the account's own name", func(t *testing.T) {
rows, err := bq.ListBillingAccountsPage(ctx, billing.ListBillingAccountsPageParams{
Q: sql.NullString{String: "Marketing", Valid: true}, PageLimit: 50,
})
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Name != "Acme Marketing" {
t.Fatalf("got %+v, want exactly Acme Marketing", rows)
}
})
t.Run("search matches the organization via the pre-resolved org_ids array", func(t *testing.T) {
// "bramble" matches no account NAME (the accounts are all named
// "... Primary"/"... Marketing"), so a hit here can only come from
// the org_id = ANY(org_ids) branch — the org-name search path a
// handler resolves via matchingOrgIDs before calling this query.
rows, err := bq.ListBillingAccountsPage(ctx, billing.ListBillingAccountsPageParams{
Q: sql.NullString{String: "bramble", Valid: true}, OrgIds: []string{bramble.orgID}, PageLimit: 50,
})
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].BillingAccountID != bramble.accountID {
t.Fatalf("got %+v, want exactly the Bramble account", rows)
}
})
t.Run("empty org_ids does not spuriously match", func(t *testing.T) {
rows, err := bq.ListBillingAccountsPage(ctx, billing.ListBillingAccountsPageParams{
Q: sql.NullString{String: "nonexistent-term-xyz", Valid: true}, PageLimit: 50,
})
if err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Errorf("got %d rows for a non-matching term, want 0", len(rows))
}
})
}
// TestListSubscriptionsPage covers the status facet and the org_id carried
// through the accounts join (so the handler can resolve/link the
// organization without a cross-module join).
func TestListSubscriptionsPage(t *testing.T) {
database := topoTestDB(t)
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
bq := billing.New(tx)
truncateBillingPagedTables(t, tx)
fx := newBillingPagedOrgAndAccount(t, tx, "Cedar Guild", "Cedar Primary")
mustExec := func(query string, args ...any) {
t.Helper()
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
t.Fatalf("fixture %q: %v", query, err)
}
}
mustExec(`INSERT INTO core.subscriptions (billing_account_id, status) VALUES ($1, 'active')`, fx.accountID)
mustExec(`INSERT INTO core.subscriptions (billing_account_id, status) VALUES ($1, 'canceled')`, fx.accountID)
rows, err := bq.ListSubscriptionsPage(ctx, billing.ListSubscriptionsPageParams{PageLimit: 50})
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 {
t.Fatalf("unfiltered: got %d rows, want 2", len(rows))
}
for _, row := range rows {
if row.OrgID != fx.orgID {
t.Errorf("row org_id = %s, want %s (the accounts join)", row.OrgID, fx.orgID)
}
}
active, err := bq.ListSubscriptionsPage(ctx, billing.ListSubscriptionsPageParams{
Status: sql.NullString{String: "active", Valid: true}, PageLimit: 50,
})
if err != nil {
t.Fatal(err)
}
if len(active) != 1 || active[0].Status != "active" {
t.Fatalf("status=active: got %+v, want exactly one active row", active)
}
}
// TestListInvoicesPage covers the stored-status facet and the derived
// 'overdue' sentinel value (design D5): totals are computed in SQL, not
// after LIMIT.
func TestListInvoicesPage(t *testing.T) {
database := topoTestDB(t)
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
bq := billing.New(tx)
truncateBillingPagedTables(t, tx)
fx := newBillingPagedOrgAndAccount(t, tx, "Driftwood Union", "Driftwood Primary")
mustExec := func(query string, args ...any) {
t.Helper()
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
t.Fatalf("fixture %q: %v", query, err)
}
}
past := time.Now().Add(-72 * time.Hour)
future := time.Now().Add(72 * time.Hour)
mustExec(`INSERT INTO core.invoices (billing_account_id, status, currency, due_date, invoice_number) VALUES ($1,'open','usd',$2,'0001')`, fx.accountID, past)
mustExec(`INSERT INTO core.invoices (billing_account_id, status, currency, due_date, invoice_number) VALUES ($1,'open','usd',$2,'0002')`, fx.accountID, future)
mustExec(`INSERT INTO core.invoices (billing_account_id, status, currency, invoice_number) VALUES ($1,'open','usd','0003')`, fx.accountID)
mustExec(`INSERT INTO core.invoices (billing_account_id, status, currency, invoice_number) VALUES ($1,'paid','usd','DRIFT-0001')`, fx.accountID)
all, err := bq.ListInvoicesPage(ctx, billing.ListInvoicesPageParams{PageLimit: 50})
if err != nil {
t.Fatal(err)
}
if len(all) != 4 {
t.Fatalf("unfiltered: got %d rows, want 4", len(all))
}
open, err := bq.ListInvoicesPage(ctx, billing.ListInvoicesPageParams{
Status: sql.NullString{String: "open", Valid: true}, PageLimit: 50,
})
if err != nil {
t.Fatal(err)
}
if len(open) != 3 {
t.Fatalf("status=open: got %d rows, want 3 (past-due, future-due, no-due-date)", len(open))
}
overdue, err := bq.ListInvoicesPage(ctx, billing.ListInvoicesPageParams{
Status: sql.NullString{String: "overdue", Valid: true}, PageLimit: 50,
})
if err != nil {
t.Fatal(err)
}
if len(overdue) != 1 {
t.Fatalf("status=overdue: got %d rows, want exactly 1", len(overdue))
}
if !overdue[0].DueDate.Valid || !overdue[0].DueDate.Time.Before(time.Now()) {
t.Errorf("the overdue row's due date must be in the past, got %+v", overdue[0].DueDate)
}
paid, err := bq.ListInvoicesPage(ctx, billing.ListInvoicesPageParams{
Status: sql.NullString{String: "paid", Valid: true}, PageLimit: 50,
})
if err != nil {
t.Fatal(err)
}
if len(paid) != 1 || paid[0].Status != "paid" {
t.Fatalf("status=paid: got %+v, want exactly one paid row", paid)
}
if !paid[0].InvoiceNumber.Valid || paid[0].InvoiceNumber.String != "DRIFT-0001" {
t.Fatalf("status=paid: InvoiceNumber = %+v, want DRIFT-0001", paid[0].InvoiceNumber)
}
// invoice-numbers D4: the search predicate matches the invoice's own
// number, not just the billing account or organization name.
byNumber, err := bq.ListInvoicesPage(ctx, billing.ListInvoicesPageParams{
Q: sql.NullString{String: "drift-000", Valid: true}, PageLimit: 50,
})
if err != nil {
t.Fatal(err)
}
if len(byNumber) != 1 || !byNumber[0].InvoiceNumber.Valid || byNumber[0].InvoiceNumber.String != "DRIFT-0001" {
t.Fatalf("search by invoice number: got %+v, want exactly the DRIFT-0001 row", byNumber)
}
}
// TestListPaymentsPage covers search (account name directly, organization
// name via the pre-resolved org_ids array) and the org_id carried through
// the accounts join.
func TestListPaymentsPage(t *testing.T) {
database := topoTestDB(t)
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
bq := billing.New(tx)
truncateBillingPagedTables(t, tx)
fx := newBillingPagedOrgAndAccount(t, tx, "Ember Workshop", "Ember Primary")
var invoiceID string
if err := tx.QueryRowContext(ctx,
`INSERT INTO core.invoices (billing_account_id, status, currency, invoice_number) VALUES ($1,'paid','usd','0001') RETURNING invoice_id`,
fx.accountID).Scan(&invoiceID); err != nil {
t.Fatalf("fixture invoice: %v", err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO core.payments (invoice_id, billing_account_id, amount, currency, status) VALUES ($1,$2,1000,'usd','succeeded')`,
invoiceID, fx.accountID); err != nil {
t.Fatalf("fixture payment: %v", err)
}
rows, err := bq.ListPaymentsPage(ctx, billing.ListPaymentsPageParams{
Q: sql.NullString{String: "ember", Valid: true}, OrgIds: []string{fx.orgID}, PageLimit: 50,
})
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].OrgID != fx.orgID {
t.Fatalf("got %+v, want exactly the Ember payment with org_id set from the accounts join", rows)
}
none, err := bq.ListPaymentsPage(ctx, billing.ListPaymentsPageParams{
Q: sql.NullString{String: "no-such-term", Valid: true}, PageLimit: 50,
})
if err != nil {
t.Fatal(err)
}
if len(none) != 0 {
t.Errorf("got %d rows for a non-matching term, want 0", len(none))
}
}