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.
333 lines
12 KiB
Go
333 lines
12 KiB
Go
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))
|
||
}
|
||
}
|