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.
277 lines
13 KiB
Go
277 lines
13 KiB
Go
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)
|
||
}
|
||
}
|