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

277 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"bytes"
"html/template"
"io/fs"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// memberTestTemplates parses the member partial set the way
// NewMemberInvoicesHandler does, for isolated template-rendering tests.
func memberTestTemplates(t *testing.T) *template.Template {
t.Helper()
partialsSub, err := fs.Sub(embeds.Templates, "templates/partials")
if err != nil {
t.Fatalf("fs.Sub: %v", err)
}
tmpl, err := template.New("member").Funcs(template.FuncMap{
"routeURL": web.RouteURL,
}).ParseFS(partialsSub, "member_*.html")
if err != nil {
t.Fatalf("ParseFS: %v", err)
}
return tmpl
}
// TestOperatorInvoicesListRendersNumber covers invoice-numbers D3/D6: the
// invoices list leads with the number right after the View control (<code>),
// the billing account beside it (a muted secondary, since this list crosses
// accounts and the number alone is ambiguous), a grandfathered
// Stripe-format number renders the same way as a platform one, and the
// empty-value marker with its disclosure tooltip shows before issuance.
func TestOperatorInvoicesListRendersNumber(t *testing.T) {
tmpl := billingTestTemplates(t)
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "operator_invoices.html", InvoicesData{
Invoices: []InvoiceViewModel{
{InvoiceID: "inv-1", InvoiceNumber: "0042", BillingAccountName: "Acme Primary", OrgName: "Acme Cooperative", OrgID: "org-1", Status: "paid", StatusClass: "success", AmountDue: "USD 10.00", AmountPaid: "USD 10.00", Currency: "USD", StripeSyncStatus: "synced", CreatedAt: "Jan 2, 2026"},
{InvoiceID: "inv-2", InvoiceNumber: "", BillingAccountName: "Bramble Primary", OrgName: "Bramble Collective", OrgID: "org-2", Status: "open", StatusClass: "warning", AmountDue: "USD 5.00", AmountPaid: "USD 0.00", Currency: "USD", StripeSyncStatus: "not_mapped", CreatedAt: "Jan 3, 2026"},
{InvoiceID: "inv-3", InvoiceNumber: "A1B2C3D4-0001", BillingAccountName: "Cedar Primary", OrgName: "Cedar Guild", OrgID: "org-3", Status: "paid", StatusClass: "success", AmountDue: "USD 20.00", AmountPaid: "USD 20.00", Currency: "USD", StripeSyncStatus: "synced", CreatedAt: "Jan 4, 2026"},
},
StripeConfigured: true,
Nav: ListNav{
BasePath: "/operator/billing/invoices",
SearchPlaceholder: "Search by organization or invoice number",
Page: 1,
Total: 3,
},
}); err != nil {
t.Fatalf("ExecuteTemplate: %v", err)
}
out := buf.String()
if !strings.Contains(out, "<code>0042</code>") {
t.Errorf("populated invoice number must render as <code>, got:\n%s", out)
}
if !strings.Contains(out, "Acme Primary") {
t.Errorf("billing account name must render beside the number, got:\n%s", out)
}
// A grandfathered Stripe-format number renders exactly like a platform
// one -- the composite index and the template do not distinguish forms.
if !strings.Contains(out, "<code>A1B2C3D4-0001</code>") {
t.Errorf("a grandfathered Stripe-format number must render as <code> too, got:\n%s", out)
}
if !strings.Contains(out, `title="Assigned when the invoice is issued."`) {
t.Errorf("missing invoice number must render the disclosure tooltip, got:\n%s", out)
}
if !strings.Contains(out, "Search by organization or invoice number") {
t.Errorf("search placeholder must mention invoice number, got:\n%s", out)
}
// The raw invoice ID must never render as visible text (maintainer
// 2026-08-23) -- it still appears once per row as the View link's href,
// which is not display.
if strings.Contains(out, ">inv-1<") || strings.Contains(out, ">inv-2<") || strings.Contains(out, ">inv-3<") {
t.Errorf("raw invoice IDs must never render as visible text in the list, got:\n%s", out)
}
}
// TestOperatorInvoiceDetailRendersNumber covers invoice-numbers D3/D5: the
// detail heading is "Invoice {number}" with the UUID muted beneath, the
// marker + tooltip before issuance, and Stripe's own number (an external
// reference, never the heading) as a secondary "Stripe invoice ..." line
// when the mapping carries one.
func TestOperatorInvoiceDetailRendersNumber(t *testing.T) {
tmpl := billingTestTemplates(t)
render := func(data OperatorInvoiceDetailData) string {
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "operator_billing_invoice_detail.html", data); err != nil {
t.Fatalf("ExecuteTemplate: %v", err)
}
return buf.String()
}
withNumber := render(OperatorInvoiceDetailData{
InvoiceID: "11111111-1111-1111-1111-111111111111",
InvoiceNumber: "0042",
StripeInvoiceNumber: "A1B2C3D4-0001",
OrgName: "Acme Cooperative",
OrgID: "org-1",
Status: "paid",
StatusClass: "success",
AmountDue: "USD 10.00",
AmountPaid: "USD 10.00",
Currency: "USD",
Period: "Jan 1, 2026 Jan 31, 2026",
StripeSyncStatus: "not_mapped",
})
if !strings.Contains(withNumber, "Invoice") || !strings.Contains(withNumber, "<code>0042</code>") {
t.Errorf("heading must be \"Invoice {number}\" with the platform number as <code>, got:\n%s", withNumber)
}
if !strings.Contains(withNumber, "11111111-1111-1111-1111-111111111111") {
t.Errorf("the UUID must still render, muted, beneath the heading, got:\n%s", withNumber)
}
if !strings.Contains(withNumber, "Stripe invoice A1B2C3D4-0001") {
t.Errorf("Stripe's number must render as a secondary \"Stripe invoice ...\" line, got:\n%s", withNumber)
}
withoutNumber := render(OperatorInvoiceDetailData{
InvoiceID: "22222222-2222-2222-2222-222222222222",
InvoiceNumber: "",
OrgName: "Bramble Collective",
OrgID: "org-2",
Status: "open",
StatusClass: "warning",
AmountDue: "USD 5.00",
AmountPaid: "USD 0.00",
Currency: "USD",
Period: "Feb 1, 2026 Feb 28, 2026",
StripeSyncStatus: "not_mapped",
})
if !strings.Contains(withoutNumber, `title="Assigned when the invoice is issued."`) {
t.Errorf("absent invoice number must render the disclosure tooltip in the heading, got:\n%s", withoutNumber)
}
if !strings.Contains(withoutNumber, "22222222-2222-2222-2222-222222222222") {
t.Errorf("the UUID must still render, muted, beneath the heading, got:\n%s", withoutNumber)
}
if strings.Contains(withoutNumber, "Stripe invoice") {
t.Errorf("no Stripe invoice line must render when the mapping carries no number, got:\n%s", withoutNumber)
}
}
// TestOperatorPaymentsViewInvoiceLinkCarriesNumber covers invoice-numbers
// D3: the payments view's "View invoice" cross-reference gains the number
// in the link's title when one exists, and stays plain when it does not.
func TestOperatorPaymentsViewInvoiceLinkCarriesNumber(t *testing.T) {
tmpl := billingTestTemplates(t)
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "operator_payments.html", PaymentsData{
Payments: []PaymentViewModel{
{PaymentID: "pay-00000001", InvoiceID: "inv-1", InvoiceNumber: "A1B2C3D4-0001", OrgName: "Acme Cooperative", OrgID: "org-1", Status: "succeeded", StatusClass: "success", Amount: "USD 10.00", PaymentMethod: "card", StripeSyncStatus: "synced", CreatedAt: "Jan 2, 2026"},
{PaymentID: "pay-00000002", InvoiceID: "inv-2", InvoiceNumber: "", OrgName: "Bramble Collective", OrgID: "org-2", Status: "succeeded", StatusClass: "success", Amount: "USD 5.00", PaymentMethod: "card", StripeSyncStatus: "synced", CreatedAt: "Jan 3, 2026"},
},
StripeConfigured: true,
Nav: ListNav{BasePath: "/operator/billing/payments", Page: 1, Total: 2},
}); err != nil {
t.Fatalf("ExecuteTemplate: %v", err)
}
out := buf.String()
if !strings.Contains(out, `title="Invoice A1B2C3D4-0001"`) {
t.Errorf("View invoice link must carry the number in its title when one exists, got:\n%s", out)
}
if strings.Count(out, `title="Invoice `) != 1 {
t.Errorf("exactly one row has a number, so exactly one link should carry the title, got:\n%s", out)
}
}
// TestOrgCompositeBillingSummaryRendersInvoiceNumber covers invoice-numbers'
// "the composite's billing section ... where an invoice is named": the
// organization composite's Latest invoice card names the number (or the
// marker with its tooltip when Stripe has not assigned one yet).
func TestOrgCompositeBillingSummaryRendersInvoiceNumber(t *testing.T) {
withNumber := onePoolFixture()
withNumber.BillingSummary = BillingSummaryViewModel{
HasAccount: true,
HasInvoice: true,
LatestInvoiceNumber: "A1B2C3D4-0001",
LatestInvoiceAmountDue: "USD 10.00",
LatestInvoiceDate: "Jan 1, 2026",
Currency: "usd",
}
out := renderOrgEnrollment(t, withNumber)
if !strings.Contains(out, "<code>A1B2C3D4-0001</code>") {
t.Errorf("Latest invoice card must name the number as <code>, got:\n%s", out)
}
withoutNumber := onePoolFixture()
withoutNumber.BillingSummary = BillingSummaryViewModel{
HasAccount: true,
HasInvoice: true,
LatestInvoiceNumber: "",
LatestInvoiceAmountDue: "USD 10.00",
LatestInvoiceDate: "Jan 1, 2026",
Currency: "usd",
}
out = renderOrgEnrollment(t, withoutNumber)
if !strings.Contains(out, `title="Assigned when the invoice is issued."`) {
t.Errorf("missing invoice number must render the disclosure tooltip, got:\n%s", out)
}
}
// TestMemberInvoiceTemplatesRenderNumber covers member-invoice-history: the
// list leads each row with the platform-assigned invoice number (including
// a grandfathered Stripe-format one, rendered the same way), and the detail
// heading is "Invoice {number}" with Stripe's own number, when the mapping
// carries one, as a secondary "Stripe invoice ..." line; both fall back to
// the empty-value marker and tooltip before issuance.
func TestMemberInvoiceTemplatesRenderNumber(t *testing.T) {
tmpl := memberTestTemplates(t)
render := func(name string, data any) string {
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
t.Fatalf("ExecuteTemplate %s: %v", name, err)
}
return buf.String()
}
listOut := render("member_invoices.html", MemberInvoicesData{
Invoices: []InvoiceListItemViewModel{
{InvoiceID: "inv-1", InvoiceNumber: "0042", Period: "May 1, 2026 May 31, 2026", AmountDue: "USD 10.00", AmountPaid: "USD 10.00", Currency: "USD", Status: "paid", StatusClass: "success"},
{InvoiceID: "inv-2", InvoiceNumber: "", Period: "Jun 1, 2026 Jun 30, 2026", AmountDue: "USD 10.00", AmountPaid: "USD 0.00", Currency: "USD", Status: "open", StatusClass: "warning"},
{InvoiceID: "inv-3", InvoiceNumber: "A1B2C3D4-0001", Period: "Jul 1, 2026 Jul 31, 2026", AmountDue: "USD 20.00", AmountPaid: "USD 20.00", Currency: "USD", Status: "paid", StatusClass: "success"},
},
})
if !strings.Contains(listOut, "<code>0042</code>") {
t.Errorf("populated invoice number must lead the row as <code>, got:\n%s", listOut)
}
if !strings.Contains(listOut, "<code>A1B2C3D4-0001</code>") {
t.Errorf("a grandfathered Stripe-format number must lead the row as <code> too, got:\n%s", listOut)
}
if !strings.Contains(listOut, `title="Assigned when the invoice is issued."`) {
t.Errorf("missing invoice number must render the disclosure tooltip, got:\n%s", listOut)
}
detailWithNumber := render("member_invoice_detail.html", MemberInvoiceDetailData{
InvoiceID: "inv-1",
InvoiceNumber: "0042",
StripeInvoiceNumber: "A1B2C3D4-0001",
Status: "paid",
StatusClass: "success",
AmountDue: "USD 10.00",
AmountPaid: "USD 10.00",
Currency: "USD",
Period: "May 1, 2026 May 31, 2026",
})
if !strings.Contains(detailWithNumber, "<code>0042</code>") {
t.Errorf("detail heading must be \"Invoice {number}\" with the platform number, got:\n%s", detailWithNumber)
}
if !strings.Contains(detailWithNumber, "Stripe invoice A1B2C3D4-0001") {
t.Errorf("Stripe's number must render as a secondary \"Stripe invoice ...\" line, got:\n%s", detailWithNumber)
}
detailWithoutNumber := render("member_invoice_detail.html", MemberInvoiceDetailData{
InvoiceID: "inv-2",
Status: "open",
StatusClass: "warning",
AmountDue: "USD 10.00",
AmountPaid: "USD 0.00",
Currency: "USD",
Period: "Jun 1, 2026 Jun 30, 2026",
})
if !strings.Contains(detailWithoutNumber, `title="Assigned when the invoice is issued."`) {
t.Errorf("absent invoice number must render the disclosure tooltip in the heading, got:\n%s", detailWithoutNumber)
}
if strings.Contains(detailWithoutNumber, "Stripe invoice") {
t.Errorf("no Stripe invoice line must render when the mapping carries no number, got:\n%s", detailWithoutNumber)
}
}