Files
member-console/internal/integrations/stripe/workflows/webhook_invoice.go
T

530 lines
20 KiB
Go

// 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"
"math"
"time"
"github.com/google/uuid"
"go.temporal.io/sdk/temporal"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
)
// webhookInvoicePayload is the scrubbed payload stored for invoice.* events.
type webhookInvoicePayload struct {
ID string `json:"id"`
Customer string `json:"customer"`
Subscription string `json:"subscription"`
Status string `json:"status"`
AmountDue int64 `json:"amount_due"`
AmountPaid int64 `json:"amount_paid"`
Currency string `json:"currency"`
PeriodStart int64 `json:"period_start"`
PeriodEnd int64 `json:"period_end"`
DueDate int64 `json:"due_date"`
PaymentIntent string `json:"payment_intent"`
DefaultPaymentMethod string `json:"default_payment_method"`
// Number is Stripe's own customer-facing invoice number (stripe-go's
// Invoice.Number field), e.g. "A1B2C3D4-0001" -- an external reference
// (invoice-numbers D5), recorded on the invoice mapping's
// stripe_invoice_number, never the invoice's identity. Empty while the
// invoice is a draft; Stripe assigns it once the invoice is finalized.
// Not a PII key (internal/integrations/stripe/web/webhook.go), so it
// survives ScrubPII unchanged.
Number string `json:"number"`
Lines invoicePayloadLines `json:"lines"`
// Livemode is the invoice 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"`
}
type invoicePayloadLines struct {
Data []invoiceLineItemPayload `json:"data"`
}
type invoiceLineItemPayload struct {
ID string `json:"id"`
SubscriptionItem string `json:"subscription_item"`
Price invoicePriceRef `json:"price"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
Description string `json:"description"`
Quantity int64 `json:"quantity"`
Period invoiceLineItemPeriod `json:"period"`
}
type invoicePriceRef struct {
ID string `json:"id"`
}
type invoiceLineItemPeriod struct {
Start int64 `json:"start"`
End int64 `json:"end"`
}
// handleInvoiceEvent dispatches invoice.* webhook events to sub-handlers.
func (a *WebhookActivities) handleInvoiceEvent(ctx context.Context, evt WebhookEvent) (string, error) {
payload, err := a.readPayload(ctx, evt.ID)
if err != nil {
return "", err
}
var inv webhookInvoicePayload
if err := json.Unmarshal(payload, &inv); err != nil {
return "", fmt.Errorf("parse invoice payload: %w", err)
}
if inv.ID == "" {
return "", fmt.Errorf("invoice payload missing id")
}
stripeQ := internalstripe.New(a.DB)
switch evt.EventType {
case "invoice.finalized":
return a.handleInvoiceFinalized(ctx, evt, inv, stripeQ)
case "invoice.paid":
return a.handleInvoicePaid(ctx, evt, inv, stripeQ)
case "invoice.payment_failed":
return a.handleInvoicePaymentFailed(ctx, evt, inv, stripeQ)
case "invoice.voided":
return a.handleInvoiceVoided(ctx, evt, inv, stripeQ)
default:
a.Logger.Info("invoice sub-event skipped", slog.String("event_type", evt.EventType))
return "skipped", nil
}
}
func (a *WebhookActivities) handleInvoiceFinalized(ctx context.Context, evt WebhookEvent, inv webhookInvoicePayload, stripeQ *internalstripe.Queries) (string, error) {
// Idempotency: skip if mapping already exists.
_, err := stripeQ.GetInvoiceMappingByStripeID(ctx, sql.NullString{String: inv.ID, Valid: true})
if err == nil {
a.Logger.Info("invoice.finalized: mapping exists, skipping",
slog.String("stripe_invoice_id", inv.ID))
return "completed", nil
}
if err != sql.ErrNoRows {
return "", fmt.Errorf("check existing invoice mapping: %w", err)
}
// Resolve billing_account_id from Stripe customer.
custMapping, err := stripeQ.GetCustomerMappingByStripeCustomerID(ctx, sql.NullString{String: inv.Customer, Valid: true})
if err != nil {
return "", fmt.Errorf("resolve billing account from customer %s: %w", inv.Customer, err)
}
billingAccountID := custMapping.BillingAccountID
// Resolve optional subscription_id.
var subscriptionID uuid.NullUUID
if inv.Subscription != "" {
subMapping, err := stripeQ.GetSubscriptionMappingByStripeID(ctx, sql.NullString{String: inv.Subscription, Valid: true})
if err != nil && err != sql.ErrNoRows {
return "", fmt.Errorf("resolve subscription %s: %w", inv.Subscription, err)
}
if err == nil {
subUUID, err := uuid.Parse(subMapping.SubscriptionID)
if err != nil {
return "", fmt.Errorf("parse subscription uuid: %w", err)
}
subscriptionID = uuid.NullUUID{UUID: subUUID, Valid: true}
}
}
// Check the invoice-level amount before opening a transaction: nothing
// has been written yet, so an out-of-range value can fail loudly with no
// cleanup required (schema-hardening D6).
amountDue, err := checkedInt32(evt.ProviderEventID, "amount_due", inv.AmountDue)
if err != nil {
return "", err
}
tx, err := a.DB.BeginTx(ctx, nil)
if err != nil {
return "", fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
billingQ := billing.New(tx)
txStripeQ := internalstripe.New(tx)
// Assign the platform's own reference number from the billing account's
// transactional counter (invoice-numbers D1/D2): issuance for a
// Stripe-originated invoice is this projection, so assignment happens
// here, inside this transaction, never in the payload. A failure later
// in this function rolls the whole transaction back (deferred
// tx.Rollback above), which rewinds this UPDATE too -- the counter is
// never burned on a failed projection.
assigned, err := billingQ.AssignNextInvoiceNumber(ctx, billingAccountID)
if err != nil {
return "", fmt.Errorf("assign invoice number: %w", err)
}
invoiceNumber := fmt.Sprintf("%04d", assigned)
// Create core invoice record with the assigned platform number.
invoice, err := billingQ.CreateInvoice(ctx, billing.CreateInvoiceParams{
BillingAccountID: billingAccountID,
SubscriptionID: subscriptionID,
Status: "open",
AmountDue: amountDue,
AmountPaid: 0,
Currency: inv.Currency,
PeriodStart: nullTimeFromUnix(inv.PeriodStart),
PeriodEnd: nullTimeFromUnix(inv.PeriodEnd),
DueDate: nullTimeFromUnix(inv.DueDate),
InvoiceNumber: sql.NullString{String: invoiceNumber, Valid: true},
})
if err != nil {
return "", fmt.Errorf("create invoice: %w", err)
}
// Create line items. A checked-conversion failure on any line item
// returns before tx.Commit below, so the deferred Rollback discards the
// invoice row above and any line items already inserted earlier in this
// same uncommitted transaction — no partial row survives (relies on the
// existing single-commit transaction rather than a separate up-front
// validation pass; see schema-hardening tasks.md 4.1).
for _, li := range inv.Lines.Data {
// Resolve price_id from stripe price ID.
priceMapping, err := txStripeQ.GetPriceMappingByStripeID(ctx, sql.NullString{String: li.Price.ID, Valid: true})
if err != nil {
a.Logger.Warn("invoice line item: price mapping not found, skipping line",
slog.String("stripe_price_id", li.Price.ID),
slog.String("stripe_invoice_id", inv.ID))
continue
}
// Resolve optional subscription_item_id.
var siID uuid.NullUUID
if li.SubscriptionItem != "" {
siMapping, err := txStripeQ.GetSubscriptionItemMappingByStripeID(ctx, sql.NullString{String: li.SubscriptionItem, Valid: true})
if err == nil {
siUUID, err := uuid.Parse(siMapping.SubscriptionItemID)
if err == nil {
siID = uuid.NullUUID{UUID: siUUID, Valid: true}
}
}
}
amount, err := checkedInt32(evt.ProviderEventID, fmt.Sprintf("lines[%s].amount", li.ID), li.Amount)
if err != nil {
return "", err
}
quantity, err := checkedInt32(evt.ProviderEventID, fmt.Sprintf("lines[%s].quantity", li.ID), li.Quantity)
if err != nil {
return "", err
}
_, err = billingQ.CreateInvoiceLineItem(ctx, billing.CreateInvoiceLineItemParams{
InvoiceID: invoice.InvoiceID,
SubscriptionItemID: siID,
PriceID: priceMapping.PriceID,
Amount: amount,
Currency: li.Currency,
Description: sql.NullString{String: li.Description, Valid: li.Description != ""},
Quantity: quantity,
PeriodStart: nullTimeFromUnix(li.Period.Start),
PeriodEnd: nullTimeFromUnix(li.Period.End),
})
if err != nil {
return "", fmt.Errorf("create invoice line item: %w", err)
}
}
// Insert stripe mapping. stripe_invoice_number carries Stripe's own
// customer-facing number (invoice-numbers D5) as received -- an
// external reference, valid only when Stripe sent a non-empty one.
_, err = txStripeQ.InsertInvoiceMapping(ctx, internalstripe.InsertInvoiceMappingParams{
InvoiceID: invoice.InvoiceID,
StripeInvoiceID: sql.NullString{String: inv.ID, Valid: true},
StripeInvoiceNumber: sql.NullString{String: inv.Number, Valid: inv.Number != ""},
SyncStatus: "synced",
Livemode: sql.NullBool{Bool: inv.Livemode, Valid: true},
})
if err != nil {
return "", fmt.Errorf("insert invoice mapping: %w", err)
}
if err := tx.Commit(); err != nil {
return "", fmt.Errorf("commit invoice.finalized: %w", err)
}
a.Logger.Info("invoice.finalized processed",
slog.String("stripe_invoice_id", inv.ID),
slog.String("invoice_id", invoice.InvoiceID))
return "completed", nil
}
func (a *WebhookActivities) handleInvoicePaid(ctx context.Context, evt WebhookEvent, inv webhookInvoicePayload, stripeQ *internalstripe.Queries) (string, error) {
// Resolve core invoice_id from stripe invoice mapping.
invoiceMapping, err := stripeQ.GetInvoiceMappingByStripeID(ctx, sql.NullString{String: inv.ID, Valid: true})
if err != nil {
return "", fmt.Errorf("resolve invoice mapping for %s: %w", inv.ID, err)
}
// Idempotency. With a payment intent, the payment mapping is the record
// of having handled it. Without one (a zero-amount invoice, one paid out
// of band) the mapping is never written, so the guard is the invoice's
// own succeeded payment; before it, a second invoice.paid for such an
// invoice created a second payment row (2026-09 audit candidate).
if inv.PaymentIntent != "" {
_, err := stripeQ.GetPaymentMappingByStripePaymentIntentID(ctx, sql.NullString{String: inv.PaymentIntent, Valid: true})
if err == nil {
a.Logger.Info("invoice.paid: payment mapping exists, skipping",
slog.String("stripe_payment_intent_id", inv.PaymentIntent))
return "completed", nil
}
if err != sql.ErrNoRows {
return "", fmt.Errorf("check existing payment mapping: %w", err)
}
} else {
payments, err := billing.New(a.DB).GetPaymentsByInvoiceID(ctx, invoiceMapping.InvoiceID)
if err != nil {
return "", fmt.Errorf("check existing payments for invoice %s: %w", invoiceMapping.InvoiceID, err)
}
for _, existing := range payments {
if existing.Status == "succeeded" {
a.Logger.Info("invoice.paid: a succeeded payment already exists for the invoice, skipping",
slog.String("invoice_id", invoiceMapping.InvoiceID), slog.String("payment_id", existing.PaymentID))
return "completed", nil
}
}
}
// Resolve billing_account_id from customer mapping.
custMapping, err := stripeQ.GetCustomerMappingByStripeCustomerID(ctx, sql.NullString{String: inv.Customer, Valid: true})
if err != nil {
return "", fmt.Errorf("resolve billing account from customer %s: %w", inv.Customer, err)
}
// Checked once, up front, before opening the transaction: amount_paid is
// written to two rows below (the invoice and the payment) and must not
// diverge, and checking before any write means an out-of-range value
// fails with nothing to roll back (schema-hardening D6).
amountPaid, err := checkedInt32(evt.ProviderEventID, "amount_paid", inv.AmountPaid)
if err != nil {
return "", err
}
tx, err := a.DB.BeginTx(ctx, nil)
if err != nil {
return "", fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
billingQ := billing.New(tx)
txStripeQ := internalstripe.New(tx)
// Mark invoice paid.
now := time.Now()
_, err = billingQ.UpdateInvoicePaid(ctx, billing.UpdateInvoicePaidParams{
InvoiceID: invoiceMapping.InvoiceID,
AmountPaid: amountPaid,
PaidAt: sql.NullTime{Time: now, Valid: true},
})
if err != nil {
return "", fmt.Errorf("update invoice paid: %w", err)
}
// invoice.paid never assigns or touches invoice_number (invoice-numbers
// D2): assignment happens once, at issuance, in handleInvoiceFinalized.
// Resolve optional payment method (already captured via payment_method.attached).
var pmID uuid.NullUUID
if inv.DefaultPaymentMethod != "" {
pmMapping, err := txStripeQ.GetPaymentMethodMappingByStripeID(ctx, sql.NullString{String: inv.DefaultPaymentMethod, Valid: true})
if err == nil {
pmUUID, err := uuid.Parse(pmMapping.PaymentMethodID)
if err == nil {
pmID = uuid.NullUUID{UUID: pmUUID, Valid: true}
}
}
}
// Create payment record.
payment, err := billingQ.CreatePayment(ctx, billing.CreatePaymentParams{
InvoiceID: invoiceMapping.InvoiceID,
BillingAccountID: custMapping.BillingAccountID,
PaymentMethodID: pmID,
Amount: amountPaid,
Currency: inv.Currency,
Status: "succeeded",
FailedAt: sql.NullTime{},
})
if err != nil {
return "", fmt.Errorf("create payment: %w", err)
}
// Insert payment mapping.
if inv.PaymentIntent != "" {
// The payment intent belongs to the invoice, so it lives in the
// invoice's environment (stripe-environment-stamp D1).
_, err = txStripeQ.InsertPaymentMapping(ctx, internalstripe.InsertPaymentMappingParams{
PaymentID: payment.PaymentID,
StripePaymentIntentID: sql.NullString{String: inv.PaymentIntent, Valid: true},
SyncStatus: "synced",
Livemode: sql.NullBool{Bool: inv.Livemode, Valid: true},
})
if err != nil {
return "", fmt.Errorf("insert payment mapping: %w", err)
}
}
if err := tx.Commit(); err != nil {
return "", fmt.Errorf("commit invoice.paid: %w", err)
}
a.Logger.Info("invoice.paid processed",
slog.String("stripe_invoice_id", inv.ID),
slog.String("payment_id", payment.PaymentID))
return "completed", nil
}
func (a *WebhookActivities) handleInvoicePaymentFailed(ctx context.Context, evt WebhookEvent, inv webhookInvoicePayload, stripeQ *internalstripe.Queries) (string, error) {
// Idempotency: skip if payment mapping already exists.
if inv.PaymentIntent != "" {
_, err := stripeQ.GetPaymentMappingByStripePaymentIntentID(ctx, sql.NullString{String: inv.PaymentIntent, Valid: true})
if err == nil {
a.Logger.Info("invoice.payment_failed: payment mapping exists, skipping",
slog.String("stripe_payment_intent_id", inv.PaymentIntent))
return "completed", nil
}
if err != sql.ErrNoRows {
return "", fmt.Errorf("check existing payment mapping: %w", err)
}
}
// Resolve core invoice_id.
invoiceMapping, err := stripeQ.GetInvoiceMappingByStripeID(ctx, sql.NullString{String: inv.ID, Valid: true})
if err != nil {
return "", fmt.Errorf("resolve invoice mapping for %s: %w", inv.ID, err)
}
// Resolve billing_account_id.
custMapping, err := stripeQ.GetCustomerMappingByStripeCustomerID(ctx, sql.NullString{String: inv.Customer, Valid: true})
if err != nil {
return "", fmt.Errorf("resolve billing account from customer %s: %w", inv.Customer, err)
}
// Checked up front, before opening the transaction, so an out-of-range
// value fails with nothing to roll back (schema-hardening D6).
amountDue, err := checkedInt32(evt.ProviderEventID, "amount_due", inv.AmountDue)
if err != nil {
return "", err
}
tx, err := a.DB.BeginTx(ctx, nil)
if err != nil {
return "", fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
billingQ := billing.New(tx)
txStripeQ := internalstripe.New(tx)
now := time.Now()
payment, err := billingQ.CreatePayment(ctx, billing.CreatePaymentParams{
InvoiceID: invoiceMapping.InvoiceID,
BillingAccountID: custMapping.BillingAccountID,
Amount: amountDue,
Currency: inv.Currency,
Status: "failed",
FailedAt: sql.NullTime{Time: now, Valid: true},
})
if err != nil {
return "", fmt.Errorf("create failed payment: %w", err)
}
if inv.PaymentIntent != "" {
// The payment intent belongs to the invoice, so it lives in the
// invoice's environment (stripe-environment-stamp D1).
_, err = txStripeQ.InsertPaymentMapping(ctx, internalstripe.InsertPaymentMappingParams{
PaymentID: payment.PaymentID,
StripePaymentIntentID: sql.NullString{String: inv.PaymentIntent, Valid: true},
SyncStatus: "synced",
Livemode: sql.NullBool{Bool: inv.Livemode, Valid: true},
})
if err != nil {
return "", fmt.Errorf("insert payment mapping: %w", err)
}
}
if err := tx.Commit(); err != nil {
return "", fmt.Errorf("commit invoice.payment_failed: %w", err)
}
a.Logger.Info("invoice.payment_failed processed",
slog.String("stripe_invoice_id", inv.ID),
slog.String("payment_id", payment.PaymentID))
return "completed", nil
}
func (a *WebhookActivities) handleInvoiceVoided(ctx context.Context, evt WebhookEvent, inv webhookInvoicePayload, stripeQ *internalstripe.Queries) (string, error) {
invoiceMapping, err := stripeQ.GetInvoiceMappingByStripeID(ctx, sql.NullString{String: inv.ID, Valid: true})
if err == sql.ErrNoRows {
a.Logger.Warn("invoice.voided: no mapping found, skipping",
slog.String("stripe_invoice_id", inv.ID))
return "completed", nil
}
if err != nil {
return "", fmt.Errorf("resolve invoice mapping for %s: %w", inv.ID, err)
}
billingQ := billing.New(a.DB)
now := time.Now()
_, err = billingQ.UpdateInvoiceVoided(ctx, billing.UpdateInvoiceVoidedParams{
InvoiceID: invoiceMapping.InvoiceID,
VoidedAt: sql.NullTime{Time: now, Valid: true},
})
if err != nil {
return "", fmt.Errorf("update invoice voided: %w", err)
}
// invoice.voided never assigns or touches invoice_number (invoice-numbers
// D2): a voided invoice keeps the number it was issued with.
a.Logger.Info("invoice.voided processed", slog.String("stripe_invoice_id", inv.ID))
return "completed", nil
}
// checkedInt32 converts a 64-bit Stripe amount or quantity into the 32-bit
// width the billing schema columns use. Stripe sends amounts in minor units
// (cents), and an ordinary invoice in a high-magnitude currency (e.g.
// Indonesian rupiah) can exceed math.MaxInt32; a bare int32(v) cast on such a
// value silently wraps to a negative number and gets stored behind a success
// log (schema-hardening D6). Out-of-range values fail loudly instead: the
// returned error is a non-retryable Temporal application error naming the
// webhook event, the field, and the offending value, so Temporal does not
// retry-spin on an event whose amount will never fit and no wrapped value is
// ever written. Never clamp.
func checkedInt32(providerEventID, field string, v int64) (int32, error) {
if v < math.MinInt32 || v > math.MaxInt32 {
return 0, temporal.NewNonRetryableApplicationError(
fmt.Sprintf("stripe webhook event %s: field %s value %d is out of int32 range", providerEventID, field, v),
"AmountOutOfRange",
nil,
)
}
return int32(v), nil
}
// nullTimeFromUnix converts a unix timestamp to sql.NullTime.
// A zero or negative value is treated as NULL.
func nullTimeFromUnix(ts int64) sql.NullTime {
if ts <= 0 {
return sql.NullTime{}
}
return sql.NullTime{Time: time.Unix(ts, 0), Valid: true}
}