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) } }