package server import ( "context" "database/sql" "errors" "fmt" "log/slog" "math" "net/http" "strconv" "strings" "time" "git.coopcloud.tech/wiki-cafe/member-console/internal/billing" "git.coopcloud.tech/wiki-cafe/member-console/internal/integration" stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store" "git.coopcloud.tech/wiki-cafe/member-console/internal/web" ) // BillingAccountViewModel represents a billing account for operator template rendering type BillingAccountViewModel struct { BillingAccountID string OrgID string OrgName string Name string Status string StripeCustomerID string StripeSyncStatus string CreatedAt string } // BillingAccountsData holds data for the billing accounts list partial type BillingAccountsData struct { Accounts []BillingAccountViewModel Error string // StripeConfigured gates the empty-view copy (see // operator_billing_accounts.html): an empty list because Stripe was // never configured (blocked on the operator) must read differently from // an empty list because Stripe is configured but no webhook events have // landed yet (waiting on the world) — the page-level "no events // processed" statement already covers the latter. StripeConfigured bool // Nav drives the shared list-controls partial (operator-list-scale): // search, pagination, and the true total for the current search. // Accounts carries no status facet (see ListBillingAccountsPage). Nav ListNav } // latestProcessedWebhookEventAt returns the most recent time any provider's // webhook event finished processing (processed_at IS NOT NULL): the four // operator billing views are projections from these events, so this is the // recency stamp the honest-surfaces delta requires (design.md Decision 3; // spec: operator-billing-views) — "data as of ..." when at least one event // has ever finished processing, ok=false when none has (a fresh deployment, // or Stripe never configured), so the caller can state that plainly instead // of rendering an empty or stale-looking table as if it were current. func (h *OperatorPartialsHandler) latestProcessedWebhookEventAt(ctx context.Context) (time.Time, bool) { var latest sql.NullTime if err := h.Database.QueryRowContext(ctx, `SELECT MAX(processed_at) FROM core.webhook_events WHERE processed_at IS NOT NULL`, ).Scan(&latest); err != nil { h.Logger.Error("failed to load latest processed webhook event", "error", err) return time.Time{}, false } if !latest.Valid { return time.Time{}, false } return latest.Time, true } // matchingOrgIDs returns the IDs of every organization whose name contains // q, case-insensitively — the Go-side resolution the four billing paged // queries use for organization-name search (design D3). internal/billing // has no existing query that joins core.organizations (grep 'core\.' // internal/billing/queries/ turns up plan_ladders/products/subscriptions // spillover only), so this change does not introduce the module's first // cross-boundary join; instead it resolves matching org IDs here and passes // them into the paged query as an ANY(...) array narg, mirroring how the // grants lane resolves product names. Returns nil (not an empty slice) when // q is empty so callers can skip the scan entirely — org_id = ANY(NULL) // never matches, which is fine because the paged query's q IS NULL branch // already accepts every row in that case. func (h *OperatorPartialsHandler) matchingOrgIDs(ctx context.Context, q string) []string { if q == "" { return nil } orgs, err := h.OrgQ.ListOrganizations(ctx) if err != nil { h.Logger.Warn("matchingOrgIDs: list organizations", slog.Any("error", err)) return nil } needle := strings.ToLower(q) ids := make([]string, 0, len(orgs)) for _, org := range orgs { if strings.Contains(strings.ToLower(org.Name), needle) { ids = append(ids, org.OrgID) } } return ids } // billingAccountsListNav builds the list-controls view model for the // billing accounts view: search only, no status facet (see // ListBillingAccountsPage for why accounts carries no status vocabulary). func (h *OperatorPartialsHandler) billingAccountsListNav(r *http.Request) ListNav { params := ParseListParams(r, "") return ListNav{ BasePath: "/operator/billing/accounts", SearchPlaceholder: "Search by organization or account name", Q: params.Q, Page: params.Page, } } // loadBillingAccountsData hydrates the billing-accounts listing: a paged, // searched view (operator-list-scale UX-4) with no status facet (see // ListBillingAccountsPage). func (h *OperatorPartialsHandler) loadBillingAccountsData(r *http.Request) BillingAccountsData { ctx := r.Context() stripeConfigured, _ := configurationReadiness(h.IntegrationConfigs, "stripe") nav := h.billingAccountsListNav(r) params := ListParams{Q: nav.Q, Page: nav.Page} orgIDs := h.matchingOrgIDs(ctx, params.Q) accounts, total, err := FetchPage(¶ms, func(limit, offset int32) ([]billing.ListBillingAccountsPageRow, int64, error) { rows, lErr := h.BillingQ.ListBillingAccountsPage(ctx, billing.ListBillingAccountsPageParams{ Q: sql.NullString{String: params.Q, Valid: params.Q != ""}, OrgIds: orgIDs, PageLimit: limit, PageOffset: offset, }) if lErr != nil || len(rows) == 0 { return rows, 0, lErr } return rows, rows[0].TotalCount, nil }) nav.Page, nav.Total = params.Page, total if err != nil { h.Logger.Error("failed to list billing accounts", "error", err) return BillingAccountsData{Error: "Failed to load billing accounts"} } vms := make([]BillingAccountViewModel, len(accounts)) for i, acc := range accounts { orgName := "" if org, err := h.OrgQ.GetOrganizationByID(ctx, acc.OrgID); err == nil { orgName = org.Name } stripeCustomerID := "" syncStatus := "not_mapped" if mapping, err := h.StripeQ.GetCustomerMappingByBillingAccountID(ctx, acc.BillingAccountID); err == nil { if mapping.StripeCustomerID.Valid { stripeCustomerID = mapping.StripeCustomerID.String } syncStatus = mapping.SyncStatus } vms[i] = BillingAccountViewModel{ BillingAccountID: acc.BillingAccountID, OrgID: acc.OrgID, OrgName: orgName, Name: acc.Name, Status: acc.Status, StripeCustomerID: stripeCustomerID, StripeSyncStatus: syncStatus, CreatedAt: acc.CreatedAt.Format("Jan 2, 2006"), } } return BillingAccountsData{ Accounts: vms, StripeConfigured: stripeConfigured, Nav: nav, } } // SubscriptionViewModel represents a subscription for operator template rendering type SubscriptionViewModel struct { SubscriptionID string BillingAccountID string BillingAccountName string OrgID string OrgName string Status string StatusClass string CurrentPeriodStart string CurrentPeriodEnd string CancelAtPeriodEnd bool StripeSubscriptionID string StripeSyncStatus string CreatedAt string } // SubscriptionsData holds data for the subscriptions list partial type SubscriptionsData struct { Subscriptions []SubscriptionViewModel Error string // StripeConfigured gates the empty-view copy; see BillingAccountsData. StripeConfigured bool // Nav drives the shared list-controls partial (operator-list-scale). Nav ListNav } // subscriptionStatusFacets is the subscriptions view's status filter // vocabulary: the exact set 00010_schema_hardening.sql's // chk_subscriptions_status_valid CHECK constraint allows, in the order the // constraint declares it. var subscriptionStatusFacets = []FacetOption{ {Value: "incomplete", Label: "Incomplete"}, {Value: "incomplete_expired", Label: "Incomplete expired"}, {Value: "trialing", Label: "Trialing"}, {Value: "active", Label: "Active"}, {Value: "past_due", Label: "Past due"}, {Value: "canceled", Label: "Canceled"}, {Value: "unpaid", Label: "Unpaid"}, {Value: "paused", Label: "Paused"}, } // subscriptionsListNav builds the list-controls view model for the // subscriptions view: search plus the subscription status facet. func (h *OperatorPartialsHandler) subscriptionsListNav(r *http.Request) ListNav { params := ParseListParams(r, "status") facet := ValidFacet(params.Facet, subscriptionStatusFacets) return ListNav{ BasePath: "/operator/billing/subscriptions", SearchPlaceholder: "Search by organization or billing account", FacetParam: "status", FacetOptions: subscriptionStatusFacets, Q: params.Q, Facet: facet, Page: params.Page, } } // loadSubscriptionsData hydrates the subscriptions listing: a paged, // searched, status-filtered view (operator-list-scale UX-4). func (h *OperatorPartialsHandler) loadSubscriptionsData(r *http.Request) SubscriptionsData { ctx := r.Context() stripeConfigured, _ := configurationReadiness(h.IntegrationConfigs, "stripe") nav := h.subscriptionsListNav(r) params := ListParams{Q: nav.Q, Facet: nav.Facet, Page: nav.Page} orgIDs := h.matchingOrgIDs(ctx, params.Q) subs, total, err := FetchPage(¶ms, func(limit, offset int32) ([]billing.ListSubscriptionsPageRow, int64, error) { rows, lErr := h.BillingQ.ListSubscriptionsPage(ctx, billing.ListSubscriptionsPageParams{ Q: sql.NullString{String: params.Q, Valid: params.Q != ""}, OrgIds: orgIDs, Status: sql.NullString{String: params.Facet, Valid: params.Facet != ""}, PageLimit: limit, PageOffset: offset, }) if lErr != nil || len(rows) == 0 { return rows, 0, lErr } return rows, rows[0].TotalCount, nil }) nav.Page, nav.Total = params.Page, total if err != nil { h.Logger.Error("failed to list subscriptions", "error", err) return SubscriptionsData{Error: "Failed to load subscriptions"} } vms := make([]SubscriptionViewModel, len(subs)) for i, sub := range subs { stripeSubID := "" syncStatus := "not_mapped" if mapping, err := h.StripeQ.GetSubscriptionMappingBySubscriptionID(ctx, sub.SubscriptionID); err == nil { if mapping.StripeSubscriptionID.Valid { stripeSubID = mapping.StripeSubscriptionID.String } syncStatus = mapping.SyncStatus } orgName := "" if org, err := h.OrgQ.GetOrganizationByID(ctx, sub.OrgID); err == nil { orgName = org.Name } periodStart := "" if sub.CurrentPeriodStart.Valid { periodStart = sub.CurrentPeriodStart.Time.Format("Jan 2, 2006") } periodEnd := "" if sub.CurrentPeriodEnd.Valid { periodEnd = sub.CurrentPeriodEnd.Time.Format("Jan 2, 2006") } vms[i] = SubscriptionViewModel{ SubscriptionID: sub.SubscriptionID, BillingAccountID: sub.BillingAccountID, BillingAccountName: sub.BillingAccountName, OrgID: sub.OrgID, OrgName: orgName, Status: sub.Status, StatusClass: getStatusBadgeClass(sub.Status), CurrentPeriodStart: periodStart, CurrentPeriodEnd: periodEnd, CancelAtPeriodEnd: sub.CancelAtPeriodEnd, StripeSubscriptionID: stripeSubID, StripeSyncStatus: syncStatus, CreatedAt: sub.CreatedAt.Format("Jan 2, 2006"), } } return SubscriptionsData{Subscriptions: vms, StripeConfigured: stripeConfigured, Nav: nav} } // InvoiceViewModel represents an invoice for operator template rendering type InvoiceViewModel struct { InvoiceID string BillingAccountID string BillingAccountName string OrgID string OrgName string Status string // Overdue is the derived, presentation-only state (design D5): status // "open" with a due date in the past. Status itself is never mutated — // Overdue is a template-rendering switch layered on top of it. Overdue bool StatusClass string AmountDue string AmountPaid string Currency string DueDate string PaidAt string StripeInvoiceID string StripeSyncStatus string CreatedAt string } // InvoicesData holds data for the invoices list partial type InvoicesData struct { Invoices []InvoiceViewModel Error string // StripeConfigured gates the empty-view copy; see BillingAccountsData. StripeConfigured bool // Nav drives the shared list-controls partial (operator-list-scale). Nav ListNav } // invoiceStatusFacets is the invoices view's status filter vocabulary: the // documented closed set (design/data-model.md: draft, open, paid, void, // uncollectible, refunded — core.invoices carries no CHECK constraint, this // is the authoritative vocabulary) plus the derived "overdue" sentinel // (design D5), placed next to "open" since it is a subset of it. var invoiceStatusFacets = []FacetOption{ {Value: "draft", Label: "Draft"}, {Value: "open", Label: "Open"}, {Value: "overdue", Label: "Overdue"}, {Value: "paid", Label: "Paid"}, {Value: "void", Label: "Void"}, {Value: "uncollectible", Label: "Uncollectible"}, {Value: "refunded", Label: "Refunded"}, } // invoiceIsOverdue reports whether an invoice presents as Overdue (design // D5, operator-billing-views: "Open invoices past due present as // Overdue"): stored status "open" with a due date in the past. An invoice // with no due date is never Overdue, and the stored status is never // consulted for mutation here — this is a read-time presentation // derivation only, the same discipline the grants Live/Superseded // derivation uses. Shared by the invoices list, the invoice detail, and // the SQL Overdue facet (ListInvoicesPage), which a test pins in // agreement with this function. func invoiceIsOverdue(status string, dueDate sql.NullTime) bool { return status == "open" && dueDate.Valid && dueDate.Time.Before(time.Now()) } // invoicesListNav builds the list-controls view model for the invoices // view: search plus the status facet (including the derived Overdue // value). func (h *OperatorPartialsHandler) invoicesListNav(r *http.Request) ListNav { params := ParseListParams(r, "status") facet := ValidFacet(params.Facet, invoiceStatusFacets) return ListNav{ BasePath: "/operator/billing/invoices", SearchPlaceholder: "Search by organization or billing account", FacetParam: "status", FacetOptions: invoiceStatusFacets, Q: params.Q, Facet: facet, Page: params.Page, } } // zeroDecimalCurrencies are the ISO-4217 codes Stripe treats as having no // minor unit (https://docs.stripe.com/currencies#zero-decimal). Amounts in // these currencies already arrive in the currency's base unit, not // hundredths, so they must not be divided by 100. Keys are lowercase to // match the lowercased lookup in formatCurrency. var zeroDecimalCurrencies = map[string]bool{ "bif": true, "clp": true, "djf": true, "gnf": true, "jpy": true, "kmf": true, "krw": true, "mga": true, "pyg": true, "rwf": true, "ugx": true, "vnd": true, "vuv": true, "xaf": true, "xof": true, "xpf": true, } // formatCurrency renders a minor-unit amount (cents, or whole units for // zero-decimal currencies) as a currency-aware display string, e.g. // "USD 10.00" or "JPY 1000". There is deliberately no hardcoded currency // symbol: Stripe supports 135+ currencies and a single glyph (e.g. "$") // would misrepresent every non-USD amount (finding #12) — the uppercase // ISO-4217 code is the only unambiguous prefix available without pulling in // locale-aware formatting. func formatCurrency(amount int32, currency string) string { code := strings.ToUpper(currency) if zeroDecimalCurrencies[strings.ToLower(currency)] { return fmt.Sprintf("%s %d", code, amount) } dollars := float64(amount) / 100 return fmt.Sprintf("%s %.2f", code, dollars) } // loadInvoicesData hydrates the invoices listing: a paged, searched, // status-filtered view (operator-list-scale UX-4) with the derived Overdue // facet (design D5). func (h *OperatorPartialsHandler) loadInvoicesData(r *http.Request) InvoicesData { ctx := r.Context() stripeConfigured, _ := configurationReadiness(h.IntegrationConfigs, "stripe") nav := h.invoicesListNav(r) params := ListParams{Q: nav.Q, Facet: nav.Facet, Page: nav.Page} orgIDs := h.matchingOrgIDs(ctx, params.Q) invoices, total, err := FetchPage(¶ms, func(limit, offset int32) ([]billing.ListInvoicesPageRow, int64, error) { rows, lErr := h.BillingQ.ListInvoicesPage(ctx, billing.ListInvoicesPageParams{ Q: sql.NullString{String: params.Q, Valid: params.Q != ""}, OrgIds: orgIDs, Status: sql.NullString{String: params.Facet, Valid: params.Facet != ""}, PageLimit: limit, PageOffset: offset, }) if lErr != nil || len(rows) == 0 { return rows, 0, lErr } return rows, rows[0].TotalCount, nil }) nav.Page, nav.Total = params.Page, total if err != nil { h.Logger.Error("failed to list invoices", "error", err) return InvoicesData{Error: "Failed to load invoices"} } vms := make([]InvoiceViewModel, len(invoices)) for i, inv := range invoices { stripeInvoiceID := "" syncStatus := "not_mapped" if mapping, err := h.StripeQ.GetInvoiceMappingByInvoiceID(ctx, inv.InvoiceID); err == nil { if mapping.StripeInvoiceID.Valid { stripeInvoiceID = mapping.StripeInvoiceID.String } syncStatus = mapping.SyncStatus } orgName := "" if org, err := h.OrgQ.GetOrganizationByID(ctx, inv.OrgID); err == nil { orgName = org.Name } dueDate := "" if inv.DueDate.Valid { dueDate = inv.DueDate.Time.Format("Jan 2, 2006") } paidAt := "" if inv.PaidAt.Valid { paidAt = inv.PaidAt.Time.Format("Jan 2, 2006") } vms[i] = InvoiceViewModel{ InvoiceID: inv.InvoiceID, BillingAccountID: inv.BillingAccountID, BillingAccountName: inv.BillingAccountName, OrgID: inv.OrgID, OrgName: orgName, Status: inv.Status, Overdue: invoiceIsOverdue(inv.Status, inv.DueDate), StatusClass: getInvoiceStatusBadgeClass(inv.Status, inv.AmountDue, inv.AmountPaid), AmountDue: formatCurrency(inv.AmountDue, inv.Currency), AmountPaid: formatCurrency(inv.AmountPaid, inv.Currency), Currency: inv.Currency, DueDate: dueDate, PaidAt: paidAt, StripeInvoiceID: stripeInvoiceID, StripeSyncStatus: syncStatus, CreatedAt: inv.CreatedAt.Format("Jan 2, 2006"), } } return InvoicesData{Invoices: vms, StripeConfigured: stripeConfigured, Nav: nav} } // OperatorInvoiceLineItemViewModel is one line item on the operator invoice // detail (design D7, task 6.2): description, quantity, amount, and period. type OperatorInvoiceLineItemViewModel struct { Description string Quantity int32 Amount string Period string } // OperatorInvoiceDetailData is the body data for // operator_billing_invoice_detail.html. type OperatorInvoiceDetailData struct { InvoiceID string OrgID string OrgName string Status string Overdue bool StatusClass string AmountDue string AmountPaid string Currency string Period string DueDate string PaidAt string LineItems []OperatorInvoiceLineItemViewModel // StripeInvoiceID / StripeSyncStatus / StripeURL: the Stripe sync state // and deep link (operator-billing-views: "Invoice shows Stripe deep // link when synced"). StripeURL is "" unless the mapping is synced and // the dashboard base URL is configured (stripeEntityURL). StripeInvoiceID string StripeSyncStatus string StripeURL string Error string } // loadOperatorInvoiceDetailData hydrates the operator invoice detail // (design D7, tasks 6.1/6.3): reuses the same projection queries the // member invoice-detail view reads (GetInvoiceByID, // GetInvoiceLineItemsByInvoiceID — member_invoices.go) plus the // operator-only organization and Stripe-mapping reads. Returns ok=false // when the invoice ID does not resolve, so the caller can answer with the // panel's ordinary 404 rather than an inline error banner (operator- // billing-views: "Unknown invoice IDs 404 in the shell"). func (h *OperatorPartialsHandler) loadOperatorInvoiceDetailData(r *http.Request, invoiceID string) (OperatorInvoiceDetailData, bool) { ctx := r.Context() invoice, err := h.BillingQ.GetInvoiceByID(ctx, invoiceID) if err != nil { return OperatorInvoiceDetailData{}, false } data := OperatorInvoiceDetailData{ InvoiceID: invoice.InvoiceID, Status: invoice.Status, Overdue: invoiceIsOverdue(invoice.Status, invoice.DueDate), StatusClass: getInvoiceStatusBadgeClass(invoice.Status, invoice.AmountDue, invoice.AmountPaid), AmountDue: formatCurrency(invoice.AmountDue, invoice.Currency), AmountPaid: formatCurrency(invoice.AmountPaid, invoice.Currency), Currency: invoice.Currency, Period: formatInvoicePeriod(invoice.PeriodStart, invoice.PeriodEnd), } if invoice.DueDate.Valid { data.DueDate = invoice.DueDate.Time.Format("Jan 2, 2006") } if invoice.PaidAt.Valid { data.PaidAt = invoice.PaidAt.Time.Format("Jan 2, 2006") } if account, err := h.BillingQ.GetBillingAccountByID(ctx, invoice.BillingAccountID); err == nil { data.OrgID = account.OrgID if org, err := h.OrgQ.GetOrganizationByID(ctx, account.OrgID); err == nil { data.OrgName = org.Name } } else { h.Logger.Warn("invoice detail: billing account not found", slog.String("invoice_id", invoiceID), slog.Any("error", err)) } data.StripeSyncStatus = "not_mapped" if mapping, err := h.StripeQ.GetInvoiceMappingByInvoiceID(ctx, invoiceID); err == nil { if mapping.StripeInvoiceID.Valid { data.StripeInvoiceID = mapping.StripeInvoiceID.String } data.StripeSyncStatus = mapping.SyncStatus if data.StripeSyncStatus == "synced" { data.StripeURL = stripeEntityURL(h.StripeDashboardURL, "invoices", data.StripeInvoiceID) } } lineItems, err := h.BillingQ.GetInvoiceLineItemsByInvoiceID(ctx, invoiceID) if err != nil { h.Logger.Error("failed to list invoice line items", slog.Any("error", err), slog.String("invoice_id", invoiceID)) data.Error = "Failed to load invoice line items" return data, true } for _, li := range lineItems { data.LineItems = append(data.LineItems, OperatorInvoiceLineItemViewModel{ Description: li.Description.String, Quantity: li.Quantity, Amount: formatCurrency(li.Amount, li.Currency), Period: formatInvoicePeriod(li.PeriodStart, li.PeriodEnd), }) } return data, true } // GetOperatorInvoiceDetailPage handles GET // /operator/billing/invoices/{invoiceID} — the per-invoice detail (design // D7; tasks 6.1-6.3). Renders inside the same operator_billing.html // wrapper the four billing views share (renderBillingPage, // operator_pages.go), with ActiveSection "invoices" so the Invoices pill // stays marked active and the recency stamp / Stripe Sync legend render // alongside it. An unknown invoice ID answers with the panel's ordinary // 404 (GetOperatorNotFound) rather than an inline error banner, per the // operator-billing-views spec's "Unknown invoice IDs 404 in the shell" // scenario. func (h *OperatorPartialsHandler) GetOperatorInvoiceDetailPage(w http.ResponseWriter, r *http.Request) { invoiceID := r.PathValue("invoiceID") data, ok := h.loadOperatorInvoiceDetailData(r, invoiceID) if !ok { h.GetOperatorNotFound(w, r) return } h.renderBillingPage(w, r, "invoices", "operator_billing_invoice_detail.html", "billing-invoices", data) } // PaymentViewModel represents a payment for operator template rendering type PaymentViewModel struct { PaymentID string InvoiceID string BillingAccountID string BillingAccountName string OrgID string OrgName string Status string StatusClass string Amount string Currency string PaymentMethod string StripePaymentIntentID string StripeSyncStatus string FailedAt string CreatedAt string } // PaymentsData holds data for the payments list partial type PaymentsData struct { Payments []PaymentViewModel Error string // StripeConfigured gates the empty-view copy; see BillingAccountsData. StripeConfigured bool // Nav drives the shared list-controls partial (operator-list-scale). Nav ListNav } // paymentsListNav builds the list-controls view model for the payments // view: search only, no status facet (operator-list-scale's "Status // filters exist where a status vocabulary exists" requirement does not // name payments). func (h *OperatorPartialsHandler) paymentsListNav(r *http.Request) ListNav { params := ParseListParams(r, "") return ListNav{ BasePath: "/operator/billing/payments", SearchPlaceholder: "Search by organization or billing account", Q: params.Q, Page: params.Page, } } // loadPaymentsData hydrates the payments listing: a paged, searched view // (operator-list-scale UX-4) with no status facet. Each payment's real // Stripe sync status comes from a reverse-lookup on payment_mappings by // local payment_id (mappings are unique per payment_id); absent a mapping // it stays "not_mapped" (finding #14). func (h *OperatorPartialsHandler) loadPaymentsData(r *http.Request) PaymentsData { ctx := r.Context() stripeConfigured, _ := configurationReadiness(h.IntegrationConfigs, "stripe") nav := h.paymentsListNav(r) params := ListParams{Q: nav.Q, Page: nav.Page} orgIDs := h.matchingOrgIDs(ctx, params.Q) payments, total, err := FetchPage(¶ms, func(limit, offset int32) ([]billing.ListPaymentsPageRow, int64, error) { rows, lErr := h.BillingQ.ListPaymentsPage(ctx, billing.ListPaymentsPageParams{ Q: sql.NullString{String: params.Q, Valid: params.Q != ""}, OrgIds: orgIDs, PageLimit: limit, PageOffset: offset, }) if lErr != nil || len(rows) == 0 { return rows, 0, lErr } return rows, rows[0].TotalCount, nil }) nav.Page, nav.Total = params.Page, total if err != nil { h.Logger.Error("failed to list payments", "error", err) return PaymentsData{Error: "Failed to load payments"} } vms := make([]PaymentViewModel, len(payments)) for i, pay := range payments { stripePaymentIntentID := "" syncStatus := "not_mapped" if h.StripeQ != nil { if mapping, err := h.StripeQ.GetPaymentMappingByPaymentID(ctx, pay.PaymentID); err == nil { if mapping.StripePaymentIntentID.Valid { stripePaymentIntentID = mapping.StripePaymentIntentID.String } syncStatus = mapping.SyncStatus } } orgName := "" if org, err := h.OrgQ.GetOrganizationByID(ctx, pay.OrgID); err == nil { orgName = org.Name } paymentMethod := "Unknown" if pay.PaymentMethodType.Valid { if pay.PaymentMethodType.String == "card" && pay.CardBrand.Valid && pay.CardLast4.Valid { paymentMethod = fmt.Sprintf("%s •••• %s", pay.CardBrand.String, pay.CardLast4.String) } else { paymentMethod = pay.PaymentMethodType.String } } failedAt := "" if pay.FailedAt.Valid { failedAt = pay.FailedAt.Time.Format("Jan 2, 2006") } vms[i] = PaymentViewModel{ PaymentID: pay.PaymentID, InvoiceID: pay.InvoiceID, BillingAccountID: pay.BillingAccountID, BillingAccountName: pay.BillingAccountName, OrgID: pay.OrgID, OrgName: orgName, Status: pay.Status, StatusClass: getPaymentStatusBadgeClass(pay.Status), Amount: formatCurrency(pay.Amount, pay.Currency), Currency: pay.Currency, PaymentMethod: paymentMethod, StripePaymentIntentID: stripePaymentIntentID, StripeSyncStatus: syncStatus, FailedAt: failedAt, CreatedAt: pay.CreatedAt.Format("Jan 2, 2006"), } } return PaymentsData{Payments: vms, StripeConfigured: stripeConfigured, Nav: nav} } // PriceViewModel represents a price for operator template rendering type PriceViewModel struct { PriceID string ProductID string UnitAmount string Currency string RecurringInterval string IsRecurring bool TrialPeriodDays int32 IsActive bool // IsDefault marks the product's default price — the one price members are // offered (readiness, catalog, checkout all track it). At most one per // product, enforced by idx_prices_one_default_per_product. IsDefault bool StripePriceID string StripeSyncStatus string CreatedAt string } // ProductPricesData holds data for the product prices partial type ProductPricesData struct { ProductID string ProductName string Prices []PriceViewModel StripeDashboardURL string // StripeConfigured gates the per-row "Sync" affordance: syncing a price is // only actionable when the deployment has Stripe wired up. StripeConfigured bool FieldErrors web.FieldErrors Success string Error string } // maxStripeUnitAmount is Stripe's maximum unit amount, in the currency's // smallest unit (https://docs.stripe.com/api/prices/create — 99,999,999 = // $999,999.99). Enforced before the float→int32 conversion in CreatePrice so a // large amount can't silently overflow to a negative int32 that stores garbage // behind a success banner and then dead-letters at sync time (finding #47). const maxStripeUnitAmount = 99999999 // CreatePrice handles POST /partials/operator/products/{productID}/prices func (h *OperatorPartialsHandler) CreatePrice(w http.ResponseWriter, r *http.Request) { productID := r.PathValue("productID") if err := r.ParseForm(); err != nil { h.renderProductPricesPage(w, r, productID, "", "Invalid request") return } amountStr := strings.TrimSpace(r.FormValue("amount")) currency := strings.TrimSpace(r.FormValue("currency")) interval := r.FormValue("recurring_interval") trialDaysStr := strings.TrimSpace(r.FormValue("trial_period_days")) errs := web.New() var unitAmount int32 if amountStr == "" { errs.Set("amount", "Amount is required.") } else { dollars, parseErr := strconv.ParseFloat(amountStr, 64) switch { case parseErr != nil || math.IsNaN(dollars) || math.IsInf(dollars, 0) || dollars <= 0: // Rejects NaN/Inf (which slip past a bare `<= 0` check) and any // non-positive amount. errs.Set("amount", "Amount must be a positive number.") default: // Convert to integer cents, rejecting sub-cent precision (0.004 → // 0) and any value above Stripe's ceiling before the int32 cast, // so nothing can overflow to a negative unit_amount (finding #47). cents := dollars * 100 rounded := math.Round(cents) switch { case math.Abs(cents-rounded) > 0.001: errs.Set("amount", "Amount must be a whole number of cents (at most two decimal places).") case rounded > maxStripeUnitAmount: errs.Set("amount", "Amount is too large — the maximum is 999,999.99.") default: unitAmount = int32(rounded) } } } if currency == "" { errs.Set("currency", "Currency is required.") } recurringInterval := sql.NullString{} if interval != "" && interval != "one_time" { recurringInterval = sql.NullString{String: interval, Valid: true} } trialPeriodDays := sql.NullInt32{} if trialDaysStr != "" { days, parseErr := strconv.Atoi(trialDaysStr) if parseErr != nil || days < 0 { errs.Set("trial_period_days", "Trial period must be a non-negative integer.") } else if days > 0 { trialPeriodDays = sql.NullInt32{Int32: int32(days), Valid: true} } } if errs.Any() { h.renderProductPricesFormErrors(w, r, productID, errs) return } price, err := h.BillingQ.CreatePrice(r.Context(), billing.CreatePriceParams{ ProductID: productID, Currency: currency, UnitAmount: unitAmount, RecurringInterval: recurringInterval, TrialPeriodDays: trialPeriodDays, }) if err != nil { if fieldErrs, ok := web.FieldErrorsFromDB(err, nil); ok { h.renderProductPricesFormErrors(w, r, productID, fieldErrs) return } h.Logger.Error("failed to create price", slog.Any("error", err)) h.renderProductPricesPage(w, r, productID, "", "Failed to create price.") return } // Creating a price does NOT enqueue a Stripe sync: a create_stripe_price // entry with no synced product can only dead-letter. Stripe sync is the // explicit operator action SyncProductToStripe ("Sync to Stripe" on the // product readiness panel or per-row on the prices table). // Branch the success copy on Stripe readiness: when Stripe is unconfigured // the readiness panel shows a "not configured" alert with no "Sync to // Stripe" button, so pointing the operator at that affordance would lie // (finding #11). if price.IsDefault { // First price for the product — it became the default automatically. if h.StripeConfigured { h.renderProductPricesPage(w, r, productID, "Price created and set as this product's default. Use \"Sync to Stripe\" to make it purchasable.", "") } else { h.renderProductPricesPage(w, r, productID, "Price created and set as this product's default. Configure Stripe to enable payment.", "") } return } if h.StripeConfigured { h.renderProductPricesPage(w, r, productID, "Price created. Make it the default to offer it to members; sync it to Stripe to enable payment.", "") } else { h.renderProductPricesPage(w, r, productID, "Price created. Make it the default to offer it to members; configure Stripe to enable payment.", "") } } // MakeDefaultPrice handles POST /partials/operator/products/{productID}/prices/{priceID}/make-default. // It moves the product's default-price marker (the price members are offered) // to the named price: clear-then-set inside one transaction so the partial // unique index (one default per product) never sees two defaults. func (h *OperatorPartialsHandler) MakeDefaultPrice(w http.ResponseWriter, r *http.Request) { ctx := r.Context() productID := r.PathValue("productID") priceID := r.PathValue("priceID") price, err := h.BillingQ.GetPrice(ctx, priceID) if err != nil || price.ProductID != productID { h.renderProductPricesPage(w, r, productID, "", "Price not found for this product.") return } if price.IsDefault { h.renderProductPricesPage(w, r, productID, "This price is already the default.", "") return } if !price.IsActive { h.renderProductPricesPage(w, r, productID, "", "An inactive price cannot be made the default.") return } tx, err := h.Database.BeginTx(ctx, nil) if err != nil { h.Logger.Error("make-default: begin tx", slog.Any("error", err), slog.String("price_id", priceID)) h.renderProductPricesPage(w, r, productID, "", "Failed to update the default price.") return } defer tx.Rollback() //nolint:errcheck // no-op after Commit qtx := billing.New(tx) if err := qtx.ClearDefaultPrice(ctx, productID); err != nil { h.renderPriceWriteError(w, r, productID, "make-default: clear", priceID, err, "Failed to update the default price.") return } if _, err := qtx.MarkDefaultPrice(ctx, billing.MarkDefaultPriceParams{PriceID: priceID, ProductID: productID}); err != nil { h.renderPriceWriteError(w, r, productID, "make-default: mark", priceID, err, "Failed to update the default price.") return } if err := tx.Commit(); err != nil { h.renderPriceWriteError(w, r, productID, "make-default: commit", priceID, err, "Failed to update the default price.") return } h.renderProductPricesPage(w, r, productID, "Default price updated — members are now offered this price.", "") } // DeactivatePrice handles POST /partials/operator/products/{productID}/prices/{priceID}/deactivate. // Deactivating retires a price from the operator's offer surface (it stays // referenced by history: subscriptions, invoices). The default price cannot be // deactivated — members would lose the purchase path — so the operator must // move the default first; the sqlc query's is_default guard backstops this. func (h *OperatorPartialsHandler) DeactivatePrice(w http.ResponseWriter, r *http.Request) { ctx := r.Context() productID := r.PathValue("productID") priceID := r.PathValue("priceID") price, err := h.BillingQ.GetPrice(ctx, priceID) if err != nil || price.ProductID != productID { h.renderProductPricesPage(w, r, productID, "", "Price not found for this product.") return } if price.IsDefault { h.renderProductPricesPage(w, r, productID, "", "The default price cannot be deactivated — make another price the default first.") return } if !price.IsActive { h.renderProductPricesPage(w, r, productID, "This price is already inactive.", "") return } if _, err := h.BillingQ.DeactivatePrice(ctx, priceID); err != nil { if errors.Is(err, sql.ErrNoRows) { // The is_default guard in the query rejected the write (the price // became the default between our read and the update). h.renderProductPricesPage(w, r, productID, "", "The default price cannot be deactivated — make another price the default first.") return } h.renderPriceWriteError(w, r, productID, "deactivate price", priceID, err, "Failed to deactivate the price.") return } h.renderProductPricesPage(w, r, productID, "Price deactivated — it is no longer offered.", "") } // renderPriceWriteError routes a failed price write to the §6 error contract: // recognizable constraint violations render as 422 + FieldErrors on the prices // view (web.FieldErrorsFromDB), anything else is logged and rendered as a // generic banner — never err.Error(). func (h *OperatorPartialsHandler) renderPriceWriteError(w http.ResponseWriter, r *http.Request, productID, op, priceID string, err error, generic string) { if fieldErrs, ok := web.FieldErrorsFromDB(err, nil); ok { h.renderProductPricesFormErrors(w, r, productID, fieldErrs) return } h.Logger.Error(op, slog.Any("error", err), slog.String("product_id", productID), slog.String("price_id", priceID)) h.renderProductPricesPage(w, r, productID, "", generic) } // SyncProductToStripe handles POST /partials/operator/products/{productID}/sync-stripe. // It makes the "Payment processing" purchasability precondition actionable: it // drives the product and one of its active prices to Stripe-mapped state by // writing the mapping rows as pending and enqueuing the catalog sync. The price // is selected by an optional price_id form value (the per-row "Sync" button on // the prices table); absent that it is the product's default price (the // readiness-panel button) — the same price readiness and checkout track. // // Product-idempotent: when the product mapping is already synced, only a // create_stripe_price entry is enqueued for the selected price — re-syncing to // enable an additional price must never create a duplicate Stripe product. // Per-price guards — no-op when Stripe is unconfigured, when there is no active // price, or when the selected price's sync is already pending/synced, so repeat // clicks never create duplicate Stripe objects. func (h *OperatorPartialsHandler) SyncProductToStripe(w http.ResponseWriter, r *http.Request) { ctx := r.Context() productID := r.PathValue("productID") if !h.StripeConfigured { h.renderProductEditPage(w, r, productID, "", "Stripe is not configured for this deployment, so pricing cannot be synced.") return } product, err := h.BillingQ.GetProductByID(ctx, productID) if err != nil { h.Logger.Error("sync-stripe: get product", slog.Any("error", err), slog.String("product_id", productID)) h.renderProductEditPage(w, r, productID, "", "Failed to load the product.") return } prices, err := h.BillingQ.ListPricesByProduct(ctx, productID) if err != nil || len(prices) == 0 { h.renderProductEditPage(w, r, productID, "", "Add an active price before syncing to Stripe.") return } // Select the price to sync: explicit price_id first, else the default // price, else (defensively, when no default is marked) the oldest active. price := prices[0] for _, p := range prices { if p.IsDefault { price = p break } } if requested := strings.TrimSpace(r.FormValue("price_id")); requested != "" { found := false for _, p := range prices { if p.PriceID == requested { price, found = p, true break } } if !found { h.renderProductEditPage(w, r, productID, "", "That price is not an active price of this product, so it cannot be synced.") return } } // Idempotency + retry. The outbox executors create a new Stripe object per run, // so never enqueue a duplicate for an already-synced or in-flight sync. But a // terminally-failed (dead-lettered) sync leaves the mapping stuck at 'pending', // so treat that as a retry — re-drive the dead-lettered entries — rather than a // no-op on the stuck pending mapping. m, mErr := h.StripeQ.GetPriceMappingByPriceID(ctx, price.PriceID) if mErr == nil && m.StripePriceID.Valid { h.renderProductEditPage(w, r, productID, "This price is already synced to Stripe.", "") return } if failed, _ := h.stripeSyncFailure(ctx, productID, price.PriceID); failed { if _, err := h.Database.ExecContext(ctx, `UPDATE core.outbox SET status = 'pending', attempts = 0, next_attempt_at = NOW(), error_message = NULL, updated_at = NOW() WHERE provider = 'stripe' AND status = 'dead_letter' AND ( (action_type = 'create_stripe_product' AND payload->>'product_id' = $1) OR (action_type = 'create_stripe_price' AND payload->>'price_id' = $2) )`, productID, price.PriceID); err != nil { h.Logger.Error("sync-stripe: reset dead-lettered outbox", slog.Any("error", err), slog.String("product_id", productID)) h.renderProductEditPage(w, r, productID, "", "Failed to retry the sync.") return } h.renderProductEditPage(w, r, productID, "Retrying sync to Stripe — the mapping will appear here shortly.", "") return } if mErr == nil && m.SyncStatus == "pending" { h.renderProductEditPage(w, r, productID, "A Stripe sync is already in progress for this price.", "") return } // Serialize concurrent syncs of this product and make the mapping writes // and the outbox enqueue atomic (finding #23). Two failure modes this // closes: (1) a mid-flight failure between the mapping upserts and the // outbox INSERT used to strand the price at sync_status='pending' with no // outbox row — permanently "sync in progress" and unretryable, since the // dead-letter retry path above only matches outbox rows that were never // created; (2) two concurrent Sync clicks both cleared the check-then-act // guards above and enqueued duplicate create_stripe_product entries. A // per-product advisory xact lock (same idiom as fulfillment/reconcile.go) // serializes them, and re-reading the mappings under the lock makes the // loser a no-op instead of a duplicate. tx, err := h.Database.BeginTx(ctx, nil) if err != nil { h.Logger.Error("sync-stripe: begin tx", slog.Any("error", err), slog.String("product_id", productID)) h.renderProductEditPage(w, r, productID, "", "Failed to start the sync.") return } defer tx.Rollback() //nolint:errcheck // no-op after Commit if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1)::bigint)`, productID); err != nil { h.Logger.Error("sync-stripe: advisory lock", slog.Any("error", err), slog.String("product_id", productID)) h.renderProductEditPage(w, r, productID, "", "Failed to start the sync.") return } qtx := stripedb.New(tx) // Re-check the price mapping under the lock: a concurrent sync that ran // between our pre-lock guards and acquiring the lock may already have // driven this price to synced or pending. if pm, err := qtx.GetPriceMappingByPriceID(ctx, price.PriceID); err == nil { if pm.StripePriceID.Valid { h.renderProductEditPage(w, r, productID, "This price is already synced to Stripe.", "") return } if pm.SyncStatus == "pending" { h.renderProductEditPage(w, r, productID, "A Stripe sync is already in progress for this price.", "") return } } // Product idempotency: a mapping with a live stripe_product_id means the // Stripe product already exists — enqueue ONLY the price sync below, never // a second create_stripe_product (which would create a duplicate Stripe // product). A pending product mapping with no ID yet means the product // create is still in flight; adding a price entry now could land before // its parent product, so treat it as in-progress. productSynced := false if pm, err := qtx.GetProductMappingByProductID(ctx, productID); err == nil { if pm.StripeProductID.Valid { productSynced = true } else if pm.SyncStatus == "pending" { h.renderProductEditPage(w, r, productID, "A Stripe sync is already in progress for this product.", "") return } } // Write pending mappings so the readiness panel immediately shows "sync // pending"; the executors upsert them to "synced" when the sync lands. An // already-synced product mapping is left untouched. if !productSynced { if _, err := qtx.UpsertProductMapping(ctx, stripedb.UpsertProductMappingParams{ ProductID: productID, SyncStatus: "pending", }); err != nil { h.Logger.Error("sync-stripe: upsert product mapping", slog.Any("error", err), slog.String("product_id", productID)) h.renderProductEditPage(w, r, productID, "", "Failed to start the sync.") return } } if _, err := qtx.UpsertPriceMapping(ctx, stripedb.UpsertPriceMappingParams{ PriceID: price.PriceID, SyncStatus: "pending", }); err != nil { h.Logger.Error("sync-stripe: upsert price mapping", slog.Any("error", err), slog.String("price_id", price.PriceID)) h.renderProductEditPage(w, r, productID, "", "Failed to start the sync.") return } pricePayload := map[string]any{ "price_id": price.PriceID, "product_id": productID, "unit_amount": price.UnitAmount, "currency": price.Currency, "recurring_interval": price.RecurringInterval.String, } if productSynced { if err := integration.Enqueue(ctx, tx, "stripe", "create_stripe_price", pricePayload); err != nil { h.Logger.Error("sync-stripe: enqueue outbox", slog.Any("error", err), slog.String("product_id", productID)) h.renderProductEditPage(w, r, productID, "", "Failed to enqueue the sync.") return } if err := tx.Commit(); err != nil { h.Logger.Error("sync-stripe: commit", slog.Any("error", err), slog.String("product_id", productID)) h.renderProductEditPage(w, r, productID, "", "Failed to enqueue the sync.") return } h.renderProductEditPage(w, r, productID, "Price sync to Stripe enqueued — the mapping will appear here shortly.", "") return } productPayload := map[string]any{ "product_id": productID, "name": product.Name, "description": product.Description.String, "display_category": product.DisplayCategory.String, } // Two Enqueue calls in the same tx rather than a batch API (task 1.7): // atomicity comes from the shared transaction, not from the helper. if err := integration.Enqueue(ctx, tx, "stripe", "create_stripe_product", productPayload); err != nil { h.Logger.Error("sync-stripe: enqueue outbox", slog.Any("error", err), slog.String("product_id", productID)) h.renderProductEditPage(w, r, productID, "", "Failed to enqueue the sync.") return } if err := integration.Enqueue(ctx, tx, "stripe", "create_stripe_price", pricePayload); err != nil { h.Logger.Error("sync-stripe: enqueue outbox", slog.Any("error", err), slog.String("product_id", productID)) h.renderProductEditPage(w, r, productID, "", "Failed to enqueue the sync.") return } if err := tx.Commit(); err != nil { h.Logger.Error("sync-stripe: commit", slog.Any("error", err), slog.String("product_id", productID)) h.renderProductEditPage(w, r, productID, "", "Failed to enqueue the sync.") return } h.renderProductEditPage(w, r, productID, "Sync to Stripe enqueued — the mapping will appear here shortly.", "") } // stripeSyncFailure reports whether the product's Stripe catalog-sync has // terminally failed (a create_stripe_product / create_stripe_price outbox entry // reached dead_letter), returning the recorded error. Transient 'failed' rows // (still auto-retried by the outbox poller) are treated as in-flight, not failed. // priceID may be empty (no active price yet) — then only the product action matches. func (h *OperatorPartialsHandler) stripeSyncFailure(ctx context.Context, productID, priceID string) (bool, string) { var errMsg string err := h.Database.QueryRowContext(ctx, `SELECT COALESCE(error_message, '') FROM core.outbox WHERE provider = 'stripe' AND status = 'dead_letter' AND ( (action_type = 'create_stripe_product' AND payload->>'product_id' = $1) OR (action_type = 'create_stripe_price' AND payload->>'price_id' = $2) ) ORDER BY updated_at DESC LIMIT 1`, productID, priceID).Scan(&errMsg) if err != nil { return false, "" } return true, errMsg } // renderProductPricesFormErrors re-renders the prices page (with the inline // add-price form) with 422 + FieldErrors populated. Routes through // renderProductPricesPage's full data load by setting FieldErrors after the // load returns — safe because no body write happens before we set the header. func (h *OperatorPartialsHandler) renderProductPricesFormErrors(w http.ResponseWriter, r *http.Request, productID string, errs web.FieldErrors) { // Re-render the full composite so the failing add-price form appears // alongside the edit form and readiness panel it now shares a page with. w.WriteHeader(http.StatusUnprocessableEntity) edit, ok := h.loadProductEditData(r, productID) if !ok { h.renderProductsPage(w, r, "", "Product not found") return } prices := h.loadProductPricesData(r, productID) prices.FieldErrors = errs h.Templates.Render(w, "operator_product_detail.html", ProductDetailData{ Edit: edit, Prices: prices, }) } // renderProductPricesPage re-renders the product composite in place. Retained as // a thin alias so the price mutation call sites (CreatePrice) keep working; the // prices view now lives on the composite detail page. func (h *OperatorPartialsHandler) renderProductPricesPage(w http.ResponseWriter, r *http.Request, productID string, success string, errMsg string) { h.renderProductDetailBody(w, r, productID, success, errMsg) } // loadProductPricesData hydrates the prices view model for one product: the // price rows, each with its Stripe mapping status, plus the dashboard link. // Best-effort — a query failure yields a partial result with Error set rather // than aborting, since this feeds the composite detail page where the product's // existence is already established. Guards a nil StripeQ (Stripe unconfigured). func (h *OperatorPartialsHandler) loadProductPricesData(r *http.Request, productID string) ProductPricesData { data := ProductPricesData{ ProductID: productID, StripeDashboardURL: h.StripeDashboardURL, StripeConfigured: h.StripeConfigured, } if product, err := h.BillingQ.GetProductByID(r.Context(), productID); err == nil { data.ProductName = product.Name } prices, err := h.BillingQ.ListPricesByProduct(r.Context(), productID) if err != nil { h.Logger.Error("failed to list prices", "error", err, "product_id", productID) data.Error = "Failed to load prices" return data } vms := make([]PriceViewModel, len(prices)) for i, price := range prices { stripePriceID := "" syncStatus := "not_mapped" if h.StripeQ != nil { if mapping, err := h.StripeQ.GetPriceMappingByPriceID(r.Context(), price.PriceID); err == nil { if mapping.StripePriceID.Valid { stripePriceID = mapping.StripePriceID.String } syncStatus = mapping.SyncStatus } } isRecurring := price.RecurringInterval.Valid interval := "" if isRecurring { interval = price.RecurringInterval.String } vms[i] = PriceViewModel{ PriceID: price.PriceID, ProductID: price.ProductID, UnitAmount: formatCurrency(price.UnitAmount, price.Currency), Currency: price.Currency, RecurringInterval: interval, IsRecurring: isRecurring, TrialPeriodDays: price.TrialPeriodDays.Int32, IsActive: price.IsActive, IsDefault: price.IsDefault, StripePriceID: stripePriceID, StripeSyncStatus: syncStatus, CreatedAt: price.CreatedAt.Format("Jan 2, 2006"), } } data.Prices = vms return data } // getStatusBadgeClass returns the Bootstrap badge class for a subscription status func getStatusBadgeClass(status string) string { switch status { case "active", "trialing": return "success" case "past_due", "unpaid", "incomplete": return "danger" case "canceled", "ended", "incomplete_expired", "paused": return "secondary" default: return "secondary" } } // getInvoiceStatusBadgeClass returns the Bootstrap badge class for an invoice status func getInvoiceStatusBadgeClass(status string, amountDue, amountPaid int32) string { switch status { case "paid": if amountDue == amountPaid { return "success" } return "warning" case "open", "draft": return "warning" case "void", "uncollectible": return "secondary" default: return "secondary" } } // getPaymentStatusBadgeClass returns the Bootstrap badge class for a payment status func getPaymentStatusBadgeClass(status string) string { switch status { case "succeeded": return "success" case "pending": return "warning" case "failed": return "danger" case "canceled": return "secondary" default: return "secondary" } } // getSyncStatusBadgeClass returns the Bootstrap badge class for a sync status func getSyncStatusBadgeClass(status string) string { switch status { case "synced": return "success" case "pending": return "warning" case "failed", "dead_letter": return "danger" case "deleted": return "secondary" default: return "secondary" } } // GetStripeEntityURL returns the Stripe dashboard deep-link for an entity. func (h *OperatorPartialsHandler) GetStripeEntityURL(entityType, stripeID string) string { return stripeEntityURL(h.StripeDashboardURL, entityType, stripeID) } // stripeEntityURL builds a Stripe dashboard deep-link for an entity, or "" when // the dashboard base URL is unconfigured or the ID is empty — callers then fall // back to rendering the raw ID as plain text. entityType is a Stripe dashboard // path segment such as "customers" or "prices". Registered in the operator // template FuncMap as `stripeEntityURL` so templates can wrap Stripe IDs in // links gated on the dashboard URL being set (finding #39). func stripeEntityURL(dashboardURL, entityType, stripeID string) string { if dashboardURL == "" || stripeID == "" { return "" } return fmt.Sprintf("%s/%s/%s", dashboardURL, entityType, stripeID) } // Helper to get current time for templates func now() time.Time { return time.Now() }