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.
119 lines
4.3 KiB
Go
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
|
|
}
|