Files
member-console/internal/server/operator_billing_overdue_test.go
T
cgalo5758 3727ff31d8 Add entitlement set rule change ledger and preview flow
Add an append-only ledger of entitlement set rule changes with per-pool
effect rows, a preview-and-commit rule change flow, and an automatic
drain that settles deferred recomputations. Rules gain a tier reduction
policy, resource keys declare over-limit behavior, and the materializer
now lowers limits when a rule stops applying.
Add entitlement set rule change ledger and preview flow

Add an append-only ledger of entitlement set rule changes with a
preview-and-commit operator flow. Rule writes now go through an enclosed
`core.commit_rule_change` function that files an act row and one
obligation per carrying pool, with a drain workflow settling deferred
recomputations. The preview dry-runs the materializer with a rule
overlay and renders per-pool buckets, reduction-policy disclosures, and
provider over-limit consequences. Materializing transactions take a
shared advisory rendezvous that rule changes hold exclusively, enforced
by a possession assertion. Add History and Entitlement changes surfaces,
a rule-less warning on five product-selection surfaces, and a
`tier_reduction_policy` column that gates FedWiki parking.
2026-09-15 03:53:28 -05:00

155 lines
5.6 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
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"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
)
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 := entitlements.BeginMaterializing(ctx, database, 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")
}
}