Files
cgalo5758 257955c9d3 Add operator list-scale contract and People directory
Governed operator lists (organizations, grants, people, billing×4) gain
server-side search, status filters, and 50-row pages with true totals
from count(*) OVER(); state is URL-addressable, out-of-range pages
clamp,
and no-match is distinct from true-empty.

People is the eighth flat sidebar entry: /operator/persons lists persons
newest-joined first (excluding the reserved system person), rows linking
to the existing detail.

Billing gains an operator invoice detail at
/operator/billing/invoices/{invoiceID} reusing the member projection;
open invoices past due present as Overdue (derived, filterable, stored
status untouched); all four views lead with the linked organization and
mute object IDs.

Grants filter over the derived Live/Superseded/Inactive state, the SQL
HAVING predicate pinned to the Go derivation by test. Embedded lists
(org composite ledger, Tier changes) adopt the shared controls under
namespaced params with sibling-state-preserving URLs and scoped htmx
swaps that hold the viewport.

Review corrections: blocked ladder Delete renders disabled with tooltip
and mutations fire toasts; collapse triggers paint their open state;
sections use outside headings; plan topology drops the orphan-product
check; domains policy collapses behind a disclosure.
2026-08-24 03:58:18 -05:00

1391 lines
54 KiB
Go

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(&params, 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(&params, 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(&params, 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(&params, 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()
}