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
|
@@ -59,27 +59,84 @@
|
|
|
59
59
|
* as `personId`; a callback with no email makes no Person call and writes no
|
|
60
60
|
* `personId`. Every one of these steps is best-effort exactly like the lead's.
|
|
61
61
|
*
|
|
62
|
-
* CHECKOUT PLAN (#7877 D10). A spoke's checkout fulfilment posts
|
|
63
|
-
* `submit` with the Checkout Session id as its `Idempotency-Key`.
|
|
64
|
-
*
|
|
65
|
-
* paid checkout lead from a registered spoke's server — `viaSpoke`, a
|
|
62
|
+
* CHECKOUT PLAN (#7877 D10, #8793 D1). A spoke's checkout fulfilment posts
|
|
63
|
+
* one `submit` with the Checkout Session id as its `Idempotency-Key`. A
|
|
64
|
+
* LIVE, paid checkout lead from a registered spoke's server — `viaSpoke`, a
|
|
66
65
|
* `cs_live_` key, `source: "web"`, `payload.page: "checkout"`, a string
|
|
67
66
|
* `payload.offering` and `payload.site`, and an `amountTotal` that is
|
|
68
|
-
* absent, `null` or a positive integer —
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
67
|
+
* absent, `null` or a positive integer — names a plan: the offering minus a
|
|
68
|
+
* leading `<site>_` (`person.ts`'s `checkoutPlanKey`). Its email is what
|
|
69
|
+
* the buyer typed at Stripe Checkout, which nobody verified, so the lead
|
|
70
|
+
* never writes any Person's plan (SEC-8748-D). It HOLDS it: on the first
|
|
71
|
+
* write of a marked lead (below) with a Person, the step logs one bare
|
|
72
|
+
* `{ event: "checkout-plan", outcome, siteId }` line at `info` — `held` for
|
|
73
|
+
* a plan key, `skipped` for none — and makes no Person call; the marked lead
|
|
74
|
+
* itself is the hold. `person.ts`'s `releaseHeldCheckoutPlan` writes it
|
|
75
|
+
* later, once, at the Person's first member-path link to the user whose
|
|
76
|
+
* verified email is the Person's own. No spoke file changes. The step never
|
|
77
|
+
* throws, never changes `routeLead`'s result, and never logs the offering,
|
|
78
|
+
* the email or the key.
|
|
74
79
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
80
|
+
* THE OFFERINGS TWIN (#8932 D7). The per-Organization Stripe webhook
|
|
81
|
+
* (`routes/webhooks/stripe-offerings.ts`) is the predicate's second source:
|
|
82
|
+
* `options.offeringsCheckout`, which it sets only after W3–W6 (its own
|
|
83
|
+
* signature, the hub's session record for that Organization and workspace,
|
|
84
|
+
* the recorded Price paid) and no request can set, a key
|
|
85
|
+
* `"stripe-offerings " + <cs_live_ session id>` (a space no `Idempotency-Key`
|
|
86
|
+
* header can carry), `source: "web"`, `payload.page: "checkout"`, a
|
|
87
|
+
* `payload.offering` `parseLookupKey` admits and a `payload.site` equal to
|
|
88
|
+
* the lead's workspace. Its plan key is the FULL lookup key (no `<site>_`
|
|
89
|
+
* prefix can match a workspace id), and its consent grant's `sourceRef` is
|
|
90
|
+
* the bare session id. A test-mode session's key fails the twin, so its lead
|
|
91
|
+
* is unmarked (#8932 C9).
|
|
92
|
+
*
|
|
93
|
+
* SPOKE CHECKOUT MARKER (#8185 D2). The same predicate — or its Offerings
|
|
94
|
+
* twin (#8932 D7) — plus "not relayed for a visitor" (`SourceMeta.relayed`),
|
|
95
|
+
* is evaluated once BEFORE the lead's create and stored as
|
|
96
|
+
* `Lead.spokeCheckout` — the hub's conclusion at write
|
|
78
97
|
* time, which `GET /v1/member/purchases` serves as a purchase
|
|
79
98
|
* (`lib/member-store.ts`). Written only in the submit create below, never by
|
|
80
99
|
* an update; an unmarked create carries no `spokeCheckout` key at all and
|
|
81
|
-
* relies on the column default. The plan
|
|
82
|
-
*
|
|
100
|
+
* relies on the column default. The plan step above runs only for a marked
|
|
101
|
+
* lead, after the write, and holds (#8793 D1), so a request relayed for a
|
|
102
|
+
* visitor never reaches it.
|
|
103
|
+
*
|
|
104
|
+
* PURCHASE EMAIL AND INBOX MESSAGE (#8757 D14). After this request's own
|
|
105
|
+
* write of a lead marked `spokeCheckout` with a Person — so never on a
|
|
106
|
+
* replay, a test-mode or relayed checkout, or a lead with no Person — the
|
|
107
|
+
* buyer gets one outbound `member` `Interaction` on that Person
|
|
108
|
+
* (`createdBy: "system:checkout"`, subject "Your purchase", no link, payload
|
|
109
|
+
* or attachment) and, fired with `void`, one `sendPurchaseConfirmationEmail`.
|
|
110
|
+
* Both name the offering only when its stripped key is one
|
|
111
|
+
* `listPlanOfferings` lists for the workspace, else "your purchase" — never
|
|
112
|
+
* the spoke-supplied `payload.offering` (SEC-8757-G) — and both bodies are
|
|
113
|
+
* stripped of control and bidi characters. The email's "Open your account"
|
|
114
|
+
* link is `options.accountOrigin` (the route resolves it, as for the invite
|
|
115
|
+
* email) plus the hub sign-in path: a marked lead is always a registered
|
|
116
|
+
* spoke's. With no origin the email goes without the link. The email is
|
|
117
|
+
* capped per recipient: more than `PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY`
|
|
118
|
+
* marked leads of this workspace for the address (any case) in the last 24
|
|
119
|
+
* hours, this one included, skips it; and per workspace: more than
|
|
120
|
+
* `PURCHASE_EMAILS_PER_SITE_PER_HOUR` marked leads in the last hour skips
|
|
121
|
+
* it. The message is still written. Neither
|
|
122
|
+
* step ever throws or changes the lead's result, and their one log line,
|
|
123
|
+
* `{ event: "checkout-purchase", step, outcome }`, names no address or
|
|
124
|
+
* offering.
|
|
125
|
+
*
|
|
126
|
+
* CHECKOUT CONSENT GRANT (#8778). After the same first write of a marked
|
|
127
|
+
* lead with a Person, a lead whose `payload.termsAccepted` equals the current
|
|
128
|
+
* `account_terms@<version>` stamp (`currentAccountTermsStamp`) — the spoke's
|
|
129
|
+
* fulfilment sends the stamp of the terms the buyer ticked in Stripe's box,
|
|
130
|
+
* else `null` — records the buyer's checkout `ConsentGrant` through #8765's
|
|
131
|
+
* `recordCheckoutConsent`: one `account_terms` row for that Person, source
|
|
132
|
+
* `checkout`, `sourceRef` the Checkout Session id (the lead's
|
|
133
|
+
* `Idempotency-Key`), at most once per Person and session. A `null` or absent
|
|
134
|
+
* value writes nothing (`consent_absent`), as does any other value
|
|
135
|
+
* (`terms_unstamped`). A replay never reaches it, and the key is left out of
|
|
136
|
+
* the replay comparison (`comparablePayload`). Never throws and never
|
|
137
|
+
* changes the lead's result; its one log line,
|
|
138
|
+
* `{ event: "checkout-consent", outcome, siteId }` (plus `errName` on
|
|
139
|
+
* `failed`), names no address or Person.
|
|
83
140
|
*
|
|
84
141
|
* VISITOR LINK (#8438 D11). When the request carried a valid consent visitor
|
|
85
142
|
* id (`SourceMeta.consentVisitorId`, set by the leads and flows routes from
|
|
@@ -95,12 +152,17 @@ import { randomUUID } from "node:crypto";
|
|
|
95
152
|
import { isDeepStrictEqual } from "node:util";
|
|
96
153
|
|
|
97
154
|
import type { Prisma } from "@working-theory/database";
|
|
98
|
-
import { sendLeadNotification } from "@working-theory/email";
|
|
155
|
+
import { sendLeadNotification, sendPurchaseConfirmationEmail } from "@working-theory/email";
|
|
99
156
|
import { logger } from "@working-theory/observability";
|
|
100
157
|
import {
|
|
158
|
+
accountInvitePath,
|
|
159
|
+
isConsentLeadSource,
|
|
101
160
|
LEAD_SITE_MARKER_KEY,
|
|
102
161
|
leadInputSchema,
|
|
103
|
-
|
|
162
|
+
parseLookupKey,
|
|
163
|
+
PURCHASE_EMAIL_DEFAULTS,
|
|
164
|
+
PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY,
|
|
165
|
+
stripPlainText,
|
|
104
166
|
} from "@working-theory/validation";
|
|
105
167
|
|
|
106
168
|
import { writeLeadFallback } from "../lib/file-fallback.js";
|
|
@@ -108,15 +170,21 @@ import { appendLeadRecord } from "../lib/lead-capture-store.js";
|
|
|
108
170
|
import { acceptsKind, resolveLeadDelivery } from "../lib/lead-delivery.js";
|
|
109
171
|
import { prisma } from "../lib/prisma.js";
|
|
110
172
|
|
|
111
|
-
import {
|
|
173
|
+
import {
|
|
174
|
+
currentAccountTermsStamp,
|
|
175
|
+
linkConsentVisitor,
|
|
176
|
+
recordCheckoutConsent,
|
|
177
|
+
type RecordCheckoutConsentOutcome,
|
|
178
|
+
} from "./consent.js";
|
|
112
179
|
import { enrichLead } from "./enrichment-shim.js";
|
|
113
180
|
import {
|
|
181
|
+
checkoutPlanKey,
|
|
114
182
|
findOrCreatePerson,
|
|
115
183
|
normalizeCapturedContact,
|
|
116
184
|
normalizeTelephone,
|
|
117
|
-
setPersonPlanOffering,
|
|
118
185
|
} from "./person.js";
|
|
119
186
|
import type { LeadContact, PersonContact } from "./person.js";
|
|
187
|
+
import { listPlanOfferings } from "./plan-offerings.js";
|
|
120
188
|
import { requestConfirmSupersedesNotification } from "./request-confirm.js";
|
|
121
189
|
|
|
122
190
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
@@ -178,6 +246,20 @@ export type RouteLeadOptions = {
|
|
|
178
246
|
* `payload` stays the raw answers.
|
|
179
247
|
*/
|
|
180
248
|
contact?: LeadContact;
|
|
249
|
+
/**
|
|
250
|
+
* Where the buyer's account lives (#8757 D14): the origin the purchase
|
|
251
|
+
* email's "Open your account" link starts sign-in on, resolved by the route
|
|
252
|
+
* from the caller, never the body. Read for a marked checkout lead only.
|
|
253
|
+
*/
|
|
254
|
+
accountOrigin?: string;
|
|
255
|
+
/**
|
|
256
|
+
* Set only by the per-Organization Stripe webhook after W5 and W6 (#8932
|
|
257
|
+
* D7) — a signature from the Organization's own secret, the hub's own
|
|
258
|
+
* session record for that Organization and workspace, and the recorded
|
|
259
|
+
* Price paid; never from a request. Admits the Offerings twin of the
|
|
260
|
+
* checkout plan predicate (`offeringsCheckoutCandidate`).
|
|
261
|
+
*/
|
|
262
|
+
offeringsCheckout?: true;
|
|
181
263
|
};
|
|
182
264
|
|
|
183
265
|
export type RouteLeadResult =
|
|
@@ -252,15 +334,48 @@ async function findLeadByKey(
|
|
|
252
334
|
})) as KeyedRow | null;
|
|
253
335
|
}
|
|
254
336
|
|
|
337
|
+
/**
|
|
338
|
+
* A consent-bearing lead's `payload.consent` — a contact lead's (#8730 D3) or,
|
|
339
|
+
* since #9130, a job application's — is written by the route on every request,
|
|
340
|
+
* its `acceptedAt` that request's receive time, so it is left out of the replay
|
|
341
|
+
* comparison for every `isConsentLeadSource` source; every key the client sent
|
|
342
|
+
* still counts.
|
|
343
|
+
*
|
|
344
|
+
* A checkout lead's `payload.termsAccepted` (#8778) is left out too: a spoke
|
|
345
|
+
* updated between a stored attempt and Stripe's redelivery sends the key the
|
|
346
|
+
* stored body lacks, and a conflict there would fail every retry. A replay
|
|
347
|
+
* writes no grant, so the key decides nothing on one.
|
|
348
|
+
*/
|
|
349
|
+
function comparablePayload(payload: unknown, source: string): unknown {
|
|
350
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
351
|
+
return payload;
|
|
352
|
+
}
|
|
353
|
+
const record = payload as Record<string, unknown>;
|
|
354
|
+
if (isConsentLeadSource(source)) {
|
|
355
|
+
const { consent: _serverWritten, ...rest } = record;
|
|
356
|
+
return rest;
|
|
357
|
+
}
|
|
358
|
+
if (record["page"] === "checkout") {
|
|
359
|
+
const { termsAccepted: _addedByUpdate, ...rest } = record;
|
|
360
|
+
return rest;
|
|
361
|
+
}
|
|
362
|
+
return payload;
|
|
363
|
+
}
|
|
364
|
+
|
|
255
365
|
/**
|
|
256
366
|
* A stored row is a replay only when it carries this request's exact `email`
|
|
257
367
|
* and `source` and a deep-equal `payload` (key order ignored, SEC-7869-3);
|
|
258
|
-
* any other row is a conflict (#7869 D4).
|
|
368
|
+
* any other row is a conflict (#7869 D4). A consent-bearing lead's
|
|
369
|
+
* server-written `payload.consent` is not compared (#8730, #9130), nor a
|
|
370
|
+
* checkout lead's `payload.termsAccepted` (#8778).
|
|
259
371
|
*/
|
|
260
372
|
function classifyKeyedRow(row: KeyedRow, input: SubmitInput): KeyedOutcome {
|
|
261
373
|
return row.email === input.email &&
|
|
262
374
|
row.source === input.source &&
|
|
263
|
-
isDeepStrictEqual(
|
|
375
|
+
isDeepStrictEqual(
|
|
376
|
+
comparablePayload(row.payload, row.source),
|
|
377
|
+
comparablePayload(input.payload, input.source),
|
|
378
|
+
)
|
|
264
379
|
? { kind: "replay", id: row.id }
|
|
265
380
|
: { kind: "conflict" };
|
|
266
381
|
}
|
|
@@ -346,6 +461,9 @@ async function resolveLeadPersonId(input: SubmitInput, contact?: LeadContact): P
|
|
|
346
461
|
});
|
|
347
462
|
}
|
|
348
463
|
|
|
464
|
+
/** A marked checkout lead's offering and site; `sessionId` is the Offerings twin's bare session id. */
|
|
465
|
+
type CheckoutCandidate = { offering: string; site: string; sessionId?: string };
|
|
466
|
+
|
|
349
467
|
/** A LIVE-mode Checkout Session id (#7877 D10, SEC-7877-C). */
|
|
350
468
|
const LIVE_CHECKOUT_SESSION_KEY = /^cs_live_[A-Za-z0-9]{1,250}$/;
|
|
351
469
|
const SITE_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
@@ -358,7 +476,7 @@ function checkoutPlanCandidate(
|
|
|
358
476
|
input: SubmitInput,
|
|
359
477
|
idempotencyKey: string | undefined,
|
|
360
478
|
viaSpoke: boolean,
|
|
361
|
-
):
|
|
479
|
+
): CheckoutCandidate | null {
|
|
362
480
|
if (viaSpoke !== true) return null;
|
|
363
481
|
if (idempotencyKey === undefined || !LIVE_CHECKOUT_SESSION_KEY.test(idempotencyKey)) {
|
|
364
482
|
return null;
|
|
@@ -381,35 +499,283 @@ function checkoutPlanCandidate(
|
|
|
381
499
|
return { offering, site };
|
|
382
500
|
}
|
|
383
501
|
|
|
502
|
+
/** The Offerings webhook's key: a space no `Idempotency-Key` header can carry, then a LIVE session id. */
|
|
503
|
+
const OFFERINGS_CHECKOUT_KEY = /^stripe-offerings (cs_live_[A-Za-z0-9]{1,250})$/;
|
|
504
|
+
|
|
384
505
|
/**
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
506
|
+
* The Offerings twin of `checkoutPlanCandidate` (#8932 D7): a lead the
|
|
507
|
+
* per-Organization Stripe webhook wrote after W5 and W6, for a LIVE session
|
|
508
|
+
* only (C9, SEC-7877-C) — a test-mode session's lead is written unmarked.
|
|
509
|
+
* Reads no amount: W6 already proved the session paid. `checkoutPlanCandidate`
|
|
510
|
+
* is not edited, so a spoke lead is marked exactly as before.
|
|
388
511
|
*/
|
|
389
|
-
|
|
512
|
+
function offeringsCheckoutCandidate(
|
|
390
513
|
input: SubmitInput,
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
):
|
|
514
|
+
idempotencyKey: string | undefined,
|
|
515
|
+
offeringsCheckout: boolean,
|
|
516
|
+
): CheckoutCandidate | null {
|
|
517
|
+
if (offeringsCheckout !== true || idempotencyKey === undefined) return null;
|
|
518
|
+
const sessionId = OFFERINGS_CHECKOUT_KEY.exec(idempotencyKey)?.[1];
|
|
519
|
+
if (sessionId === undefined) return null;
|
|
520
|
+
const { payload } = input;
|
|
521
|
+
if (input.source !== "web" || payload["page"] !== "checkout") return null;
|
|
522
|
+
const offering = payload["offering"];
|
|
523
|
+
if (typeof offering !== "string" || parseLookupKey(offering) === null) return null;
|
|
524
|
+
const site = payload[LEAD_SITE_MARKER_KEY];
|
|
525
|
+
if (typeof site !== "string" || site !== input.siteId) return null;
|
|
526
|
+
return { offering, site, sessionId };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** The candidate's offering minus a leading `<site>_` (#7877 D10). */
|
|
530
|
+
function stripSitePrefix(candidate: CheckoutCandidate): string {
|
|
394
531
|
const prefix = `${candidate.site}_`;
|
|
395
|
-
|
|
532
|
+
return candidate.offering.startsWith(prefix)
|
|
396
533
|
? candidate.offering.slice(prefix.length)
|
|
397
534
|
: candidate.offering;
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Hold the plan of a marked checkout lead (#7877 D10, #8793 D1): no Person
|
|
539
|
+
* call — the marked lead itself is the hold, released only at the Person's
|
|
540
|
+
* verified first link (`person.ts`'s `releaseHeldCheckoutPlan`). Logs
|
|
541
|
+
* `held` for an offering that is a plan key, else `skipped`, and the site
|
|
542
|
+
* only.
|
|
543
|
+
*/
|
|
544
|
+
function applyCheckoutPlan(
|
|
545
|
+
input: SubmitInput,
|
|
546
|
+
candidate: CheckoutCandidate,
|
|
547
|
+
): void {
|
|
548
|
+
const outcome = checkoutPlanKey(candidate.offering, candidate.site) === null ? "skipped" : "held";
|
|
549
|
+
logger.info({ event: "checkout-plan", outcome, siteId: input.siteId }, "checkout plan");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ─── Purchase email and Inbox message (#8757 D14) ────────────────────────────
|
|
553
|
+
|
|
554
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
555
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
556
|
+
/**
|
|
557
|
+
* A workspace's purchase emails per hour, whatever the address (SEC review of
|
|
558
|
+
* #8770, M1): a marked lead's inputs are all the spoke's own, so the
|
|
559
|
+
* per-recipient cap alone would let a spoke key mail any number of distinct
|
|
560
|
+
* addresses. The invite email's per-site cap
|
|
561
|
+
* (`INVITE_EMAILS_PER_SITE_PER_HOUR`) has the same value.
|
|
562
|
+
*/
|
|
563
|
+
const PURCHASE_EMAILS_PER_SITE_PER_HOUR = 50;
|
|
564
|
+
const GENERIC_PURCHASE_BODY = "Thanks for your purchase.";
|
|
565
|
+
/** How long the lead write waits on the offering's name before it says "your purchase". */
|
|
566
|
+
const OFFERING_NAME_BUDGET_MS = 1500;
|
|
567
|
+
/**
|
|
568
|
+
* Stripe's special-case currencies, whose minor units differ from the ISO
|
|
569
|
+
* decimals `Intl` formats with: the amount is left out rather than misstated.
|
|
570
|
+
*/
|
|
571
|
+
const AMOUNT_UNSTATED_CURRENCIES = new Set(["HUF", "ISK", "TWD", "UGX"]);
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* The purchase email's sender: every completion email's rule
|
|
575
|
+
* (`AILK_DELIVERABLE_EMAIL_FROM`, else `AILK_LEAD_NOTIFICATION_FROM`; `||`, so
|
|
576
|
+
* "" names none), the same as `magnet-completion.ts`'s `inviteSenderAddress`,
|
|
577
|
+
* kept here so the lead path does not load that module's graph.
|
|
578
|
+
*/
|
|
579
|
+
function purchaseSenderAddress(): string | undefined {
|
|
580
|
+
return (
|
|
581
|
+
process.env.AILK_DELIVERABLE_EMAIL_FROM ||
|
|
582
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM ||
|
|
583
|
+
undefined
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
type PurchaseStep = "message" | "email";
|
|
588
|
+
|
|
589
|
+
function logPurchase(siteId: string, step: PurchaseStep, outcome: string): void {
|
|
590
|
+
const line = { event: "checkout-purchase", step, outcome, siteId };
|
|
591
|
+
if (outcome === "failed") logger.error(line, "checkout purchase step failed");
|
|
592
|
+
else logger.info(line, "checkout purchase step");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* The offering's listed name, or `null` when its stripped key is not listed,
|
|
597
|
+
* the read fails, or it outlasts `OFFERING_NAME_BUDGET_MS`.
|
|
598
|
+
*/
|
|
599
|
+
async function listedOfferingName(siteId: string, offering: string): Promise<string | null> {
|
|
600
|
+
let timer: NodeJS.Timeout | undefined;
|
|
601
|
+
const budget = new Promise<null>((resolve) => {
|
|
602
|
+
timer = setTimeout(() => resolve(null), OFFERING_NAME_BUDGET_MS);
|
|
603
|
+
});
|
|
604
|
+
try {
|
|
605
|
+
const listed = await Promise.race([listPlanOfferings(siteId), budget]);
|
|
606
|
+
if (!Array.isArray(listed)) return null;
|
|
607
|
+
const name = listed.find((item) => item.key === offering)?.name;
|
|
608
|
+
const clean = name === undefined ? "" : stripPlainText(name).trim();
|
|
609
|
+
return clean === "" ? null : clean;
|
|
610
|
+
} catch {
|
|
611
|
+
return null;
|
|
612
|
+
} finally {
|
|
613
|
+
clearTimeout(timer);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** `amountTotal` (minor units) in `currency`, formatted, or `null` when either is unusable. */
|
|
618
|
+
function formattedAmount(amountTotal: unknown, currency: unknown): string | null {
|
|
619
|
+
if (typeof amountTotal !== "number" || !Number.isInteger(amountTotal) || amountTotal <= 0) {
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
if (typeof currency !== "string" || !/^[A-Za-z]{3}$/.test(currency)) return null;
|
|
623
|
+
const code = currency.toUpperCase();
|
|
624
|
+
if (AMOUNT_UNSTATED_CURRENCIES.has(code)) return null;
|
|
625
|
+
try {
|
|
626
|
+
const format = new Intl.NumberFormat("en", { style: "currency", currency: code });
|
|
627
|
+
const digits = format.resolvedOptions().maximumFractionDigits ?? 2;
|
|
628
|
+
return format.format(amountTotal / 10 ** digits);
|
|
629
|
+
} catch {
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** The one plain-text body the email and the message share. */
|
|
635
|
+
function purchaseBody(name: string | null, amount: string | null): string {
|
|
636
|
+
const sentence =
|
|
637
|
+
name === null ? GENERIC_PURCHASE_BODY : PURCHASE_EMAIL_DEFAULTS.body.replaceAll("{offering}", name);
|
|
638
|
+
return stripPlainText(amount === null ? sentence : `${sentence} Total: ${amount}.`);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** The purchase email, capped per recipient (D14). Never throws; fired with `void`. */
|
|
642
|
+
async function sendPurchaseEmail(
|
|
643
|
+
input: SubmitInput,
|
|
644
|
+
body: string,
|
|
645
|
+
accountOrigin: string | undefined,
|
|
646
|
+
): Promise<void> {
|
|
647
|
+
let outcome: string;
|
|
648
|
+
try {
|
|
649
|
+
const recent = await prisma.lead.count({
|
|
650
|
+
where: {
|
|
651
|
+
workspaceId: input.siteId,
|
|
652
|
+
spokeCheckout: true,
|
|
653
|
+
email: { equals: input.email, mode: "insensitive" },
|
|
654
|
+
createdAt: { gte: new Date(Date.now() - DAY_MS) },
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
const siteHour = await prisma.lead.count({
|
|
658
|
+
where: {
|
|
659
|
+
workspaceId: input.siteId,
|
|
660
|
+
spokeCheckout: true,
|
|
661
|
+
createdAt: { gte: new Date(Date.now() - HOUR_MS) },
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
const fromAddress = purchaseSenderAddress();
|
|
665
|
+
if (recent > PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY) {
|
|
666
|
+
outcome = "capped_recipient";
|
|
667
|
+
} else if (siteHour > PURCHASE_EMAILS_PER_SITE_PER_HOUR) {
|
|
668
|
+
outcome = "capped_site";
|
|
669
|
+
} else if (!fromAddress) {
|
|
670
|
+
outcome = "sender_unconfigured";
|
|
671
|
+
} else {
|
|
672
|
+
let accountLink: string | undefined;
|
|
673
|
+
if (accountOrigin !== undefined) {
|
|
674
|
+
try {
|
|
675
|
+
accountLink = accountOrigin + accountInvitePath("hub", input.email);
|
|
676
|
+
} catch {
|
|
677
|
+
// An address the sign-in hint refuses: the email goes without the link.
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const result = await sendPurchaseConfirmationEmail({
|
|
681
|
+
to: input.email,
|
|
682
|
+
fromAddress,
|
|
683
|
+
subject: PURCHASE_EMAIL_DEFAULTS.subject,
|
|
684
|
+
body,
|
|
685
|
+
cta: PURCHASE_EMAIL_DEFAULTS.cta,
|
|
686
|
+
...(accountLink !== undefined && { accountLink }),
|
|
687
|
+
});
|
|
688
|
+
outcome = result.ok ? "sent" : "failed";
|
|
689
|
+
}
|
|
690
|
+
} catch {
|
|
691
|
+
outcome = "failed";
|
|
692
|
+
}
|
|
693
|
+
logPurchase(input.siteId, "email", outcome);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* The buyer's Inbox message and purchase email for a marked checkout lead
|
|
698
|
+
* just written (D14). Never throws and never changes the lead's result.
|
|
699
|
+
*/
|
|
700
|
+
async function recordPurchase(
|
|
701
|
+
input: SubmitInput,
|
|
702
|
+
personId: string,
|
|
703
|
+
candidate: CheckoutCandidate,
|
|
704
|
+
accountOrigin: string | undefined,
|
|
705
|
+
): Promise<void> {
|
|
706
|
+
// One guard around the whole step: it runs inside `routeLead`'s persist
|
|
707
|
+
// `try`, where a throw after the row is written would be read as a failed
|
|
708
|
+
// write and the lead captured a second time.
|
|
709
|
+
let body: string;
|
|
710
|
+
try {
|
|
711
|
+
const name = await listedOfferingName(input.siteId, stripSitePrefix(candidate));
|
|
712
|
+
body = purchaseBody(name, formattedAmount(input.payload["amountTotal"], input.payload["currency"]));
|
|
713
|
+
} catch {
|
|
714
|
+
logPurchase(input.siteId, "message", "failed");
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
await prisma.interaction.create({
|
|
719
|
+
data: {
|
|
720
|
+
workspaceId: input.siteId,
|
|
721
|
+
personId,
|
|
722
|
+
channel: "member",
|
|
723
|
+
direction: "outbound",
|
|
724
|
+
subject: PURCHASE_EMAIL_DEFAULTS.subject,
|
|
725
|
+
body,
|
|
726
|
+
createdBy: "system:checkout",
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
logPurchase(input.siteId, "message", "written");
|
|
730
|
+
} catch {
|
|
731
|
+
logPurchase(input.siteId, "message", "failed");
|
|
732
|
+
}
|
|
733
|
+
void sendPurchaseEmail(input, body, accountOrigin);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The buyer's checkout consent grant for a marked checkout lead just written
|
|
738
|
+
* (#8778). Never throws and never changes the lead's result.
|
|
739
|
+
*/
|
|
740
|
+
async function recordSpokeCheckoutConsent(
|
|
741
|
+
input: SubmitInput,
|
|
742
|
+
personId: string,
|
|
743
|
+
sessionId: string,
|
|
744
|
+
): Promise<void> {
|
|
745
|
+
const accepted = input.payload["termsAccepted"];
|
|
746
|
+
let outcome:
|
|
747
|
+
| RecordCheckoutConsentOutcome
|
|
748
|
+
| "consent_absent"
|
|
749
|
+
| "terms_unstamped"
|
|
750
|
+
| "failed";
|
|
751
|
+
let errName: string | undefined;
|
|
752
|
+
try {
|
|
753
|
+
const expected = currentAccountTermsStamp();
|
|
754
|
+
if (accepted === undefined || accepted === null) {
|
|
755
|
+
outcome = "consent_absent";
|
|
756
|
+
} else if (expected === null || accepted !== expected.stamp) {
|
|
757
|
+
outcome = "terms_unstamped";
|
|
758
|
+
} else {
|
|
759
|
+
outcome = await recordCheckoutConsent({
|
|
760
|
+
workspaceId: input.siteId,
|
|
761
|
+
personId,
|
|
762
|
+
sessionId,
|
|
763
|
+
policyVersion: expected.policyVersion,
|
|
764
|
+
});
|
|
408
765
|
}
|
|
766
|
+
} catch (err) {
|
|
767
|
+
// The error's class only (a Prisma error's message can carry values).
|
|
768
|
+
outcome = "failed";
|
|
769
|
+
errName = err instanceof Error ? err.name : "UnknownError";
|
|
409
770
|
}
|
|
410
|
-
const line = {
|
|
411
|
-
|
|
412
|
-
|
|
771
|
+
const line = {
|
|
772
|
+
event: "checkout-consent",
|
|
773
|
+
outcome,
|
|
774
|
+
siteId: input.siteId,
|
|
775
|
+
...(errName !== undefined && { errName }),
|
|
776
|
+
};
|
|
777
|
+
if (outcome === "failed") logger.error(line, "checkout terms grant failed");
|
|
778
|
+
else logger.info(line, "checkout terms grant");
|
|
413
779
|
}
|
|
414
780
|
|
|
415
781
|
async function persistToDb(
|
|
@@ -418,13 +784,18 @@ async function persistToDb(
|
|
|
418
784
|
viaSpoke = false,
|
|
419
785
|
relayed = false,
|
|
420
786
|
contact?: LeadContact,
|
|
787
|
+
accountOrigin?: string,
|
|
788
|
+
offeringsCheckout = false,
|
|
421
789
|
): Promise<PersistResult> {
|
|
422
790
|
if (input.kind === "submit") {
|
|
423
791
|
const personId = await resolveLeadPersonId(input, contact);
|
|
424
792
|
// #8185 D2 — the plan rule's own predicate (pure: input, key, flag),
|
|
425
793
|
// evaluated before the create so its result can be stored. A relayed
|
|
426
|
-
// request is never a spoke's server call (D3).
|
|
427
|
-
|
|
794
|
+
// request is never a spoke's server call (D3). #8932 D7 — or its
|
|
795
|
+
// Offerings twin, set only by the per-Organization Stripe webhook.
|
|
796
|
+
const candidate =
|
|
797
|
+
checkoutPlanCandidate(input, idempotencyKey, viaSpoke) ??
|
|
798
|
+
offeringsCheckoutCandidate(input, idempotencyKey, offeringsCheckout);
|
|
428
799
|
const spokeCheckout = candidate !== null && relayed !== true;
|
|
429
800
|
let row: DbRow;
|
|
430
801
|
try {
|
|
@@ -470,9 +841,20 @@ async function persistToDb(
|
|
|
470
841
|
if (!winner) throw err;
|
|
471
842
|
return { ok: "keyed", outcome: classifyKeyedRow(winner, input) };
|
|
472
843
|
}
|
|
473
|
-
// #7877 D10 — only after THIS request's own write, so a replay
|
|
844
|
+
// #7877 D10 — only after THIS request's own write, so a replay holds nothing.
|
|
474
845
|
if (candidate !== null && personId !== null) {
|
|
475
|
-
|
|
846
|
+
// #8757 D14 — the marked lead only: live, unrelayed, this write.
|
|
847
|
+
if (spokeCheckout) {
|
|
848
|
+
// #8793 D1 — the plan is held, never written here.
|
|
849
|
+
applyCheckoutPlan(input, candidate);
|
|
850
|
+
await recordPurchase(input, personId, candidate, accountOrigin);
|
|
851
|
+
// #8778 — a marked lead's key is its live Checkout Session id;
|
|
852
|
+
// #8932 D7 — the Offerings twin's key is prefixed, so its bare id.
|
|
853
|
+
const sessionId = candidate.sessionId ?? idempotencyKey;
|
|
854
|
+
if (sessionId !== undefined) {
|
|
855
|
+
await recordSpokeCheckoutConsent(input, personId, sessionId);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
476
858
|
}
|
|
477
859
|
return { ok: true, id: row.id, source: "db", skipEmail: false, personId };
|
|
478
860
|
}
|
|
@@ -836,6 +1218,8 @@ export async function routeLead(
|
|
|
836
1218
|
sourceMeta.viaSpoke === true,
|
|
837
1219
|
sourceMeta.relayed === true,
|
|
838
1220
|
input.kind === "submit" ? options.contact : undefined,
|
|
1221
|
+
input.kind === "submit" ? options.accountOrigin : undefined,
|
|
1222
|
+
input.kind === "submit" && options.offeringsCheckout === true,
|
|
839
1223
|
);
|
|
840
1224
|
// #7869 D5 — a lost race answers like the lookup above: no capture
|
|
841
1225
|
// record, no notification.
|