Files
member-console/internal/workflows/stripe/webhook_payment_method.go

199 lines
8.0 KiB
Go

package stripe
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/stripe"
)
// 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}
}