Files
member-console/internal/server/operator_billing.go
T

1682 lines
68 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
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/forms"
"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"
)
// EnvironmentNotice is the one muted line above a billing view that names
// the rows the environment filter is holding back and carries the switch
// between the two states (stripe-environment-stamp D7). It is a
// data-preservation fact, not a hint: the rows exist, this view is not
// showing them, and the line says which and how many.
//
// Text is empty when the filter hides nothing, and the template renders
// neither the line nor the switch then.
type EnvironmentNotice struct {
Text string
LinkLabel string
LinkHref string
// Target and SyncSelect are the hosting list's htmx selectors, copied
// from its ListNav. When Target is set the switch issues the scoped
// request every other control of the scaffold issues, so an embedded
// view swaps its own panel instead of navigating the whole page; when
// it is empty the switch is a plain link.
Target string
SyncSelect string
}
// Show reports whether the line renders.
func (n EnvironmentNotice) Show() bool { return n.Text != "" }
// billingEnvFilter is one billing view's resolved environment state: which
// core ids the paged query must leave out, how many rows that is, and the
// mode the key is in. Resolved once per request, before the page is
// fetched, because filtering after FetchPage would leave the pager
// counting rows it does not render (task 6.1).
type billingEnvFilter struct {
// All is the env=all state: nothing is excluded and the rows from the
// other environment carry a badge.
All bool
// OutsideIDs are the core ids whose stripe mapping records the
// environment the key is not in. Resolved in both states: the default
// state hands them to the paged query, and the all state still needs
// them to count what the default state would have hidden.
OutsideIDs []string
// Hidden is how many rows the filter holds back from this view under
// the search and the facet the operator has in force, which is what
// the absence line names. Resolved by resolveHidden, not by counting
// OutsideIDs: a search that matches none of the out-of-environment
// rows hides nothing, and the line must not claim otherwise.
Hidden int64
// KeyMode is the environment the API key is in, "" when the deployment
// has no key the console could classify. An empty mode filters nothing:
// there is no environment to compare a stamp against.
KeyMode string
}
// ExcludeIDs is what the paged query must leave out: the out-of-environment
// ids in the default state, nothing in the all state.
func (f billingEnvFilter) ExcludeIDs() []string {
if f.All {
return nil
}
return f.OutsideIDs
}
// The four billing views the environment filter applies to, named so
// resolveEnvFilter can pick each one's *OutsideMode pair in one place
// rather than four call sites passing method values (which would panic on
// the nil Stripe querier a deployment without Stripe carries).
type billingEnvView string
const (
envViewAccounts billingEnvView = "billing accounts"
envViewSubscriptions billingEnvView = "subscriptions"
envViewInvoices billingEnvView = "invoices"
envViewPayments billingEnvView = "payments"
)
// resolveEnvFilter reads one view's out-of-environment ids from the Stripe
// store. Both states read them: the default state excludes them from the
// page, and the all state needs them to say how many rows it is showing
// that the default state would not.
func (h *OperatorPartialsHandler) resolveEnvFilter(ctx context.Context, view billingEnvView, all bool) billingEnvFilter {
f := billingEnvFilter{All: all, KeyMode: h.StripeMode}
if h.StripeQ == nil || h.StripeMode == "" {
return f
}
live := h.StripeMode == stripedb.ModeLive
var (
ids []string
err error
)
switch view {
case envViewAccounts:
ids, err = h.StripeQ.ListBillingAccountIDsOutsideMode(ctx, live)
case envViewSubscriptions:
ids, err = h.StripeQ.ListSubscriptionIDsOutsideMode(ctx, live)
case envViewInvoices:
ids, err = h.StripeQ.ListInvoiceIDsOutsideMode(ctx, live)
case envViewPayments:
ids, err = h.StripeQ.ListPaymentIDsOutsideMode(ctx, live)
}
if err != nil {
h.Logger.Warn("failed to resolve rows outside the key's stripe environment",
slog.String("view", string(view)), slog.Any("error", err))
return f
}
f.OutsideIDs = ids
return f
}
// resolveHidden counts how many rows the environment filter holds back
// from this view, under every predicate the page query applies: the
// search the operator typed, the status facet, and the ids a search
// pre-resolved elsewhere. count runs the view's count query with the
// exclusion it is handed, so the two runs differ in nothing but the
// exclusion and their difference is exactly what the line must name.
// Counting OutsideIDs instead would report the whole out-of-environment
// set, which a search matching none of it would contradict.
func (f *billingEnvFilter) resolveHidden(logger *slog.Logger, view billingEnvView, count func(exclude []string) (int64, error)) {
if f.KeyMode == "" || len(f.OutsideIDs) == 0 {
return
}
shown, err := count(f.OutsideIDs)
if err == nil {
var all int64
all, err = count(nil)
if err == nil && all > shown {
f.Hidden = all - shown
}
}
if err != nil {
logger.Warn("failed to count rows outside the key's stripe environment",
slog.String("view", string(view)), slog.Any("error", err))
}
}
// environmentNotice builds the absence line for one view. singular and
// plural are that view's noun; the environment word is the other one's,
// because the line is about what is not here.
func environmentNotice(nav ListNav, f billingEnvFilter, singular, plural string) EnvironmentNotice {
if f.KeyMode == "" {
return EnvironmentNotice{}
}
// The all state always carries the line, hidden rows or none: it is the
// only way back to the key's own environment, and an operator who
// switched to all and then searched would otherwise be stranded there.
if f.All {
return EnvironmentNotice{
Text: "Showing all environments.",
LinkLabel: "Show " + f.KeyMode + " only",
LinkHref: nav.EnvURL(false),
Target: nav.Target,
SyncSelect: nav.SyncSelect,
}
}
if f.Hidden == 0 {
return EnvironmentNotice{}
}
return EnvironmentNotice{
Text: formatCount(f.Hidden) + " " + pluralize(f.Hidden, singular, plural) +
" from " + stripedb.OtherMode(f.KeyMode) + " mode " +
pluralize(f.Hidden, "is", "are") + " not shown.",
LinkLabel: "Show all",
LinkHref: nav.EnvURL(true),
Target: nav.Target,
SyncSelect: nav.SyncSelect,
}
}
// rowEnvState is the badge state for one row in the all state: the
// environment the row's mapping records, when that is the one the key is
// not in. Empty everywhere else, and the template renders no badge then,
// so only the rows the ordinary state would have hidden are marked.
func rowEnvState(livemode sql.NullBool, keyMode string, all bool) string {
if !all || keyMode == "" || stripedb.MappingAgrees(livemode, keyMode) {
return ""
}
return stripedb.OtherMode(keyMode)
}
// 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
// EnvState is the Stripe environment this row's mapping records when
// the view is showing every environment and this row is from the one
// the key is not in; "" otherwise (stripe-environment-stamp D7).
EnvState string
}
// BillingAccountsData holds data for the billing accounts list partial
type BillingAccountsData struct {
Accounts []BillingAccountViewModel
Error string
// EnvNotice is the absence line and its switch, empty when the
// environment filter hides nothing (stripe-environment-stamp D7).
EnvNotice EnvironmentNotice
// 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
}
// matchingInvoiceIDsByStripeNumber returns the invoice ids whose
// stripe.invoice_mappings row carries a stripe_invoice_number containing q,
// case-insensitively (invoice-numbers D6: the invoices search also matches
// Stripe's number). Mirrors matchingOrgIDs's cross-boundary pattern: the
// match runs in SQL against the stripe schema (ListInvoiceMappingsByStripeNumberSubstring),
// but internal/billing's own query never joins it, so the ids are
// pre-resolved here and passed into ListInvoicesPage as an ANY(...) array
// narg. Returns nil when q is empty, same reasoning as matchingOrgIDs.
func (h *OperatorPartialsHandler) matchingInvoiceIDsByStripeNumber(ctx context.Context, q string) []string {
if q == "" {
return nil
}
ids, err := h.StripeQ.ListInvoiceMappingsByStripeNumberSubstring(ctx, sql.NullString{String: q, Valid: true})
if err != nil {
h.Logger.Warn("matchingInvoiceIDsByStripeNumber: list invoice mappings", slog.Any("error", err))
return nil
}
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,
Extra: EnvExtra(ParseEnvAll(r)),
}
}
// 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)
env := h.resolveEnvFilter(ctx, envViewAccounts, ParseEnvAll(r))
env.resolveHidden(h.Logger, envViewAccounts, func(exclude []string) (int64, error) {
return h.BillingQ.CountBillingAccountsPage(ctx, billing.CountBillingAccountsPageParams{
Q: sql.NullString{String: params.Q, Valid: params.Q != ""},
OrgIds: orgIDs,
ExcludeIds: exclude,
})
})
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,
ExcludeIds: env.ExcludeIDs(),
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"
envState := ""
if mapping, err := h.StripeQ.GetCustomerMappingByBillingAccountID(ctx, acc.BillingAccountID); err == nil {
if mapping.StripeCustomerID.Valid {
stripeCustomerID = mapping.StripeCustomerID.String
}
syncStatus = mapping.SyncStatus
envState = rowEnvState(mapping.Livemode, h.StripeMode, env.All)
}
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"),
EnvState: envState,
}
}
return BillingAccountsData{
Accounts: vms,
StripeConfigured: stripeConfigured,
Nav: nav,
EnvNotice: environmentNotice(nav, env, "billing account", "billing accounts"),
}
}
// SubscriptionViewModel represents a subscription for operator template rendering
type SubscriptionViewModel struct {
SubscriptionID string
BillingAccountID string
BillingAccountName string
OrgID string
OrgName string
Status string
StatusState string
CurrentPeriodStart string
CurrentPeriodEnd string
CancelAtPeriodEnd bool
StripeSubscriptionID string
StripeSyncStatus string
CreatedAt string
// EnvState marks a row from the environment the key is not in, in the
// all state only; see BillingAccountViewModel.
EnvState string
}
// SubscriptionsData holds data for the subscriptions list partial
type SubscriptionsData struct {
Subscriptions []SubscriptionViewModel
Error string
// EnvNotice is the absence line and its switch; see BillingAccountsData.
EnvNotice EnvironmentNotice
// 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,
Extra: EnvExtra(ParseEnvAll(r)),
}
}
// 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)
env := h.resolveEnvFilter(ctx, envViewSubscriptions, ParseEnvAll(r))
env.resolveHidden(h.Logger, envViewSubscriptions, func(exclude []string) (int64, error) {
return h.BillingQ.CountSubscriptionsPage(ctx, billing.CountSubscriptionsPageParams{
Q: sql.NullString{String: params.Q, Valid: params.Q != ""},
OrgIds: orgIDs,
Status: sql.NullString{String: params.Facet, Valid: params.Facet != ""},
ExcludeIds: exclude,
})
})
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 != ""},
ExcludeIds: env.ExcludeIDs(),
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"
envState := ""
if mapping, err := h.StripeQ.GetSubscriptionMappingBySubscriptionID(ctx, sub.SubscriptionID); err == nil {
if mapping.StripeSubscriptionID.Valid {
stripeSubID = mapping.StripeSubscriptionID.String
}
syncStatus = mapping.SyncStatus
envState = rowEnvState(mapping.Livemode, h.StripeMode, env.All)
}
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,
StatusState: sub.Status,
CurrentPeriodStart: periodStart,
CurrentPeriodEnd: periodEnd,
CancelAtPeriodEnd: sub.CancelAtPeriodEnd,
StripeSubscriptionID: stripeSubID,
StripeSyncStatus: syncStatus,
CreatedAt: sub.CreatedAt.Format("Jan 2, 2006"),
EnvState: envState,
}
}
return SubscriptionsData{
Subscriptions: vms,
StripeConfigured: stripeConfigured,
Nav: nav,
EnvNotice: environmentNotice(nav, env, "subscription", "subscriptions"),
}
}
// InvoiceViewModel represents an invoice for operator template rendering
type InvoiceViewModel struct {
InvoiceID string
// InvoiceNumber is the platform-assigned reference number
// (invoice-numbers D1-D3), empty until the invoice is issued — the
// template renders the empty-value marker with its disclosure tooltip in
// that case. BillingAccountName renders beside it (invoice-numbers D6):
// this list crosses billing accounts, so the number alone is ambiguous.
InvoiceNumber 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
StatusState string
AmountDue string
AmountPaid string
Currency string
DueDate string
PaidAt string
StripeInvoiceID string
StripeSyncStatus string
CreatedAt string
// EnvState marks a row from the environment the key is not in, in the
// all state only; see BillingAccountViewModel.
EnvState string
}
// InvoicesData holds data for the invoices list partial
type InvoicesData struct {
Invoices []InvoiceViewModel
Error string
// EnvNotice is the absence line and its switch; see BillingAccountsData.
EnvNotice EnvironmentNotice
// 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 (organization or invoice number, per invoice-numbers D4)
// 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 invoice number",
FacetParam: "status",
FacetOptions: invoiceStatusFacets,
Q: params.Q,
Facet: facet,
Page: params.Page,
Extra: EnvExtra(ParseEnvAll(r)),
}
}
// 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)
invoiceIDs := h.matchingInvoiceIDsByStripeNumber(ctx, params.Q)
env := h.resolveEnvFilter(ctx, envViewInvoices, ParseEnvAll(r))
env.resolveHidden(h.Logger, envViewInvoices, func(exclude []string) (int64, error) {
return h.BillingQ.CountInvoicesPage(ctx, billing.CountInvoicesPageParams{
Q: sql.NullString{String: params.Q, Valid: params.Q != ""},
OrgIds: orgIDs,
InvoiceIds: invoiceIDs,
Status: sql.NullString{String: params.Facet, Valid: params.Facet != ""},
ExcludeIds: exclude,
})
})
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,
InvoiceIds: invoiceIDs,
Status: sql.NullString{String: params.Facet, Valid: params.Facet != ""},
ExcludeIds: env.ExcludeIDs(),
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"
envState := ""
if mapping, err := h.StripeQ.GetInvoiceMappingByInvoiceID(ctx, inv.InvoiceID); err == nil {
if mapping.StripeInvoiceID.Valid {
stripeInvoiceID = mapping.StripeInvoiceID.String
}
syncStatus = mapping.SyncStatus
envState = rowEnvState(mapping.Livemode, h.StripeMode, env.All)
}
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,
InvoiceNumber: inv.InvoiceNumber.String,
BillingAccountID: inv.BillingAccountID,
BillingAccountName: inv.BillingAccountName,
OrgID: inv.OrgID,
OrgName: orgName,
Status: inv.Status,
Overdue: invoiceIsOverdue(inv.Status, inv.DueDate),
StatusState: invoiceStatusState(inv.Status, inv.AmountDue, inv.AmountPaid, invoiceIsOverdue(inv.Status, inv.DueDate)),
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"),
EnvState: envState,
}
}
return InvoicesData{
Invoices: vms,
StripeConfigured: stripeConfigured,
Nav: nav,
EnvNotice: environmentNotice(nav, env, "invoice", "invoices"),
}
}
// 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
// InvoiceNumber is the platform-assigned reference number
// (invoice-numbers D1-D3); empty until the invoice is issued, in which
// case the heading renders the empty-value marker and its disclosure
// tooltip instead.
InvoiceNumber string
// StripeInvoiceNumber is Stripe's own customer-facing number
// (invoice-numbers D5), an external reference shown as a secondary
// "Stripe invoice ..." line when the mapping carries one; never the
// heading.
StripeInvoiceNumber string
OrgID string
OrgName string
Status string
Overdue bool
StatusState 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
// StripeConfigured gates the Stripe fact row: provenance is noise on a
// deployment with no provider (maintainer, 2026-08-30).
StripeConfigured bool
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
}
stripeConfigured, _ := configurationReadiness(h.IntegrationConfigs, "stripe")
data := OperatorInvoiceDetailData{
StripeConfigured: stripeConfigured,
InvoiceID: invoice.InvoiceID,
InvoiceNumber: invoice.InvoiceNumber.String,
Status: invoice.Status,
Overdue: invoiceIsOverdue(invoice.Status, invoice.DueDate),
StatusState: invoiceStatusState(invoice.Status, invoice.AmountDue, invoice.AmountPaid, invoiceIsOverdue(invoice.Status, invoice.DueDate)),
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
}
if mapping.StripeInvoiceNumber.Valid {
data.StripeInvoiceNumber = mapping.StripeInvoiceNumber.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
// InvoiceNumber is the platform-assigned reference number
// (invoice-numbers D1-D3), carried into the "View invoice" cross-reference's
// link title; empty until the invoice is issued.
InvoiceNumber string
BillingAccountID string
BillingAccountName string
OrgID string
OrgName string
Status string
StatusState string
Amount string
Currency string
PaymentMethod string
StripePaymentIntentID string
StripeSyncStatus string
FailedAt string
CreatedAt string
// EnvState marks a row from the environment the key is not in, in the
// all state only; see BillingAccountViewModel.
EnvState string
}
// PaymentsData holds data for the payments list partial
type PaymentsData struct {
Payments []PaymentViewModel
Error string
// EnvNotice is the absence line and its switch; see BillingAccountsData.
EnvNotice EnvironmentNotice
// 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,
Extra: EnvExtra(ParseEnvAll(r)),
}
}
// 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)
env := h.resolveEnvFilter(ctx, envViewPayments, ParseEnvAll(r))
env.resolveHidden(h.Logger, envViewPayments, func(exclude []string) (int64, error) {
return h.BillingQ.CountPaymentsPage(ctx, billing.CountPaymentsPageParams{
Q: sql.NullString{String: params.Q, Valid: params.Q != ""},
OrgIds: orgIDs,
ExcludeIds: exclude,
})
})
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,
ExcludeIds: env.ExcludeIDs(),
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"
envState := ""
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
envState = rowEnvState(mapping.Livemode, h.StripeMode, env.All)
}
}
orgName := ""
if org, err := h.OrgQ.GetOrganizationByID(ctx, pay.OrgID); err == nil {
orgName = org.Name
}
// Empty (not "Unknown"): the template renders the same muted "—"
// every other not-applicable cell in this table uses (ACC-32 — a
// word here was one more spelling of "none" beside "—" and "No").
paymentMethod := ""
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,
InvoiceNumber: pay.InvoiceNumber.String,
BillingAccountID: pay.BillingAccountID,
BillingAccountName: pay.BillingAccountName,
OrgID: pay.OrgID,
OrgName: orgName,
Status: pay.Status,
StatusState: 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"),
EnvState: envState,
}
}
return PaymentsData{
Payments: vms,
StripeConfigured: stripeConfigured,
Nav: nav,
EnvNotice: environmentNotice(nav, env, "payment", "payments"),
}
}
// 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
// PriceForm is the price-add declaration (operator.product.price.add),
// bound to whatever was submitted (fresh, or a refusal). Its own
// HasErrors() reopens the Add price panel on a refusal (design D20).
PriceForm forms.FormView
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,
// reading the body through the price-add declaration. It no longer reads
// trial_period_days (finding FA-6): no field on the declaration renders
// it, so the column stays NULL from here until the payments model builds
// trials.
func (h *OperatorPartialsHandler) CreatePrice(w http.ResponseWriter, r *http.Request) {
productID := r.PathValue("productID")
values, errs := productPriceForm.Parse(r)
amountStr := values.String("amount")
currency := values.String("currency")
interval := values.String("recurring_interval")
var unitAmount int32
if amountStr != "" {
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.Field("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.Field("amount", "Amount must be a whole number of cents (at most two decimal places).")
case rounded > maxStripeUnitAmount:
errs.Field("amount", "Amount is too large; the maximum is 999,999.99.")
default:
unitAmount = int32(rounded)
}
}
}
recurringInterval := sql.NullString{}
if interval != "" && interval != "one_time" {
recurringInterval = sql.NullString{String: interval, Valid: true}
}
if errs.Any() {
h.renderProductPricesRefusal(w, r, productID, values, errs)
return
}
price, err := h.BillingQ.CreatePrice(r.Context(), billing.CreatePriceParams{
ProductID: productID,
Currency: currency,
UnitAmount: unitAmount,
RecurringInterval: recurringInterval,
})
if err != nil {
if fe, ok := web.FieldErrorsFromDB(err, nil); ok {
for field, msg := range fe {
errs.Field(field, msg)
}
h.renderProductPricesRefusal(w, r, productID, values, errs)
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 (Make default,
// Deactivate: row-level action triggers with no form of their own, spec
// form-library's ActionTrigger) to the §6 error contract: a recognizable
// constraint violation renders as the page's own error banner
// (web.FieldErrorsFromDB with a nil constraint map always answers under
// the form-level key), 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.renderProductPricesPage(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.
//
// "Already synced" is a claim about an id this deployment can reach, so
// it holds only while the mapping's recorded environment agrees with the
// key and the environment check has not marked the row stale
// (stripe-environment-stamp D5). Otherwise the press falls through to
// the never-synced path below, which creates the object again in the
// environment the key is in and lets the executor's mapping replace the
// unreachable id.
m, mErr := h.StripeQ.GetPriceMappingByPriceID(ctx, price.PriceID)
if mErr == nil && m.StripePriceID.Valid && stripedb.MappingReachable(m.Livemode, m.SyncStatus, h.StripeMode) {
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 && stripedb.MappingReachable(pm.Livemode, pm.SyncStatus, h.StripeMode) {
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.
//
// A product id the current key cannot reach is not a product that
// already exists here: the price create would attach the new price to a
// product in the other environment and fail, so the product is created
// again too (stripe-environment-stamp D5).
productSynced := false
if pm, err := qtx.GetProductMappingByProductID(ctx, productID); err == nil {
if pm.StripeProductID.Valid {
productSynced = stripedb.MappingReachable(pm.Livemode, pm.SyncStatus, h.StripeMode)
} 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
// (the entry's Temporal workflow is still retrying them) 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.
// renderProductPricesRefusal re-renders the full composite at 422 with the
// price declaration in submission mode: the alongside edit form and
// readiness panel it shares the page with render as usual (design D9).
func (h *OperatorPartialsHandler) renderProductPricesRefusal(w http.ResponseWriter, r *http.Request, productID string, values forms.Values, errs *forms.Errors) {
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.PriceForm = productPriceFormView(productID, values, 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,
// The panel's fresh (unrefused) state: closed, nothing typed. A
// refusal's caller (renderProductPricesRefusal) overwrites this
// with the submission bound back.
PriceForm: productPriceFormView(productID, forms.NewValues(), nil),
}
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
}
// 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()
}
// invoiceStatusState derives the one presentation state an invoice badge
// renders (anatomy-sweep 5.1, "the class funcs return states"): the
// Overdue derivation wins, a paid invoice that did not cover its amount
// presents as partially paid, everything else is the stored status.
func invoiceStatusState(status string, amountDue, amountPaid int32, overdue bool) string {
if overdue {
return "overdue"
}
if status == "paid" && amountPaid < amountDue {
return "partially_paid"
}
return status
}