// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo 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"` // Livemode is the payment method object's own flag, the environment the // id lives in (stripe-environment-stamp D1). It comes from the object // inside the stored payload, not from the event envelope, which this // activity never sees; every Stripe object carries it, so a parsed // payload always has a real answer here. Livemode bool `json:"livemode"` } // 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", Livemode: sql.NullBool{Bool: pm.Livemode, Valid: true}, }) 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} }