fix(billing): deep-audit P0/P1 fixes for the pinned Stripe API (2026-04-22.dahlia)
Independent audit (verified vs Stripe Basil 2025-03-31 changelog): - P0: period end moved off the top-level Subscription -> read sub.items.data[0].current_period_end (grace/lapse now actually fires; was always null -> past_due members never went 'late'). - P0: invoice subscription metadata moved under invoice.parent. subscription_details -> read parent (metadata member-resolution was always failing, mislinking dues payments). - P1: handle only invoice.paid (not also invoice.payment_succeeded) + pre-insert dedupe on (space_id,platform,external_id) -> no double payment-ledger rows. - P2: idempotencyKey on customers.create -> no duplicate Stripe customers under concurrent checkout. - 040: documented the 'do not wrap in manual BEGIN/COMMIT' caveat. (P0 webhook-reprocess-on-failure was fixed in ca34e53.) Audit verdict: isolation/secrets/RLS/status-bounds were already sound.
This commit is contained in:
@@ -95,7 +95,13 @@ export async function POST(
|
|||||||
customerId,
|
customerId,
|
||||||
)
|
)
|
||||||
if (!memberId) return
|
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(
|
await admin.from('member_billing').upsert(
|
||||||
{
|
{
|
||||||
@@ -142,29 +148,46 @@ export async function POST(
|
|||||||
await applySubscription(event.data.object as Stripe.Subscription)
|
await applySubscription(event.data.object as Stripe.Subscription)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'invoice.paid':
|
// Handle ONLY invoice.paid (Stripe also fires invoice.payment_succeeded
|
||||||
case '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 inv = event.data.object as Stripe.Invoice
|
||||||
const customerId = typeof inv.customer === 'string' ? inv.customer : inv.customer?.id ?? null
|
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<string, string> } }
|
||||||
|
}).parent
|
||||||
const memberId = await resolveMemberId(
|
const memberId = await resolveMemberId(
|
||||||
(inv.subscription_details?.metadata?.member_id as string | undefined) || undefined,
|
invParent?.subscription_details?.metadata?.member_id || undefined,
|
||||||
customerId,
|
customerId,
|
||||||
)
|
)
|
||||||
await admin.from('payments').insert({
|
// Ledger idempotency: invoice.paid + a retry are distinct event ids
|
||||||
space_id: spaceId,
|
// but the same invoice; never double-record one charge.
|
||||||
member_id: memberId,
|
const { data: dupe } = await admin
|
||||||
platform: 'stripe',
|
.from('payments')
|
||||||
amount: (inv.amount_paid ?? 0) / 100,
|
.select('id')
|
||||||
currency: (inv.currency ?? 'usd').toUpperCase(),
|
.eq('space_id', spaceId)
|
||||||
description: inv.description ?? 'Stripe membership dues',
|
.eq('platform', 'stripe')
|
||||||
status: memberId ? 'linked' : 'unlinked',
|
.eq('external_id', inv.id)
|
||||||
link_status: memberId ? 'linked' : 'unlinked',
|
.maybeSingle()
|
||||||
external_id: inv.id,
|
if (!dupe) {
|
||||||
payer_email: inv.customer_email ?? null,
|
await admin.from('payments').insert({
|
||||||
from_identifier: inv.customer_email ?? null,
|
space_id: spaceId,
|
||||||
transaction_date: new Date().toISOString(),
|
member_id: memberId,
|
||||||
raw_data: { event_id: event.id, invoice: inv.id },
|
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) {
|
if (memberId) {
|
||||||
await admin
|
await admin
|
||||||
.from('space_members')
|
.from('space_members')
|
||||||
|
|||||||
+10
-5
@@ -141,11 +141,16 @@ export async function startDuesCheckout() {
|
|||||||
|
|
||||||
let customerId = billing?.stripe_customer_id as string | undefined
|
let customerId = billing?.stripe_customer_id as string | undefined
|
||||||
if (!customerId) {
|
if (!customerId) {
|
||||||
const customer = await stripe.customers.create({
|
// Idempotency key keyed by member: concurrent checkouts won't create
|
||||||
email: (m.email as string | null) ?? undefined,
|
// duplicate Stripe customers (Stripe returns the same customer).
|
||||||
name: (m.display_name as string | null) ?? undefined,
|
const customer = await stripe.customers.create(
|
||||||
metadata: { space_id: member.space_id, member_id: member.id },
|
{
|
||||||
})
|
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
|
customerId = customer.id
|
||||||
await admin
|
await admin
|
||||||
.from('member_billing')
|
.from('member_billing')
|
||||||
|
|||||||
@@ -18,6 +18,12 @@
|
|||||||
--
|
--
|
||||||
-- Idempotent: ADD VALUE IF NOT EXISTS, CREATE TABLE/INDEX IF NOT EXISTS,
|
-- Idempotent: ADD VALUE IF NOT EXISTS, CREATE TABLE/INDEX IF NOT EXISTS,
|
||||||
-- DROP POLICY IF EXISTS then CREATE.
|
-- 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';
|
ALTER TYPE public.payment_platform ADD VALUE IF NOT EXISTS 'stripe';
|
||||||
|
|||||||
Reference in New Issue
Block a user