···4647 case "invoice.payment_failed": {
48 const invoice = event.data.object;
49+ const sub = invoice.parent?.subscription_details?.subscription;
50 const subId =
51+ typeof sub === "string"
52+ ? sub
53+ : typeof sub === "object" && sub
54+ ? sub.id
55+ : "";
56 await inngest.send({
57 name: "stripe/invoice.payment.failed",
58 data: {
+5-3
specs/2026-02-03-pro-tier.md
···17- `stripe_customer_id` (text, unique)
18- `stripe_subscription_id` (text, unique, nullable)
19- `plan` (text) — Price ID from `stripe/products.ts`, e.g., `pro_monthly_v1_usd`
20-- `status` (text) — mirrors Stripe: `trialing`, `active`, `past_due`, `canceled`, `unpaid`
21- `current_period_end` (timestamp)
22- `created_at`, `updated_at`
23···3435### SKU → Entitlements Mapping
3637-Entitlements for each Stripe Product are stored in Stripe's product metadata, not locally. Example product metadata:
003839```json
40{
···42}
43```
4445-This keeps Stripe as the source of truth for what each SKU grants.
4647### Stripe Product Sync
48
···17- `stripe_customer_id` (text, unique)
18- `stripe_subscription_id` (text, unique, nullable)
19- `plan` (text) — Price ID from `stripe/products.ts`, e.g., `pro_monthly_v1_usd`
20+- `status` (text) — mirrors Stripe (`trialing`, `active`, `past_due`, `canceled`, `unpaid`) plus custom `canceling` (set when `cancel_at_period_end` is true, indicates subscription is active but will not renew)
21- `current_period_end` (timestamp)
22- `created_at`, `updated_at`
23···3435### SKU → Entitlements Mapping
3637+Entitlements for each product are defined in code (`stripe/products.ts`) alongside the product definitions. The sync script pushes this metadata to Stripe for consistency, but the codebase is the source of truth.
38+39+Example from `stripe/products.ts`:
4041```json
42{
···44}
45```
4647+The sync script ensures Stripe metadata stays in sync with code definitions.
4849### Stripe Product Sync
50