This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

stripe: auto-sync webhook endpoint enabled_events on startup

Adding a new case in the webhook handler used to require also clicking
into the Stripe dashboard to subscribe the endpoint to that event,
which is easy to forget — adding customer.deleted in the previous
commit was the immediate trigger.

EVENTS_HANDLED in stripe.js is now the single source of truth.
syncWebhookEvents() runs at startup, finds the endpoint matching
${PUBLIC_URL}/webhooks/stripe, and extends its enabled_events with
anything missing. Non-destructive: existing events stay, '*' is
respected, no endpoint match logs a clear "create one" warning.

+76 -7
+1 -6
readme.md
··· 163 163 164 164 1. Create a Product with a recurring Price; copy the `price_…` id into `STRIPE_PRICE_ID`. The Subscribe button label is rendered from the resolved price, not the source — change the price in Stripe and the button updates on next restart. 165 165 2. Enable the Customer Portal in the Stripe dashboard (Settings → Customer Portal). Allow subscription cancellation. 166 - 3. Add a webhook endpoint pointing at `${PUBLIC_URL}/webhooks/stripe`. Subscribe to: 167 - - `checkout.session.completed` 168 - - `customer.subscription.created` 169 - - `customer.subscription.updated` 170 - - `customer.subscription.deleted` 171 - - `customer.deleted` 166 + 3. Add a webhook endpoint pointing at `${PUBLIC_URL}/webhooks/stripe`. Pick **any** event to subscribe to initially (Stripe requires at least one) — disjecta auto-extends `enabled_events` at startup to match `EVENTS_HANDLED` in `src/stripe.js`, so adding a new event case in code is the only step needed going forward. 172 167 4. Copy the webhook signing secret into `STRIPE_WEBHOOK_SECRET`. 173 168 174 169 ### Billing state model
+5 -1
src/index.js
··· 3 3 import { startBot, client as botClient } from './bot.js'; 4 4 import { app } from './web.js'; 5 5 import { db } from './db.js'; 6 - import { resolvePrice } from './stripe.js'; 6 + import { resolvePrice, syncWebhookEvents } from './stripe.js'; 7 7 8 8 const port = Number(process.env.PORT ?? 3000); 9 9 10 10 // Fire-and-forget: the Subscribe button renders a label once this lands. 11 11 // If it never resolves (no key, Stripe down) we fall back to plain "Subscribe". 12 12 resolvePrice(); 13 + 14 + // Keep the Stripe webhook endpoint's enabled_events list in sync with the 15 + // handler in web.js, so adding a new case there doesn't silently miss in prod. 16 + syncWebhookEvents(); 13 17 14 18 startBot(); 15 19
+70
src/stripe.js
··· 29 29 return `${prefix}${formatted}/${interval}`; 30 30 } 31 31 32 + // Webhook events the handler in web.js cares about. The single source of 33 + // truth — `syncWebhookEvents()` keeps the Stripe dashboard endpoint in sync 34 + // so adding a new case here doesn't silently fail in prod. 35 + export const EVENTS_HANDLED = [ 36 + 'checkout.session.completed', 37 + 'customer.subscription.created', 38 + 'customer.subscription.updated', 39 + 'customer.subscription.deleted', 40 + 'customer.deleted', 41 + ]; 42 + 43 + // On startup, look up the webhook endpoint matching ${PUBLIC_URL}/webhooks/stripe 44 + // and add any of EVENTS_HANDLED that aren't already enabled. We only ever 45 + // extend — never remove — so events the admin added in the dashboard for 46 + // unrelated reasons stay put. 47 + export async function syncWebhookEvents() { 48 + if (!stripe) return null; 49 + const publicUrl = process.env.PUBLIC_URL; 50 + if (!publicUrl) return null; 51 + const expectedUrl = `${publicUrl}/webhooks/stripe`; 52 + 53 + let endpoint; 54 + try { 55 + const out = []; 56 + for await (const ep of stripe.webhookEndpoints.list({ limit: 100 })) { 57 + out.push(ep); 58 + } 59 + endpoint = out.find(ep => ep.url === expectedUrl); 60 + } catch (err) { 61 + console.warn(`stripe: could not list webhook endpoints (${err.message}); skipping event sync.`); 62 + return null; 63 + } 64 + 65 + if (!endpoint) { 66 + console.warn( 67 + `stripe: no webhook endpoint registered for ${expectedUrl}. ` + 68 + `Create one in the Stripe dashboard (Developers → Webhooks → Add endpoint) ` + 69 + `and put its signing secret into STRIPE_WEBHOOK_SECRET. ` + 70 + `Subscribe to: ${EVENTS_HANDLED.join(', ')}.` 71 + ); 72 + return null; 73 + } 74 + 75 + // `enabled_events: ['*']` means "all events" — nothing to add. 76 + if (endpoint.enabled_events?.includes('*')) { 77 + console.log(`stripe: webhook endpoint ${endpoint.id} subscribes to all events; nothing to sync.`); 78 + return endpoint; 79 + } 80 + 81 + const existing = new Set(endpoint.enabled_events ?? []); 82 + const missing = EVENTS_HANDLED.filter(e => !existing.has(e)); 83 + if (missing.length === 0) { 84 + console.log(`stripe: webhook endpoint ${endpoint.id} already covers ${EVENTS_HANDLED.length} events.`); 85 + return endpoint; 86 + } 87 + 88 + const merged = [...existing, ...missing]; 89 + try { 90 + const updated = await stripe.webhookEndpoints.update(endpoint.id, { enabled_events: merged }); 91 + console.log(`stripe: webhook endpoint ${endpoint.id} extended with ${missing.length} new event(s): ${missing.join(', ')}.`); 92 + return updated; 93 + } catch (err) { 94 + console.warn( 95 + `stripe: failed to extend webhook endpoint ${endpoint.id} with ${missing.join(', ')} ` + 96 + `(${err.message}). Add these manually in the dashboard.` 97 + ); 98 + return endpoint; 99 + } 100 + } 101 + 32 102 export async function resolvePrice() { 33 103 if (!stripe || !process.env.STRIPE_PRICE_ID) { 34 104 priceInfo = null;