199 lines
8.0 KiB
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}
|
|
}
|