Files
member-console/internal/integrations/stripe/workflows/webhook_payment_method.go
T
cgalo5758 1a8e13f880 Extract integrations into registered trees
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.
2026-07-06 11:01:07 -05:00

199 lines
8.0 KiB
Go

package workflows
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
)
// webhookPaymentMethodPayload is the scrubbed payload stored for payment_method.* events.
type webhookPaymentMethodPayload struct {
ID string `json:"id"`
Customer string `json:"customer"`
Type string `json:"type"`
Card *paymentMethodCard `json:"card"`
}
// paymentMethodCard holds the safe descriptor fields from Stripe's card object.
// Raw card numbers and CVCs are never present in Stripe webhook payloads.
type paymentMethodCard struct {
Brand string `json:"brand"`
Last4 string `json:"last4"`
ExpMonth int32 `json:"exp_month"`
ExpYear int32 `json:"exp_year"`
Funding string `json:"funding"`
}
// handlePaymentMethodEvent dispatches payment_method.* webhook events to sub-handlers.
func (a *WebhookActivities) handlePaymentMethodEvent(ctx context.Context, evt WebhookEvent) (string, error) {
payload, err := a.readPayload(ctx, evt.ID)
if err != nil {
return "", err
}
var pm webhookPaymentMethodPayload
if err := json.Unmarshal(payload, &pm); err != nil {
return "", fmt.Errorf("parse payment method payload: %w", err)
}
if pm.ID == "" {
return "", fmt.Errorf("payment method payload missing id")
}
stripeQ := internalstripe.New(a.DB)
switch evt.EventType {
case "payment_method.attached":
return a.handlePaymentMethodAttached(ctx, evt, pm, stripeQ)
case "payment_method.detached":
return a.handlePaymentMethodDetached(ctx, evt, pm, stripeQ)
case "payment_method.updated":
return a.handlePaymentMethodUpdated(ctx, evt, pm, stripeQ)
default:
a.Logger.Info("payment_method sub-event skipped", slog.String("event_type", evt.EventType))
return "skipped", nil
}
}
func (a *WebhookActivities) handlePaymentMethodAttached(ctx context.Context, evt WebhookEvent, pm webhookPaymentMethodPayload, stripeQ *internalstripe.Queries) (string, error) {
// Resolve billing_account_id from Stripe customer.
if pm.Customer == "" {
a.Logger.Warn("payment_method.attached: no customer on payload, skipping",
slog.String("stripe_pm_id", pm.ID))
return "completed", nil
}
custMapping, err := stripeQ.GetCustomerMappingByStripeCustomerID(ctx, sql.NullString{String: pm.Customer, Valid: true})
if err == sql.ErrNoRows {
a.Logger.Warn("payment_method.attached: customer mapping not found, skipping",
slog.String("stripe_customer_id", pm.Customer),
slog.String("stripe_pm_id", pm.ID))
return "completed", nil
}
if err != nil {
return "", fmt.Errorf("resolve billing account from customer %s: %w", pm.Customer, err)
}
// Idempotency: if mapping already exists, update safe fields and return.
existing, err := stripeQ.GetPaymentMethodMappingByStripeID(ctx, sql.NullString{String: pm.ID, Valid: true})
if err == nil {
billingQ := billing.New(a.DB)
_, err = billingQ.UpdatePaymentMethodSafeFields(ctx, billing.UpdatePaymentMethodSafeFieldsParams{
PaymentMethodID: existing.PaymentMethodID,
CardBrand: cardStringField(pm.Card, func(c *paymentMethodCard) string { return c.Brand }),
CardLast4: cardStringField(pm.Card, func(c *paymentMethodCard) string { return c.Last4 }),
CardExpMonth: cardInt32Field(pm.Card, func(c *paymentMethodCard) int32 { return c.ExpMonth }),
CardExpYear: cardInt32Field(pm.Card, func(c *paymentMethodCard) int32 { return c.ExpYear }),
Funding: cardStringField(pm.Card, func(c *paymentMethodCard) string { return c.Funding }),
})
if err != nil {
return "", fmt.Errorf("update payment method safe fields: %w", err)
}
a.Logger.Info("payment_method.attached: mapping exists, updated safe fields",
slog.String("stripe_pm_id", pm.ID))
return "completed", nil
}
if err != sql.ErrNoRows {
return "", fmt.Errorf("check existing payment method mapping: %w", err)
}
billingQ := billing.New(a.DB)
newPM, err := billingQ.CreatePaymentMethod(ctx, billing.CreatePaymentMethodParams{
BillingAccountID: custMapping.BillingAccountID,
PmType: pm.Type,
CardBrand: cardStringField(pm.Card, func(c *paymentMethodCard) string { return c.Brand }),
CardLast4: cardStringField(pm.Card, func(c *paymentMethodCard) string { return c.Last4 }),
CardExpMonth: cardInt32Field(pm.Card, func(c *paymentMethodCard) int32 { return c.ExpMonth }),
CardExpYear: cardInt32Field(pm.Card, func(c *paymentMethodCard) int32 { return c.ExpYear }),
Funding: cardStringField(pm.Card, func(c *paymentMethodCard) string { return c.Funding }),
IsDefault: false,
})
if err != nil {
return "", fmt.Errorf("create payment method: %w", err)
}
_, err = stripeQ.UpsertPaymentMethodMapping(ctx, internalstripe.UpsertPaymentMethodMappingParams{
PaymentMethodID: newPM.PaymentMethodID,
StripePaymentMethodID: sql.NullString{String: pm.ID, Valid: true},
SyncStatus: "synced",
})
if err != nil {
return "", fmt.Errorf("upsert payment method mapping: %w", err)
}
a.Logger.Info("payment_method.attached processed",
slog.String("stripe_pm_id", pm.ID),
slog.String("payment_method_id", newPM.PaymentMethodID))
return "completed", nil
}
func (a *WebhookActivities) handlePaymentMethodDetached(ctx context.Context, evt WebhookEvent, pm webhookPaymentMethodPayload, stripeQ *internalstripe.Queries) (string, error) {
mapping, err := stripeQ.GetPaymentMethodMappingByStripeID(ctx, sql.NullString{String: pm.ID, Valid: true})
if err == sql.ErrNoRows {
a.Logger.Warn("payment_method.detached: no mapping found, skipping",
slog.String("stripe_pm_id", pm.ID))
return "completed", nil
}
if err != nil {
return "", fmt.Errorf("lookup payment method mapping: %w", err)
}
billingQ := billing.New(a.DB)
if err := billingQ.MarkPaymentMethodDetached(ctx, mapping.PaymentMethodID); err != nil {
return "", fmt.Errorf("mark payment method detached: %w", err)
}
a.Logger.Info("payment_method.detached processed", slog.String("stripe_pm_id", pm.ID))
return "completed", nil
}
func (a *WebhookActivities) handlePaymentMethodUpdated(ctx context.Context, evt WebhookEvent, pm webhookPaymentMethodPayload, stripeQ *internalstripe.Queries) (string, error) {
mapping, err := stripeQ.GetPaymentMethodMappingByStripeID(ctx, sql.NullString{String: pm.ID, Valid: true})
if err == sql.ErrNoRows {
a.Logger.Warn("payment_method.updated: no mapping found, skipping",
slog.String("stripe_pm_id", pm.ID))
return "completed", nil
}
if err != nil {
return "", fmt.Errorf("lookup payment method mapping: %w", err)
}
billingQ := billing.New(a.DB)
_, err = billingQ.UpdatePaymentMethodSafeFields(ctx, billing.UpdatePaymentMethodSafeFieldsParams{
PaymentMethodID: mapping.PaymentMethodID,
CardBrand: cardStringField(pm.Card, func(c *paymentMethodCard) string { return c.Brand }),
CardLast4: cardStringField(pm.Card, func(c *paymentMethodCard) string { return c.Last4 }),
CardExpMonth: cardInt32Field(pm.Card, func(c *paymentMethodCard) int32 { return c.ExpMonth }),
CardExpYear: cardInt32Field(pm.Card, func(c *paymentMethodCard) int32 { return c.ExpYear }),
Funding: cardStringField(pm.Card, func(c *paymentMethodCard) string { return c.Funding }),
})
if err != nil {
return "", fmt.Errorf("update payment method safe fields: %w", err)
}
a.Logger.Info("payment_method.updated processed", slog.String("stripe_pm_id", pm.ID))
return "completed", nil
}
// cardStringField safely extracts a string field from a nullable card object.
func cardStringField(card *paymentMethodCard, fn func(*paymentMethodCard) string) sql.NullString {
if card == nil {
return sql.NullString{}
}
v := fn(card)
return sql.NullString{String: v, Valid: v != ""}
}
// cardInt32Field safely extracts an int32 field from a nullable card object.
func cardInt32Field(card *paymentMethodCard, fn func(*paymentMethodCard) int32) sql.NullInt32 {
if card == nil {
return sql.NullInt32{}
}
v := fn(card)
return sql.NullInt32{Int32: v, Valid: v != 0}
}