create-ailk 0.21.0 → 0.23.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/component-catalog.json +186 -8
- package/component-catalog.md +9 -7
- package/dist/add-module.js +20 -14
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +294 -12
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +12 -3
- package/dist/derive-architecture.js +20 -8
- package/dist/design-package.js +7 -2
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +5 -5
- 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 +3 -2
- package/dist/lib/scaffold-render-sweep.js +5 -8
- package/dist/module-architecture.js +99 -9
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/programmatic.js +19 -5
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/spoke-checkout.d.ts +65 -41
- package/dist/spoke-checkout.js +208 -430
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +52 -26
- package/dist/sync-routes.d.ts +38 -1
- package/dist/sync-routes.js +187 -13
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +8 -4
- package/dist/topology.js +25 -1
- package/dist/update.js +15 -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/designs/minimalism/DESIGN.md +1 -1
- 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 +9 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +27 -0
- package/templates/apps/api/CLAUDE.md +87 -17
- 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 +3 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/lib/stripe.ts +74 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- 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/auth.ts +13 -6
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/spec.ts +33 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -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 +1300 -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 +386 -0
- 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 +268 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- 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 +4 -0
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- 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/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +164 -7
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -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 +5 -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 +245 -36
- package/templates/apps/api/src/server.ts +163 -46
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -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 +1383 -133
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +431 -47
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -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 +868 -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 +396 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1003 -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 +164 -17
- package/templates/apps/api/src/services/plan-offerings.ts +125 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +27 -15
- package/templates/apps/mcp/CLAUDE.md +22 -14
- 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 +4 -4
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- 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__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- 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/purchase.ts +30 -2
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +24 -31
- package/templates/apps/web/CLAUDE.md +393 -9
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +295 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +120 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +23 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +319 -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 +141 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +424 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +72 -11
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +160 -13
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +466 -36
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +172 -57
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -60
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +341 -4
- 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__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -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]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -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]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- 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]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- 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]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- 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/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- 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/auth.ts +28 -2
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +7 -1
- package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -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 +101 -25
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +16 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +7 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/in-page-magnet.ts +416 -0
- package/templates/apps/web/lib/magnet-routes.ts +42 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +202 -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/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +449 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +71 -20
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/security-headers.config.ts +17 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -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 +69 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -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 +64 -1
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -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/package.json +1 -1
- package/templates/database/seed.ts +62 -3
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
- 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/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/meeting-slugs.ts +0 -63
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file plan-offerings.test.ts
|
|
3
|
+
*
|
|
4
|
+
* The plan offerings re-sourced from the Offerings shown set (#8934, spec
|
|
5
|
+
* docs/specs/8934-re-source-the-plan-offerings-spec.md D1–D6; AC-1, AC-3,
|
|
6
|
+
* AC-4, AC-5): a connected, claimed workspace lists one item per sellable
|
|
7
|
+
* Price in its shown set, keyed by the Price's full lookup key; an unconnected
|
|
8
|
+
* or unclaimed workspace keeps the page list; a licence or snapshot that
|
|
9
|
+
* cannot be read answers `{ failed }`; and the three consumers accept a
|
|
10
|
+
* listed key with no change to their own code.
|
|
11
|
+
*
|
|
12
|
+
* Runs over `createInMemoryPrisma()` and #8876's `seedOfferings`. External
|
|
13
|
+
* dependency boundaries mocked:
|
|
14
|
+
* - apps/api/src/lib/prisma — the in-memory, where-clause-honouring double.
|
|
15
|
+
* Its `lead.count` is arranged per test: the purchase email's cap reads
|
|
16
|
+
* with `mode: "insensitive"`, a filter the double does not model.
|
|
17
|
+
* - @working-theory/license — `checkLicense` per case; a keyless storage
|
|
18
|
+
* file (`absent`).
|
|
19
|
+
* - @working-theory/stripe-client — touching any export throws.
|
|
20
|
+
* - @working-theory/email — the operator notification and purchase email.
|
|
21
|
+
* - public-site-resolver's `workspaceContentAdapter` — a fixture adapter
|
|
22
|
+
* stands in for the workspace's content store.
|
|
23
|
+
*/
|
|
24
|
+
import { inspect } from "node:util";
|
|
25
|
+
|
|
26
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); the in-memory double is the repo-standard where-honouring test double for it.
|
|
27
|
+
jest.mock("../../lib/prisma.js", () =>
|
|
28
|
+
jest
|
|
29
|
+
.requireActual<typeof import("../../__tests__/factories/in-memory-prisma")>(
|
|
30
|
+
"../../__tests__/factories/in-memory-prisma",
|
|
31
|
+
)
|
|
32
|
+
.createInMemoryPrisma(),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const mockCheckLicense = jest.fn();
|
|
36
|
+
jest.mock("@working-theory/license", () => ({
|
|
37
|
+
checkLicense: (...args: unknown[]) => mockCheckLicense(...args),
|
|
38
|
+
readStoredJwtState: () => Promise.resolve({ state: "absent" }),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
jest.mock(
|
|
42
|
+
"@working-theory/stripe-client",
|
|
43
|
+
() =>
|
|
44
|
+
new Proxy(
|
|
45
|
+
{},
|
|
46
|
+
{
|
|
47
|
+
get: (_target, key) => {
|
|
48
|
+
if (key === "__esModule") return true;
|
|
49
|
+
throw new Error(`@working-theory/stripe-client.${String(key)} was touched`);
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const mockSendLeadNotification = jest.fn();
|
|
56
|
+
const mockSendPurchaseConfirmationEmail = jest.fn();
|
|
57
|
+
jest.mock("@working-theory/email", () => ({
|
|
58
|
+
sendLeadNotification: (...args: unknown[]) => mockSendLeadNotification(...args),
|
|
59
|
+
sendPurchaseConfirmationEmail: (...args: unknown[]) =>
|
|
60
|
+
mockSendPurchaseConfirmationEmail(...args),
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
const mockWorkspaceContentAdapter = jest.fn();
|
|
64
|
+
// eslint-disable-next-line no-restricted-syntax -- the content adapter seam: a fixture adapter stands in for the workspace's content store
|
|
65
|
+
jest.mock("../public-site-resolver.js", () => ({
|
|
66
|
+
...jest.requireActual("../public-site-resolver.js"),
|
|
67
|
+
workspaceContentAdapter: (...args: unknown[]) => mockWorkspaceContentAdapter(...args),
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
import { logger } from "@working-theory/observability";
|
|
71
|
+
import {
|
|
72
|
+
parseLookupKey,
|
|
73
|
+
planOfferingItemSchema,
|
|
74
|
+
planOfferingSchema,
|
|
75
|
+
planOfferingsResponseSchema,
|
|
76
|
+
} from "@working-theory/validation";
|
|
77
|
+
|
|
78
|
+
import { prisma } from "../../lib/prisma.js";
|
|
79
|
+
import { routeLead } from "../lead-routing";
|
|
80
|
+
import { magnetCompletionFor } from "../magnet-completion";
|
|
81
|
+
import {
|
|
82
|
+
FIXTURE_ORG,
|
|
83
|
+
FIXTURE_ORG_2_WS,
|
|
84
|
+
FIXTURE_WS,
|
|
85
|
+
FIXTURE_WS_2,
|
|
86
|
+
OFFERINGS_FIXTURE_CATALOG,
|
|
87
|
+
seedOfferings,
|
|
88
|
+
} from "../offerings/__tests__/fixtures.js";
|
|
89
|
+
import { normalizeCatalog, type CatalogPrice, type CatalogProduct } from "../offerings/index.js";
|
|
90
|
+
import { resolveMemberPersonId } from "../person";
|
|
91
|
+
import { listPlanOfferings } from "../plan-offerings";
|
|
92
|
+
|
|
93
|
+
type Row = Record<string, unknown>;
|
|
94
|
+
// The in-memory double's own surface (`__db`, `__calls`) and its recorded
|
|
95
|
+
// delegates are not on the PrismaClient type the import carries, so it is
|
|
96
|
+
// named here once.
|
|
97
|
+
// eslint-disable-next-line no-restricted-syntax -- justified above
|
|
98
|
+
const store = prisma as unknown as {
|
|
99
|
+
__db: Record<string, Row[]>;
|
|
100
|
+
__calls: Array<{ model: string; op: string }>;
|
|
101
|
+
lead: Record<string, jest.Mock>;
|
|
102
|
+
workspaceOfferings: Record<string, jest.Mock>;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const CLAIMED = { tier: "pro", plan: null, features: ["commerce"], expiresAt: null, revoked: false };
|
|
106
|
+
const FREE = { tier: "free", plan: null, features: [], expiresAt: null, revoked: false };
|
|
107
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
108
|
+
|
|
109
|
+
/** D1's table for the seed. */
|
|
110
|
+
const SHOWN_SET_ITEMS = [
|
|
111
|
+
{ key: "basic_once", name: "Basic (One-time)", type: "plan" },
|
|
112
|
+
{ key: "growth_monthly", name: "Growth (Monthly)", type: "plan" },
|
|
113
|
+
{ key: "growth_yearly", name: "Growth (Annual)", type: "plan" },
|
|
114
|
+
{ key: "seo-audit_monthly", name: "SEO audit (Monthly)", type: "addon" },
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
// The route suite's fixture pages and their keying (#8514 D9).
|
|
118
|
+
const page = (slug: string, title: string) => ({ slug, title });
|
|
119
|
+
const SERVICES = [
|
|
120
|
+
page("services/retainer", "Monthly retainer"),
|
|
121
|
+
page("services/Bad Key!", "Not a key"),
|
|
122
|
+
page("audit", "Brand audit"),
|
|
123
|
+
];
|
|
124
|
+
const PRODUCTS = [page("products/starter-kit", "Starter kit"), page("retainer", "Duplicate retainer")];
|
|
125
|
+
const PAGE_ITEMS = [
|
|
126
|
+
{ key: "retainer", name: "Monthly retainer", type: "service" },
|
|
127
|
+
{ key: "audit", name: "Brand audit", type: "service" },
|
|
128
|
+
{ key: "starter-kit", name: "Starter kit", type: "product" },
|
|
129
|
+
];
|
|
130
|
+
const fixtureAdapter = () => ({
|
|
131
|
+
listPages: jest.fn(async (type: string) =>
|
|
132
|
+
type === "Service" ? SERVICES : type === "Product" ? PRODUCTS : [],
|
|
133
|
+
),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const OFFERINGS_MODELS = ["stripeConnection", "organizationOfferings", "workspaceOfferings"];
|
|
137
|
+
const offeringsReads = () => store.__calls.filter((c) => OFFERINGS_MODELS.includes(c.model));
|
|
138
|
+
|
|
139
|
+
const enabledRow = () =>
|
|
140
|
+
store.__db["organizationOfferings"]!.find((r) => r["organizationId"] === FIXTURE_ORG)!;
|
|
141
|
+
const offeringsRow = () =>
|
|
142
|
+
store.__db["workspaceOfferings"]!.find((r) => r["workspaceId"] === FIXTURE_WS)!;
|
|
143
|
+
const interactions = () => (store.__db["interaction"] ??= []);
|
|
144
|
+
const people = () => (store.__db["person"] ??= []);
|
|
145
|
+
|
|
146
|
+
// ─── Logs: no line carries Stripe text (AC-5) ───────────────────────────────
|
|
147
|
+
|
|
148
|
+
const EMOJI_NAME = "\u{1F600}".repeat(200);
|
|
149
|
+
const FORBIDDEN_LOG_TEXT = [
|
|
150
|
+
...OFFERINGS_FIXTURE_CATALOG.products.flatMap((p) => [
|
|
151
|
+
p.name,
|
|
152
|
+
...(p.description ? [p.description] : []),
|
|
153
|
+
...(p.marketing_features ?? []).map((f) => f.name),
|
|
154
|
+
]),
|
|
155
|
+
EMOJI_NAME.slice(0, 20),
|
|
156
|
+
"prod_",
|
|
157
|
+
"price_",
|
|
158
|
+
];
|
|
159
|
+
const LOGGER_METHODS = ["error", "warn", "info", "debug"] as const;
|
|
160
|
+
const CONSOLE_METHODS = ["log", "info", "warn", "error", "debug"] as const;
|
|
161
|
+
let spies: jest.SpyInstance[] = [];
|
|
162
|
+
|
|
163
|
+
function leakedLines(): string[] {
|
|
164
|
+
return spies
|
|
165
|
+
.flatMap((spy) => spy.mock.calls.map((args) => inspect(args, { depth: 8 })))
|
|
166
|
+
.filter((line) => FORBIDDEN_LOG_TEXT.some((text) => line.includes(text)));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── The lead route (AC-3) ───────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
const BUYER = "buyer@x.io";
|
|
172
|
+
const U = "user_00000000-0000-7000-8000-000000008934";
|
|
173
|
+
let keySeq = 0;
|
|
174
|
+
const sourceMeta = { ip: "127.0.0.1", userAgent: "jest", receivedAt: new Date().toISOString(), viaSpoke: true };
|
|
175
|
+
|
|
176
|
+
/** A marked spoke checkout lead (#8793's shape) for `offering`, from slug site `wendi`. */
|
|
177
|
+
async function checkoutLead(offering: string): Promise<void> {
|
|
178
|
+
keySeq += 1;
|
|
179
|
+
await routeLead(
|
|
180
|
+
{
|
|
181
|
+
kind: "submit",
|
|
182
|
+
siteId: FIXTURE_WS,
|
|
183
|
+
source: "web",
|
|
184
|
+
email: BUYER,
|
|
185
|
+
payload: { site: "wendi", page: "checkout", offering, amountTotal: 900, currency: "usd" },
|
|
186
|
+
},
|
|
187
|
+
sourceMeta,
|
|
188
|
+
{ idempotencyKey: `cs_live_h8934Plan${keySeq}` }, // gitleaks:allow -- a synthetic Checkout Session id shape, not a credential
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The newest purchase message's body. */
|
|
193
|
+
const lastPurchaseBody = () => String(interactions().at(-1)?.["body"]);
|
|
194
|
+
const GENERIC = /^Thanks for your purchase\./;
|
|
195
|
+
|
|
196
|
+
const ENV_KEYS = [
|
|
197
|
+
"DATABASE_URL",
|
|
198
|
+
"NODE_ENV",
|
|
199
|
+
"AILK_LICENSE_KEY",
|
|
200
|
+
"AILK_LEAD_NOTIFICATION_TO",
|
|
201
|
+
"AILK_LEAD_NOTIFICATION_FROM",
|
|
202
|
+
"AILK_DELIVERABLE_EMAIL_FROM",
|
|
203
|
+
] as const;
|
|
204
|
+
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
205
|
+
|
|
206
|
+
beforeEach(async () => {
|
|
207
|
+
for (const key of Object.keys(store.__db)) delete store.__db[key];
|
|
208
|
+
saved = {};
|
|
209
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
210
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
211
|
+
delete process.env.NODE_ENV;
|
|
212
|
+
delete process.env.AILK_LICENSE_KEY;
|
|
213
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
214
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
215
|
+
process.env.AILK_DELIVERABLE_EMAIL_FROM = "orders@example.com";
|
|
216
|
+
mockCheckLicense.mockReset().mockResolvedValue(CLAIMED);
|
|
217
|
+
mockWorkspaceContentAdapter.mockReset().mockImplementation(async () => fixtureAdapter());
|
|
218
|
+
mockSendLeadNotification.mockReset().mockResolvedValue({ ok: true, messageId: "m" });
|
|
219
|
+
mockSendPurchaseConfirmationEmail.mockReset().mockResolvedValue({ ok: true, messageId: "m2" });
|
|
220
|
+
store.lead["count"]!.mockReset().mockResolvedValue(1);
|
|
221
|
+
await seedOfferings(prisma);
|
|
222
|
+
store.__calls.length = 0;
|
|
223
|
+
spies = [
|
|
224
|
+
...LOGGER_METHODS.map((m) => jest.spyOn(logger, m).mockImplementation(() => undefined as never)),
|
|
225
|
+
...CONSOLE_METHODS.map((m) => jest.spyOn(console, m).mockImplementation(() => undefined)),
|
|
226
|
+
];
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
afterEach(() => {
|
|
230
|
+
const leaked = leakedLines();
|
|
231
|
+
for (const spy of spies) spy.mockRestore();
|
|
232
|
+
for (const k of ENV_KEYS) {
|
|
233
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
234
|
+
else process.env[k] = saved[k];
|
|
235
|
+
}
|
|
236
|
+
// AC-5: across every case, no log or console line carries a product name,
|
|
237
|
+
// description, feature string, or a `prod_` or `price_` id.
|
|
238
|
+
expect(leaked).toEqual([]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("a connected workspace lists its shown set's lookup keys, and only those (AC-1)", () => {
|
|
242
|
+
it("one item per sellable Price, keyed by its full lookup key, with no page and no Stripe call", async () => {
|
|
243
|
+
const offerings = await listPlanOfferings(FIXTURE_WS);
|
|
244
|
+
expect(offerings).toEqual(SHOWN_SET_ITEMS);
|
|
245
|
+
if (!Array.isArray(offerings)) throw new Error("expected a list");
|
|
246
|
+
for (const { key } of offerings) {
|
|
247
|
+
expect(parseLookupKey(key)).not.toBeNull();
|
|
248
|
+
expect(planOfferingSchema.safeParse(key).success).toBe(true);
|
|
249
|
+
}
|
|
250
|
+
expect(() => planOfferingsResponseSchema.parse({ offerings })).not.toThrow();
|
|
251
|
+
expect(mockWorkspaceContentAdapter).not.toHaveBeenCalled();
|
|
252
|
+
// The bidi add-on is in the catalog but neither enabled nor shown.
|
|
253
|
+
expect(offerings.map((o) => o.key)).not.toContain("audit-plus_monthly");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("a product switched off in the Organization's enabled set is absent on the next call", async () => {
|
|
257
|
+
const { snapshot, snapshotAt } = offeringsRow();
|
|
258
|
+
enabledRow()["enabledProductIds"] = (enabledRow()["enabledProductIds"] as string[]).filter(
|
|
259
|
+
(id) => id !== "prod_fixture_seo_audit",
|
|
260
|
+
);
|
|
261
|
+
expect(await listPlanOfferings(FIXTURE_WS)).toEqual(
|
|
262
|
+
SHOWN_SET_ITEMS.filter((item) => item.key !== "seo-audit_monthly"),
|
|
263
|
+
);
|
|
264
|
+
expect(offeringsRow()["snapshot"]).toBe(snapshot);
|
|
265
|
+
expect(offeringsRow()["snapshotAt"]).toBe(snapshotAt);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("a product switched off in the workspace's shown set is absent on the next call", async () => {
|
|
269
|
+
const { snapshot, snapshotAt } = offeringsRow();
|
|
270
|
+
offeringsRow()["shownProductIds"] = (offeringsRow()["shownProductIds"] as string[]).filter(
|
|
271
|
+
(id) => id !== "prod_fixture_growth",
|
|
272
|
+
);
|
|
273
|
+
expect(await listPlanOfferings(FIXTURE_WS)).toEqual(
|
|
274
|
+
SHOWN_SET_ITEMS.filter((item) => !item.key.startsWith("growth_")),
|
|
275
|
+
);
|
|
276
|
+
expect(offeringsRow()["snapshot"]).toBe(snapshot);
|
|
277
|
+
expect(offeringsRow()["snapshotAt"]).toBe(snapshotAt);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("an empty shown set lists none: a fact, not a failure", async () => {
|
|
281
|
+
offeringsRow()["shownProductIds"] = [];
|
|
282
|
+
expect(await listPlanOfferings(FIXTURE_WS)).toEqual([]);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("the consumers accept a listed key with no change to their own code (AC-3)", () => {
|
|
287
|
+
it("(a) the purchase message names a listed lookup key, and only a listed one", async () => {
|
|
288
|
+
await checkoutLead("growth_monthly");
|
|
289
|
+
expect(interactions()).toHaveLength(1);
|
|
290
|
+
expect(lastPurchaseBody()).toContain("Growth (Monthly)");
|
|
291
|
+
|
|
292
|
+
await checkoutLead("growth_quarterly");
|
|
293
|
+
expect(lastPurchaseBody()).toMatch(GENERIC);
|
|
294
|
+
// A page the adapter lists is not a connected workspace's offering.
|
|
295
|
+
await checkoutLead("retainer");
|
|
296
|
+
expect(lastPurchaseBody()).toMatch(GENERIC);
|
|
297
|
+
expect(interactions()).toHaveLength(3);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("(b) the held plan released at the verified first link is a key the list names", async () => {
|
|
301
|
+
await checkoutLead("growth_monthly");
|
|
302
|
+
(store.__db["user"] ??= []).push({ id: U, email: BUYER, emailVerified: true });
|
|
303
|
+
const personId = await resolveMemberPersonId(FIXTURE_WS, U, BUYER);
|
|
304
|
+
const buyer = people().find((p) => p["id"] === personId)!;
|
|
305
|
+
expect(buyer["planOffering"]).toBe("growth_monthly");
|
|
306
|
+
const offerings = await listPlanOfferings(FIXTURE_WS);
|
|
307
|
+
if (!Array.isArray(offerings)) throw new Error("expected a list");
|
|
308
|
+
expect(offerings.find((o) => o.key === buyer["planOffering"])?.name).toBe("Growth (Monthly)");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("(c) a checkout magnet completes on an unconnected workspace's page key, and degrades to lead on a connected one", async () => {
|
|
312
|
+
const magnet = (workspaceId: string, checkoutOffering: string): Row => ({
|
|
313
|
+
id: `flow_${workspaceId.slice(-4)}`,
|
|
314
|
+
workspaceId,
|
|
315
|
+
siteId: workspaceId,
|
|
316
|
+
slug: "buy",
|
|
317
|
+
status: "live",
|
|
318
|
+
title: "Buy",
|
|
319
|
+
config: {},
|
|
320
|
+
completion: "checkout",
|
|
321
|
+
checkoutOffering,
|
|
322
|
+
});
|
|
323
|
+
store.__db["flowDefinition"] = [magnet(FIXTURE_ORG_2_WS, "retainer"), magnet(FIXTURE_WS, "growth")];
|
|
324
|
+
const noTarget = () => Promise.resolve(null);
|
|
325
|
+
expect(await magnetCompletionFor(FIXTURE_ORG_2_WS, "buy", noTarget)).toMatchObject({
|
|
326
|
+
next: { completion: "checkout", checkoutOffering: "retainer" },
|
|
327
|
+
});
|
|
328
|
+
expect(await magnetCompletionFor(FIXTURE_WS, "buy", noTarget)).toBeNull();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("(d) a licence or snapshot that cannot be read is { failed }, never a pass", () => {
|
|
332
|
+
const failures: Array<[string, () => Promise<void>, string]> = [
|
|
333
|
+
[
|
|
334
|
+
"an undetermined licence",
|
|
335
|
+
async () => {
|
|
336
|
+
mockCheckLicense.mockReset().mockRejectedValue(new Error("licence read failed"));
|
|
337
|
+
},
|
|
338
|
+
"LicenseUndetermined",
|
|
339
|
+
],
|
|
340
|
+
[
|
|
341
|
+
"a snapshot 25 hours old",
|
|
342
|
+
async () => {
|
|
343
|
+
for (const key of Object.keys(store.__db)) delete store.__db[key];
|
|
344
|
+
await seedOfferings(prisma, { now: new Date(Date.now() - 25 * HOUR_MS) });
|
|
345
|
+
},
|
|
346
|
+
"OfferingsSnapshotExpired",
|
|
347
|
+
],
|
|
348
|
+
[
|
|
349
|
+
"an unreadable connection",
|
|
350
|
+
async () => {
|
|
351
|
+
store.__db["stripeConnection"]![0]!["mode"] = "prod";
|
|
352
|
+
},
|
|
353
|
+
"OfferingsSnapshotEmpty",
|
|
354
|
+
],
|
|
355
|
+
[
|
|
356
|
+
"a selection read that rejects",
|
|
357
|
+
async () => {
|
|
358
|
+
store.workspaceOfferings["findUnique"]!.mockRejectedValueOnce(new Error("db down"));
|
|
359
|
+
},
|
|
360
|
+
"Error",
|
|
361
|
+
],
|
|
362
|
+
];
|
|
363
|
+
|
|
364
|
+
it.each(failures)("%s", async (_label, arm, failed) => {
|
|
365
|
+
await arm();
|
|
366
|
+
expect(await listPlanOfferings(FIXTURE_WS)).toEqual({ failed });
|
|
367
|
+
await arm();
|
|
368
|
+
await checkoutLead("growth_monthly");
|
|
369
|
+
expect(lastPurchaseBody()).toMatch(GENERIC);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("an undetermined licence reads no Offerings or workspace row", async () => {
|
|
373
|
+
mockCheckLicense.mockReset().mockRejectedValue(new Error("licence read failed"));
|
|
374
|
+
store.__calls.length = 0;
|
|
375
|
+
expect(await listPlanOfferings(FIXTURE_WS)).toEqual({ failed: "LicenseUndetermined" });
|
|
376
|
+
expect(store.__calls.filter((c) => [...OFFERINGS_MODELS, "workspace"].includes(c.model))).toEqual([]);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe("an unconnected or unclaimed workspace keeps the page list (AC-4)", () => {
|
|
382
|
+
it.each([
|
|
383
|
+
["a selection with no connection", FIXTURE_ORG_2_WS],
|
|
384
|
+
["a connection with no selection", FIXTURE_WS_2],
|
|
385
|
+
["an unknown workspace", "wksp_01920000-8934-7000-8000-0000000000ff"],
|
|
386
|
+
])("%s answers the page list", async (_label, workspaceId) => {
|
|
387
|
+
expect(await listPlanOfferings(workspaceId)).toEqual(PAGE_ITEMS);
|
|
388
|
+
expect(mockWorkspaceContentAdapter).toHaveBeenCalledWith(workspaceId);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("an unclaimed, connected workspace answers the page list and reads no Offerings row", async () => {
|
|
392
|
+
mockCheckLicense.mockReset().mockResolvedValue(FREE);
|
|
393
|
+
expect(await listPlanOfferings(FIXTURE_WS)).toEqual(PAGE_ITEMS);
|
|
394
|
+
expect(mockWorkspaceContentAdapter).toHaveBeenCalledWith(FIXTURE_WS);
|
|
395
|
+
expect(offeringsReads()).toEqual([]);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
describe("names and the item schema (AC-5)", () => {
|
|
400
|
+
it("a 200-code-point emoji name keeps its billing word, fits 200 units and splits no surrogate", async () => {
|
|
401
|
+
const product = {
|
|
402
|
+
id: "prod_fixture_emoji",
|
|
403
|
+
active: true,
|
|
404
|
+
name: EMOJI_NAME,
|
|
405
|
+
description: null,
|
|
406
|
+
marketing_features: [],
|
|
407
|
+
unit_label: null,
|
|
408
|
+
metadata: { ailk_group: "plan" },
|
|
409
|
+
} as CatalogProduct;
|
|
410
|
+
const price = {
|
|
411
|
+
id: "price_fixture_emoji_monthly",
|
|
412
|
+
active: true,
|
|
413
|
+
product: "prod_fixture_emoji",
|
|
414
|
+
lookup_key: "emoji_monthly",
|
|
415
|
+
type: "recurring",
|
|
416
|
+
recurring: { interval: "month", interval_count: 1, usage_type: "licensed" },
|
|
417
|
+
billing_scheme: "per_unit",
|
|
418
|
+
unit_amount: 100,
|
|
419
|
+
currency: "usd",
|
|
420
|
+
} as CatalogPrice;
|
|
421
|
+
const [emoji] = normalizeCatalog({ products: [product], prices: [price] }).offerings;
|
|
422
|
+
expect(emoji?.name).toBe(EMOJI_NAME);
|
|
423
|
+
enabledRow()["enabledProductIds"] = ["prod_fixture_emoji"];
|
|
424
|
+
offeringsRow()["shownProductIds"] = ["prod_fixture_emoji"];
|
|
425
|
+
offeringsRow()["snapshot"] = { v: 1, offerings: [emoji] };
|
|
426
|
+
|
|
427
|
+
const offerings = await listPlanOfferings(FIXTURE_WS);
|
|
428
|
+
if (!Array.isArray(offerings)) throw new Error(`expected a list, got ${offerings.failed}`);
|
|
429
|
+
const [item] = offerings;
|
|
430
|
+
expect(item?.key).toBe("emoji_monthly");
|
|
431
|
+
expect(item?.name.endsWith(" (Monthly)")).toBe(true);
|
|
432
|
+
expect(item!.name.length).toBeLessThanOrEqual(200);
|
|
433
|
+
expect(item!.name).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/);
|
|
434
|
+
expect(planOfferingItemSchema.safeParse(item).success).toBe(true);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("the item schema takes the Offerings groups beside the page types", () => {
|
|
438
|
+
const item = (type: string) => ({ key: "growth_monthly", name: "Growth (Monthly)", type });
|
|
439
|
+
for (const type of ["service", "product", "plan", "addon"]) {
|
|
440
|
+
expect(planOfferingItemSchema.safeParse(item(type)).success).toBe(true);
|
|
441
|
+
}
|
|
442
|
+
expect(planOfferingItemSchema.safeParse(item("offering")).success).toBe(false);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file checkout-buyer.ts
|
|
3
3
|
* @description Resolves a signed-in user into the Checkout buyer
|
|
4
|
-
* `POST /v1/checkout/sessions` pins and stamps (#6866 D3)
|
|
4
|
+
* `POST /v1/checkout/sessions` pins and stamps (#6866 D3), and — the other
|
|
5
|
+
* way round — a completed Checkout Session into its buyer's Person, the
|
|
6
|
+
* subject of the terms grant the hub webhook records (#8765 D14,
|
|
7
|
+
* `checkoutBuyerPerson`).
|
|
5
8
|
*
|
|
6
9
|
* A buyer is a user whose email is VERIFIED — the customer created for them
|
|
7
10
|
* carries the account email, and it must never carry an unproven one — and
|
|
@@ -11,13 +14,18 @@
|
|
|
11
14
|
* never re-points a non-null `billingCustomerId` (#6113), so a new stamped
|
|
12
15
|
* customer could only be an orphan.
|
|
13
16
|
*
|
|
14
|
-
* Base tree (N4): imports only `verified-email.ts
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
+
* Base tree (N4): imports only `verified-email.ts`, `person.ts`,
|
|
18
|
+
* `lib/site.ts`, `lib/proposal-paid-store.ts` and `lib/prisma.ts` — all base
|
|
19
|
+
* tree — never the commerce
|
|
20
|
+
* overlay's `services/subscription.ts`. The billing read selects no email, so
|
|
21
|
+
* `tools/lint-email-authz.ts` has nothing to flag.
|
|
17
22
|
*/
|
|
18
23
|
|
|
19
24
|
import { prisma } from "../lib/prisma.js";
|
|
25
|
+
import { proposalPersonId } from "../lib/proposal-paid-store.js";
|
|
26
|
+
import { resolveSiteWorkspaceId } from "../lib/site.js";
|
|
20
27
|
|
|
28
|
+
import { findOrCreatePerson } from "./person.js";
|
|
21
29
|
import { findVerifiedEmail } from "./verified-email.js";
|
|
22
30
|
|
|
23
31
|
export type CheckoutBuyer = {
|
|
@@ -70,3 +78,91 @@ export async function resolveCheckoutBuyer(
|
|
|
70
78
|
},
|
|
71
79
|
};
|
|
72
80
|
}
|
|
81
|
+
|
|
82
|
+
// ─── The buyer's Person (#8765 D14) ──────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/** What `checkoutBuyerPerson` reads of a signed `checkout.session.completed`. */
|
|
85
|
+
export type CheckoutBuyerSession = {
|
|
86
|
+
payment_status?: string | null;
|
|
87
|
+
customer_details?: { email?: string | null } | null;
|
|
88
|
+
metadata?: Record<string, string> | null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type CheckoutBuyerPerson = { workspaceId: string; personId: string };
|
|
92
|
+
|
|
93
|
+
function metadataString(
|
|
94
|
+
metadata: Record<string, string> | null | undefined,
|
|
95
|
+
key: string,
|
|
96
|
+
): string | null {
|
|
97
|
+
const value: unknown = metadata?.[key];
|
|
98
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The Person a completed Checkout Session's terms grant belongs to, or
|
|
103
|
+
* `null` (the webhook's `person_unresolved`). In order:
|
|
104
|
+
*
|
|
105
|
+
* - a proposal session (`metadata.ailk_proposal` + `ailk_workspace`): that
|
|
106
|
+
* piece's Person, when the proposal exists in that workspace;
|
|
107
|
+
* - a flow checkout (`metadata.flowCheckoutId`): `flowPerson`, the Person
|
|
108
|
+
* the webhook's Person step found or created for it — read, never
|
|
109
|
+
* created here.
|
|
110
|
+
* Neither falls through to the payer's typed email below;
|
|
111
|
+
* - a signed-in buyer (`metadata.ailk_user_id`, which
|
|
112
|
+
* `POST /v1/checkout/sessions` stamps only for a verified buyer, #6866):
|
|
113
|
+
* the Person of that user's VERIFIED email in the site workspace, found
|
|
114
|
+
* or created — in test mode too, since a verified email is the user's
|
|
115
|
+
* own; a user with no verified email is `null`, never the payer's typed
|
|
116
|
+
* email (SEC-8765-F);
|
|
117
|
+
* - any other paid LIVE-mode session with `customer_details.email`: that
|
|
118
|
+
* email's Person in the site workspace, found or created. A test-mode
|
|
119
|
+
* session never takes this branch, so a sandbox cannot mint a Person
|
|
120
|
+
* from a typed email.
|
|
121
|
+
*
|
|
122
|
+
* The site workspace is `resolveSiteWorkspaceId()`; `null` there is `null`
|
|
123
|
+
* here. Never throws — a store failure is `null`.
|
|
124
|
+
*
|
|
125
|
+
* @param session the signature-verified event's session object.
|
|
126
|
+
* @param livemode the signed event's `livemode`.
|
|
127
|
+
* @param flowPerson a flow checkout's Person, from `resolveCheckoutPerson`.
|
|
128
|
+
*/
|
|
129
|
+
export async function checkoutBuyerPerson(
|
|
130
|
+
session: CheckoutBuyerSession,
|
|
131
|
+
livemode: boolean,
|
|
132
|
+
flowPerson: CheckoutBuyerPerson | null = null,
|
|
133
|
+
): Promise<CheckoutBuyerPerson | null> {
|
|
134
|
+
const metadata = session.metadata;
|
|
135
|
+
try {
|
|
136
|
+
const pieceId = metadataString(metadata, "ailk_proposal");
|
|
137
|
+
const proposalWorkspaceId = metadataString(metadata, "ailk_workspace");
|
|
138
|
+
if (pieceId !== null && proposalWorkspaceId !== null) {
|
|
139
|
+
const personId = await proposalPersonId(proposalWorkspaceId, pieceId);
|
|
140
|
+
return personId === null
|
|
141
|
+
? null
|
|
142
|
+
: { workspaceId: proposalWorkspaceId, personId };
|
|
143
|
+
}
|
|
144
|
+
if (metadataString(metadata, "flowCheckoutId") !== null) return flowPerson;
|
|
145
|
+
|
|
146
|
+
const buyerUserId = metadataString(metadata, "ailk_user_id");
|
|
147
|
+
let email: string;
|
|
148
|
+
if (buyerUserId !== null) {
|
|
149
|
+
const verified = await findVerifiedEmail(buyerUserId);
|
|
150
|
+
if (!verified.ok) return null;
|
|
151
|
+
email = verified.value.email;
|
|
152
|
+
} else {
|
|
153
|
+
const payerEmail = session.customer_details?.email;
|
|
154
|
+
const paid =
|
|
155
|
+
session.payment_status === "paid" ||
|
|
156
|
+
session.payment_status === "no_payment_required";
|
|
157
|
+
if (!livemode || !paid || typeof payerEmail !== "string") return null;
|
|
158
|
+
email = payerEmail;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const workspaceId = await resolveSiteWorkspaceId();
|
|
162
|
+
if (workspaceId === null) return null;
|
|
163
|
+
const person = await findOrCreatePerson(workspaceId, { email });
|
|
164
|
+
return person.ok ? { workspaceId, personId: person.value.personId } : null;
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -34,6 +34,7 @@ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
|
34
34
|
import type { Prisma } from "@working-theory/database";
|
|
35
35
|
import { logger } from "@working-theory/observability";
|
|
36
36
|
import {
|
|
37
|
+
ACCOUNT_TERMS_PURPOSE_ID,
|
|
37
38
|
consentPolicyHref,
|
|
38
39
|
consentPurposeById,
|
|
39
40
|
consentPurposeKind,
|
|
@@ -52,6 +53,7 @@ import {
|
|
|
52
53
|
effectiveConsent,
|
|
53
54
|
latestVisitorChoice,
|
|
54
55
|
ownConsentRows,
|
|
56
|
+
personHasCheckoutGrant,
|
|
55
57
|
userPlatformRows,
|
|
56
58
|
type PurposeConsentState,
|
|
57
59
|
} from "../lib/consent-store.js";
|
|
@@ -507,6 +509,99 @@ export async function recordPersonConsent(
|
|
|
507
509
|
});
|
|
508
510
|
}
|
|
509
511
|
|
|
512
|
+
export type RecordCheckoutConsentOutcome =
|
|
513
|
+
"recorded" | "unchanged" | "purpose_unknown" | "version_changed";
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* `<purposeId>@<policyVersion>` of the account terms as the registry reads
|
|
517
|
+
* them now, and that version — the only stamp a checkout grant is recorded
|
|
518
|
+
* for (SEC-8765-E). A spoke purchase's lead names the stamp it accepted
|
|
519
|
+
* (#8778); derived, never written down, so a version bump retires every
|
|
520
|
+
* older stamp.
|
|
521
|
+
*/
|
|
522
|
+
export function currentAccountTermsStamp(): {
|
|
523
|
+
stamp: string;
|
|
524
|
+
policyVersion: number;
|
|
525
|
+
} | null {
|
|
526
|
+
const purpose = consentPurposeById(
|
|
527
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
528
|
+
ACCOUNT_TERMS_PURPOSE_ID,
|
|
529
|
+
);
|
|
530
|
+
return purpose
|
|
531
|
+
? {
|
|
532
|
+
stamp: `${purpose.id}@${purpose.policyVersion}`,
|
|
533
|
+
policyVersion: purpose.policyVersion,
|
|
534
|
+
}
|
|
535
|
+
: null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* The buyer's grant from a completed Stripe Checkout whose terms box was
|
|
540
|
+
* accepted (#8765 D14): ONE `account_terms` row, subject `person` /
|
|
541
|
+
* `personId`, source `checkout`, `sourceRef` the Checkout Session id, locale
|
|
542
|
+
* `en`, and the registry's current statement, hash and policy version — the
|
|
543
|
+
* statement the box showed once its link keys were resolved (D12).
|
|
544
|
+
*
|
|
545
|
+
* One transaction whose first statement is an advisory lock on
|
|
546
|
+
* `ailk:consent:<workspaceId>:checkout:<sessionId>`; then, when this Person
|
|
547
|
+
* already holds this session's `checkout` row, nothing is written (NFR7) — so
|
|
548
|
+
* a webhook redelivery, even after a restart, adds no row. The row goes
|
|
549
|
+
* through `recordConsent` on the transaction client. A `checkout` row is
|
|
550
|
+
* unverified (D13): it reaches only the operator's any-source reading.
|
|
551
|
+
* When the site registry's `account_terms` version is not the version the
|
|
552
|
+
* session was stamped with, nothing is written (`version_changed`): the row
|
|
553
|
+
* would otherwise record terms the box never showed.
|
|
554
|
+
*
|
|
555
|
+
* @param input.workspaceId the server-resolved workspace of the buyer's Person.
|
|
556
|
+
* @param input.personId the buyer's Person, resolved by `checkoutBuyerPerson`.
|
|
557
|
+
* @param input.sessionId the signed event's Checkout Session id.
|
|
558
|
+
* @param input.policyVersion the version the session's `ailk_terms` stamp names.
|
|
559
|
+
*/
|
|
560
|
+
export async function recordCheckoutConsent(input: {
|
|
561
|
+
workspaceId: string;
|
|
562
|
+
personId: string;
|
|
563
|
+
sessionId: string;
|
|
564
|
+
policyVersion: number;
|
|
565
|
+
}): Promise<RecordCheckoutConsentOutcome> {
|
|
566
|
+
const config = await consentConfigForSite(input.workspaceId);
|
|
567
|
+
const purpose = consentPurposeById(config, ACCOUNT_TERMS_PURPOSE_ID);
|
|
568
|
+
if (!purpose) return "purpose_unknown";
|
|
569
|
+
if (purpose.policyVersion !== input.policyVersion) return "version_changed";
|
|
570
|
+
|
|
571
|
+
return prisma.$transaction(async (tx) => {
|
|
572
|
+
await lockConsent(
|
|
573
|
+
tx,
|
|
574
|
+
`ailk:consent:${input.workspaceId}:checkout:${input.sessionId}`,
|
|
575
|
+
);
|
|
576
|
+
if (
|
|
577
|
+
await personHasCheckoutGrant(
|
|
578
|
+
input.workspaceId,
|
|
579
|
+
input.personId,
|
|
580
|
+
purpose.id,
|
|
581
|
+
input.sessionId,
|
|
582
|
+
tx,
|
|
583
|
+
)
|
|
584
|
+
) {
|
|
585
|
+
return "unchanged" as const;
|
|
586
|
+
}
|
|
587
|
+
await recordConsent(
|
|
588
|
+
{
|
|
589
|
+
siteId: input.workspaceId,
|
|
590
|
+
subjectType: "person",
|
|
591
|
+
subjectId: input.personId,
|
|
592
|
+
personId: input.personId,
|
|
593
|
+
source: "checkout",
|
|
594
|
+
sourceRef: input.sessionId,
|
|
595
|
+
locale: "en",
|
|
596
|
+
config,
|
|
597
|
+
consents: { [purpose.id]: true },
|
|
598
|
+
},
|
|
599
|
+
tx,
|
|
600
|
+
);
|
|
601
|
+
return "recorded" as const;
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
510
605
|
/**
|
|
511
606
|
* One registry purpose as a settings screen reads it (D6): the wording in
|
|
512
607
|
* `locale`, the policy link, and the caller's OWN state. `state` is `null`
|