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

515 lines
22 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"
"net/http"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/instance"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
stripewf "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/workflows"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
"go.temporal.io/sdk/temporal"
)
// The Stripe provider page (design D24; integration-settings ADDED
// requirement "The Stripe provider page carries its status and the
// delivery-queue report"): Stripe's own home, the destination of its name
// on the Integrations table and the overview's Integrations card, distinct
// from its settings page (which now carries configuration only). Carries
// the configured state, the mode, and the Delivery queue section moved
// here from the settings page (operator_integration_settings.go).
// StripeIntegrationData is the body for operator_integration_stripe.html.
type StripeIntegrationData struct {
// Configured/Missing: the same required-key resolution
// (configurationReadiness) the settings page and the Integrations
// table use, so all three surfaces read the same fact.
Configured bool
Missing []string
// ModeLabel is "Test mode" or "Live mode", from the mode derived from
// the Stripe API key at boot
// — mirrors OverviewStripeFacts.ModeLabel (operator_overview.go) so the
// overview's Stripe row and this page never disagree.
ModeLabel string
// Description is the provider manifest's declared description, when it
// carries one ("" otherwise); Header() falls back to a generic lead.
Description string
// DeliveryQueueHeader/DeliveryQueue/DeliveryQueueEntries carry the
// "Delivery queue" section, moved here from the settings page
// (integration-settings ADDED requirement: "The Stripe provider page
// carries its status and the delivery-queue report"). Also read by the
// landing surface's Stripe element (operator_overview.go's
// loadOverviewStripeFacts, which only summarizes DeliveryQueue) so the
// two surfaces can never disagree about what the outbox holds.
DeliveryQueueHeader SectionHeader
DeliveryQueue DeliveryQueue
DeliveryQueueEntries []DeadLetterEntry
// InboundEventsHeader/InboundEvents/InboundEventEntries carry the
// "Inbound events" section: Stripe's webhook events that failed
// processing through every retry round and were dead-lettered
// (stripe-integration-infrastructure: "Failed events are retried, then
// dead-lettered"). The outbox section above is outbound work; this is
// the inbound counterpart, and the overview's Stripe row counts both.
InboundEventsHeader SectionHeader
InboundEvents InboundEvents
InboundEventEntries []DeadLetterEntry
// EnvironmentCheckHeader/EnvironmentCheck carry the "Environment
// check" section (stripe-environment-stamp D3): where the read-back of
// the mapped Stripe ids stands under the key in force, and the control
// that runs it again. Rendered only when Stripe is configured, since
// there is no key to read anything back under otherwise.
EnvironmentCheckHeader SectionHeader
EnvironmentCheck EnvironmentCheck
}
// EnvironmentCheck is the Environment check section's state: one resting
// line naming where the read-back stands, and the control beside it
// (stripe-environment-stamp D3).
type EnvironmentCheck struct {
Line string
}
// InboundEvents is the health of Stripe's inbound webhook events: how many
// have exhausted their retries, and how many arrived from the environment
// the configured key cannot reach. Received, retrying and processing rows
// need no operator and are not counted.
type InboundEvents struct {
DeadLetter int64
// Refused counts the events captured from the other Stripe environment
// (stripe-environment-stamp D6). They are not a failure an operator
// retries: nothing here will ever process them, and the count is how a
// deployment learns an old endpoint is still pointed at it.
Refused int64
// RefusedLine names where those events came from and which key they
// met. Empty when none has been refused, so the section says nothing
// about an environment nothing arrived from.
RefusedLine string
Available bool
}
// NeedsAttention reports whether inbound events hold work that will not
// resolve on its own.
func (e InboundEvents) NeedsAttention() bool { return e.DeadLetter > 0 }
// Quiet reports whether the section has nothing to report: no event has
// exhausted its retries and none has arrived from the other environment.
func (e InboundEvents) Quiet() bool { return e.DeadLetter == 0 && e.Refused == 0 }
// loadInboundEvents counts Stripe's dead-lettered and refused webhook
// events. Raw SQL against core.webhook_events, as loadDeadLetterEntries
// does for the outbox; a nil db or a failed probe leaves Available false.
// keyMode is the environment the configured key is in, which is what makes
// the refused line sayable: with two environments, an event the key
// refused came from the other one.
func loadInboundEvents(ctx context.Context, db *sql.DB, keyMode string, logger *slog.Logger) InboundEvents {
if db == nil {
return InboundEvents{}
}
var deadLetter, refused int64
if err := db.QueryRowContext(ctx,
`SELECT COUNT(*) FILTER (WHERE status = 'dead_letter'),
COUNT(*) FILTER (WHERE status = 'refused')
FROM core.webhook_events WHERE provider = 'stripe'`,
).Scan(&deadLetter, &refused); err != nil {
logger.Warn("inbound events: status count failed", slog.Any("error", err))
return InboundEvents{}
}
return InboundEvents{
DeadLetter: deadLetter,
Refused: refused,
RefusedLine: refusedEventsLine(refused, keyMode),
Available: true,
}
}
// refusedEventsLine states the refused events in the two environments'
// own words: the one they came from and the one the key is in. No key
// configured refuses nothing, so there is no line to write.
func refusedEventsLine(refused int64, keyMode string) string {
if refused == 0 || keyMode == "" {
return ""
}
eventMode := stripedb.ModeTest
if keyMode == stripedb.ModeTest {
eventMode = stripedb.ModeLive
}
return fmt.Sprintf("%d %s arrived in %s mode under a %s key.",
refused, pluralize(refused, "event", "events"), eventMode, keyMode)
}
// refusedEventDetail is what a refused row says in the table's detail
// column: it carries no error, because nothing failed. The event came from
// the environment the key cannot read, and was kept rather than processed
// (stripe-environment-stamp D6).
const refusedEventDetail = "Refused, mode mismatch"
// loadInboundDeadLetterEntries lists Stripe's dead-lettered and refused
// webhook events for the Inbound events table, capped like the outbox
// table. OperationType carries the event type (e.g. "invoice.paid").
//
// Dead-lettered rows sort ahead of refused ones, and each group is newest
// first. The two statuses share a table but not a reader's stake: a
// dead-lettered event needs an operator, while a refused one is recorded
// and finished with. An old endpoint still delivering to a moved key can
// refuse events by the hundred, and under one time ordering that flood
// pushes every dead-lettered row past the cap.
func loadInboundDeadLetterEntries(ctx context.Context, db *sql.DB, logger *slog.Logger) []DeadLetterEntry {
if db == nil {
return nil
}
rows, err := db.QueryContext(ctx,
`SELECT event_type, COALESCE(error_message, ''), retry_count, updated_at, status
FROM core.webhook_events
WHERE provider = 'stripe' AND status IN ('dead_letter', 'refused')
ORDER BY (status = 'dead_letter') DESC, updated_at DESC
LIMIT $1`, deadLetterEntryLimit)
if err != nil {
logger.Warn("inbound events: dead-letter entries query failed", slog.Any("error", err))
return nil
}
defer rows.Close()
var entries []DeadLetterEntry
for rows.Next() {
var e DeadLetterEntry
var updatedAt time.Time
var status string
if err := rows.Scan(&e.OperationType, &e.ErrorMessage, &e.Attempts, &updatedAt, &status); err != nil {
logger.Warn("inbound events: dead-letter entry scan failed", slog.Any("error", err))
return entries
}
if status == "refused" {
e.Refused = true
e.ErrorMessage = refusedEventDetail
}
e.UpdatedAt = updatedAt.Format("Jan 2, 2006 3:04 PM")
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
logger.Warn("inbound events: dead-letter entries rows failed", slog.Any("error", err))
}
return entries
}
// DeliveryQueue is the shared integration outbox's health, split by what an
// operator would do about each bucket. Retrying rows are in-flight and need
// no action; DeadLetter rows have exhausted their retries and will not move
// without intervention, so only that bucket gets alarm styling
// (integration-settings: "Only dead-letter outbox work raises alarm
// styling").
type DeliveryQueue struct {
Pending int64
Retrying int64
DeadLetter int64
Available bool
}
// NeedsAttention reports whether the delivery queue holds work that will
// not resolve on its own.
func (q DeliveryQueue) NeedsAttention() bool { return q.DeadLetter > 0 }
// Queued is the draining-normally count: enqueued or retrying work, as
// opposed to the dead-lettered work NeedsAttention names.
func (q DeliveryQueue) Queued() int64 { return q.Pending + q.Retrying }
// loadDeliveryQueue probes the shared integration outbox (core.outbox).
// Today only Stripe enqueues into it (operator_billing.go); an unavailable
// probe leaves Available false rather than a set of zeroes neither caller
// could stand behind. A nil querier (a handler built without one, as some
// tests do) also degrades to unavailable rather than panicking.
func loadDeliveryQueue(ctx context.Context, iq integration.Querier, logger *slog.Logger) DeliveryQueue {
if iq == nil {
return DeliveryQueue{}
}
counts, err := iq.CountOutboxByStatus(ctx)
if err != nil {
logger.Warn("delivery queue: outbox health probe failed", slog.Any("error", err))
return DeliveryQueue{}
}
return DeliveryQueue{
Pending: counts.PendingCount,
Retrying: counts.FailedCount,
DeadLetter: counts.DeadLetterCount,
Available: true,
}
}
// DeadLetterEntry is one dead-lettered outbox row: it has exhausted its
// retries and will not move without an operator (integration-settings:
// "the dead-letter table with its operation identifiers rendered as
// <code>"). OperationType is the outbox's action_type column (e.g.
// "create_stripe_price") — the write the queue was attempting.
type DeadLetterEntry struct {
OperationType string
ErrorMessage string
Attempts int32
UpdatedAt string
// Refused marks a row the table lists for a reason other than
// exhausted retries: an inbound event captured from the other Stripe
// environment. It carries no alarm styling, because no operator can
// move it and none should try (stripe-environment-stamp D6). Outbox
// entries never set it.
Refused bool
}
// deadLetterEntryLimit caps the Delivery queue section's table so a
// long-neglected queue cannot blow up the provider page; the counts above
// (DeliveryQueue.DeadLetter) still state the true total regardless.
const deadLetterEntryLimit = 50
// loadDeadLetterEntries lists the Stripe outbox's dead-lettered rows for
// the Delivery queue section's table. Raw SQL against core.outbox directly
// (the same cross-schema pattern operator_pages.go's ownedResourceKeys
// uses) rather than a new query on the integration module, which this lane
// does not own. Degrades to an empty list on failure — the counts above
// still say a dead-letter bucket exists even when the row-level detail
// cannot be fetched, and a nil db (a handler built without one, as some
// tests do) degrades the same way rather than panicking.
func loadDeadLetterEntries(ctx context.Context, db *sql.DB, logger *slog.Logger) []DeadLetterEntry {
if db == nil {
return nil
}
rows, err := db.QueryContext(ctx,
`SELECT action_type, COALESCE(error_message, ''), attempts, updated_at
FROM core.outbox
WHERE provider = 'stripe' AND status = 'dead_letter'
ORDER BY updated_at DESC
LIMIT $1`, deadLetterEntryLimit)
if err != nil {
logger.Warn("delivery queue: dead-letter entries query failed", slog.Any("error", err))
return nil
}
defer rows.Close()
var entries []DeadLetterEntry
for rows.Next() {
var e DeadLetterEntry
var updatedAt time.Time
if err := rows.Scan(&e.OperationType, &e.ErrorMessage, &e.Attempts, &updatedAt); err != nil {
logger.Warn("delivery queue: dead-letter entry scan failed", slog.Any("error", err))
return entries
}
e.UpdatedAt = updatedAt.Format("Jan 2, 2006 3:04 PM")
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
logger.Warn("delivery queue: dead-letter entries rows failed", slog.Any("error", err))
}
return entries
}
// environmentCheckDateFormat is the page's existing date format, shared
// with the two dead-letter tables above.
const environmentCheckDateFormat = "Jan 2, 2006 3:04 PM"
// loadEnvironmentCheck states where the read-back of the mapped Stripe ids
// stands, in one line (stripe-environment-stamp D3). Four states, each
// with its own fact to carry:
//
// - no record, and nothing running: the check has never run here;
// - a record with a start and no finish: a run is under way, and the
// line says how many ids it is reading back;
// - a finished record under the key in force: when it ran and what it
// found;
// - a finished record under another key: the key moved since, so the
// counts describe ids nobody has confirmed under this one, and the
// line says how many are waiting.
//
// fingerprint is the digest of the key this process holds. A failed read
// degrades to the never-run line rather than to nothing: the section still
// offers the control, which is the way out of any of these states.
func loadEnvironmentCheck(ctx context.Context, db *sql.DB, fingerprint string, logger *slog.Logger) EnvironmentCheck {
if db == nil {
return EnvironmentCheck{Line: environmentNeverChecked}
}
raw, found, err := instance.NewStore(db).GetJSON(ctx, instance.StripeEnvironmentCheck)
if err != nil {
logger.Warn("environment check: record read failed", slog.Any("error", err))
return EnvironmentCheck{Line: environmentNeverChecked}
}
rec, ok := stripedb.DecodeEnvironmentCheckRecord(raw, found)
if !ok || rec.StartedAt == nil {
return EnvironmentCheck{Line: environmentNeverChecked}
}
// Only the state that needs a count pays for one.
var mapped int64
switch {
case rec.Running():
mapped = countMappedStripeIDs(ctx, db, false, logger)
case rec.KeyFingerprint != fingerprint:
mapped = countMappedStripeIDs(ctx, db, true, logger)
}
return EnvironmentCheck{Line: environmentCheckLine(rec, fingerprint, mapped)}
}
// environmentNeverChecked is the line for a deployment where the read-back
// has not run: no record, an unreadable one, or one written before a run
// ever started.
const environmentNeverChecked = "Not checked yet."
// environmentCheckLine writes the resting line for a record that exists.
// mapped is the count the state needs: the ids a run in progress is
// reading back, or the ids left unverified after the key moved. The
// states that need no count ignore it.
func environmentCheckLine(rec stripedb.EnvironmentCheckRecord, fingerprint string, mapped int64) string {
switch {
case rec.StartedAt == nil:
return environmentNeverChecked
case rec.Running():
return fmt.Sprintf("Checking %d Stripe %s under the current key.",
mapped, pluralize(mapped, "id", "ids"))
case rec.KeyFingerprint != fingerprint:
return fmt.Sprintf("API key changed since the last check. %d %s unverified.",
mapped, pluralize(mapped, "mapping", "mappings"))
}
return fmt.Sprintf("Last checked %s. %d verified, %d stale.",
rec.FinishedAt.Local().Format(environmentCheckDateFormat), rec.Verified(), rec.Stale)
}
// countMappedStripeIDs counts the product and price mappings holding a
// Stripe id, or, with unverifiedOnly, the subset no check has confirmed
// under the key in force. Raw SQL across the stripe schema, the way
// loadInboundEvents reads core.webhook_events: the two counts exist for
// one line of copy each and are not worth a generated query. A failed
// count reads zero, which makes the line say less rather than wrong.
func countMappedStripeIDs(ctx context.Context, db *sql.DB, unverifiedOnly bool, logger *slog.Logger) int64 {
query := `SELECT (SELECT COUNT(*) FROM stripe.product_mappings WHERE stripe_product_id IS NOT NULL)
+ (SELECT COUNT(*) FROM stripe.price_mappings WHERE stripe_price_id IS NOT NULL)`
if unverifiedOnly {
query = `SELECT (SELECT COUNT(*) FROM stripe.product_mappings WHERE stripe_product_id IS NOT NULL AND verified_at IS NULL)
+ (SELECT COUNT(*) FROM stripe.price_mappings WHERE stripe_price_id IS NOT NULL AND verified_at IS NULL)`
}
var n int64
if err := db.QueryRowContext(ctx, query).Scan(&n); err != nil {
logger.Warn("environment check: mapping count failed", slog.Any("error", err))
return 0
}
return n
}
// stripeModeLabel names the mode derived from the Stripe API key at boot
// (server.Config.StripeMode). A deployment with no key configured reads
// "Test mode": nothing it shows is live money, and the page states
// separately that Stripe is unconfigured.
func stripeModeLabel(mode string) string {
if mode == "live" {
return "Live mode"
}
return "Test mode"
}
// GetStripeIntegrationPage handles GET /operator/integrations/stripe.
func (h *OperatorPartialsHandler) GetStripeIntegrationPage(w http.ResponseWriter, r *http.Request) {
configured, missing := configurationReadiness(h.IntegrationConfigs, "stripe")
modeLabel := stripeModeLabel(h.StripeMode)
bodyData := StripeIntegrationData{
Configured: configured,
Missing: missing,
ModeLabel: modeLabel,
Description: stripedb.ProviderSource().ProviderManifest().Description,
EnvironmentCheckHeader: SectionHeader{Title: "Environment check"},
DeliveryQueueHeader: SectionHeader{Title: "Delivery queue"},
InboundEventsHeader: SectionHeader{Title: "Inbound events"},
}
var iq integration.Querier
if h.Database != nil {
iq = integration.New(h.Database)
}
bodyData.EnvironmentCheck = loadEnvironmentCheck(r.Context(), h.Database, h.StripeKeyFingerprint, h.Logger)
bodyData.DeliveryQueue = loadDeliveryQueue(r.Context(), iq, h.Logger)
bodyData.DeliveryQueueEntries = loadDeadLetterEntries(r.Context(), h.Database, h.Logger)
bodyData.InboundEvents = loadInboundEvents(r.Context(), h.Database, h.StripeMode, h.Logger)
bodyData.InboundEventEntries = loadInboundDeadLetterEntries(r.Context(), h.Database, h.Logger)
page := h.buildOperatorPageData(r)
page.IAPosition = "integration:integrations:stripe"
page.ActiveCapability = "integrations"
page.BodyTemplate = "operator_integration_stripe.html"
page.BodyData = bodyData
h.Templates.Render(w, "operator.html", page)
}
// environmentCheckWait bounds how long the control's request waits on the
// run before answering. A catalog of a few dozen ids settles inside it, so
// the common answer is the counts; a larger one answers that the check
// started and the section says the rest on the next load. The wait is
// short because it is a request holding a worker, not a job.
const environmentCheckWait = 3 * time.Second
// PostStripeEnvironmentCheck handles
// POST /partials/operator/integrations/stripe/environment-check: the
// provider page's "Check now" control (stripe-environment-stamp
// D3). It starts the same workflow boot starts, under the same id, so an
// operator pressing it while the boot-started run is still going is
// refused by Temporal and told so.
//
// It waits a few seconds on the result rather than none: an operator who
// pressed the control wants the outcome, and a catalog this size usually
// has one by then. The section re-renders either way, so the page never
// disagrees with the record.
func (h *OperatorPartialsHandler) PostStripeEnvironmentCheck(w http.ResponseWriter, r *http.Request) {
if !h.StripeConfigured {
http.Error(w, "Stripe is not configured.", http.StatusNotFound)
return
}
if h.TemporalClient == nil {
fireErrorToast(w, "Temporal is unavailable.")
h.renderStripeEnvironmentCheck(w, r)
return
}
run, err := stripewf.StartEnvironmentCheck(r.Context(), h.TemporalClient, queues.Main, stripewf.EnvironmentCheckTriggerOperator)
if err != nil {
if temporal.IsWorkflowExecutionAlreadyStartedError(err) {
fireSuccessToast(w, "Check running.")
h.renderStripeEnvironmentCheck(w, r)
return
}
h.Logger.Error("failed to start the stripe environment check workflow", slog.Any("error", err))
fireErrorToast(w, "Could not start the check.")
h.renderStripeEnvironmentCheck(w, r)
return
}
waitCtx, cancel := context.WithTimeout(r.Context(), environmentCheckWait)
defer cancel()
var result stripewf.EnvironmentCheckResult
// Get returns an error for two different facts: the wait ran out while
// the run went on, and the run itself failed. Only the deadline means
// the check is still going, so the two are told apart by the wait
// context rather than reported as one.
switch err := run.Get(waitCtx, &result); {
case err == nil:
fireSuccessToast(w, fmt.Sprintf("%d checked, %d stale.", result.Checked, result.Stale))
case errors.Is(waitCtx.Err(), context.DeadlineExceeded):
fireSuccessToast(w, "Check started.")
default:
// The failure itself goes to the log, not the toast: a workflow
// error carries the activity's wrapped Go error, which is the
// leak docs/operator-ux-conventions.md §4 keeps out of the UI.
h.Logger.Error("the stripe environment check failed", slog.Any("error", err))
fireErrorToast(w, "The check failed.")
}
h.renderStripeEnvironmentCheck(w, r)
}
// renderStripeEnvironmentCheck re-renders the Environment check section
// alone, which is the whole of what the control changes.
func (h *OperatorPartialsHandler) renderStripeEnvironmentCheck(w http.ResponseWriter, r *http.Request) {
h.Templates.Render(w, "operator_integration_stripe_environment.html",
loadEnvironmentCheck(r.Context(), h.Database, h.StripeKeyFingerprint, h.Logger))
}