create-ailk 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -14
- package/component-catalog.json +149 -5
- package/component-catalog.md +7 -6
- package/dist/add-module.js +17 -10
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +301 -13
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +7 -5
- package/dist/derive-architecture.js +26 -32
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +0 -4
- package/dist/lib/npm-auth.d.ts +9 -0
- package/dist/lib/npm-auth.js +48 -0
- package/dist/lib/scaffold-render-sweep.d.ts +36 -2
- package/dist/lib/scaffold-render-sweep.js +50 -8
- package/dist/module-architecture.js +115 -10
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/spoke-checkout.d.ts +38 -35
- package/dist/spoke-checkout.js +66 -229
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +33 -12
- package/dist/sync-routes.d.ts +68 -5
- package/dist/sync-routes.js +455 -29
- package/dist/template-record.d.ts +1 -1
- package/dist/template-record.js +3 -2
- package/dist/update.js +10 -0
- package/package.json +3 -3
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
- package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/templates/.env.example +13 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/CLAUDE.md +4 -2
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +34 -0
- package/templates/apps/api/CLAUDE.md +94 -19
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
- package/templates/apps/api/src/config/index.ts +109 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -1
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/site.ts +4 -1
- package/templates/apps/api/src/lib/stripe.ts +77 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
- package/templates/apps/api/src/openapi/spec.ts +343 -7
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1426 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
- package/templates/apps/api/src/routes/checkout/session.ts +57 -25
- package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
- package/templates/apps/api/src/routes/consent/account.ts +4 -2
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
- package/templates/apps/api/src/routes/content/get-page.ts +34 -119
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
- package/templates/apps/api/src/routes/content/offerings.ts +184 -0
- package/templates/apps/api/src/routes/content/pricing.ts +176 -0
- package/templates/apps/api/src/routes/content/search.ts +16 -45
- package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/index.ts +377 -308
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +6 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
- package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +93 -17
- package/templates/apps/api/src/server.ts +165 -51
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -2
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
- package/templates/apps/api/src/services/__tests__/person.test.ts +260 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
- package/templates/apps/api/src/services/abandon-email.ts +28 -5
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +113 -58
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +348 -0
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +975 -0
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +400 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1083 -0
- package/templates/apps/api/src/services/offerings/index.ts +614 -0
- package/templates/apps/api/src/services/offerings/source.ts +140 -0
- package/templates/apps/api/src/services/offerings/store.ts +150 -0
- package/templates/apps/api/src/services/person.ts +147 -9
- package/templates/apps/api/src/services/plan-offerings.ts +96 -30
- package/templates/apps/api/src/services/project-listings.ts +43 -31
- package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
- package/templates/apps/mcp/CLAUDE.md +32 -18
- package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +95 -14
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
- package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
- package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
- package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
- package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
- package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
- package/templates/apps/mcp/__tests__/server.test.ts +13 -11
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/package.json +1 -0
- package/templates/apps/mcp/src/abuse/index.ts +18 -11
- package/templates/apps/mcp/src/http-transport.ts +47 -9
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/lib/site-config.ts +53 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
- package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
- package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
- package/templates/apps/mcp/src/tools/index.ts +22 -21
- package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
- package/templates/apps/mcp/src/tools/purchase.ts +30 -2
- package/templates/apps/mcp/src/tools/read_content.ts +46 -40
- package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
- package/templates/apps/mcp/src/tools/search_content.ts +20 -24
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +10 -31
- package/templates/apps/web/CLAUDE.md +324 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +20 -6
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +369 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
- package/templates/apps/web/app/[locale]/layout.tsx +55 -4
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
- package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
- package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +64 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +9 -20
- package/templates/apps/web/lib/deck-page.ts +1 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- package/templates/apps/web/lib/in-page-magnet.ts +416 -0
- package/templates/apps/web/lib/magnet-routes.ts +44 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +205 -0
- package/templates/apps/web/lib/offerings-pricing.ts +326 -0
- package/templates/apps/web/lib/posting-title.ts +17 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
- package/templates/apps/web/messages/en.json +49 -15
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/security-headers.config.ts +17 -0
- package/templates/content/en/BlogList/blog.mdx +4 -71
- package/templates/content/en/BookCall/book-a-call.mdx +1 -41
- package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
- package/templates/content/en/BookCall/onsite-service.mdx +1 -17
- package/templates/content/en/BookCall/start-project.mdx +1 -17
- package/templates/content/en/ContactPage/contact.mdx +6 -63
- package/templates/content/en/HomePage/home.mdx +3 -21
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
- package/templates/database/__tests__/offerings-migration.test.ts +197 -0
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
- package/templates/database/__tests__/offerings.db.test.ts +303 -0
- package/templates/database/__tests__/seed.test.ts +63 -1
- package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
- package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +57 -2
- package/templates/tools/lint-architecture.ts +205 -1
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/api/src/routes/content/slug.ts +0 -9
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
- package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
- package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/checkout.ts +0 -93
- package/templates/apps/web/lib/meeting-slugs.ts +0 -63
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
* POST /api/webhooks/stripe — org-scoped billing path (#3601, spec AC2/AC4)
|
|
3
3
|
*
|
|
4
4
|
* Covers the Organization billing-link branch keyed on
|
|
5
|
-
* metadata.organizationId (spec D2)
|
|
6
|
-
* Organization (real org AND
|
|
7
|
-
*
|
|
5
|
+
* metadata.organizationId (spec D2), proof-based since #9067 D13 (AC-7): a
|
|
6
|
+
* checkout with organizationId links the Organization (real org AND
|
|
7
|
+
* individual-as-default-org) only when the row recorded that session and has
|
|
8
|
+
* no customer, idempotently; an unrecorded session, a bound customer and any
|
|
9
|
+
* customer.created write nothing and log `org_bind_unproven`. A checkout
|
|
10
|
+
* WITHOUT organizationId takes the User path and never touches the
|
|
8
11
|
* Organization (single-tenant, degrade-clean regression floor).
|
|
9
12
|
*
|
|
10
13
|
* Companion to stripe.test.ts (the User-path tests, which stay unmodified —
|
|
11
14
|
* AC4 regression floor).
|
|
12
15
|
*/
|
|
16
|
+
import { Writable } from "node:stream";
|
|
17
|
+
|
|
13
18
|
import type { FastifyInstance } from "fastify";
|
|
14
19
|
import Fastify from "fastify";
|
|
15
20
|
|
|
@@ -70,8 +75,17 @@ const REAL_ORG_ID = "org_00000000-0000-7000-8000-0000000000ab";
|
|
|
70
75
|
const VALID_BODY = Buffer.from(JSON.stringify({ type: "test" }));
|
|
71
76
|
const VALID_SIGNATURE = "t=1234,v1=abc";
|
|
72
77
|
|
|
78
|
+
let logLines: string[] = [];
|
|
79
|
+
|
|
73
80
|
async function buildTestServer(): Promise<FastifyInstance> {
|
|
74
|
-
|
|
81
|
+
logLines = [];
|
|
82
|
+
const stream = new Writable({
|
|
83
|
+
write(chunk, _encoding, done) {
|
|
84
|
+
logLines.push(String(chunk));
|
|
85
|
+
done();
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
const server = Fastify({ logger: { level: "info", stream } });
|
|
75
89
|
await server.register(async (scope) => {
|
|
76
90
|
scope.addContentTypeParser(
|
|
77
91
|
"application/json",
|
|
@@ -88,13 +102,14 @@ function checkoutEvent(
|
|
|
88
102
|
id: string,
|
|
89
103
|
metadata: Record<string, string> | null,
|
|
90
104
|
customer = "cus_test_org_123",
|
|
105
|
+
sessionId = "cs_test_org",
|
|
91
106
|
) {
|
|
92
107
|
return {
|
|
93
108
|
id,
|
|
94
109
|
type: "checkout.session.completed",
|
|
95
110
|
data: {
|
|
96
111
|
object: {
|
|
97
|
-
id:
|
|
112
|
+
id: sessionId,
|
|
98
113
|
amount_total: 9900,
|
|
99
114
|
currency: "usd",
|
|
100
115
|
customer,
|
|
@@ -117,7 +132,7 @@ async function fire(server: FastifyInstance) {
|
|
|
117
132
|
});
|
|
118
133
|
}
|
|
119
134
|
|
|
120
|
-
describe("POST /api/webhooks/stripe — org-scoped billing (#3601)", () => {
|
|
135
|
+
describe("POST /api/webhooks/stripe — org-scoped billing (#3601, proof-based since #9067 D13)", () => {
|
|
121
136
|
let server: FastifyInstance;
|
|
122
137
|
|
|
123
138
|
beforeEach(async () => {
|
|
@@ -127,6 +142,8 @@ describe("POST /api/webhooks/stripe — org-scoped billing (#3601)", () => {
|
|
|
127
142
|
...configModule.config,
|
|
128
143
|
STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
|
|
129
144
|
});
|
|
145
|
+
(prisma.organization.findFirst as jest.Mock).mockResolvedValue(null);
|
|
146
|
+
(prisma.organization.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
|
130
147
|
server = await buildTestServer();
|
|
131
148
|
});
|
|
132
149
|
|
|
@@ -135,85 +152,85 @@ describe("POST /api/webhooks/stripe — org-scoped billing (#3601)", () => {
|
|
|
135
152
|
jest.restoreAllMocks();
|
|
136
153
|
});
|
|
137
154
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
155
|
+
/** The arm's `org_bind_unproven` lines, each with no customer or Organization id. */
|
|
156
|
+
function unprovenLines(...ids: string[]): string[] {
|
|
157
|
+
const lines = logLines.filter((line) => line.includes("org_bind_unproven"));
|
|
158
|
+
for (const line of lines) for (const id of ids) expect(line).not.toContain(id);
|
|
159
|
+
return lines;
|
|
160
|
+
}
|
|
141
161
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
});
|
|
162
|
+
function boundWrite(organizationId: string, sessionId: string, customerId: string) {
|
|
163
|
+
return {
|
|
164
|
+
where: { id: organizationId, planCheckoutSessionId: sessionId, billingCustomerId: null },
|
|
165
|
+
data: { billingProvider: "stripe", billingCustomerId: customerId, planCheckoutSessionId: null },
|
|
166
|
+
};
|
|
167
|
+
}
|
|
149
168
|
|
|
169
|
+
// AC-7 — the recorded session binds into an empty column, clearing the record.
|
|
170
|
+
it("binds the Organization only to the plan session it recorded, idempotent on replay", async () => {
|
|
150
171
|
mockVerifyWebhookSignature.mockReturnValue(
|
|
151
|
-
checkoutEvent("evt_org_001", { organizationId:
|
|
172
|
+
checkoutEvent("evt_org_001", { organizationId: "org_1" }, "cus_1", "cs_1"),
|
|
152
173
|
);
|
|
153
174
|
const r1 = await fire(server);
|
|
154
175
|
expect(r1.statusCode).toBe(200);
|
|
155
|
-
expect(prisma.organization.
|
|
156
|
-
expect(prisma.organization.
|
|
157
|
-
|
|
158
|
-
where: { id: REAL_ORG_ID },
|
|
159
|
-
data: expect.objectContaining({
|
|
160
|
-
billingProvider: "stripe",
|
|
161
|
-
billingCustomerId: customerId,
|
|
162
|
-
}),
|
|
163
|
-
}),
|
|
164
|
-
);
|
|
176
|
+
expect(prisma.organization.updateMany).toHaveBeenCalledTimes(1);
|
|
177
|
+
expect(prisma.organization.updateMany).toHaveBeenCalledWith(boundWrite("org_1", "cs_1", "cus_1"));
|
|
178
|
+
expect(prisma.organization.update).not.toHaveBeenCalled();
|
|
165
179
|
// The User path is never taken for an org checkout.
|
|
166
180
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
181
|
+
expect(unprovenLines()).toHaveLength(0);
|
|
167
182
|
|
|
168
|
-
// Replay the SAME event id →
|
|
169
|
-
mockVerifyWebhookSignature.mockReturnValue(
|
|
170
|
-
checkoutEvent("evt_org_001", { organizationId: REAL_ORG_ID }, customerId),
|
|
171
|
-
);
|
|
183
|
+
// Replay the SAME event id → the duplicate check stops it before the arm.
|
|
172
184
|
const r2 = await fire(server);
|
|
173
185
|
expect(r2.statusCode).toBe(200);
|
|
174
|
-
expect(prisma.organization.
|
|
186
|
+
expect(prisma.organization.updateMany).toHaveBeenCalledTimes(1);
|
|
175
187
|
});
|
|
176
188
|
|
|
177
|
-
//
|
|
178
|
-
//
|
|
189
|
+
// AC-7 — a second delivery of the same session under a new event id, after
|
|
190
|
+
// binding, is the idempotent no-op (typed-composite match).
|
|
179
191
|
it("is a no-op when the org is already linked to this customer (typed-composite match)", async () => {
|
|
180
|
-
const customerId = "cus_already_linked";
|
|
181
192
|
(prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce({
|
|
182
|
-
id:
|
|
193
|
+
id: "org_1",
|
|
183
194
|
billingProvider: "stripe",
|
|
184
|
-
billingCustomerId:
|
|
195
|
+
billingCustomerId: "cus_1",
|
|
185
196
|
});
|
|
197
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
198
|
+
checkoutEvent("evt_org_002", { organizationId: "org_1" }, "cus_1", "cs_1"),
|
|
199
|
+
);
|
|
200
|
+
const r = await fire(server);
|
|
201
|
+
expect(r.statusCode).toBe(200);
|
|
202
|
+
expect(prisma.organization.updateMany).not.toHaveBeenCalled();
|
|
203
|
+
expect(prisma.organization.update).not.toHaveBeenCalled();
|
|
204
|
+
expect(logLines.some((line) => line.includes("idempotent no-op"))).toBe(true);
|
|
205
|
+
});
|
|
186
206
|
|
|
207
|
+
// AC-7 — the row holds another session id or none, or already holds a
|
|
208
|
+
// customer: the guarded write moves no row, so nothing binds.
|
|
209
|
+
it.each([
|
|
210
|
+
["holds another session id or none", { organizationId: "org_1" }],
|
|
211
|
+
["already holds cus_other", { organizationId: "org_1" }],
|
|
212
|
+
])("writes nothing and logs org_bind_unproven when the row %s", async (_label, metadata) => {
|
|
213
|
+
(prisma.organization.updateMany as jest.Mock).mockResolvedValueOnce({ count: 0 });
|
|
187
214
|
mockVerifyWebhookSignature.mockReturnValue(
|
|
188
|
-
checkoutEvent("
|
|
215
|
+
checkoutEvent("evt_org_003", metadata, "cus_1", "cs_1"),
|
|
189
216
|
);
|
|
190
217
|
const r = await fire(server);
|
|
191
218
|
expect(r.statusCode).toBe(200);
|
|
192
219
|
expect(prisma.organization.update).not.toHaveBeenCalled();
|
|
220
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
221
|
+
expect(unprovenLines("cus_1", "org_1")).toHaveLength(1);
|
|
193
222
|
});
|
|
194
223
|
|
|
195
224
|
// AC2 — an individual is billed on their sentinel default Organization
|
|
196
|
-
// (orgs-of-one): the SAME org path,
|
|
225
|
+
// (orgs-of-one): the SAME org path, under the same proof.
|
|
197
226
|
it("bills an individual on their sentinel default Organization (orgs-of-one)", async () => {
|
|
198
|
-
const customerId = "cus_individual_xyz";
|
|
199
|
-
(prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
200
|
-
(prisma.organization.update as jest.Mock).mockResolvedValueOnce({
|
|
201
|
-
id: DEFAULT_ORG_ID,
|
|
202
|
-
billingProvider: "stripe",
|
|
203
|
-
billingCustomerId: customerId,
|
|
204
|
-
});
|
|
205
|
-
|
|
206
227
|
mockVerifyWebhookSignature.mockReturnValue(
|
|
207
|
-
checkoutEvent(
|
|
208
|
-
"evt_org_003",
|
|
209
|
-
{ organizationId: DEFAULT_ORG_ID },
|
|
210
|
-
customerId,
|
|
211
|
-
),
|
|
228
|
+
checkoutEvent("evt_org_004", { organizationId: DEFAULT_ORG_ID }, "cus_individual_xyz", "cs_indiv"),
|
|
212
229
|
);
|
|
213
230
|
const r = await fire(server);
|
|
214
231
|
expect(r.statusCode).toBe(200);
|
|
215
|
-
expect(prisma.organization.
|
|
216
|
-
|
|
232
|
+
expect(prisma.organization.updateMany).toHaveBeenCalledWith(
|
|
233
|
+
boundWrite(DEFAULT_ORG_ID, "cs_indiv", "cus_individual_xyz"),
|
|
217
234
|
);
|
|
218
235
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
219
236
|
});
|
|
@@ -243,22 +260,17 @@ describe("POST /api/webhooks/stripe — org-scoped billing (#3601)", () => {
|
|
|
243
260
|
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
|
244
261
|
expect(prisma.organization.findFirst).not.toHaveBeenCalled();
|
|
245
262
|
expect(prisma.organization.update).not.toHaveBeenCalled();
|
|
263
|
+
expect(prisma.organization.updateMany).not.toHaveBeenCalled();
|
|
246
264
|
});
|
|
247
265
|
|
|
248
|
-
// customer.created
|
|
249
|
-
it("
|
|
250
|
-
const customerId = "cus_created_org";
|
|
251
|
-
(prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
252
|
-
(prisma.organization.update as jest.Mock).mockResolvedValueOnce({
|
|
253
|
-
id: REAL_ORG_ID,
|
|
254
|
-
});
|
|
255
|
-
|
|
266
|
+
// AC-7 — a customer's own metadata proves nothing: customer.created binds no Organization.
|
|
267
|
+
it("writes nothing and logs org_bind_unproven on customer.created with metadata.organizationId", async () => {
|
|
256
268
|
mockVerifyWebhookSignature.mockReturnValue({
|
|
257
269
|
id: "evt_cust_005",
|
|
258
270
|
type: "customer.created",
|
|
259
271
|
data: {
|
|
260
272
|
object: {
|
|
261
|
-
id:
|
|
273
|
+
id: "cus_created_org",
|
|
262
274
|
email: "org-admin@example.com",
|
|
263
275
|
metadata: { organizationId: REAL_ORG_ID },
|
|
264
276
|
},
|
|
@@ -266,16 +278,16 @@ describe("POST /api/webhooks/stripe — org-scoped billing (#3601)", () => {
|
|
|
266
278
|
});
|
|
267
279
|
const r = await fire(server);
|
|
268
280
|
expect(r.statusCode).toBe(200);
|
|
269
|
-
expect(prisma.organization.
|
|
270
|
-
|
|
271
|
-
);
|
|
281
|
+
expect(prisma.organization.findFirst).not.toHaveBeenCalled();
|
|
282
|
+
expect(prisma.organization.update).not.toHaveBeenCalled();
|
|
283
|
+
expect(prisma.organization.updateMany).not.toHaveBeenCalled();
|
|
272
284
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
285
|
+
expect(unprovenLines("cus_created_org", REAL_ORG_ID)).toHaveLength(1);
|
|
273
286
|
});
|
|
274
287
|
|
|
275
288
|
// A DB failure on the org link never throws (200 still returned).
|
|
276
289
|
it("returns 200 even when the org billing update throws", async () => {
|
|
277
|
-
(prisma.organization.
|
|
278
|
-
(prisma.organization.update as jest.Mock).mockRejectedValueOnce(
|
|
290
|
+
(prisma.organization.updateMany as jest.Mock).mockRejectedValueOnce(
|
|
279
291
|
new Error("db unreachable"),
|
|
280
292
|
);
|
|
281
293
|
|
|
@@ -200,6 +200,7 @@ function expectHandledAsToday() {
|
|
|
200
200
|
expect(prisma.callback.updateMany).toHaveBeenCalledTimes(1);
|
|
201
201
|
expect(prisma.user.findFirst).toHaveBeenCalledWith({
|
|
202
202
|
where: { billingProvider: "stripe", billingCustomerId: CUSTOMER_ID },
|
|
203
|
+
select: { id: true },
|
|
203
204
|
});
|
|
204
205
|
expect(foreignSiteLogs()).toHaveLength(0);
|
|
205
206
|
}
|
|
@@ -624,17 +625,17 @@ describe.each(HUB_STAMPS)("case 13 — a billing event with %s", (_label, stamp)
|
|
|
624
625
|
});
|
|
625
626
|
});
|
|
626
627
|
|
|
627
|
-
|
|
628
|
+
// #9067 D13 — a customer's own metadata proves no plan checkout: the
|
|
629
|
+
// event passes the site filter and still binds no Organization.
|
|
630
|
+
it("customer.created with organizationId passes the filter and writes no Organization", async () => {
|
|
628
631
|
mockVerifyWebhookSignature.mockReturnValue(
|
|
629
632
|
customerEvent("customer.created", { ...stamp, organizationId: ORG_ID }),
|
|
630
633
|
);
|
|
631
634
|
const res = await post();
|
|
632
635
|
|
|
633
636
|
expect(res.statusCode).toBe(200);
|
|
634
|
-
expect(prisma.organization.update).
|
|
635
|
-
|
|
636
|
-
data: { billingProvider: "stripe", billingCustomerId: CUSTOMER_ID },
|
|
637
|
-
});
|
|
637
|
+
expect(prisma.organization.update).not.toHaveBeenCalled();
|
|
638
|
+
expect(prisma.organization.updateMany).not.toHaveBeenCalled();
|
|
638
639
|
});
|
|
639
640
|
|
|
640
641
|
it("customer.subscription.updated writes the subscriber's dunning record", async () => {
|
|
@@ -1129,7 +1129,10 @@ describe("POST /api/webhooks/stripe", () => {
|
|
|
1129
1129
|
|
|
1130
1130
|
// T9 (F8 — organizationId keeps precedence over the user signal)
|
|
1131
1131
|
it("T9: organizationId takes the Organization path even with a user signal", async () => {
|
|
1132
|
+
// #9067 D13 — under the proof rule: the arm binds only the session the
|
|
1133
|
+
// Organization recorded, and the User path is still never reached.
|
|
1132
1134
|
(prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
1135
|
+
(prisma.organization.updateMany as jest.Mock).mockResolvedValueOnce({ count: 1 });
|
|
1133
1136
|
|
|
1134
1137
|
const { statusCode } = await withLogCapture(
|
|
1135
1138
|
checkoutCompleted("cus_org", {
|
|
@@ -1139,9 +1142,12 @@ describe("POST /api/webhooks/stripe", () => {
|
|
|
1139
1142
|
);
|
|
1140
1143
|
|
|
1141
1144
|
expect(statusCode).toBe(200);
|
|
1142
|
-
expect(prisma.organization.
|
|
1143
|
-
expect.objectContaining({
|
|
1145
|
+
expect(prisma.organization.updateMany).toHaveBeenCalledWith(
|
|
1146
|
+
expect.objectContaining({
|
|
1147
|
+
where: { id: "org_1", planCheckoutSessionId: "cs_test_6852", billingCustomerId: null },
|
|
1148
|
+
}),
|
|
1144
1149
|
);
|
|
1150
|
+
expect(prisma.organization.update).not.toHaveBeenCalled();
|
|
1145
1151
|
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
1146
1152
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
1147
1153
|
});
|
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
* Routes:
|
|
5
5
|
* POST /v1/webhooks/stripe — verify Stripe signature, dispatch event,
|
|
6
6
|
* fire telemetry, respond 200.
|
|
7
|
+
* POST /v1/webhooks/stripe/:connectionToken — the per-Organization
|
|
8
|
+
* receiver (#8932 D1): the token names ONE
|
|
9
|
+
* Organization's StripeConnection, whose own
|
|
10
|
+
* secret verifies the delivery and whose
|
|
11
|
+
* recorded Offerings sessions it fulfils.
|
|
12
|
+
* Base tree like the legacy receiver — the
|
|
13
|
+
* commerce claim is read at call time — with
|
|
14
|
+
* its own rate-limit bucket.
|
|
7
15
|
* POST /v1/webhooks/calcom — verify the scheduling provider's signature,
|
|
8
16
|
* normalize a booking event into an
|
|
9
17
|
* Appointment row (#6085, brief H7).
|
|
@@ -32,9 +40,11 @@ import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
|
|
|
32
40
|
import { isModuleEnabled } from "../../config/modules.js";
|
|
33
41
|
import { loadSlice } from "../../lib/slice-load.js";
|
|
34
42
|
|
|
43
|
+
import { stripeOfferingsWebhookHandler } from "./stripe-offerings.js";
|
|
35
44
|
import { stripeWebhookHandler } from "./stripe.js";
|
|
36
45
|
|
|
37
46
|
export const STRIPE_WEBHOOK_RATE_LIMIT_PER_MIN = 100;
|
|
47
|
+
export const STRIPE_OFFERINGS_WEBHOOK_RATE_LIMIT_PER_MIN = 100;
|
|
38
48
|
export const CALCOM_WEBHOOK_RATE_LIMIT_PER_MIN = 100;
|
|
39
49
|
|
|
40
50
|
// Kept off the `import()` call site so `tsc` never resolves it and the OSS
|
|
@@ -70,6 +80,19 @@ export const webhooksRoutes: FastifyPluginAsync = async (fastify) => {
|
|
|
70
80
|
stripeWebhookHandler,
|
|
71
81
|
);
|
|
72
82
|
|
|
83
|
+
fastify.post(
|
|
84
|
+
"/v1/webhooks/stripe/:connectionToken",
|
|
85
|
+
{
|
|
86
|
+
config: {
|
|
87
|
+
rateLimit: {
|
|
88
|
+
max: STRIPE_OFFERINGS_WEBHOOK_RATE_LIMIT_PER_MIN,
|
|
89
|
+
timeWindow: "1 minute",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
stripeOfferingsWebhookHandler,
|
|
94
|
+
);
|
|
95
|
+
|
|
73
96
|
if (isModuleEnabled("scheduling")) {
|
|
74
97
|
const mod = (await loadSlice(CALCOM_HANDLER, () => import(CALCOM_HANDLER))) as {
|
|
75
98
|
calcomWebhookHandler?: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|