Files
member-console/internal/integrations/stripe/workflows/webhook_subscription.go
T
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00

119 lines
4.3 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"git.coopcloud.tech/wiki-cafe/member-console/internal/fulfillment"
)
// webhookCheckoutSessionPayload is the minimal scrubbed shape we read from a
// checkout.session.completed event. Only ids drive fulfillment — item, price,
// status, and period detail are fetched from the Stripe API by the reconciler,
// never trusted from the event payload.
type webhookCheckoutSessionPayload struct {
Mode string `json:"mode"`
Subscription string `json:"subscription"`
}
// webhookSubscriptionIDPayload is the minimal scrubbed shape for
// customer.subscription.* events: the subscription id is all reconcile needs.
type webhookSubscriptionIDPayload struct {
ID string `json:"id"`
}
// handleCheckoutSessionEvent treats checkout.session.completed as a thin eager
// trigger. It extracts the subscription id and delegates to the reconciler,
// which fetches authoritative state from the Stripe API. An empty payload
// line_items list (which Stripe always sends) no longer blocks fulfillment.
func (a *WebhookActivities) handleCheckoutSessionEvent(ctx context.Context, evt WebhookEvent) (string, error) {
if evt.EventType != "checkout.session.completed" {
a.Logger.Info("checkout sub-event skipped", slog.String("event_type", evt.EventType))
return "skipped", nil
}
payload, err := a.readPayload(ctx, evt.ID)
if err != nil {
return "", err
}
var session webhookCheckoutSessionPayload
if err := json.Unmarshal(payload, &session); err != nil {
return "", fmt.Errorf("parse checkout session payload: %w", err)
}
if session.Mode != "subscription" {
a.Logger.Info("checkout.session.completed skipped (not subscription mode)",
slog.String("mode", session.Mode))
return "completed", nil
}
if session.Subscription == "" {
return "", fmt.Errorf("checkout.session.completed missing subscription id")
}
reason := fmt.Sprintf("stripe:checkout.session.completed:%s", evt.ProviderEventID)
if err := fulfillment.ReconcileSubscription(ctx, a.DB, a.Logger, session.Subscription, reason); err != nil {
return "", fmt.Errorf("reconcile subscription: %w", err)
}
a.Logger.Info("checkout.session.completed reconciled",
slog.String("stripe_subscription_id", session.Subscription))
return "completed", nil
}
// handleSubscriptionEvent routes customer.subscription.{created,updated,deleted}
// to the reconciler. Each event is a thin trigger; reconcile fetches the current
// subscription state and converges core records and entitlements. Convergence —
// not a skip-if-mapping-exists guard — provides idempotency.
func (a *WebhookActivities) handleSubscriptionEvent(ctx context.Context, evt WebhookEvent) (string, error) {
switch evt.EventType {
case "customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted":
// handled below
default:
a.Logger.Info("subscription sub-event skipped", slog.String("event_type", evt.EventType))
return "skipped", nil
}
payload, err := a.readPayload(ctx, evt.ID)
if err != nil {
return "", err
}
var sub webhookSubscriptionIDPayload
if err := json.Unmarshal(payload, &sub); err != nil {
return "", fmt.Errorf("parse subscription payload: %w", err)
}
if sub.ID == "" {
return "", fmt.Errorf("subscription payload missing id")
}
reason := fmt.Sprintf("stripe:%s:%s", evt.EventType, evt.ProviderEventID)
if err := fulfillment.ReconcileSubscription(ctx, a.DB, a.Logger, sub.ID, reason); err != nil {
return "", fmt.Errorf("reconcile subscription: %w", err)
}
a.Logger.Info("subscription event reconciled",
slog.String("event_type", evt.EventType),
slog.String("stripe_subscription_id", sub.ID))
return "completed", nil
}
// readPayload reads the scrubbed webhook payload for an event.
func (a *WebhookActivities) readPayload(ctx context.Context, eventID int64) ([]byte, 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)`,
eventID,
).Scan(&payloadBytes)
if err != nil {
return nil, fmt.Errorf("read webhook payload: %w", err)
}
return payloadBytes, nil
}