Files
member-console/internal/server/operator_billing_overdue_test.go
T
cgalo5758 257955c9d3 Add operator list-scale contract and People directory
Governed operator lists (organizations, grants, people, billing×4) gain
server-side search, status filters, and 50-row pages with true totals
from count(*) OVER(); state is URL-addressable, out-of-range pages
clamp,
and no-match is distinct from true-empty.

People is the eighth flat sidebar entry: /operator/persons lists persons
newest-joined first (excluding the reserved system person), rows linking
to the existing detail.

Billing gains an operator invoice detail at
/operator/billing/invoices/{invoiceID} reusing the member projection;
open invoices past due present as Overdue (derived, filterable, stored
status untouched); all four views lead with the linked organization and
mute object IDs.

Grants filter over the derived Live/Superseded/Inactive state, the SQL
HAVING predicate pinned to the Go derivation by test. Embedded lists
(org composite ledger, Tier changes) adopt the shared controls under
namespaced params with sibling-state-preserving URLs and scoped htmx
swaps that hold the viewport.

Review corrections: blocked ladder Delete renders disabled with tooltip
and mutations fire toasts; collapse triggers paint their open state;
sections use outside headings; plan topology drops the orphan-product
check; domains policy collapses behind a disclosure.
2026-08-24 03:58:18 -05:00

144 lines
5.1 KiB
Go

package server
// Overdue derivation (design D5; operator-billing-views: "Open invoices
// past due present as Overdue"; tasks 4.1/4.2): invoiceIsOverdue is the one
// Go helper the invoices list, the invoice detail, and the SQL Overdue
// facet must all agree on. Stored status is never mutated by this
// derivation — these tests only ever read status back unchanged.
import (
"context"
"database/sql"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
)
func TestInvoiceIsOverdue(t *testing.T) {
now := time.Now()
cases := []struct {
name string
status string
dueDate sql.NullTime
want bool
}{
{"open, past due date -> overdue", "open", sql.NullTime{Time: now.Add(-time.Nanosecond), Valid: true}, true},
{"open, far past due date -> overdue", "open", sql.NullTime{Time: now.Add(-72 * time.Hour), Valid: true}, true},
{"open, future due date -> not overdue", "open", sql.NullTime{Time: now.Add(time.Second), Valid: true}, false},
{"open, no due date -> never overdue", "open", sql.NullTime{}, false},
{"draft, past due date -> not overdue (not open)", "draft", sql.NullTime{Time: now.Add(-time.Hour), Valid: true}, false},
{"paid, past due date -> not overdue (not open)", "paid", sql.NullTime{Time: now.Add(-time.Hour), Valid: true}, false},
{"void, past due date -> not overdue (not open)", "void", sql.NullTime{Time: now.Add(-time.Hour), Valid: true}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := invoiceIsOverdue(tc.status, tc.dueDate); got != tc.want {
t.Errorf("invoiceIsOverdue(%q, %+v) = %v, want %v", tc.status, tc.dueDate, got, tc.want)
}
})
}
}
// TestInvoiceOverdueSQLAgreesWithGoHelper pins design D5's agreement
// requirement: the SQL 'overdue' facet in ListInvoicesPage must select
// exactly the rows invoiceIsOverdue classifies overdue on a mixed fixture
// (one open-past-due, one open-future-due, one open-no-due-date, one
// paid) — never more (which would over-flag), never fewer (which would
// under-count "who is behind on payments?").
func TestInvoiceOverdueSQLAgreesWithGoHelper(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, "Foxglove Trust", "Foxglove Primary")
mustInvoice := func(status string, dueDate *time.Time) string {
t.Helper()
var id string
var err error
if dueDate == nil {
err = tx.QueryRowContext(ctx,
`INSERT INTO core.invoices (billing_account_id, status, currency) VALUES ($1,$2,'usd') RETURNING invoice_id`,
fx.accountID, status).Scan(&id)
} else {
err = tx.QueryRowContext(ctx,
`INSERT INTO core.invoices (billing_account_id, status, currency, due_date) VALUES ($1,$2,'usd',$3) RETURNING invoice_id`,
fx.accountID, status, *dueDate).Scan(&id)
}
if err != nil {
t.Fatalf("fixture invoice (status=%s): %v", status, err)
}
return id
}
past := time.Now().Add(-48 * time.Hour)
future := time.Now().Add(48 * time.Hour)
openPastDue := mustInvoice("open", &past)
openFutureDue := mustInvoice("open", &future)
openNoDueDate := mustInvoice("open", nil)
paid := mustInvoice("paid", &past)
all, err := bq.ListInvoicesPage(ctx, billing.ListInvoicesPageParams{PageLimit: 50})
if err != nil {
t.Fatal(err)
}
if len(all) != 4 {
t.Fatalf("fixture setup: got %d invoices, want 4", len(all))
}
// The Go-side reference set: exactly the rows invoiceIsOverdue flags.
wantOverdue := map[string]bool{}
for _, inv := range all {
if invoiceIsOverdue(inv.Status, inv.DueDate) {
wantOverdue[inv.InvoiceID] = true
}
}
if len(wantOverdue) != 1 || !wantOverdue[openPastDue] {
t.Fatalf("Go helper reference set = %v, want exactly {%s}", wantOverdue, openPastDue)
}
sqlOverdue, err := bq.ListInvoicesPage(ctx, billing.ListInvoicesPageParams{
Status: sql.NullString{String: "overdue", Valid: true}, PageLimit: 50,
})
if err != nil {
t.Fatal(err)
}
gotOverdue := map[string]bool{}
for _, inv := range sqlOverdue {
gotOverdue[inv.InvoiceID] = true
}
if len(gotOverdue) != len(wantOverdue) {
t.Fatalf("SQL overdue facet returned %d rows, Go helper flags %d", len(gotOverdue), len(wantOverdue))
}
for id := range wantOverdue {
if !gotOverdue[id] {
t.Errorf("SQL overdue facet missed invoice %s, which the Go helper classifies overdue", id)
}
}
for id := range gotOverdue {
if !wantOverdue[id] {
t.Errorf("SQL overdue facet over-flagged invoice %s, which the Go helper does not classify overdue", id)
}
}
// Never-overdue invoices stay out, and stored status is untouched.
for _, id := range []string{openFutureDue, openNoDueDate, paid} {
if gotOverdue[id] {
t.Errorf("invoice %s must not appear in the SQL overdue facet", id)
}
}
var storedStatus string
if err := tx.QueryRowContext(ctx, `SELECT status FROM core.invoices WHERE invoice_id = $1`, openPastDue).Scan(&storedStatus); err != nil {
t.Fatal(err)
}
if storedStatus != "open" {
t.Errorf("stored status of the overdue invoice = %q, want unchanged %q (Overdue is presentation-only)", storedStatus, "open")
}
}