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 (),
// 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, "0042") {
t.Errorf("populated invoice number must render as , 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, "A1B2C3D4-0001") {
t.Errorf("a grandfathered Stripe-format number must render as 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, "0042") {
t.Errorf("heading must be \"Invoice {number}\" with the platform number as , 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, "A1B2C3D4-0001") {
t.Errorf("Latest invoice card must name the number as , 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, "0042") {
t.Errorf("populated invoice number must lead the row as , got:\n%s", listOut)
}
if !strings.Contains(listOut, "A1B2C3D4-0001") {
t.Errorf("a grandfathered Stripe-format number must lead the row as 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, "0042") {
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)
}
}