Files
member-console/internal/server/invoice_number_render_test.go
T
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00

301 lines
14 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.
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
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,
"helpIcon": helpIcon,
}).ParseFS(partialsSub, "member_*.html")
if err != nil {
t.Fatalf("ParseFS: %v", err)
}
tmpl = template.Must(web.ParseUIPartials(tmpl))
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", StatusState: "paid", 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", StatusState: "open", 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", StatusState: "paid", 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, `<a href="/operator/billing/invoices/inv-1">0042</a>`) {
t.Errorf("populated invoice number must render as the row's link (chrome-conventions row navigation), got:\n%s", out)
}
if strings.Contains(out, "Acme Primary") {
t.Errorf("the billing account name is retired from invoice rows (maintainer 2026-08-30), 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, `<a href="/operator/billing/invoices/inv-3">A1B2C3D4-0001</a>`) {
t.Errorf("a grandfathered Stripe-format number must render as the link too, got:\n%s", out)
}
if !strings.Contains(out, `>Unnumbered</a>`) || !strings.Contains(out, `title="A reference number is assigned when the invoice is issued."`) {
t.Errorf("a missing invoice number must render the Unnumbered link with its 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{
StripeConfigured: true,
InvoiceID: "11111111-1111-1111-1111-111111111111",
InvoiceNumber: "0042",
StripeInvoiceNumber: "A1B2C3D4-0001",
OrgName: "Acme Cooperative",
OrgID: "org-1",
Status: "paid",
StatusState: "paid",
AmountDue: "USD 10.00",
AmountPaid: "USD 10.00",
Currency: "USD",
Period: "Jan 1, 2026 Jan 31, 2026",
StripeSyncStatus: "not_mapped",
})
if h := (OperatorInvoiceDetailData{InvoiceNumber: "0042"}).WrapperHeader(); h.Title != "Invoice 0042" || len(h.Crumbs) != 2 {
t.Errorf("the wrapper header must title the detail \"Invoice {number}\" below the Billing / Invoices trail, got %+v", h)
}
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, ">A1B2C3D4-0001</span>") {
t.Errorf("Stripe's number must render as the secondary reference in the Stripe fact row, got:\n%s", withNumber)
}
withoutNumber := render(OperatorInvoiceDetailData{
InvoiceID: "22222222-2222-2222-2222-222222222222",
InvoiceNumber: "",
OrgName: "Bramble Collective",
OrgID: "org-2",
Status: "open",
StatusState: "open",
AmountDue: "USD 5.00",
AmountPaid: "USD 0.00",
Currency: "USD",
Period: "Feb 1, 2026 Feb 28, 2026",
StripeSyncStatus: "not_mapped",
})
if h := (OperatorInvoiceDetailData{}).WrapperHeader(); h.Title != "Invoice" {
t.Errorf("an absent invoice number titles the detail plain \"Invoice\", got %q", h.Title)
}
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", StatusState: "paid", 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", StatusState: "paid", 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, `<a href="/operator/billing/invoices/inv-1">A1B2C3D4-0001</a>`) {
t.Errorf("the invoice cross-reference must link the invoice's own number, got:\n%s", out)
}
if !strings.Contains(out, `<a href="/operator/billing/invoices/inv-2">Invoice</a>`) {
t.Errorf("an unnumbered invoice cross-reference links the plain word Invoice, 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,
AccountStatus: "active",
HasInvoice: true,
LatestInvoiceID: "inv-1",
LatestInvoiceNumber: "A1B2C3D4-0001",
LatestInvoiceDate: "Jan 1, 2026",
InvoiceState: "paid",
OutstandingBalanceFormatted: "USD 0.00",
Currency: "usd",
}
out := renderOrgEnrollment(t, withNumber)
// design D5, round 2: the number is a plain link, not <code>. Round 6
// collapses the cell to one line: badge, then the number, then a muted
// middot and the date; the invoice's own amount is no longer shown here
// (the outstanding balance row already carries money owed).
if !strings.Contains(out, `<a href="/operator/billing/invoices/inv-1">A1B2C3D4-0001</a>`) {
t.Errorf("Latest invoice card must name the number as a plain link, got:\n%s", out)
}
if strings.Contains(out, "<code>A1B2C3D4-0001</code>") {
t.Errorf("Latest invoice card must not wrap the number in <code>, got:\n%s", out)
}
badgeIdx := strings.Index(out, `text-bg-success">Paid`)
numberIdx := strings.Index(out, ">A1B2C3D4-0001</a>")
dateIdx := strings.Index(out, `<small class="text-muted">· Jan 1, 2026</small>`)
if badgeIdx < 0 || numberIdx < 0 || dateIdx < 0 || !(badgeIdx < numberIdx && numberIdx < dateIdx) {
t.Errorf("expected the invoice cell to read badge, number, then the muted middot and date, got:\n%s", out)
}
withoutNumber := onePoolFixture()
withoutNumber.BillingSummary = BillingSummaryViewModel{
HasAccount: true,
AccountStatus: "active",
HasInvoice: true,
LatestInvoiceNumber: "",
LatestInvoiceDate: "Jan 1, 2026",
InvoiceState: "open",
OutstandingBalanceFormatted: "USD 0.00",
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", StatusState: "paid"},
{InvoiceID: "inv-2", InvoiceNumber: "", Period: "Jun 1, 2026 Jun 30, 2026", AmountDue: "USD 10.00", AmountPaid: "USD 0.00", Currency: "USD", Status: "open", StatusState: "open"},
{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", StatusState: "paid"},
},
})
if !strings.Contains(listOut, `hx-target="#billing-content" hx-swap="innerHTML">0042</a>`) {
t.Errorf("populated invoice number must lead the row as the row's link (chrome-conventions row navigation), got:\n%s", listOut)
}
if !strings.Contains(listOut, `hx-target="#billing-content" hx-swap="innerHTML">A1B2C3D4-0001</a>`) {
t.Errorf("a grandfathered Stripe-format number must lead the row as the link too, got:\n%s", listOut)
}
if !strings.Contains(listOut, `>Unnumbered</a>`) || !strings.Contains(listOut, `data-bs-content="A reference number is assigned when the invoice is issued."`) {
t.Errorf("missing invoice number must render the Unnumbered link with its disclosure popover, got:\n%s", listOut)
}
detailWithNumber := render("member_invoice_detail.html", MemberInvoiceDetailData{
InvoiceID: "inv-1",
InvoiceNumber: "0042",
StripeInvoiceNumber: "A1B2C3D4-0001",
Status: "paid",
StatusState: "paid",
AmountDue: "USD 10.00",
AmountPaid: "USD 10.00",
Currency: "USD",
Period: "May 1, 2026 May 31, 2026",
})
if !strings.Contains(detailWithNumber, "0042") || strings.Contains(detailWithNumber, "<code>0042</code>") {
t.Errorf("detail heading must be \"Invoice {number}\" with the platform number as plain text, 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",
StatusState: "open",
AmountDue: "USD 10.00",
AmountPaid: "USD 0.00",
Currency: "USD",
Period: "Jun 1, 2026 Jun 30, 2026",
})
if !strings.Contains(detailWithoutNumber, `data-bs-content="Assigned when the invoice is issued."`) {
t.Errorf("absent invoice number must render the disclosure popover 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)
}
}