diff --git a/app/api/stripe/webhook/[space]/route.ts b/app/api/stripe/webhook/[space]/route.ts index faa5479..f217573 100644 --- a/app/api/stripe/webhook/[space]/route.ts +++ b/app/api/stripe/webhook/[space]/route.ts @@ -95,7 +95,13 @@ export async function POST( customerId, ) if (!memberId) return - const periodEnd = isoFromUnix((sub as unknown as { current_period_end?: number }).current_period_end) + // API 2026-04-22.dahlia (Basil 2025-03-31): the billing period moved off + // the top-level Subscription onto each subscription item. Dues are a + // single-price subscription, so item 0 carries the period end. + const periodEnd = isoFromUnix( + (sub.items?.data?.[0] as unknown as { current_period_end?: number } | undefined) + ?.current_period_end, + ) await admin.from('member_billing').upsert( { @@ -142,29 +148,46 @@ export async function POST( await applySubscription(event.data.object as Stripe.Subscription) break } - case 'invoice.paid': - case 'invoice.payment_succeeded': { + // Handle ONLY invoice.paid (Stripe also fires invoice.payment_succeeded + // for the same invoice — handling both would double the ledger). + case 'invoice.paid': { const inv = event.data.object as Stripe.Invoice const customerId = typeof inv.customer === 'string' ? inv.customer : inv.customer?.id ?? null + // API 2026-04-22.dahlia (Basil): invoice subscription + its metadata + // moved under invoice.parent.subscription_details. + const invParent = (inv as unknown as { + parent?: { subscription_details?: { metadata?: Record } } + }).parent const memberId = await resolveMemberId( - (inv.subscription_details?.metadata?.member_id as string | undefined) || undefined, + invParent?.subscription_details?.metadata?.member_id || undefined, customerId, ) - await admin.from('payments').insert({ - space_id: spaceId, - member_id: memberId, - platform: 'stripe', - amount: (inv.amount_paid ?? 0) / 100, - currency: (inv.currency ?? 'usd').toUpperCase(), - description: inv.description ?? 'Stripe membership dues', - status: memberId ? 'linked' : 'unlinked', - link_status: memberId ? 'linked' : 'unlinked', - external_id: inv.id, - payer_email: inv.customer_email ?? null, - from_identifier: inv.customer_email ?? null, - transaction_date: new Date().toISOString(), - raw_data: { event_id: event.id, invoice: inv.id }, - }) + // Ledger idempotency: invoice.paid + a retry are distinct event ids + // but the same invoice; never double-record one charge. + const { data: dupe } = await admin + .from('payments') + .select('id') + .eq('space_id', spaceId) + .eq('platform', 'stripe') + .eq('external_id', inv.id) + .maybeSingle() + if (!dupe) { + await admin.from('payments').insert({ + space_id: spaceId, + member_id: memberId, + platform: 'stripe', + amount: (inv.amount_paid ?? 0) / 100, + currency: (inv.currency ?? 'usd').toUpperCase(), + description: inv.description ?? 'Stripe membership dues', + status: memberId ? 'linked' : 'unlinked', + link_status: memberId ? 'linked' : 'unlinked', + external_id: inv.id, + payer_email: inv.customer_email ?? null, + from_identifier: inv.customer_email ?? null, + transaction_date: new Date().toISOString(), + raw_data: { event_id: event.id, invoice: inv.id }, + }) + } if (memberId) { await admin .from('space_members') diff --git a/lib/actions/stripe.ts b/lib/actions/stripe.ts index 9f4d7cd..b55f053 100644 --- a/lib/actions/stripe.ts +++ b/lib/actions/stripe.ts @@ -141,11 +141,16 @@ export async function startDuesCheckout() { let customerId = billing?.stripe_customer_id as string | undefined if (!customerId) { - const customer = await stripe.customers.create({ - email: (m.email as string | null) ?? undefined, - name: (m.display_name as string | null) ?? undefined, - metadata: { space_id: member.space_id, member_id: member.id }, - }) + // Idempotency key keyed by member: concurrent checkouts won't create + // duplicate Stripe customers (Stripe returns the same customer). + const customer = await stripe.customers.create( + { + email: (m.email as string | null) ?? undefined, + name: (m.display_name as string | null) ?? undefined, + metadata: { space_id: member.space_id, member_id: member.id }, + }, + { idempotencyKey: `cust:${member.space_id}:${member.id}` }, + ) customerId = customer.id await admin .from('member_billing') diff --git a/scripts/040_stripe_billing.sql b/scripts/040_stripe_billing.sql index 0d74d32..f7b8655 100644 --- a/scripts/040_stripe_billing.sql +++ b/scripts/040_stripe_billing.sql @@ -18,6 +18,12 @@ -- -- Idempotent: ADD VALUE IF NOT EXISTS, CREATE TABLE/INDEX IF NOT EXISTS, -- DROP POLICY IF EXISTS then CREATE. +-- +-- Apply this file as-is (Supabase SQL editor / psql). Do NOT wrap it in a +-- manual BEGIN/COMMIT: `ALTER TYPE ... ADD VALUE` historically could not run +-- inside a transaction block (fine on Supabase PG15 unwrapped). The new +-- value is not used by any statement here, so a single implicit transaction +-- is safe and re-runs are no-ops. -- ============================================================================= ALTER TYPE public.payment_platform ADD VALUE IF NOT EXISTS 'stripe';