647 lines
26 KiB
Go
647 lines
26 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package workflows
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/common"
|
|
enumspb "go.temporal.io/api/enums/v1"
|
|
"go.temporal.io/sdk/activity"
|
|
"go.temporal.io/sdk/client"
|
|
"go.temporal.io/sdk/temporal"
|
|
"go.temporal.io/sdk/workflow"
|
|
)
|
|
|
|
// WebhookEvent is the handle a workflow carries for one core.webhook_events
|
|
// row: enough to name the row and the event in the Temporal UI, never the
|
|
// payload (activities read that from the row).
|
|
type WebhookEvent struct {
|
|
ID int64 `db:"id"`
|
|
Provider string `db:"provider"`
|
|
ProviderEventID string `db:"provider_event_id"`
|
|
EventType string `db:"event_type"`
|
|
// Status and RetryCount are the row's state when the handle was built.
|
|
// Nothing in the workflow reads them: Temporal counts the attempts and
|
|
// the row is re-read by every activity. They stay for the tests that
|
|
// build handles from rows.
|
|
Status string `db:"status"`
|
|
RetryCount int `db:"retry_count"`
|
|
}
|
|
|
|
// WebhookEventWorkflowID is the Workflow ID for one Stripe event: Stripe's
|
|
// own event id, which is unique per event, so a redelivery that reaches the
|
|
// endpoint and starts the workflow again lands on the running execution
|
|
// (WorkflowIDConflictPolicy USE_EXISTING) instead of a second one.
|
|
func WebhookEventWorkflowID(providerEventID string) string {
|
|
return "stripe-webhook-" + providerEventID
|
|
}
|
|
|
|
// The retry budget for processing one event. Temporal owns the schedule:
|
|
// the activity is retried from one second, doubling to ten minutes, for as
|
|
// long as the budget allows; a handler that declares its error
|
|
// non-retryable (checkedInt32's AmountOutOfRange) ends it at once. When the
|
|
// budget is spent the workflow dead-letters the row for an operator (the
|
|
// Stripe provider page lists them) and fails, which is what the Temporal UI
|
|
// should show. A day covers a database outage, a worker that needs a
|
|
// deploy, and an invoice.paid that arrived before its invoice.finalized
|
|
// was projected. (2026-09 security audit, remediation design D8.)
|
|
const (
|
|
webhookRetryBudget = 24 * time.Hour
|
|
webhookRetryMaxInterval = 10 * time.Minute
|
|
webhookAttemptTimeout = time.Minute
|
|
)
|
|
|
|
// ProcessStripeWebhookEvent is one Stripe event's whole life after the
|
|
// endpoint recorded it: process it, retrying on Temporal's schedule, and
|
|
// either mark it done or dead-letter it. One execution per event, started
|
|
// by the webhook endpoint (and by the boot sweep for rows left unfinished).
|
|
func ProcessStripeWebhookEvent(ctx workflow.Context, evt WebhookEvent) error {
|
|
var acts *WebhookActivities
|
|
|
|
processCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
|
|
StartToCloseTimeout: webhookAttemptTimeout,
|
|
ScheduleToCloseTimeout: webhookRetryBudget,
|
|
RetryPolicy: &temporal.RetryPolicy{
|
|
InitialInterval: time.Second,
|
|
BackoffCoefficient: 2.0,
|
|
MaximumInterval: webhookRetryMaxInterval,
|
|
// MaximumAttempts stays 0: the budget, not a count, bounds it.
|
|
},
|
|
})
|
|
err := workflow.ExecuteActivity(processCtx, acts.ProcessWebhookEvent, evt).Get(ctx, nil)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
workflow.GetLogger(ctx).Error("stripe webhook event exhausted its retries; dead-lettering",
|
|
"event_id", evt.ProviderEventID, "event_type", evt.EventType, "error", err)
|
|
markCtx := workflow.WithActivityOptions(ctx, common.DefaultActivityOptions())
|
|
if markErr := workflow.ExecuteActivity(markCtx, acts.MarkEventDeadLetter, evt.ID, err.Error()).Get(ctx, nil); markErr != nil {
|
|
workflow.GetLogger(ctx).Error("could not dead-letter the event row", "event_id", evt.ProviderEventID, "error", markErr)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// StartWebhookEvent starts the event's workflow, idempotently: a start for
|
|
// an execution that is already running is not an error. The endpoint calls
|
|
// this after recording the row; the boot sweep calls it for rows an earlier
|
|
// process left unfinished.
|
|
func StartWebhookEvent(ctx context.Context, c client.Client, taskQueue string, evt WebhookEvent) error {
|
|
_, err := c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
|
|
ID: WebhookEventWorkflowID(evt.ProviderEventID),
|
|
TaskQueue: taskQueue,
|
|
WorkflowIDConflictPolicy: enumspb.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING,
|
|
}, ProcessStripeWebhookEvent, evt)
|
|
if err != nil && temporal.IsWorkflowExecutionAlreadyStartedError(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// unfinishedStripeEventsSQL selects the rows the boot sweep hands to their
|
|
// workflows: Stripe's, in any state short of finished.
|
|
const unfinishedStripeEventsSQL = `SELECT id, provider, provider_event_id, event_type
|
|
FROM core.webhook_events
|
|
WHERE provider = 'stripe' AND status IN ('received', 'processing', 'failed')
|
|
ORDER BY received_at ASC`
|
|
|
|
// SweepUnfinishedWebhookEvents starts a workflow for every Stripe row that
|
|
// is not finished (`received`, `processing`, `failed`). Run once at boot: it
|
|
// picks up rows recorded by a process that died before starting their
|
|
// workflow, and, on the release that replaced the polling processor, the
|
|
// rows that processor left behind. Each start is idempotent, so a row whose
|
|
// workflow is already running costs one no-op call.
|
|
func SweepUnfinishedWebhookEvents(ctx context.Context, c client.Client, taskQueue string, db *sql.DB, logger *slog.Logger) error {
|
|
rows, err := db.QueryContext(ctx, unfinishedStripeEventsSQL)
|
|
if err != nil {
|
|
return fmt.Errorf("list unfinished webhook events: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var started, failed int
|
|
for rows.Next() {
|
|
var e WebhookEvent
|
|
if err := rows.Scan(&e.ID, &e.Provider, &e.ProviderEventID, &e.EventType); err != nil {
|
|
return fmt.Errorf("scan unfinished webhook event: %w", err)
|
|
}
|
|
if err := StartWebhookEvent(ctx, c, taskQueue, e); err != nil {
|
|
failed++
|
|
logger.Warn("boot sweep: could not start the event's workflow",
|
|
slog.String("event_id", e.ProviderEventID), slog.Any("error", err))
|
|
continue
|
|
}
|
|
started++
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("unfinished webhook events: %w", err)
|
|
}
|
|
if started+failed > 0 {
|
|
logger.Info("boot sweep: unfinished stripe webhook events handed to their workflows",
|
|
slog.Int("started", started), slog.Int("failed", failed))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WebhookActivities holds dependencies for webhook processing activities.
|
|
type WebhookActivities struct {
|
|
DB *sql.DB
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// NewWebhookActivities creates a new WebhookActivities instance.
|
|
func NewWebhookActivities(db *sql.DB, logger *slog.Logger) *WebhookActivities {
|
|
return &WebhookActivities{DB: db, Logger: logger}
|
|
}
|
|
|
|
// ProcessWebhookEvent transitions an event to 'processing', dispatches it,
|
|
// and marks it 'completed' or 'skipped'. A failed attempt is recorded on the
|
|
// row (`failed`, the attempt number Temporal reports, the error) before the
|
|
// error goes back to Temporal to schedule the next one, so the operator
|
|
// page and the row agree with the Temporal UI about where an event stands.
|
|
func (a *WebhookActivities) ProcessWebhookEvent(ctx context.Context, evt WebhookEvent) error {
|
|
// Transition to processing
|
|
_, err := a.DB.ExecContext(ctx,
|
|
`UPDATE core.webhook_events
|
|
SET status = 'processing', updated_at = NOW()
|
|
WHERE id = $1 AND received_at = (SELECT received_at FROM core.webhook_events WHERE id = $1)`,
|
|
evt.ID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("mark processing: %w", err)
|
|
}
|
|
|
|
// Dispatch to event-type handler.
|
|
finalStatus, err := a.dispatchEvent(ctx, evt)
|
|
if err != nil {
|
|
a.recordFailedAttempt(ctx, evt.ID, currentAttempt(ctx), err)
|
|
// A non-retryable *temporal.ApplicationError (e.g. the checked money
|
|
// conversions in webhook_invoice.go — schema-hardening D6) must reach
|
|
// the Temporal worker as-is: the SDK's failure conversion type-switches
|
|
// on the returned error's own dynamic type to decide whether to retry,
|
|
// so wrapping it with fmt.Errorf's "%w" here would bury it inside a
|
|
// plain *fmt.wrapError and the activity would retry-spin on an amount
|
|
// that will never fit. Errors of any other shape keep the same
|
|
// dispatch-context wrapping as before.
|
|
var appErr *temporal.ApplicationError
|
|
if errors.As(err, &appErr) {
|
|
return appErr
|
|
}
|
|
return fmt.Errorf("dispatch %s: %w", evt.EventType, err)
|
|
}
|
|
|
|
// Mark final status
|
|
_, err = a.DB.ExecContext(ctx,
|
|
`UPDATE core.webhook_events
|
|
SET status = $1, processed_at = NOW(), updated_at = NOW()
|
|
WHERE id = $2 AND received_at = (SELECT received_at FROM core.webhook_events WHERE id = $2)`,
|
|
finalStatus, evt.ID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("mark %s: %w", finalStatus, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// currentAttempt is Temporal's attempt number for this activity execution,
|
|
// or 1 when the activity runs outside Temporal (tests call it directly).
|
|
func currentAttempt(ctx context.Context) int32 {
|
|
if activity.IsActivity(ctx) {
|
|
return activity.GetInfo(ctx).Attempt
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// recordFailedAttempt writes a failed attempt to the row. Best effort: the
|
|
// error that matters is the one going back to Temporal, and a row that
|
|
// could not be updated is usually the same outage.
|
|
func (a *WebhookActivities) recordFailedAttempt(ctx context.Context, eventID int64, attempt int32, procErr error) {
|
|
if _, err := a.DB.ExecContext(ctx,
|
|
`UPDATE core.webhook_events
|
|
SET status = 'failed', retry_count = $1, error_message = $2, updated_at = NOW()
|
|
WHERE id = $3 AND received_at = (SELECT received_at FROM core.webhook_events WHERE id = $3)`,
|
|
attempt, procErr.Error(), eventID,
|
|
); err != nil {
|
|
a.Logger.Warn("could not record the failed attempt on the webhook event row",
|
|
slog.Int64("event_id", eventID), slog.Any("error", err))
|
|
}
|
|
}
|
|
|
|
// MarkEventDeadLetter records that an event's workflow gave up: the retry
|
|
// budget is spent or the failure was terminal. The row keeps the last
|
|
// attempt count and error; only an operator moves it from here.
|
|
func (a *WebhookActivities) MarkEventDeadLetter(ctx context.Context, eventID int64, errMsg string) error {
|
|
if _, err := a.DB.ExecContext(ctx,
|
|
`UPDATE core.webhook_events
|
|
SET status = 'dead_letter', error_message = $1, updated_at = NOW()
|
|
WHERE id = $2 AND received_at = (SELECT received_at FROM core.webhook_events WHERE id = $2)`,
|
|
errMsg, eventID,
|
|
); err != nil {
|
|
return fmt.Errorf("mark dead_letter: %w", err)
|
|
}
|
|
a.Logger.Warn("webhook event moved to dead letter", slog.Int64("event_id", eventID), slog.String("error", errMsg))
|
|
return nil
|
|
}
|
|
|
|
// supersededByNewerEvent reports whether a completed event for the same
|
|
// Stripe object carries a later event time than evt. Stripe does not
|
|
// guarantee delivery order, and the product, price and customer handlers
|
|
// project what an event says about its object (synced, deleted, active),
|
|
// so an older event applied after a newer one would put back a state Stripe
|
|
// has already moved past. The event time is Stripe's `created` on the
|
|
// event, recorded by the endpoint; rows from before it was recorded compare
|
|
// as never superseded.
|
|
func (a *WebhookActivities) supersededByNewerEvent(ctx context.Context, evt WebhookEvent, objectID string) (bool, error) {
|
|
var superseded bool
|
|
err := a.DB.QueryRowContext(ctx,
|
|
`SELECT EXISTS (
|
|
SELECT 1 FROM core.webhook_events newer
|
|
WHERE newer.provider = 'stripe'
|
|
AND newer.status = 'completed'
|
|
AND newer.payload->>'id' = $1
|
|
AND newer.provider_event_at > (SELECT provider_event_at FROM core.webhook_events WHERE id = $2)
|
|
)`,
|
|
objectID, evt.ID,
|
|
).Scan(&superseded)
|
|
if err != nil {
|
|
return false, fmt.Errorf("check for a newer applied event: %w", err)
|
|
}
|
|
if superseded {
|
|
a.Logger.Info("stale webhook event skipped: a newer event for the object was already applied",
|
|
slog.String("event_id", evt.ProviderEventID), slog.String("event_type", evt.EventType), slog.String("object_id", objectID))
|
|
}
|
|
return superseded, nil
|
|
}
|
|
|
|
// dispatchEvent routes webhook events to type-specific handlers.
|
|
// Returns the final status string ("completed" or "skipped").
|
|
func (a *WebhookActivities) dispatchEvent(ctx context.Context, evt WebhookEvent) (string, error) {
|
|
switch {
|
|
case strings.HasPrefix(evt.EventType, "checkout.session."):
|
|
return a.handleCheckoutSessionEvent(ctx, evt)
|
|
case strings.HasPrefix(evt.EventType, "customer.subscription."):
|
|
return a.handleSubscriptionEvent(ctx, evt)
|
|
case strings.HasPrefix(evt.EventType, "customer."):
|
|
return a.handleCustomerEvent(ctx, evt)
|
|
case strings.HasPrefix(evt.EventType, "product."):
|
|
return a.handleProductEvent(ctx, evt)
|
|
case strings.HasPrefix(evt.EventType, "price."):
|
|
return a.handlePriceEvent(ctx, evt)
|
|
case strings.HasPrefix(evt.EventType, "invoice."):
|
|
return a.handleInvoiceEvent(ctx, evt)
|
|
case strings.HasPrefix(evt.EventType, "payment_method."):
|
|
return a.handlePaymentMethodEvent(ctx, evt)
|
|
default:
|
|
a.Logger.Info("webhook event skipped (unhandled type)",
|
|
slog.String("event_id", evt.ProviderEventID),
|
|
slog.String("event_type", evt.EventType))
|
|
return "skipped", nil
|
|
}
|
|
}
|
|
|
|
// webhookCustomerPayload represents the scrubbed payload for customer events.
|
|
type webhookCustomerPayload struct {
|
|
ID string `json:"id"` // Stripe Customer ID (cus_…)
|
|
// Livemode is the object's own flag, the environment the id lives in
|
|
// (stripe-environment-stamp D1). It is read from the object inside the
|
|
// stored payload, not from the event envelope; every Stripe object
|
|
// carries it, so a parsed payload always has a real answer here.
|
|
Livemode bool `json:"livemode"`
|
|
}
|
|
|
|
// handleCustomerEvent processes customer.created, customer.updated, customer.deleted events.
|
|
func (a *WebhookActivities) handleCustomerEvent(ctx context.Context, evt WebhookEvent) (string, error) {
|
|
// Read the scrubbed payload from the webhook_events table.
|
|
var payloadBytes []byte
|
|
err := a.DB.QueryRowContext(ctx,
|
|
`SELECT payload FROM core.webhook_events
|
|
WHERE id = $1 AND received_at = (SELECT received_at FROM core.webhook_events WHERE id = $1)`,
|
|
evt.ID,
|
|
).Scan(&payloadBytes)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read webhook payload: %w", err)
|
|
}
|
|
|
|
var payload webhookCustomerPayload
|
|
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
|
return "", fmt.Errorf("parse customer webhook payload: %w", err)
|
|
}
|
|
|
|
stripeCustomerID := payload.ID
|
|
if stripeCustomerID == "" {
|
|
return "", fmt.Errorf("customer webhook payload missing id")
|
|
}
|
|
if stale, err := a.supersededByNewerEvent(ctx, evt, stripeCustomerID); err != nil {
|
|
return "", err
|
|
} else if stale {
|
|
return "skipped", nil
|
|
}
|
|
|
|
q := internalstripe.New(a.DB)
|
|
|
|
switch evt.EventType {
|
|
case "customer.created":
|
|
// Idempotent upsert: if mapping already exists (written by outbox), update timestamp.
|
|
// If not, create with sync_status = 'synced'.
|
|
// We need the billing_account_id — look up by stripe_customer_id first.
|
|
existing, err := q.GetCustomerMappingByStripeCustomerID(ctx, sql.NullString{String: stripeCustomerID, Valid: true})
|
|
if err == nil {
|
|
// The upsert rather than UpdateCustomerMappingSyncStatus: same
|
|
// row, same status, same touched updated_at, and it can carry
|
|
// the environment the parsed customer reports
|
|
// (stripe-environment-stamp D1). The status-only update has no
|
|
// column for the flag, so a mapping whose first news of Stripe
|
|
// is customer.created would stay unstamped.
|
|
if _, err := q.UpsertCustomerMapping(ctx, internalstripe.UpsertCustomerMappingParams{
|
|
BillingAccountID: existing.BillingAccountID,
|
|
StripeCustomerID: sql.NullString{String: stripeCustomerID, Valid: true},
|
|
SyncStatus: "synced",
|
|
Livemode: sql.NullBool{Bool: payload.Livemode, Valid: true},
|
|
}); err != nil {
|
|
return "", fmt.Errorf("update existing mapping: %w", err)
|
|
}
|
|
} else if err == sql.ErrNoRows {
|
|
// No mapping yet — the outbox hasn't run or this was created externally.
|
|
// We can't create a mapping without a billing_account_id, so log and skip.
|
|
a.Logger.Warn("customer.created webhook: no mapping found for stripe customer (created externally?)",
|
|
slog.String("stripe_customer_id", stripeCustomerID))
|
|
return "completed", nil
|
|
} else {
|
|
return "", fmt.Errorf("lookup mapping by stripe customer: %w", err)
|
|
}
|
|
|
|
case "customer.updated":
|
|
// Touch updated_at on the mapping row.
|
|
existing, err := q.GetCustomerMappingByStripeCustomerID(ctx, sql.NullString{String: stripeCustomerID, Valid: true})
|
|
if err == sql.ErrNoRows {
|
|
a.Logger.Warn("customer.updated webhook: no mapping found",
|
|
slog.String("stripe_customer_id", stripeCustomerID))
|
|
return "completed", nil
|
|
} else if err != nil {
|
|
return "", fmt.Errorf("lookup mapping: %w", err)
|
|
}
|
|
// The upsert in place of UpdateCustomerMappingSyncStatus: same row,
|
|
// same status, same touched updated_at, and it can carry the
|
|
// environment the parsed customer reports
|
|
// (stripe-environment-stamp D1).
|
|
if _, err := q.UpsertCustomerMapping(ctx, internalstripe.UpsertCustomerMappingParams{
|
|
BillingAccountID: existing.BillingAccountID,
|
|
StripeCustomerID: existing.StripeCustomerID,
|
|
SyncStatus: existing.SyncStatus,
|
|
Livemode: sql.NullBool{Bool: payload.Livemode, Valid: true},
|
|
}); err != nil {
|
|
return "", fmt.Errorf("update mapping timestamp: %w", err)
|
|
}
|
|
|
|
case "customer.deleted":
|
|
existing, err := q.GetCustomerMappingByStripeCustomerID(ctx, sql.NullString{String: stripeCustomerID, Valid: true})
|
|
if err == sql.ErrNoRows {
|
|
a.Logger.Warn("customer.deleted webhook: no mapping found",
|
|
slog.String("stripe_customer_id", stripeCustomerID))
|
|
return "completed", nil
|
|
} else if err != nil {
|
|
return "", fmt.Errorf("lookup mapping: %w", err)
|
|
}
|
|
if err := q.UpdateCustomerMappingSyncStatus(ctx, internalstripe.UpdateCustomerMappingSyncStatusParams{
|
|
BillingAccountID: existing.BillingAccountID,
|
|
SyncStatus: "deleted",
|
|
}); err != nil {
|
|
return "", fmt.Errorf("mark mapping deleted: %w", err)
|
|
}
|
|
|
|
default:
|
|
a.Logger.Info("customer sub-event skipped",
|
|
slog.String("event_type", evt.EventType))
|
|
return "skipped", nil
|
|
}
|
|
|
|
a.Logger.Info("customer webhook processed",
|
|
slog.String("event_type", evt.EventType),
|
|
slog.String("stripe_customer_id", stripeCustomerID))
|
|
return "completed", nil
|
|
}
|
|
|
|
// webhookProductPayload represents the scrubbed payload for product events.
|
|
type webhookProductPayload struct {
|
|
ID string `json:"id"` // Stripe Product ID (prod_…)
|
|
// Livemode is the object's own flag, the environment the id lives in
|
|
// (stripe-environment-stamp D1). It is read from the object inside the
|
|
// stored payload, not from the event envelope; every Stripe object
|
|
// carries it, so a parsed payload always has a real answer here.
|
|
Livemode bool `json:"livemode"`
|
|
}
|
|
|
|
// handleProductEvent processes product.created, product.updated, product.deleted events.
|
|
func (a *WebhookActivities) handleProductEvent(ctx context.Context, evt WebhookEvent) (string, error) {
|
|
var payloadBytes []byte
|
|
err := a.DB.QueryRowContext(ctx,
|
|
`SELECT payload FROM core.webhook_events
|
|
WHERE id = $1 AND received_at = (SELECT received_at FROM core.webhook_events WHERE id = $1)`,
|
|
evt.ID,
|
|
).Scan(&payloadBytes)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read webhook payload: %w", err)
|
|
}
|
|
|
|
var payload webhookProductPayload
|
|
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
|
return "", fmt.Errorf("parse product webhook payload: %w", err)
|
|
}
|
|
|
|
stripeProductID := payload.ID
|
|
if stripeProductID == "" {
|
|
return "", fmt.Errorf("product webhook payload missing id")
|
|
}
|
|
if stale, err := a.supersededByNewerEvent(ctx, evt, stripeProductID); err != nil {
|
|
return "", err
|
|
} else if stale {
|
|
return "skipped", nil
|
|
}
|
|
|
|
q := internalstripe.New(a.DB)
|
|
|
|
switch evt.EventType {
|
|
case "product.created":
|
|
existing, err := q.GetProductMappingByStripeID(ctx, sql.NullString{String: stripeProductID, Valid: true})
|
|
if err == nil {
|
|
// The mapping exists, so re-upsert with the same status to
|
|
// touch updated_at, and stamp the environment the parsed product
|
|
// reports (stripe-environment-stamp D1), the same flag the
|
|
// updated branch writes.
|
|
_, err = q.UpsertProductMapping(ctx, internalstripe.UpsertProductMappingParams{
|
|
ProductID: existing.ProductID,
|
|
StripeProductID: sql.NullString{String: stripeProductID, Valid: true},
|
|
SyncStatus: "synced",
|
|
Livemode: sql.NullBool{Bool: payload.Livemode, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("update existing product mapping: %w", err)
|
|
}
|
|
} else if err == sql.ErrNoRows {
|
|
// No mapping yet — webhook arrived before outbox. Can't create without product_id.
|
|
a.Logger.Warn("product.created webhook: no mapping found for stripe product (created externally?)",
|
|
slog.String("stripe_product_id", stripeProductID))
|
|
return "completed", nil
|
|
} else {
|
|
return "", fmt.Errorf("lookup product mapping by stripe id: %w", err)
|
|
}
|
|
|
|
case "product.updated":
|
|
existing, err := q.GetProductMappingByStripeID(ctx, sql.NullString{String: stripeProductID, Valid: true})
|
|
if err == sql.ErrNoRows {
|
|
a.Logger.Warn("product.updated webhook: no mapping found",
|
|
slog.String("stripe_product_id", stripeProductID))
|
|
return "completed", nil
|
|
} else if err != nil {
|
|
return "", fmt.Errorf("lookup product mapping: %w", err)
|
|
}
|
|
_, err = q.UpsertProductMapping(ctx, internalstripe.UpsertProductMappingParams{
|
|
ProductID: existing.ProductID,
|
|
StripeProductID: existing.StripeProductID,
|
|
SyncStatus: existing.SyncStatus,
|
|
Livemode: sql.NullBool{Bool: payload.Livemode, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("update product mapping timestamp: %w", err)
|
|
}
|
|
|
|
case "product.deleted":
|
|
existing, err := q.GetProductMappingByStripeID(ctx, sql.NullString{String: stripeProductID, Valid: true})
|
|
if err == sql.ErrNoRows {
|
|
a.Logger.Warn("product.deleted webhook: no mapping found",
|
|
slog.String("stripe_product_id", stripeProductID))
|
|
return "completed", nil
|
|
} else if err != nil {
|
|
return "", fmt.Errorf("lookup product mapping: %w", err)
|
|
}
|
|
if err := q.MarkProductMappingDeleted(ctx, existing.ProductID); err != nil {
|
|
return "", fmt.Errorf("mark product mapping deleted: %w", err)
|
|
}
|
|
|
|
default:
|
|
a.Logger.Info("product sub-event skipped",
|
|
slog.String("event_type", evt.EventType))
|
|
return "skipped", nil
|
|
}
|
|
|
|
a.Logger.Info("product webhook processed",
|
|
slog.String("event_type", evt.EventType),
|
|
slog.String("stripe_product_id", stripeProductID))
|
|
return "completed", nil
|
|
}
|
|
|
|
// webhookPricePayload represents the scrubbed payload for price events.
|
|
type webhookPricePayload struct {
|
|
ID string `json:"id"` // Stripe Price ID (price_…)
|
|
Active bool `json:"active"` // Whether the price is active
|
|
// Livemode is the object's own flag, the environment the id lives in
|
|
// (stripe-environment-stamp D1). It is read from the object inside the
|
|
// stored payload, not from the event envelope; every Stripe object
|
|
// carries it, so a parsed payload always has a real answer here.
|
|
Livemode bool `json:"livemode"`
|
|
}
|
|
|
|
// handlePriceEvent processes price.created and price.updated events.
|
|
func (a *WebhookActivities) handlePriceEvent(ctx context.Context, evt WebhookEvent) (string, error) {
|
|
var payloadBytes []byte
|
|
err := a.DB.QueryRowContext(ctx,
|
|
`SELECT payload FROM core.webhook_events
|
|
WHERE id = $1 AND received_at = (SELECT received_at FROM core.webhook_events WHERE id = $1)`,
|
|
evt.ID,
|
|
).Scan(&payloadBytes)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read webhook payload: %w", err)
|
|
}
|
|
|
|
var payload webhookPricePayload
|
|
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
|
return "", fmt.Errorf("parse price webhook payload: %w", err)
|
|
}
|
|
|
|
stripePriceID := payload.ID
|
|
if stripePriceID == "" {
|
|
return "", fmt.Errorf("price webhook payload missing id")
|
|
}
|
|
if stale, err := a.supersededByNewerEvent(ctx, evt, stripePriceID); err != nil {
|
|
return "", err
|
|
} else if stale {
|
|
return "skipped", nil
|
|
}
|
|
|
|
q := internalstripe.New(a.DB)
|
|
|
|
switch evt.EventType {
|
|
case "price.created":
|
|
existing, err := q.GetPriceMappingByStripeID(ctx, sql.NullString{String: stripePriceID, Valid: true})
|
|
if err == nil {
|
|
// The mapping exists, so re-upsert with the same status to
|
|
// touch updated_at, and stamp the environment the parsed price
|
|
// reports (stripe-environment-stamp D1), the same flag the
|
|
// updated branch writes.
|
|
_, err = q.UpsertPriceMapping(ctx, internalstripe.UpsertPriceMappingParams{
|
|
PriceID: existing.PriceID,
|
|
StripePriceID: sql.NullString{String: stripePriceID, Valid: true},
|
|
SyncStatus: "synced",
|
|
Livemode: sql.NullBool{Bool: payload.Livemode, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("update existing price mapping: %w", err)
|
|
}
|
|
} else if err == sql.ErrNoRows {
|
|
// No mapping yet — webhook arrived before outbox. Can't create without price_id.
|
|
a.Logger.Warn("price.created webhook: no mapping found for stripe price (created externally?)",
|
|
slog.String("stripe_price_id", stripePriceID))
|
|
return "completed", nil
|
|
} else {
|
|
return "", fmt.Errorf("lookup price mapping by stripe id: %w", err)
|
|
}
|
|
|
|
case "price.updated":
|
|
existing, err := q.GetPriceMappingByStripeID(ctx, sql.NullString{String: stripePriceID, Valid: true})
|
|
if err == sql.ErrNoRows {
|
|
a.Logger.Warn("price.updated webhook: no mapping found",
|
|
slog.String("stripe_price_id", stripePriceID))
|
|
return "completed", nil
|
|
} else if err != nil {
|
|
return "", fmt.Errorf("lookup price mapping: %w", err)
|
|
}
|
|
|
|
if !payload.Active {
|
|
// Stripe archives prices rather than deleting them.
|
|
if err := q.MarkPriceMappingDeleted(ctx, existing.PriceID); err != nil {
|
|
return "", fmt.Errorf("mark price mapping deleted: %w", err)
|
|
}
|
|
} else {
|
|
// Active price update — bump updated_at.
|
|
_, err = q.UpsertPriceMapping(ctx, internalstripe.UpsertPriceMappingParams{
|
|
PriceID: existing.PriceID,
|
|
StripePriceID: existing.StripePriceID,
|
|
SyncStatus: existing.SyncStatus,
|
|
Livemode: sql.NullBool{Bool: payload.Livemode, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("update price mapping timestamp: %w", err)
|
|
}
|
|
}
|
|
|
|
default:
|
|
a.Logger.Info("price sub-event skipped",
|
|
slog.String("event_type", evt.EventType))
|
|
return "skipped", nil
|
|
}
|
|
|
|
a.Logger.Info("price webhook processed",
|
|
slog.String("event_type", evt.EventType),
|
|
slog.String("stripe_price_id", stripePriceID))
|
|
return "completed", nil
|
|
}
|