Add an explicit registry with capability hooks for migrations, routes, workflows, config, and UI assets. Move FedWiki fully and Stripe's separable store, workflow, and webhook pieces under internal/integrations. Drive startup wiring from declarations, including config validation, secret file pairs, CSRF exemptions, UI composition, and workflow startup. Move integration DB roles and grants into their owning migration streams, and route outbox writes through a shared enqueue helper.
116 lines
4.1 KiB
Go
116 lines
4.1 KiB
Go
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
|
|
}
|