Files
member-console/internal/server/operator_billing_overdue_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

151 lines
5.4 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"
"fmt"
"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")
// invoice-numbers D3: every non-draft row needs a number
// (chk_invoices_issued_have_number); a per-call counter keeps them
// unique within this one account.
n := 0
mustInvoice := func(status string, dueDate *time.Time) string {
t.Helper()
n++
number := fmt.Sprintf("%04d", n)
var id string
var err error
if dueDate == nil {
err = tx.QueryRowContext(ctx,
`INSERT INTO core.invoices (billing_account_id, status, currency, invoice_number) VALUES ($1,$2,'usd',$3) RETURNING invoice_id`,
fx.accountID, status, number).Scan(&id)
} else {
err = tx.QueryRowContext(ctx,
`INSERT INTO core.invoices (billing_account_id, status, currency, due_date, invoice_number) VALUES ($1,$2,'usd',$3,$4) RETURNING invoice_id`,
fx.accountID, status, *dueDate, number).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")
}
}