Stripe webhooks, deeper billing UX, and email delivery alerts - #940
Conversation
|
Warning Review limit reached
Next review available in: 21 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (37)
📝 WalkthroughWalkthroughChangesThe PR adds Stripe webhook ingestion with signature verification, subscription synchronization, idempotency tracking, and 30-day retention. Billing pages expose subscription status and actions. It also adds an hourly email delivery burst alert with KV cooldown and administrator notifications. Stripe billing synchronization
Email delivery burst alerts
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Stripe
participant WorkerRoute
participant WebhookProcessor
participant BillingSync
participant WebhookLedger
Stripe->>WorkerRoute: POST signed webhook
WorkerRoute->>WebhookProcessor: Verify raw request
WebhookProcessor->>BillingSync: Process supported event
BillingSync->>WebhookLedger: Record event id
WorkerRoute-->>Stripe: Return success or retry status
sequenceDiagram
participant Cron
participant AlertChecker
participant Database
participant KV
participant Administrators
Cron->>AlertChecker: Run hourly lane
AlertChecker->>Database: Count complaints and bounces
AlertChecker->>KV: Check cooldown
AlertChecker->>Administrators: Send alert when threshold is met
AlertChecker->>KV: Store cooldown marker
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
🔎 Preview deployed: https://kody-pr-940.kody-a99.workers.dev Worker: Mocks:
|
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/worker/src/billing/subscription-sync.ts (1)
22-31: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winAdd a friendly message for
user_not_found.
resolveBillingErrorMessage()falls back toerrorCode ?? trimmed, so whenbillingLinkerrors usenew BillingLinkError('user_not_found', ...), this code andloadAccountBillingData({ errorCode: 'user_not_found' })can render the raw string instead of a user-friendly message. Adduser_not_found: ...tobillingErrorMessages.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/worker/src/billing/subscription-sync.ts` around lines 22 - 31, Add a user-friendly `user_not_found` entry to the `billingErrorMessages` mapping used by `resolveBillingErrorMessage()`, so both `BillingLinkError` instances and `loadAccountBillingData({ errorCode: 'user_not_found' })` resolve to the friendly message instead of the raw error code.
🧹 Nitpick comments (6)
packages/worker/src/billing/stripe-webhooks.workers.test.ts (1)
294-294: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMove
vi.unstubAllGlobals()into anafterEachso a failing assertion can't leak thefetchstub.Both tests unstub as the last statement of the body; if any earlier
expectthrows, the stubbedfetchsurvives into the next test and produces cascading, misleading failures.♻️ Proposed cleanup
-import { expect, test, vi } from 'vitest' +import { afterEach, expect, test, vi } from 'vitest'+afterEach(() => { + vi.unstubAllGlobals() +})Then drop the trailing
vi.unstubAllGlobals()from both test bodies.Also applies to: 362-362
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/worker/src/billing/stripe-webhooks.workers.test.ts` at line 294, Move vi.unstubAllGlobals() into an afterEach hook covering both webhook tests, then remove the trailing calls from each test body so cleanup runs even when assertions fail.packages/worker/src/billing/stripe-webhooks.ts (3)
329-334: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
recordshadows the importedrecordschema helper.Not a bug today —
record()is only called at module scope (Lines 32, 48) — but any future use inside this function silently resolves to the string result. Rename the local.♻️ Proposed rename
- const record = await recordStripeWebhookEvent({ + const recordResult = await recordStripeWebhookEvent({ env: input.env, eventId: event.id, eventType: event.type, now: input.now, }) - if (record === 'duplicate') { + if (recordResult === 'duplicate') { return { status: 200, body: { ok: true, duplicate: true } } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/worker/src/billing/stripe-webhooks.ts` around lines 329 - 334, Rename the local result of recordStripeWebhookEvent in the webhook handler to avoid shadowing the imported record schema helper. Update all references to this local value within the handler while preserving the existing module-scope record() usage.
141-144: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
console.errorfor expected business outcomes will pollute error alerting.
customer_already_linkedfires on every legitimate checkout replay, andinvoice.payment_failedis a routine declined-card event, not a system error. Both are informational; demote toconsole.info/console.warnso genuine webhook failures stay signal.Also applies to: 205-209
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/worker/src/billing/stripe-webhooks.ts` around lines 141 - 144, Update the webhook logging for expected outcomes in the checkout-link handling and invoice.payment_failed path to use console.info or console.warn instead of console.error. Keep genuine webhook failure logging unchanged, and ensure both customer_already_linked and routine payment-declined events no longer trigger error alerting.
165-172: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDuplicated customer extraction; the
parseSafefallback is dead code.Both handlers repeat the same block, and
customerObjectSchema(all-optional) succeeds for any record input, so thereadStringFieldfallback never runs. Collapse to a single helper.♻️ Proposed dedupe
+function readEventCustomerId(object: Record<string, unknown>): string | null { + return readStringField(object, 'customer') +} + async function handleCustomerSubscriptionChange(input: { env: Env object: Record<string, unknown> now?: Date }) { - const parsed = parseSafe(customerObjectSchema, input.object) - const customerId = - (parsed.success ? readCustomerId(parsed.value.customer) : null) ?? - readStringField(input.object, 'customer') + const customerId = readEventCustomerId(input.object) if (!customerId) {Apply the same substitution in
handleInvoicePaymentFailed, then dropcustomerObjectSchemaandreadCustomerIdif unused elsewhere.Also applies to: 188-195
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/worker/src/billing/stripe-webhooks.ts` around lines 165 - 172, Deduplicate customer ID extraction in both handlers, including handleInvoicePaymentFailed, by using one shared helper that reads the customer field directly from the webhook object. Remove the redundant parseSafe/customerObjectSchema path and delete customerObjectSchema and readCustomerId if no longer referenced elsewhere; preserve the existing missing-customer error and early return behavior.packages/worker/src/billing/stripe-webhook-signature.ts (1)
11-12: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider relocating
timingSafeEqualStringto a shared crypto util.A billing signature primitive importing from
#worker/maintenance-handler.tsinverts the expected dependency direction and drags the maintenance module into this module's graph. Moving it next totoHexin@kody-internal/sharedwould keep the boundary clean.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/worker/src/billing/stripe-webhook-signature.ts` around lines 11 - 12, Move timingSafeEqualString from `#worker/maintenance-handler.ts` into the shared crypto utilities alongside toHex, export it there, and update the billing signature module to import it from `@kody-internal/shared`. Remove the maintenance-module dependency while preserving the function’s existing behavior and consumers.packages/worker/src/billing/subscription-sync.ts (1)
99-183: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsolidate the three near-identical candidate lookups.
The stable-user-id, customer-id, and email lookups in
resolveBillingUserForCheckoutLinkare structurally identical (same query shape, same row mapping), differing only by the WHERE column. Extracting a small helper reduces copy/paste risk in this security-sensitive attribution path.♻️ Proposed refactor
+async function findCandidateByColumn( + env: SyncEnv, + column: 'stable_user_id' | 'stripe_customer_id', + value: string, +): Promise<BillingUser | null> { + const row = await env.APP_DB.prepare( + `SELECT id, email, stable_user_id FROM users WHERE ${column} = ?`, + ) + .bind(value) + .first<{ id: number; email: string; stable_user_id: string }>() + return row + ? { id: row.id, email: row.email, stableUserId: row.stable_user_id } + : null +} + const stableUserIdHint = input.stableUserIdHint?.trim() if (stableUserIdHint) { - const row = await input.env.APP_DB.prepare( - `SELECT id, email, stable_user_id FROM users WHERE stable_user_id = ?`, - ) - .bind(stableUserIdHint) - .first<{ id: number; email: string; stable_user_id: string }>() - await pushCandidate( - row ? { id: row.id, email: row.email, stableUserId: row.stable_user_id } : null, - ) + await pushCandidate( + await findCandidateByColumn(input.env, 'stable_user_id', stableUserIdHint), + ) }(similarly for
customerId; the email lookup stays separate since it useslower(email))🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/worker/src/billing/subscription-sync.ts` around lines 99 - 183, Refactor the repeated stable-user-id and customer-id query/mapping logic in resolveBillingUserForCheckoutLink into a small reusable helper parameterized by the WHERE column and value, then pass its result to pushCandidate. Keep the email lookup separate because it uses lower(email), and preserve the existing candidate ordering, deduplication, and validation behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/worker/client/routes/account-billing.tsx`:
- Around line 481-501: Remove the click handler that unconditionally calls
preventDefault and window.location.assign from the manage-subscription anchor in
the showManageCta branch. Preserve the href={billingPortalPath} and existing
styling so normal clicks and modifier or middle-clicks use native link behavior.
In `@packages/worker/src/app/email-delivery-alerts.ts`:
- Around line 159-175: Update the email delivery alert flow around
sendCloudflareEmail to inspect its result before reporting success or writing
the six-hour cooldown marker. When the result is { ok: false, skipped: true },
return a non-notified status or throw so no notification marker is persisted;
preserve the existing success path, and add a regression test covering the
skipped result.
- Line 23: Scope the email delivery alert flow by threading the owning userId
through event queries, admin-recipient resolution, and cooldown reads/writes.
Update emailDeliveryAlertKvKey to include userId so each user has an independent
alert state and cooldown, ensuring no cross-user events, recipients, or
suppression state are shared.
---
Outside diff comments:
In `@packages/worker/src/billing/subscription-sync.ts`:
- Around line 22-31: Add a user-friendly `user_not_found` entry to the
`billingErrorMessages` mapping used by `resolveBillingErrorMessage()`, so both
`BillingLinkError` instances and `loadAccountBillingData({ errorCode:
'user_not_found' })` resolve to the friendly message instead of the raw error
code.
---
Nitpick comments:
In `@packages/worker/src/billing/stripe-webhook-signature.ts`:
- Around line 11-12: Move timingSafeEqualString from
`#worker/maintenance-handler.ts` into the shared crypto utilities alongside toHex,
export it there, and update the billing signature module to import it from
`@kody-internal/shared`. Remove the maintenance-module dependency while preserving
the function’s existing behavior and consumers.
In `@packages/worker/src/billing/stripe-webhooks.ts`:
- Around line 329-334: Rename the local result of recordStripeWebhookEvent in
the webhook handler to avoid shadowing the imported record schema helper. Update
all references to this local value within the handler while preserving the
existing module-scope record() usage.
- Around line 141-144: Update the webhook logging for expected outcomes in the
checkout-link handling and invoice.payment_failed path to use console.info or
console.warn instead of console.error. Keep genuine webhook failure logging
unchanged, and ensure both customer_already_linked and routine payment-declined
events no longer trigger error alerting.
- Around line 165-172: Deduplicate customer ID extraction in both handlers,
including handleInvoicePaymentFailed, by using one shared helper that reads the
customer field directly from the webhook object. Remove the redundant
parseSafe/customerObjectSchema path and delete customerObjectSchema and
readCustomerId if no longer referenced elsewhere; preserve the existing
missing-customer error and early return behavior.
In `@packages/worker/src/billing/stripe-webhooks.workers.test.ts`:
- Line 294: Move vi.unstubAllGlobals() into an afterEach hook covering both
webhook tests, then remove the trailing calls from each test body so cleanup
runs even when assertions fail.
In `@packages/worker/src/billing/subscription-sync.ts`:
- Around line 99-183: Refactor the repeated stable-user-id and customer-id
query/mapping logic in resolveBillingUserForCheckoutLink into a small reusable
helper parameterized by the WHERE column and value, then pass its result to
pushCandidate. Keep the email lookup separate because it uses lower(email), and
preserve the existing candidate ordering, deduplication, and validation
behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 3cac0260-9d55-43f4-9aec-d91ddd786b63
📒 Files selected for processing (36)
.github/workflows/deploy.ymldocs/contributing/architecture/data-storage.mddocs/contributing/architecture/entitlements.mddocs/contributing/architecture/primitives.yamldocs/contributing/environment-variables.mddocs/contributing/security.mddocs/contributing/setup-manifest.mdpackages/worker/.env.examplepackages/worker/client/routes/account-billing.tsxpackages/worker/migrations/0093-stripe-webhook-events.sqlpackages/worker/src/app/account-billing-data.node.test.tspackages/worker/src/app/account-billing-data.tspackages/worker/src/app/account-retention-dispositions.tspackages/worker/src/app/email-delivery-alerts.node.test.tspackages/worker/src/app/email-delivery-alerts.tspackages/worker/src/app/handlers/account-billing.tspackages/worker/src/app/handlers/stripe-webhook.tspackages/worker/src/app/loader-data.tspackages/worker/src/app/retention.node.test.tspackages/worker/src/app/retention.tspackages/worker/src/app/router.tspackages/worker/src/app/routes.tspackages/worker/src/billing/billing-config.node.test.tspackages/worker/src/billing/billing-config.tspackages/worker/src/billing/stripe-client.tspackages/worker/src/billing/stripe-webhook-signature.node.test.tspackages/worker/src/billing/stripe-webhook-signature.tspackages/worker/src/billing/stripe-webhooks.tspackages/worker/src/billing/stripe-webhooks.workers.test.tspackages/worker/src/billing/subscription-sync.tspackages/worker/src/billing/subscription-sync.workers.test.tspackages/worker/src/entitlements/test-schema.tspackages/worker/src/env-schema.tspackages/worker/src/index.tspackages/worker/src/index.workers.test.tstools/migration-ledger.json
| export const emailDeliveryAlertThreshold = 20 | ||
| /** Avoid re-paging on the same sustained spike every hour. */ | ||
| export const emailDeliveryAlertCooldownMinutes = 6 * 60 | ||
| export const emailDeliveryAlertKvKey = 'ops-alert:email-delivery-burst:v1' |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Scope this alert lane and its cooldown by userId.
This aggregates all users’ delivery events, resolves all admin recipients, and uses one shared cooldown key. A burst from one user can disclose/suppress alert state for unrelated users. Thread an owner userId through this lane, apply it to the event and recipient queries, and namespace the KV key by that ID.
As per coding guidelines, “every read/write path must be scoped by userId” and “Cross-user data sharing is a bug.”
Also applies to: 72-79, 85-96, 138-145
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/worker/src/app/email-delivery-alerts.ts` at line 23, Scope the email
delivery alert flow by threading the owning userId through event queries,
admin-recipient resolution, and cooldown reads/writes. Update
emailDeliveryAlertKvKey to include userId so each user has an independent alert
state and cooldown, ensuring no cross-user events, recipients, or suppression
state are shared.
Source: Coding guidelines
POST /webhooks/stripe verifies Stripe-Signature, idempotently records event ids, and reuses checkout link + plan refresh helpers so customer linking no longer depends on the success redirect alone. Cron poll stays as backup; resolveSubscriptionPlan now returns subscriptionStatus for UX. Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Mirror the auth-denial ops alert: count Cloudflare Email Sending complained/bounced/failed/rejected outcomes over 60 minutes, page admins at threshold 20 with a 6h KV cooldown, and document the lane. Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Pass Stripe subscriptionStatus through billing loader data, surface status badges and past_due guidance, add Subscribe/Manage/Usage CTAs, and fix entitlements docs to match always-on billing page refresh. Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Process Stripe webhooks before recording event ids so failures do not ack concurrent duplicates; count only complained/bounced for delivery alerts; retain stripe_webhook_events for 30 days. Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
e6476db to
52ca450
Compare
Stripe only maps active/trialing subscriptions to stripe_plan, so past_due and unpaid already drop paid access — say restore, not keep. Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9184c6d. Configure here.
| })} | ||
| > | ||
| Manage subscription | ||
| </a> |
There was a problem hiding this comment.
Portal link breaks SPA navigation
Medium Severity
The new Manage subscription anchor uses a plain href to /account/billing/portal, but the global click handler intercepts same-origin links and SPA-navigates. That path has no client loader or route—only a server GET that 302s to Stripe—so a normal click updates the URL without a full document request and never opens the billing portal.
Reviewed by Cursor Bugbot for commit 9184c6d. Configure here.
Fold post-#937/#939/#940 account verification tests into fewer workflow journeys and drop low-signal Playwright coverage. - account-values handler: list/select/save/delete + rejection matrix - account-memories handler: list/filter/select + soft/force delete - activity-data helpers + load/cursor; billing refresh workflow without instructional error-copy pins - auth-session password-change matrix; auth-denial and email-delivery alert cron/threshold/cooldown journeys - trim activity e2e instructional empty-state copy; drop memories e2e (values e2e + memories handler cover the pattern); drop jobs schedule text pin Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
…#958) * test: consolidate low-signal workers tests into workflow journeys Collapses 32 tests across four files down to 15, following the 'fewer longer workflow tests' principle from testing-principles.md. run-records.workers.test.ts (20 → 9): - Merge eager-begin/finish, finish-only upsert, one-shot recordRunRecord, waitUntil non-blocking, and execute on-failure policy into a single 'write surfaces journey' test. - Delete 'sandbox level markers become structured log levels' — covered by the existing sandbox e2e test which already asserts structured log levels on each entry. - Merge retention-priority, cap-protect-running, stale-reconcile, stale-cap-evict, and age-cap scenarios into 'cap and stale retention journey' (each with its own userId for DO isolation). - Extend 'write after idle re-arms alarm and age-prunes' into a full 'alarm lifecycle' narrative that also covers the fresh-arm far-future deadline assertion and the self-termination assertion from the two standalone alarm tests. runs.workers.test.ts (5 → 2): - Keep the auth guard test unchanged. - Merge run_list filter smoke, run_get with logs/foreign/missing rejection, run_summary counts, and run_list/run_summary tenant isolation into a single 'run capabilities smoke' test. Filter/ pagination matrix remains in run-records.workers.test.ts. execute-console-capture.workers.test.ts (4 → 2): - Merge success-levels, throw-capture, and unshimmed-methods into 'console capture contract'; keep 'reused dynamic workers' separate. stripe-webhooks.workers.test.ts (3 → 2): - Fold the 503-when-secret-unset guard into the main journey test as an upfront check before any seeding. Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * test: fold Sentry, D1 retry, and small unit microtests into journeys Collapse post-#934 one-string-per-filter and sibling unit cases toward fewer longer workflow tests. - sentry-options: fold UserCodeError hint + platform keep into the main filter journey; delete standalone user-code-error.node.test.ts - d1-retry: keep representative matchers and one retry/fail path - observability: fold conversationId/storageId/detail context into the platform-bug reporting journey - activation: merge twice-same-package, HTTP ignore, sticky timestamp, fast path, and never-throw degradation - fold stripe signature fixture, package-workflow sentry tags, invocation surface/name helpers, and package_service_states count + entitlement boundary Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * test: consolidate account list/detail and alert suites Fold post-#937/#939/#940 account verification tests into fewer workflow journeys and drop low-signal Playwright coverage. - account-values handler: list/select/save/delete + rejection matrix - account-memories handler: list/filter/select + soft/force delete - activity-data helpers + load/cursor; billing refresh workflow without instructional error-copy pins - auth-session password-change matrix; auth-denial and email-delivery alert cron/threshold/cooldown journeys - trim activity e2e instructional empty-state copy; drop memories e2e (values e2e + memories handler cover the pattern); drop jobs schedule text pin Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * style: apply oxfmt after mistaken prettier pass CI format:check uses oxfmt (tabs/single quotes). A Prettier pass had rewritten touched test files to spaces/double quotes. Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>


Summary
Three invite-cohort readiness follow-ups, orchestrated as parallel tracks then integrated:
POST /webhooks/stripeSTRIPE_WEBHOOK_SECRET), process-then-record idempotency (stripe_webhook_events), 30-day retention prunecheckout.session.completed(link customer without relying on success redirect),customer.subscription.updated/deleted,invoice.payment_failedsubscriptionStatus/account/billingUX — status badge (incl.past_due), clear Subscribe / Manage / View usage actions, webhook-aware copycomplained/bouncedburst (≥20/hour), KV cooldown, complements per-user outbound pauseStripe ops (already done in this agent run)
we_1TxB2NLAQpAnsYszAr7S6krK→https://heykody.dev/webhooks/stripewith the four events aboveSTRIPE_WEBHOOK_SECRET(deploy.yml syncs via--set-from-env-optional)Test plan
typecheck,lint,format:check,migrations:check,primitives:checknpm run validate/account/billingshows statusSystem recap — extends existing primitives (medium risk)
Mode: recap · Base:
main@367cd150· Head:1cc057c5Classification: extends — Stripe billing gains signed webhook ingress + idempotency table; scheduled cron gains a second ops alert; account billing UI surfaces subscription status.
Primitives touched
billingd1-app-dbstripe_webhook_events+ retention prunescheduled-cronapp-ui/account/billingstatus + CTA depthentitlementsSystem map
Stripe events enter a signed webhook, update per-user
stripe_plan, and the billing UI reads refreshed status; a separate hourly cron pages admins on email reputation bursts.Legend: green = composes (wiring only) · amber = extended by this PR · red = new primitive · gray = context (unchanged, included only when an edge crosses it).
Change flow
Invariants
client_reference_id/ linkedstripe_customer_id; alerts use aggregate delivery counts + admin emails only.Summary by CodeRabbit
STRIPE_WEBHOOK_SECRETand updated billing/retention/setup guidance.