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