From d7fd17adbd72524f4603cd7e7c161b9af8fcf6f1 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Sun, 25 Oct 2026 01:00:50 +0000 Subject: [PATCH 1/3] feat(billing): connect Capve Stripe resources and reconcile invoice events --- docs/billing-setup.md | 35 +++++++++---- docs/stripe-integration-plan.md | 27 ++++++++++ tests/billing.test.ts | 92 ++++++++++++++++++++++++++++++++- worker/routes/billing.ts | 79 +++++++++++++++++++++++----- worker/types.ts | 2 + wrangler.toml | 2 +- 6 files changed, 211 insertions(+), 26 deletions(-) create mode 100644 docs/stripe-integration-plan.md diff --git a/docs/billing-setup.md b/docs/billing-setup.md index f4030a8..89075c7 100644 --- a/docs/billing-setup.md +++ b/docs/billing-setup.md @@ -17,15 +17,32 @@ After trial expiry, an active or trialing Stripe subscription with an unexpired ## Stripe, before enabling billing -1. Select the Stripe account that owns Capve. Start in test mode, then repeat the setup in live mode. Keep test and live IDs/keys separate. -2. Create two products, `Capve Starter` and `Capve Pro`, with **monthly recurring USD prices**, $74.50 and $149.50. Do not create usage-based prices or overage charges. Trial timing comes from Capve, not a second 14-day Stripe promotion. -3. In Stripe's customer portal settings, allow payment method updates, cancellation at period end, and switching between these two prices. Apply plan changes immediately with proration so an upgrade restores reports on the confirmed subscription update. Disable unrelated prices and quantities. Keep payment collection enabled for trial Checkout. -4. Add webhook `https://capve.app/api/billing/webhook` for `customer.subscription.created`, `customer.subscription.updated`, and `customer.subscription.deleted`. Select the API version matching the installed Stripe SDK, currently 2026-09-30.endive. The handler verifies the raw payload signature, then retrieves the current subscription before updating D1. Do not use a Checkout success redirect as proof of payment. -5. Set Worker secrets with `bunx wrangler secret put NAME --env production` for `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`. Never paste them into chat or git. Keep CI/deploy credentials at Cap-go org level. -6. Set non-secret production vars `STRIPE_PRICE_STARTER` and `STRIPE_PRICE_PRO` to the two price IDs. Add `BILLING_ENABLED = "true"` only after Stripe and Bento setup is complete. These vars must be included in `env.production.vars` in wrangler.toml for later deployments to retain them. -7. Apply migrations through the normal deployment workflow after review and merge. Coordinate the initial migration and billing activation; if activation is delayed, follow the existing-account correction below. Before launch, follow the Medialab steps below. Martin performs deployment; agents must not deploy. - -Stripe prices are the charging source of truth. Verify that the configured IDs are the correct account, currency, amount and monthly interval. Changing `shared/billing.ts` alone does not change Stripe charges. Configure Stripe's failed-payment retries and receipts; Capve sends the reporting-access notices through Bento. +Live resources are already created in **CodepushGo, LLC** (`acct_1UUFFLPth1byRSsb`), October 25, 2026. Do not duplicate them: + +| Resource | ID | +| --- | --- | +| Starter Product / monthly USD 74.50 Price | `capve_starter` / `price_1UUFc0Pth1byRSsbw73wF7yf` | +| Pro Product / monthly USD 149.50 Price | `capve_pro` / `price_1UUFcCPth1byRSsbOwrw6lvS` | +| Capve portal configuration | `bpc_1UUFeHPth1byRSsb0svtijZb` | +| Webhook endpoint (disabled pending launch) | `we_1UUFfqPth1byRSsblazybdxg` | + +Production Price IDs and portal configuration are committed in `wrangler.toml`. **Billing and automatic tax remain explicitly disabled.** Self-hosters supply their own account's resources. Local development and CI must use mocks or separate sandbox resources; never use these live IDs with sandbox keys. + +1. Create a restricted runtime API key in Stripe's Dashboard. Allow Customer write, Checkout Session write (including retrieval/expiration), Subscription read and Customer Portal Session write. Verify dependencies and permissions with sandbox Checkout, subscription retrieval and portal creation before launch. The Stripe MCP OAuth connection configures the merchant account but cannot issue or install a Worker runtime key. +2. Set Worker secrets using `bunx wrangler secret put STRIPE_SECRET_KEY --env production` and `bunx wrangler secret put STRIPE_WEBHOOK_SECRET --env production`. Retrieve this endpoint's signing secret in Stripe Workbench. Enter secrets privately, never in git or chat. Keep CI/deploy credentials at Cap-go org level. +3. Review and merge this integration, then Martin deploys through the normal workflow. The endpoint URL is `https://capve.app/api/billing/webhook`, API version `2026-09-30.endive`. Enable the existing endpoint after the handler and secrets are ready. It subscribes to `customer.subscription.created`, `.updated`, `.deleted`; `checkout.session.completed`, `.async_payment_succeeded`, `.async_payment_failed`; and `invoice.paid`, `.payment_failed`, `.payment_action_required`, `.finalization_failed`. +4. Configure Stripe payment recovery emails, failed-payment retries and operational notifications. Capve logs event/account/subscription references for invoice failures and action/finalization issues. Bento's existing cron sends subscription/report-access notices; it does not send a separate email for each invoice attempt. Monitor Stripe Workbench failed deliveries and Worker warnings. Refunds, disputes and fraud warnings require operator review in Stripe; Capve does not automatically refund, cancel or interrupt tracking in response. +5. Complete the sandbox verification below, Bento setup and delayed-rollout trial correction. Confirm Medialab's stored date. Only then set `BILLING_ENABLED = "true"` for Martin's activation deployment. + +The Capve portal allows billing address/name/email/tax-ID updates, payment method changes, invoice history/downloads, and switching only between Capve's two prices. Upgrades are prorated and invoiced immediately; downgrades and cancellations take effect at period end. Plan changes during a trial preserve the trial. Subscription Billing generates invoices automatically; do not create a second invoice for a subscription charge. + +Checkout collects a billing address and business tax ID, saves the Customer's address/name, uses flexible subscription billing and preserves the existing remaining trial. The signed webhook retrieves current subscription state before updating access. A paid invoice, Checkout completion, or success redirect alone cannot unlock reporting. Invoice/Checkout reconciliation does not advance the subscription-event timestamp, so those independent event streams cannot suppress later subscription updates. + +### Tax activation + +Stripe Tax Settings already have an active head-office configuration and account default tax code. No tax registrations were recorded when inspected. The existing account-wide settings were preserved. Both new Prices have unspecified tax behavior and the Products inherit the existing account tax category; confirm that classification and tax-inclusive/exclusive treatment before collecting tax. Creating these resources does not establish tax compliance. + +Keep `STRIPE_AUTOMATIC_TAX = "false"` until the merchant confirms its actual registrations, product category and advertised price tax treatment. Recording a registration in Stripe does not register the business with a tax authority. Once confirmed, add the appropriate existing registrations, validate a sandbox tax calculation and its `taxability_reason`, then enable automatic tax in a separate reviewed deployment. No registration was invented or added by this setup. ## Existing-account activation and delayed rollout diff --git a/docs/stripe-integration-plan.md b/docs/stripe-integration-plan.md new file mode 100644 index 0000000..293208c --- /dev/null +++ b/docs/stripe-integration-plan.md @@ -0,0 +1,27 @@ +# Capve Stripe integration plan and setup receipt + +Prepared October 25, 2026 with the official Stripe plugin and Stripe MCP implementation planner. + +Merchant: **CodepushGo, LLC**, `acct_1UUFFLPth1byRSsb` (live). +Planner guide: `iguide_61VddRyaTF2bIbtbp41Pth1byRSsb`; accepted hosted subscription Checkout blueprint: `bp_fcb608d2a120`. + +## Implemented plan + +Use server-created hosted Checkout Sessions for fixed monthly subscriptions, flexible billing mode, and a dedicated customer portal for plan changes, payment methods, billing details and invoice downloads. Subscription Billing generates recurring invoices. Starter is USD 74.50/month (1M clicks per UTC month); Pro is USD 149.50/month (60M clicks). No metered charges or overage invoices. + +The implementation preserves verified owner authorization, D1 trial dates, idempotent Customer/Checkout creation, signed webhooks, current-subscription reconciliation, successor guards and continuous tracking while reporting is locked. Invoice and Checkout events reconcile current state without granting access based on those events alone or advancing the subscription-event watermark. Tax address and business ID collection are enabled in Checkout. Automatic tax remains a separate opt-in setting. + +## Live setup completed + +- Products `capve_starter` and `capve_pro` and their default monthly Prices are active. +- Dedicated Capve portal `bpc_1UUFeHPth1byRSsb0svtijZb` is active, with invoice history, billing/payment updates, prorated upgrades, period-end downgrades and period-end cancellation. This was the first portal configuration on the account, so Stripe also marked it as the default; Capve explicitly selects it by ID. +- Webhook `we_1UUFfqPth1byRSsblazybdxg` is created for the production URL and the installed SDK API version. It is disabled pending secrets and deployment. +- Resource IDs are recorded in [billing setup](billing-setup.md) and production config. Billing remains disabled. + +## Remaining activation requirements + +The MCP connection cannot issue a restricted runtime API key or install Worker secrets. A private runtime key and webhook signing secret must be installed in Cloudflare. Martin performs production deployment, as required by AGENTS.md. No payments, Customers, subscriptions, refunds or tax registrations were created, and no Worker was deployed by this setup. + +No active Tax registrations were recorded in Stripe. Existing merchant origin and tax defaults were preserved. Merchant confirmation of actual registrations, product category and price tax treatment is required before automatic tax activation. Sandbox coverage and an actual tax calculation are required before launch; mocked tests do not establish live tax collection. + +Configure Smart Retries, Stripe payment emails and operational alerts in the Dashboard. Bento remains responsible for Capve trial and reporting-access notices. Operators handle refund/dispute/fraud decisions in Stripe. See the full activation and verification checklist in [billing setup](billing-setup.md). diff --git a/tests/billing.test.ts b/tests/billing.test.ts index ade2e88..7c0b90a 100644 --- a/tests/billing.test.ts +++ b/tests/billing.test.ts @@ -128,8 +128,88 @@ describe("hosted billing", () => { expect((await billingStatus(stripeEnv, accountId)).reportsAllowed).toBe(false); }); - it("reuses an open Checkout on retry and preserves the remaining trial", async () => { - const configured = { ...billingEnv, STRIPE_SECRET_KEY: "sk_test_fake", STRIPE_PRICE_STARTER: "price_starter" }; + it("reconciles invoice recovery and failed payments without advancing the subscription event watermark", async () => { + await env.DB.prepare(`UPDATE account_billing SET stripe_customer_id = 'cus_invoice', + stripe_subscription_id = 'sub_invoice', stripe_event_created = 500, trial_ends_at = 1, + subscription_status = 'past_due' WHERE account_id = ?`).bind(accountId).run(); + const configured = { ...billingEnv, STRIPE_SECRET_KEY: "sk_test_fake", STRIPE_WEBHOOK_SECRET: "whsec_test", STRIPE_PRICE_PRO: "price_pro" }; + let subscription = { id: "sub_invoice", object: "subscription", customer: "cus_invoice", status: "active", trial_end: null, + items: { data: [{ price: { id: "price_pro" }, current_period_end: Math.floor(Date.now() / 1000) + 86400 }] } }; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => Response.json(subscription)); + const stripe = new Stripe("sk_test_fake"); + async function deliver(type: string, customer = "cus_invoice") { + const payload = JSON.stringify({ id: "evt_invoice", object: "event", created: 900, type, + data: { object: { id: "in_test", object: "invoice", customer, + parent: { type: "subscription_details", subscription_details: { subscription: "sub_invoice" } } } } }); + const signature = await stripe.webhooks.generateTestHeaderStringAsync({ payload, secret: "whsec_test", cryptoProvider: Stripe.createSubtleCryptoProvider() }); + return call("/api/billing/webhook", { method: "POST", headers: { "stripe-signature": signature }, body: payload }, configured); + } + expect((await deliver("invoice.paid")).status).toBe(200); + expect((await billingStatus(configured, accountId)).reportsAllowed).toBe(true); + expect((await env.DB.prepare("SELECT stripe_event_created FROM account_billing WHERE account_id = ?").bind(accountId) + .first<{ stripe_event_created: number }>())?.stripe_event_created).toBe(500); + subscription = { ...subscription, status: "past_due" }; + expect((await deliver("invoice.payment_failed")).status).toBe(200); + expect((await billingStatus(configured, accountId)).reportsAllowed).toBe(false); + // A delayed paid invoice cannot reactivate a currently past-due subscription. + expect((await deliver("invoice.paid")).status).toBe(200); + expect((await billingStatus(configured, accountId)).reportsAllowed).toBe(false); + subscription = { ...subscription, status: "active" }; + expect((await deliver("invoice.payment_action_required")).status).toBe(200); + expect((await deliver("invoice.finalization_failed")).status).toBe(200); + expect((await deliver("invoice.paid", "cus_other")).status).toBe(200); + subscription = { ...subscription, customer: "cus_other" }; + expect((await deliver("invoice.paid")).status).toBe(400); + subscription = { ...subscription, customer: "cus_invoice" }; + subscription = { ...subscription, items: { data: [{ ...subscription.items.data[0], price: { id: "price_unrelated" } }] } }; + expect((await deliver("invoice.paid")).status).toBe(200); + expect((await billingStatus(configured, accountId)).reportsAllowed).toBe(false); + }); + + it("retries an invoice reconciliation if a subscription webhook wins the race", async () => { + await env.DB.prepare(`UPDATE account_billing SET stripe_customer_id = 'cus_race', + stripe_subscription_id = 'sub_race', stripe_event_created = 500, trial_ends_at = 1 WHERE account_id = ?`).bind(accountId).run(); + const configured = { ...billingEnv, STRIPE_SECRET_KEY: "sk_test_fake", STRIPE_WEBHOOK_SECRET: "whsec_test", STRIPE_PRICE_PRO: "price_pro" }; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + await env.DB.prepare("UPDATE account_billing SET stripe_event_created = 600, subscription_status = 'canceled' WHERE account_id = ?").bind(accountId).run(); + return Response.json({ id: "sub_race", customer: "cus_race", status: "active", trial_end: null, + items: { data: [{ price: { id: "price_pro" }, current_period_end: Math.floor(Date.now() / 1000) + 86400 }] } }); + }); + const payload = JSON.stringify({ id: "evt_race", object: "event", created: 900, type: "invoice.paid", + data: { object: { id: "in_race", object: "invoice", customer: "cus_race", + parent: { subscription_details: { subscription: "sub_race" } } } } }); + const signature = await new Stripe("sk_test_fake").webhooks.generateTestHeaderStringAsync({ payload, secret: "whsec_test", cryptoProvider: Stripe.createSubtleCryptoProvider() }); + expect((await call("/api/billing/webhook", { method: "POST", headers: { "stripe-signature": signature }, body: payload }, configured)).status).toBe(503); + expect((await billingRow(configured, accountId)).subscription_status).toBe("canceled"); + expect((await billingStatus(configured, accountId)).reportsAllowed).toBe(false); + }); + + it("does not grant access from Checkout completion when the subscription is incomplete", async () => { + await env.DB.prepare("UPDATE account_billing SET stripe_customer_id = 'cus_checkout', trial_ends_at = 1 WHERE account_id = ?").bind(accountId).run(); + const configured = { ...billingEnv, STRIPE_SECRET_KEY: "sk_test_fake", STRIPE_WEBHOOK_SECRET: "whsec_test", STRIPE_PRICE_PRO: "price_pro" }; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => Response.json({ id: "sub_checkout", object: "subscription", + customer: "cus_checkout", status: "incomplete", trial_end: null, + items: { data: [{ price: { id: "price_pro" }, current_period_end: Math.floor(Date.now() / 1000) + 86400 }] } })); + for (const type of ["checkout.session.completed", "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed"]) { + const payload = JSON.stringify({ id: "evt_checkout", object: "event", created: 900, type, + data: { object: { id: "cs_test", object: "checkout.session", mode: "subscription", customer: "cus_checkout", subscription: "sub_checkout" } } }); + const signature = await new Stripe("sk_test_fake").webhooks.generateTestHeaderStringAsync({ payload, secret: "whsec_test", cryptoProvider: Stripe.createSubtleCryptoProvider() }); + expect((await call("/api/billing/webhook", { method: "POST", headers: { "stripe-signature": signature }, body: payload }, configured)).status).toBe(200); + expect((await billingStatus(configured, accountId)).reportsAllowed).toBe(false); + } + }); + + it("uses the dedicated portal configuration for invoice and subscription self-service", async () => { + await env.DB.prepare("UPDATE account_billing SET stripe_customer_id = 'cus_portal' WHERE account_id = ?").bind(accountId).run(); + const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ url: "https://billing.stripe.com/session" })); + const response = await call("/api/billing/portal", { method: "POST" }, { ...billingEnv, + STRIPE_SECRET_KEY: "sk_test_fake", STRIPE_PORTAL_CONFIGURATION: "bpc_capve" }); + expect(response.status).toBe(200); + expect(new URLSearchParams(String(fetch.mock.calls[0][1]?.body)).get("configuration")).toBe("bpc_capve"); + }); + + it.each(["false", "true"])("reuses Checkout, preserves the trial and honors automatic tax=%s", async (automaticTax) => { + const configured = { ...billingEnv, STRIPE_SECRET_KEY: "sk_test_fake", STRIPE_PRICE_STARTER: "price_starter", STRIPE_AUTOMATIC_TAX: automaticTax }; const checkoutBodies: string[] = []; vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { const url = String(input); @@ -144,6 +224,14 @@ describe("hosted billing", () => { expect(checkoutBodies).toHaveLength(1); expect(checkoutBodies[0]).toContain("trial_end"); expect(checkoutBodies[0]).toContain("price_starter"); + const params = new URLSearchParams(checkoutBodies[0]); + expect(params.get("billing_address_collection")).toBe("required"); + expect(params.get("customer_update[address]")).toBe("auto"); + expect(params.get("tax_id_collection[enabled]")).toBe("true"); + expect(params.get("automatic_tax[enabled]")).toBe(automaticTax); + expect(params.get("subscription_data[billing_mode][type]")).toBe("flexible"); + expect(params.get("integration_identifier")).toMatch(/^capve_checkout_[a-z]{8}$/); + expect(params.has("payment_method_types")).toBe(false); }); it("allows subscribing again after a completed Checkout's subscription was canceled", async () => { diff --git a/worker/routes/billing.ts b/worker/routes/billing.ts index 830af4d..6d5b4e8 100644 --- a/worker/routes/billing.ts +++ b/worker/routes/billing.ts @@ -92,9 +92,16 @@ billing.post("/checkout", async (c) => { checkout = await stripe.checkout.sessions.create({ customer, mode: "subscription", client_reference_id: session.account_id, line_items: [{ price: priceId, quantity: 1 }], + billing_address_collection: "required", + tax_id_collection: { enabled: true }, + customer_update: { address: "auto", name: "auto" }, + automatic_tax: { enabled: c.env.STRIPE_AUTOMATIC_TAX === "true" }, + integration_identifier: `capve_checkout_${Array.from(attempt.request_id.replaceAll("-", "").slice(0, 8), + (digit) => String.fromCharCode(97 + parseInt(digit, 16))).join("")}`, success_url: `${c.env.APP_URL}/app?entity=billing&checkout=success`, cancel_url: `${c.env.APP_URL}/app?entity=billing`, subscription_data: { + billing_mode: { type: "flexible" }, metadata: { capve_account_id: session.account_id }, ...(attempt.trial_end ? { trial_end: attempt.trial_end } : {}), }, @@ -120,6 +127,7 @@ billing.post("/portal", async (c) => { const row = await billingRow(c.env, session.account_id); if (!row.stripe_customer_id) return c.json({ error: "No billing account yet" }, 400); const portal = await stripeClient(c.env).billingPortal.sessions.create({ + ...(c.env.STRIPE_PORTAL_CONFIGURATION ? { configuration: c.env.STRIPE_PORTAL_CONFIGURATION } : {}), customer: row.stripe_customer_id, return_url: `${c.env.APP_URL}/app?entity=billing`, }); return c.json({ url: portal.url }); @@ -138,32 +146,75 @@ billing.post("/webhook", async (c) => { } catch { return c.json({ error: "Invalid Stripe signature" }, 400); } - if (!["customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted"].includes(event.type)) - return c.json({ received: true }); + let subscriptionId: string | null = null; + let eventCustomerId: string | null = null; const object = event.data.object; - if (!("object" in object) || object.object !== "subscription") return c.json({ error: "Expected subscription" }, 400); - // Retrieve current state so delayed events cannot reactivate a canceled subscription. - const sub = await stripe.subscriptions.retrieve(object.id); - const customerId = typeof sub.customer === "string" ? sub.customer : sub.customer.id; + const idOf = (value: string | { id: string } | null): string | null => + typeof value === "string" ? value : value?.id ?? null; + switch (event.type) { + case "customer.subscription.created": + case "customer.subscription.updated": + case "customer.subscription.deleted": { + const subscription = object as Stripe.Subscription; + subscriptionId = subscription.id; + eventCustomerId = idOf(subscription.customer); + break; + } + case "checkout.session.completed": + case "checkout.session.async_payment_succeeded": + case "checkout.session.async_payment_failed": { + const checkout = object as Stripe.Checkout.Session; + if (checkout.mode !== "subscription") return c.json({ received: true }); + subscriptionId = idOf(checkout.subscription); + eventCustomerId = idOf(checkout.customer); + break; + } + case "invoice.paid": + case "invoice.payment_failed": + case "invoice.payment_action_required": + case "invoice.finalization_failed": { + const invoice = object as Stripe.Invoice; + subscriptionId = idOf(invoice.parent?.subscription_details?.subscription ?? null); + eventCustomerId = idOf(invoice.customer); + break; + } + default: + return c.json({ received: true }); + } + if (!subscriptionId || !eventCustomerId) return c.json({ received: true }); + const known = await c.env.DB.prepare("SELECT account_id, stripe_subscription_id, subscription_status, stripe_event_created FROM account_billing WHERE stripe_customer_id = ?") + .bind(eventCustomerId).first<{ account_id: string; stripe_subscription_id: string | null; subscription_status: string | null; stripe_event_created: number }>(); + if (!known) return c.json({ received: true }); + // Retrieve current state: a paid invoice or Checkout completion alone never grants access. + const sub = await stripe.subscriptions.retrieve(subscriptionId); + if (idOf(sub.customer) !== eventCustomerId) return c.json({ error: "Subscription customer mismatch" }, 400); const item = sub.items.data[0]; const plan = item?.price.id === c.env.STRIPE_PRICE_STARTER ? "starter" : item?.price.id === c.env.STRIPE_PRICE_PRO ? "pro" : null; - const known = await c.env.DB.prepare("SELECT account_id, stripe_subscription_id, subscription_status FROM account_billing WHERE stripe_customer_id = ?") - .bind(customerId).first<{ account_id: string; stripe_subscription_id: string | null; subscription_status: string | null }>(); - if (!known) return c.json({ received: true }); // Never let events for an old subscription replace its successor. if (known.stripe_subscription_id && known.stripe_subscription_id !== sub.id && !["canceled", "incomplete_expired"].includes(known.subscription_status ?? "")) return c.json({ received: true }); const endsAt = sub.status === "trialing" ? sub.trial_end : item?.current_period_end; - await c.env.DB.prepare(`UPDATE account_billing SET + const subscriptionEvent = event.type.startsWith("customer.subscription."); + const updated = await c.env.DB.prepare(`UPDATE account_billing SET plan = COALESCE(?, plan), stripe_subscription_id = ?, subscription_status = ?, - subscription_ends_at = ?, stripe_event_created = ?, + subscription_ends_at = ?, stripe_event_created = MAX(stripe_event_created, ?), trial_ends_at = CASE WHEN ? IS NOT NULL AND ? > trial_ends_at THEN ? ELSE trial_ends_at END - WHERE account_id = ? AND stripe_event_created <= ?`) + WHERE account_id = ? AND stripe_event_created <= ? + AND (? = 1 OR stripe_event_created = ?) + AND COALESCE(stripe_subscription_id, '') = COALESCE(?, '')`) .bind(plan, sub.id, plan ? sub.status : "unrecognized_price", endsAt ? endsAt * 1000 : null, - event.created, sub.trial_end, (sub.trial_end ?? 0) * 1000, (sub.trial_end ?? 0) * 1000, - known.account_id, event.created).run(); + subscriptionEvent ? event.created : 0, sub.trial_end, (sub.trial_end ?? 0) * 1000, (sub.trial_end ?? 0) * 1000, + known.account_id, subscriptionEvent ? event.created : known.stripe_event_created, + subscriptionEvent ? 1 : 0, known.stripe_event_created, known.stripe_subscription_id).run(); + // Retry independent-stream reconciliation if a subscription event won the race. + if (!updated.meta.changes && !subscriptionEvent) return c.json({ error: "Subscription changed; retry event" }, 503); + if (["invoice.payment_failed", "invoice.payment_action_required", "invoice.finalization_failed"].includes(event.type)) { + // Stripe owns payment recovery; log references only, never invoice details or payment URLs. + console.warn("Capve billing invoice requires attention", { eventId: event.id, type: event.type, + accountId: known.account_id, subscriptionId: sub.id }); + } return c.json({ received: true }); }); diff --git a/worker/types.ts b/worker/types.ts index 099128a..bb71141 100644 --- a/worker/types.ts +++ b/worker/types.ts @@ -15,6 +15,8 @@ export type Env = { STRIPE_WEBHOOK_SECRET?: string; STRIPE_PRICE_STARTER?: string; STRIPE_PRICE_PRO?: string; + STRIPE_PORTAL_CONFIGURATION?: string; + STRIPE_AUTOMATIC_TAX?: string; BENTO_SITE_UUID?: string; BENTO_PUBLISHABLE_KEY?: string; BENTO_SECRET_KEY?: string; diff --git a/wrangler.toml b/wrangler.toml index 4cd651f..0e25296 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -51,7 +51,7 @@ PLATFORM_ADMIN_EMAILS = "martindonadieu@gmail.com" # Keep worker name "velo" (wrangler defaults to velo-production otherwise). name = "velo" # AUTH_SECRET is a secret: bunx wrangler secret put AUTH_SECRET --env production -vars = { APP_URL = "https://capve.app", EMAIL_FROM = "noreply@capve.app", PLATFORM_ADMIN_EMAILS = "martindonadieu@gmail.com" } +vars = { APP_URL = "https://capve.app", EMAIL_FROM = "noreply@capve.app", PLATFORM_ADMIN_EMAILS = "martindonadieu@gmail.com", BILLING_ENABLED = "false", STRIPE_AUTOMATIC_TAX = "false", STRIPE_PRICE_STARTER = "price_1UUFc0Pth1byRSsbw73wF7yf", STRIPE_PRICE_PRO = "price_1UUFcCPth1byRSsbOwrw6lvS", STRIPE_PORTAL_CONFIGURATION = "bpc_1UUFeHPth1byRSsb0svtijZb" } [[env.production.send_email]] name = "EMAIL" From bf939d542cbbb6395b06ed192432c91878909721 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Sun, 25 Oct 2026 01:04:11 +0000 Subject: [PATCH 2/3] fix(billing): retry subscription events that lose an update race --- tests/billing.test.ts | 13 +++++++------ worker/routes/billing.ts | 5 +++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/billing.test.ts b/tests/billing.test.ts index 7c0b90a..91062ee 100644 --- a/tests/billing.test.ts +++ b/tests/billing.test.ts @@ -166,18 +166,19 @@ describe("hosted billing", () => { expect((await billingStatus(configured, accountId)).reportsAllowed).toBe(false); }); - it("retries an invoice reconciliation if a subscription webhook wins the race", async () => { + it.each(["invoice.paid", "customer.subscription.updated"])("retries %s if another webhook wins the subscription-ID race", async (type) => { await env.DB.prepare(`UPDATE account_billing SET stripe_customer_id = 'cus_race', - stripe_subscription_id = 'sub_race', stripe_event_created = 500, trial_ends_at = 1 WHERE account_id = ?`).bind(accountId).run(); + stripe_subscription_id = NULL, stripe_event_created = 500, trial_ends_at = 1 WHERE account_id = ?`).bind(accountId).run(); const configured = { ...billingEnv, STRIPE_SECRET_KEY: "sk_test_fake", STRIPE_WEBHOOK_SECRET: "whsec_test", STRIPE_PRICE_PRO: "price_pro" }; vi.spyOn(globalThis, "fetch").mockImplementation(async () => { - await env.DB.prepare("UPDATE account_billing SET stripe_event_created = 600, subscription_status = 'canceled' WHERE account_id = ?").bind(accountId).run(); + await env.DB.prepare("UPDATE account_billing SET stripe_event_created = 600, stripe_subscription_id = 'sub_race', subscription_status = 'canceled' WHERE account_id = ?").bind(accountId).run(); return Response.json({ id: "sub_race", customer: "cus_race", status: "active", trial_end: null, items: { data: [{ price: { id: "price_pro" }, current_period_end: Math.floor(Date.now() / 1000) + 86400 }] } }); }); - const payload = JSON.stringify({ id: "evt_race", object: "event", created: 900, type: "invoice.paid", - data: { object: { id: "in_race", object: "invoice", customer: "cus_race", - parent: { subscription_details: { subscription: "sub_race" } } } } }); + const payload = JSON.stringify({ id: "evt_race", object: "event", created: 900, type, + data: { object: type === "invoice.paid" ? { id: "in_race", object: "invoice", customer: "cus_race", + parent: { subscription_details: { subscription: "sub_race" } } } : + { id: "sub_race", object: "subscription", customer: "cus_race" } } }); const signature = await new Stripe("sk_test_fake").webhooks.generateTestHeaderStringAsync({ payload, secret: "whsec_test", cryptoProvider: Stripe.createSubtleCryptoProvider() }); expect((await call("/api/billing/webhook", { method: "POST", headers: { "stripe-signature": signature }, body: payload }, configured)).status).toBe(503); expect((await billingRow(configured, accountId)).subscription_status).toBe("canceled"); diff --git a/worker/routes/billing.ts b/worker/routes/billing.ts index 6d5b4e8..d087133 100644 --- a/worker/routes/billing.ts +++ b/worker/routes/billing.ts @@ -208,8 +208,9 @@ billing.post("/webhook", async (c) => { subscriptionEvent ? event.created : 0, sub.trial_end, (sub.trial_end ?? 0) * 1000, (sub.trial_end ?? 0) * 1000, known.account_id, subscriptionEvent ? event.created : known.stripe_event_created, subscriptionEvent ? 1 : 0, known.stripe_event_created, known.stripe_subscription_id).run(); - // Retry independent-stream reconciliation if a subscription event won the race. - if (!updated.meta.changes && !subscriptionEvent) return c.json({ error: "Subscription changed; retry event" }, 503); + // Retry a lost race, but acknowledge a genuinely stale subscription event. + if (!updated.meta.changes && (!subscriptionEvent || known.stripe_event_created <= event.created)) + return c.json({ error: "Subscription changed; retry event" }, 503); if (["invoice.payment_failed", "invoice.payment_action_required", "invoice.finalization_failed"].includes(event.type)) { // Stripe owns payment recovery; log references only, never invoice details or payment URLs. console.warn("Capve billing invoice requires attention", { eventId: event.id, type: event.type, From 0971636c485e88804d76f253a1c9e7396ad8353a Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Sun, 25 Oct 2026 01:09:19 +0000 Subject: [PATCH 3/3] fix(billing): serialize concurrent webhook reconciliation writes --- docs/billing-setup.md | 2 +- .../0028_billing_reconciliation_revision.sql | 3 ++ tests/billing.test.ts | 38 +++++++++++++++++++ tests/schema.ts | 3 +- worker/routes/billing.ts | 10 +++-- 5 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 migrations/0028_billing_reconciliation_revision.sql diff --git a/docs/billing-setup.md b/docs/billing-setup.md index 89075c7..8e5a72b 100644 --- a/docs/billing-setup.md +++ b/docs/billing-setup.md @@ -36,7 +36,7 @@ Production Price IDs and portal configuration are committed in `wrangler.toml`. The Capve portal allows billing address/name/email/tax-ID updates, payment method changes, invoice history/downloads, and switching only between Capve's two prices. Upgrades are prorated and invoiced immediately; downgrades and cancellations take effect at period end. Plan changes during a trial preserve the trial. Subscription Billing generates invoices automatically; do not create a second invoice for a subscription charge. -Checkout collects a billing address and business tax ID, saves the Customer's address/name, uses flexible subscription billing and preserves the existing remaining trial. The signed webhook retrieves current subscription state before updating access. A paid invoice, Checkout completion, or success redirect alone cannot unlock reporting. Invoice/Checkout reconciliation does not advance the subscription-event timestamp, so those independent event streams cannot suppress later subscription updates. +Checkout collects a billing address and business tax ID, saves the Customer's address/name, uses flexible subscription billing and preserves the existing remaining trial. The signed webhook retrieves current subscription state before updating access. A paid invoice, Checkout completion, or success redirect alone cannot unlock reporting. Invoice/Checkout reconciliation does not advance the subscription-event timestamp, so those independent event streams cannot suppress later subscription updates. Migration 0028 adds a separate reconciliation revision: every successful webhook update increments it, and handlers retry if another handler has changed the revision since they read the account. This also prevents invoice/Checkout snapshots from overwriting one another. Apply the migration before deploying the updated handler. ### Tax activation diff --git a/migrations/0028_billing_reconciliation_revision.sql b/migrations/0028_billing_reconciliation_revision.sql new file mode 100644 index 0000000..47961e7 --- /dev/null +++ b/migrations/0028_billing_reconciliation_revision.sql @@ -0,0 +1,3 @@ +-- Serialize all subscription, invoice and Checkout reconciliation snapshots. +-- Keep this revision independent of Stripe's subscription-event timestamp. +ALTER TABLE account_billing ADD COLUMN reconciliation_revision INTEGER NOT NULL DEFAULT 0; diff --git a/tests/billing.test.ts b/tests/billing.test.ts index 91062ee..802bd01 100644 --- a/tests/billing.test.ts +++ b/tests/billing.test.ts @@ -185,6 +185,44 @@ describe("hosted billing", () => { expect((await billingStatus(configured, accountId)).reportsAllowed).toBe(false); }); + it.each(["invoice.paid", "checkout.session.completed"].flatMap((type) => [ + { type, older: "active", latest: "past_due" }, + { type, older: "past_due", latest: "active" }, + ]))("retries overlapping $type snapshots from $older to $latest", async ({ type, older, latest }) => { + await env.DB.prepare(`UPDATE account_billing SET stripe_customer_id = 'cus_overlap', + stripe_subscription_id = 'sub_overlap', stripe_event_created = 500, trial_ends_at = 1, + reconciliation_revision = 0 WHERE account_id = ?`).bind(accountId).run(); + const configured = { ...billingEnv, STRIPE_SECRET_KEY: "sk_test_fake", STRIPE_WEBHOOK_SECRET: "whsec_test", STRIPE_PRICE_PRO: "price_pro" }; + const stripe = new Stripe("sk_test_fake"); + async function deliver(eventType: string, eventId: string) { + const object = eventType.startsWith("invoice.") ? { id: "in_overlap", object: "invoice", customer: "cus_overlap", + parent: { subscription_details: { subscription: "sub_overlap" } } } : + { id: "cs_overlap", object: "checkout.session", mode: "subscription", customer: "cus_overlap", subscription: "sub_overlap" }; + const payload = JSON.stringify({ id: eventId, object: "event", created: 900, type: eventType, data: { object } }); + const signature = await stripe.webhooks.generateTestHeaderStringAsync({ payload, secret: "whsec_test", cryptoProvider: Stripe.createSubtleCryptoProvider() }); + return call("/api/billing/webhook", { method: "POST", headers: { "stripe-signature": signature }, body: payload }, configured); + } + let retrievals = 0; + let competingResponse: Response | undefined; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + const first = ++retrievals === 1; + // A second handler commits its newer snapshot while the first retrieval is in flight. + if (first) competingResponse = await deliver("invoice.paid", "evt_newer_snapshot"); + return Response.json({ id: "sub_overlap", object: "subscription", customer: "cus_overlap", status: first ? older : latest, + trial_end: null, items: { data: [{ price: { id: "price_pro" }, current_period_end: Math.floor(Date.now() / 1000) + 86400 }] } }); + }); + expect((await deliver(type, "evt_older_snapshot")).status).toBe(503); + expect(competingResponse?.status).toBe(200); + expect((await billingRow(configured, accountId)).subscription_status).toBe(latest); + expect((await billingStatus(configured, accountId)).reportsAllowed).toBe(latest === "active"); + expect(await env.DB.prepare("SELECT stripe_event_created, reconciliation_revision FROM account_billing WHERE account_id = ?") + .bind(accountId).first()).toEqual({ stripe_event_created: 500, reconciliation_revision: 1 }); + expect((await deliver(type, "evt_older_snapshot")).status).toBe(200); + expect((await billingStatus(configured, accountId)).reportsAllowed).toBe(latest === "active"); + expect(await env.DB.prepare("SELECT stripe_event_created, reconciliation_revision FROM account_billing WHERE account_id = ?") + .bind(accountId).first()).toEqual({ stripe_event_created: 500, reconciliation_revision: 2 }); + }); + it("does not grant access from Checkout completion when the subscription is incomplete", async () => { await env.DB.prepare("UPDATE account_billing SET stripe_customer_id = 'cus_checkout', trial_ends_at = 1 WHERE account_id = ?").bind(accountId).run(); const configured = { ...billingEnv, STRIPE_SECRET_KEY: "sk_test_fake", STRIPE_WEBHOOK_SECRET: "whsec_test", STRIPE_PRICE_PRO: "price_pro" }; diff --git a/tests/schema.ts b/tests/schema.ts index 3d25a43..24d44cd 100644 --- a/tests/schema.ts +++ b/tests/schema.ts @@ -29,7 +29,8 @@ CREATE TABLE account_billing ( stripe_subscription_id TEXT UNIQUE, subscription_status TEXT, subscription_ends_at INTEGER, - stripe_event_created INTEGER NOT NULL DEFAULT 0 + stripe_event_created INTEGER NOT NULL DEFAULT 0, + reconciliation_revision INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE billing_usage ( account_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/worker/routes/billing.ts b/worker/routes/billing.ts index d087133..16e04d2 100644 --- a/worker/routes/billing.ts +++ b/worker/routes/billing.ts @@ -182,8 +182,8 @@ billing.post("/webhook", async (c) => { return c.json({ received: true }); } if (!subscriptionId || !eventCustomerId) return c.json({ received: true }); - const known = await c.env.DB.prepare("SELECT account_id, stripe_subscription_id, subscription_status, stripe_event_created FROM account_billing WHERE stripe_customer_id = ?") - .bind(eventCustomerId).first<{ account_id: string; stripe_subscription_id: string | null; subscription_status: string | null; stripe_event_created: number }>(); + const known = await c.env.DB.prepare("SELECT account_id, stripe_subscription_id, subscription_status, stripe_event_created, reconciliation_revision FROM account_billing WHERE stripe_customer_id = ?") + .bind(eventCustomerId).first<{ account_id: string; stripe_subscription_id: string | null; subscription_status: string | null; stripe_event_created: number; reconciliation_revision: number }>(); if (!known) return c.json({ received: true }); // Retrieve current state: a paid invoice or Checkout completion alone never grants access. const sub = await stripe.subscriptions.retrieve(subscriptionId); @@ -200,14 +200,16 @@ billing.post("/webhook", async (c) => { const updated = await c.env.DB.prepare(`UPDATE account_billing SET plan = COALESCE(?, plan), stripe_subscription_id = ?, subscription_status = ?, subscription_ends_at = ?, stripe_event_created = MAX(stripe_event_created, ?), + reconciliation_revision = reconciliation_revision + 1, trial_ends_at = CASE WHEN ? IS NOT NULL AND ? > trial_ends_at THEN ? ELSE trial_ends_at END WHERE account_id = ? AND stripe_event_created <= ? AND (? = 1 OR stripe_event_created = ?) - AND COALESCE(stripe_subscription_id, '') = COALESCE(?, '')`) + AND COALESCE(stripe_subscription_id, '') = COALESCE(?, '') + AND reconciliation_revision = ?`) .bind(plan, sub.id, plan ? sub.status : "unrecognized_price", endsAt ? endsAt * 1000 : null, subscriptionEvent ? event.created : 0, sub.trial_end, (sub.trial_end ?? 0) * 1000, (sub.trial_end ?? 0) * 1000, known.account_id, subscriptionEvent ? event.created : known.stripe_event_created, - subscriptionEvent ? 1 : 0, known.stripe_event_created, known.stripe_subscription_id).run(); + subscriptionEvent ? 1 : 0, known.stripe_event_created, known.stripe_subscription_id, known.reconciliation_revision).run(); // Retry a lost race, but acknowledge a genuinely stale subscription event. if (!updated.meta.changes && (!subscriptionEvent || known.stripe_event_created <= event.created)) return c.json({ error: "Subscription changed; retry event" }, 503);