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.
144 lines
5.1 KiB
Go
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")
|
|
}
|
|
}
|