create-ailk 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -14
- package/component-catalog.json +149 -5
- package/component-catalog.md +7 -6
- package/dist/add-module.js +17 -10
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +301 -13
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +7 -5
- package/dist/derive-architecture.js +26 -32
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +0 -4
- package/dist/lib/npm-auth.d.ts +9 -0
- package/dist/lib/npm-auth.js +48 -0
- package/dist/lib/scaffold-render-sweep.d.ts +36 -2
- package/dist/lib/scaffold-render-sweep.js +50 -8
- package/dist/module-architecture.js +115 -10
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/spoke-checkout.d.ts +38 -35
- package/dist/spoke-checkout.js +66 -229
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +33 -12
- package/dist/sync-routes.d.ts +68 -5
- package/dist/sync-routes.js +455 -29
- package/dist/template-record.d.ts +1 -1
- package/dist/template-record.js +3 -2
- package/dist/update.js +10 -0
- package/package.json +3 -3
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
- package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/templates/.env.example +13 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/CLAUDE.md +4 -2
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +34 -0
- package/templates/apps/api/CLAUDE.md +94 -19
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
- package/templates/apps/api/src/config/index.ts +109 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -1
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/site.ts +4 -1
- package/templates/apps/api/src/lib/stripe.ts +77 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
- package/templates/apps/api/src/openapi/spec.ts +343 -7
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1426 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
- package/templates/apps/api/src/routes/checkout/session.ts +57 -25
- package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
- package/templates/apps/api/src/routes/consent/account.ts +4 -2
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
- package/templates/apps/api/src/routes/content/get-page.ts +34 -119
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
- package/templates/apps/api/src/routes/content/offerings.ts +184 -0
- package/templates/apps/api/src/routes/content/pricing.ts +176 -0
- package/templates/apps/api/src/routes/content/search.ts +16 -45
- package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/index.ts +377 -308
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +6 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
- package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +93 -17
- package/templates/apps/api/src/server.ts +165 -51
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -2
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
- package/templates/apps/api/src/services/__tests__/person.test.ts +260 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
- package/templates/apps/api/src/services/abandon-email.ts +28 -5
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +113 -58
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +348 -0
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +975 -0
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +400 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1083 -0
- package/templates/apps/api/src/services/offerings/index.ts +614 -0
- package/templates/apps/api/src/services/offerings/source.ts +140 -0
- package/templates/apps/api/src/services/offerings/store.ts +150 -0
- package/templates/apps/api/src/services/person.ts +147 -9
- package/templates/apps/api/src/services/plan-offerings.ts +96 -30
- package/templates/apps/api/src/services/project-listings.ts +43 -31
- package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
- package/templates/apps/mcp/CLAUDE.md +32 -18
- package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +95 -14
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
- package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
- package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
- package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
- package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
- package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
- package/templates/apps/mcp/__tests__/server.test.ts +13 -11
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/package.json +1 -0
- package/templates/apps/mcp/src/abuse/index.ts +18 -11
- package/templates/apps/mcp/src/http-transport.ts +47 -9
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/lib/site-config.ts +53 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
- package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
- package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
- package/templates/apps/mcp/src/tools/index.ts +22 -21
- package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
- package/templates/apps/mcp/src/tools/purchase.ts +30 -2
- package/templates/apps/mcp/src/tools/read_content.ts +46 -40
- package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
- package/templates/apps/mcp/src/tools/search_content.ts +20 -24
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +10 -31
- package/templates/apps/web/CLAUDE.md +324 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +20 -6
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +369 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
- package/templates/apps/web/app/[locale]/layout.tsx +55 -4
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
- package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
- package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +64 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +9 -20
- package/templates/apps/web/lib/deck-page.ts +1 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- package/templates/apps/web/lib/in-page-magnet.ts +416 -0
- package/templates/apps/web/lib/magnet-routes.ts +44 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +205 -0
- package/templates/apps/web/lib/offerings-pricing.ts +326 -0
- package/templates/apps/web/lib/posting-title.ts +17 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
- package/templates/apps/web/messages/en.json +49 -15
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/security-headers.config.ts +17 -0
- package/templates/content/en/BlogList/blog.mdx +4 -71
- package/templates/content/en/BookCall/book-a-call.mdx +1 -41
- package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
- package/templates/content/en/BookCall/onsite-service.mdx +1 -17
- package/templates/content/en/BookCall/start-project.mdx +1 -17
- package/templates/content/en/ContactPage/contact.mdx +6 -63
- package/templates/content/en/HomePage/home.mdx +3 -21
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
- package/templates/database/__tests__/offerings-migration.test.ts +197 -0
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
- package/templates/database/__tests__/offerings.db.test.ts +303 -0
- package/templates/database/__tests__/seed.test.ts +63 -1
- package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
- package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +57 -2
- package/templates/tools/lint-architecture.ts +205 -1
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/api/src/routes/content/slug.ts +0 -9
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
- package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
- package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/checkout.ts +0 -93
- package/templates/apps/web/lib/meeting-slugs.ts +0 -63
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Stripe-backed Offerings catalog source (#8922, spec
|
|
3
|
+
* docs/specs/8922-the-may-sell-product-list-spec.md D2; contract
|
|
4
|
+
* docs/specs/8879-the-offerings-security-contract-key-spec.md C8, T1, T7) —
|
|
5
|
+
* the `OfferingsSourceFor` the routes pass to the read services: one
|
|
6
|
+
* Organization's active Products and Prices, read through its own
|
|
7
|
+
* connection's client (#8907 D12) and paged to the normalizer's caps.
|
|
8
|
+
*
|
|
9
|
+
* `stripeCatalogSourceFor` does no work when called, so it can never throw
|
|
10
|
+
* outside the services' failure memo (#8876 D8). It takes a bare
|
|
11
|
+
* Organization id and checks neither the tenant nor the claim — its callers
|
|
12
|
+
* do — so `architecture.yaml` confines it to the exact files that call it.
|
|
13
|
+
*
|
|
14
|
+
* BOUNDED (SEC-8922-3). Each list is paged by calls, not items: at most
|
|
15
|
+
* `OFFERINGS_CATALOG_PRODUCTS_MAX / 100` product calls and
|
|
16
|
+
* `OFFERINGS_CATALOG_PRICES_MAX / 100` price calls, stopping early on
|
|
17
|
+
* `has_more: false`, an empty page, a page whose last id repeats the cursor
|
|
18
|
+
* it was asked with, or a full count. The whole read is bounded by
|
|
19
|
+
* `OFFERINGS_CATALOG_READ_TIMEOUT_MS`; after it fails, no further call is
|
|
20
|
+
* started.
|
|
21
|
+
*
|
|
22
|
+
* A list cut off at its cap while Stripe still had more is logged as
|
|
23
|
+
* `catalog_truncated` (SEC-8922-7), with the Organization and nothing else.
|
|
24
|
+
*
|
|
25
|
+
* Nothing is filtered or cached here: the client lives only inside
|
|
26
|
+
* one read (C8), and every field is normalized once by `normalizeCatalog`
|
|
27
|
+
* (T1). A disconnected or unusable connection rejects, never reading as an
|
|
28
|
+
* empty catalog; a Stripe error rejects as itself, and the services turn it
|
|
29
|
+
* into `OfferingsSourceError` without its text.
|
|
30
|
+
*/
|
|
31
|
+
import { logger } from "@working-theory/observability";
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
OFFERINGS_CATALOG_PRICES_MAX,
|
|
35
|
+
OFFERINGS_CATALOG_PRODUCTS_MAX,
|
|
36
|
+
type OfferingsCatalog,
|
|
37
|
+
type OfferingsCatalogSource,
|
|
38
|
+
} from "./catalog.js";
|
|
39
|
+
import { stripeClientForOrganization } from "./connection.js";
|
|
40
|
+
|
|
41
|
+
import type { OfferingsSourceFor } from "./index.js";
|
|
42
|
+
|
|
43
|
+
/** The longest one catalog read may take, connection lookup included. */
|
|
44
|
+
export const OFFERINGS_CATALOG_READ_TIMEOUT_MS = 20_000;
|
|
45
|
+
|
|
46
|
+
const PAGE_LIMIT = 100;
|
|
47
|
+
|
|
48
|
+
/** No usable connection, or the read timed out. The message is fixed; there is no cause. */
|
|
49
|
+
export class OfferingsCatalogUnavailableError extends Error {
|
|
50
|
+
constructor() {
|
|
51
|
+
super("The Offerings catalog source is unavailable");
|
|
52
|
+
this.name = "OfferingsCatalogUnavailableError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ListParams {
|
|
57
|
+
active: true;
|
|
58
|
+
limit: number;
|
|
59
|
+
starting_after?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface ListPage<T> {
|
|
63
|
+
data: T[];
|
|
64
|
+
has_more: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One list paged to `maxItems`, by at most `maxItems / PAGE_LIMIT` calls.
|
|
69
|
+
* `truncated` when the bound stopped it while Stripe still had more.
|
|
70
|
+
*/
|
|
71
|
+
async function pageAll<T extends { id?: unknown }>(
|
|
72
|
+
list: (params: ListParams) => PromiseLike<ListPage<T>>,
|
|
73
|
+
maxItems: number,
|
|
74
|
+
aborted: () => boolean,
|
|
75
|
+
): Promise<{ items: T[]; truncated: boolean }> {
|
|
76
|
+
const items: T[] = [];
|
|
77
|
+
let after: string | undefined;
|
|
78
|
+
for (let call = 0; call < maxItems / PAGE_LIMIT && items.length < maxItems; call++) {
|
|
79
|
+
if (aborted()) throw new OfferingsCatalogUnavailableError();
|
|
80
|
+
const page = await list({
|
|
81
|
+
active: true,
|
|
82
|
+
limit: PAGE_LIMIT,
|
|
83
|
+
...(after !== undefined && { starting_after: after }),
|
|
84
|
+
});
|
|
85
|
+
items.push(...page.data);
|
|
86
|
+
const last = page.data.at(-1)?.id;
|
|
87
|
+
if (!page.has_more || typeof last !== "string" || last === after) return { items, truncated: false };
|
|
88
|
+
after = last;
|
|
89
|
+
}
|
|
90
|
+
return { items, truncated: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The Organization's catalog source. Builds nothing until it is read. */
|
|
94
|
+
export const stripeCatalogSourceFor: OfferingsSourceFor = (
|
|
95
|
+
organizationId: string,
|
|
96
|
+
): OfferingsCatalogSource => ({
|
|
97
|
+
async listActiveCatalog(): Promise<OfferingsCatalog> {
|
|
98
|
+
let aborted = false;
|
|
99
|
+
const isAborted = () => aborted;
|
|
100
|
+
const read = (async (): Promise<OfferingsCatalog> => {
|
|
101
|
+
const connection = await stripeClientForOrganization(organizationId);
|
|
102
|
+
if (connection.kind !== "ok") throw new OfferingsCatalogUnavailableError();
|
|
103
|
+
const { client } = connection;
|
|
104
|
+
const products = pageAll(
|
|
105
|
+
(params) => client.products.list(params),
|
|
106
|
+
OFFERINGS_CATALOG_PRODUCTS_MAX,
|
|
107
|
+
isAborted,
|
|
108
|
+
);
|
|
109
|
+
const prices = pageAll((params) => client.prices.list(params), OFFERINGS_CATALOG_PRICES_MAX, isAborted);
|
|
110
|
+
// Each loop is caught on its own, so a page that rejects after the
|
|
111
|
+
// other loop or the timeout has failed the read is never unhandled.
|
|
112
|
+
products.catch(() => undefined);
|
|
113
|
+
prices.catch(() => undefined);
|
|
114
|
+
const [productPages, pricePages] = await Promise.all([products, prices]);
|
|
115
|
+
if (productPages.truncated || pricePages.truncated) {
|
|
116
|
+
logger.warn(
|
|
117
|
+
{ organizationId, reason: "catalog_truncated" },
|
|
118
|
+
"[offerings] catalog over its read cap",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return { products: productPages.items, prices: pricePages.items };
|
|
122
|
+
})();
|
|
123
|
+
read.catch(() => undefined);
|
|
124
|
+
|
|
125
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
126
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
127
|
+
timer = setTimeout(() => {
|
|
128
|
+
aborted = true;
|
|
129
|
+
reject(new OfferingsCatalogUnavailableError());
|
|
130
|
+
}, OFFERINGS_CATALOG_READ_TIMEOUT_MS);
|
|
131
|
+
});
|
|
132
|
+
try {
|
|
133
|
+
return await Promise.race([read, timeout]);
|
|
134
|
+
} finally {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
// A loop still running after the other one failed starts no more calls.
|
|
137
|
+
aborted = true;
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Offerings sets and the snapshot (#8876, spec
|
|
3
|
+
* docs/specs/8876-the-offerings-data-model-typed-spec.md D6) — the one file
|
|
4
|
+
* naming the org-enabled set, the workspace selection and its snapshot, and,
|
|
5
|
+
* for this service, the workspace's Organization.
|
|
6
|
+
*
|
|
7
|
+
* Every argument is a server-resolved id, never a request value, except a
|
|
8
|
+
* set or selection handed to a writer, which its caller checked and the
|
|
9
|
+
* writer parses; and no function lists or enumerates. The selection read
|
|
10
|
+
* never names the snapshot, so checkout's allowlist (contract A4) can never
|
|
11
|
+
* read the display copy.
|
|
12
|
+
*
|
|
13
|
+
* This file writes the snapshot, only into a row that exists; for #8922, the
|
|
14
|
+
* org-enabled set; and, for #8926, the workspace selection and Recommended,
|
|
15
|
+
* creating its row.
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
offeringsProductIdsSchema,
|
|
19
|
+
workspaceOfferingsSelectionSchema,
|
|
20
|
+
type OfferingsSnapshot,
|
|
21
|
+
} from "@working-theory/validation";
|
|
22
|
+
|
|
23
|
+
import { prisma } from "../../lib/prisma.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* An Offerings result, or a set handed to a writer, failed its schema. The
|
|
27
|
+
* message is fixed; there is no cause. Re-exported by `index.ts`.
|
|
28
|
+
*/
|
|
29
|
+
export class OfferingsShapeError extends Error {
|
|
30
|
+
constructor() {
|
|
31
|
+
super("An Offerings result failed its schema");
|
|
32
|
+
this.name = "OfferingsShapeError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WorkspaceSelection {
|
|
37
|
+
shownProductIds: string[];
|
|
38
|
+
/** The shown Prices (#9070 D1); empty for a row saved per Product. */
|
|
39
|
+
shownPriceIds: string[];
|
|
40
|
+
recommendedPlanProductId: string | null;
|
|
41
|
+
recommendedAddonProductId: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface WorkspaceSnapshotRow {
|
|
45
|
+
snapshot: unknown;
|
|
46
|
+
snapshotAt: Date | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The workspace's own Organization, or `null` for no workspace. */
|
|
50
|
+
export async function organizationIdOfWorkspace(workspaceId: string): Promise<string | null> {
|
|
51
|
+
const row = await prisma.workspace.findUnique({
|
|
52
|
+
where: { id: workspaceId },
|
|
53
|
+
select: { organizationId: true },
|
|
54
|
+
});
|
|
55
|
+
return row?.organizationId ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The org-enabled set; no row is the empty set. */
|
|
59
|
+
export async function readEnabledProductIds(organizationId: string): Promise<string[]> {
|
|
60
|
+
const row = await prisma.organizationOfferings.findUnique({
|
|
61
|
+
where: { organizationId },
|
|
62
|
+
select: { enabledProductIds: true },
|
|
63
|
+
});
|
|
64
|
+
return row?.enabledProductIds ?? [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Replace the org-enabled set (#8922 D4), creating the row on an
|
|
69
|
+
* Organization's first save. Every set is parsed first — at most 100
|
|
70
|
+
* distinct Stripe ids — so the bound holds for every writer.
|
|
71
|
+
*/
|
|
72
|
+
export async function writeEnabledProductIds(organizationId: string, productIds: string[]): Promise<void> {
|
|
73
|
+
const parsed = offeringsProductIdsSchema.safeParse(productIds);
|
|
74
|
+
if (!parsed.success) throw new OfferingsShapeError();
|
|
75
|
+
await prisma.organizationOfferings.upsert({
|
|
76
|
+
where: { organizationId },
|
|
77
|
+
create: { organizationId, enabledProductIds: parsed.data },
|
|
78
|
+
update: { enabledProductIds: parsed.data },
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The shown set and Recommended, or `null` for no row. Never the snapshot. */
|
|
83
|
+
export async function readWorkspaceSelection(workspaceId: string): Promise<WorkspaceSelection | null> {
|
|
84
|
+
return prisma.workspaceOfferings.findUnique({
|
|
85
|
+
where: { workspaceId },
|
|
86
|
+
select: {
|
|
87
|
+
shownProductIds: true,
|
|
88
|
+
shownPriceIds: true,
|
|
89
|
+
recommendedPlanProductId: true,
|
|
90
|
+
recommendedAddonProductId: true,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Replace the workspace's selection (#8926 D5), creating the row on its first
|
|
97
|
+
* save. Every selection is parsed first — at most 100 distinct Product ids
|
|
98
|
+
* and 300 Price ids, Prices exactly when Products (#9070 D4), each
|
|
99
|
+
* Recommended shown — so the database's CHECKs never fire and no new save is
|
|
100
|
+
* at the per-Product grain. Never names the snapshot, so a save keeps the
|
|
101
|
+
* last good copy.
|
|
102
|
+
*/
|
|
103
|
+
export async function writeWorkspaceSelection(
|
|
104
|
+
workspaceId: string,
|
|
105
|
+
selection: WorkspaceSelection,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const parsed = workspaceOfferingsSelectionSchema.safeParse(selection);
|
|
108
|
+
if (!parsed.success) throw new OfferingsShapeError();
|
|
109
|
+
const { shownProductIds, shownPriceIds, recommendedPlanProductId, recommendedAddonProductId } =
|
|
110
|
+
parsed.data;
|
|
111
|
+
await prisma.workspaceOfferings.upsert({
|
|
112
|
+
where: { workspaceId },
|
|
113
|
+
create: { workspaceId, shownProductIds, shownPriceIds, recommendedPlanProductId, recommendedAddonProductId },
|
|
114
|
+
update: { shownProductIds, shownPriceIds, recommendedPlanProductId, recommendedAddonProductId },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The last-good snapshot and its time, or `null` for no row. */
|
|
119
|
+
export async function readWorkspaceSnapshot(workspaceId: string): Promise<WorkspaceSnapshotRow | null> {
|
|
120
|
+
return prisma.workspaceOfferings.findUnique({
|
|
121
|
+
where: { workspaceId },
|
|
122
|
+
select: { snapshot: true, snapshotAt: true },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Write a parsed snapshot and its time together (the pairing CHECK). Creates
|
|
128
|
+
* no row, and never replaces a snapshot dated after `at` (unless it is dated
|
|
129
|
+
* more than `futureSkewMs` past it, which no reader serves): `false` when
|
|
130
|
+
* nothing was written.
|
|
131
|
+
*/
|
|
132
|
+
export async function writeWorkspaceSnapshot(
|
|
133
|
+
workspaceId: string,
|
|
134
|
+
snapshot: OfferingsSnapshot,
|
|
135
|
+
at: Date,
|
|
136
|
+
futureSkewMs: number,
|
|
137
|
+
): Promise<boolean> {
|
|
138
|
+
const { count } = await prisma.workspaceOfferings.updateMany({
|
|
139
|
+
where: {
|
|
140
|
+
workspaceId,
|
|
141
|
+
OR: [
|
|
142
|
+
{ snapshotAt: null },
|
|
143
|
+
{ snapshotAt: { lte: at } },
|
|
144
|
+
{ snapshotAt: { gt: new Date(at.getTime() + futureSkewMs) } },
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
data: { snapshot, snapshotAt: at },
|
|
148
|
+
});
|
|
149
|
+
return count > 0;
|
|
150
|
+
}
|
|
@@ -19,12 +19,21 @@
|
|
|
19
19
|
* suites that pin that outcome.
|
|
20
20
|
* - `resolveMemberPersonId` — the member reads' Person (#7877 D8): the one
|
|
21
21
|
* linked to the caller, else the one whose email is the caller's
|
|
22
|
-
* verified email, linked on first read by `stampPersonUser`'s rule.
|
|
22
|
+
* verified email, linked on first read by `stampPersonUser`'s rule. That
|
|
23
|
+
* first link — and no other — releases the Person's held checkout plan
|
|
24
|
+
* (#8793 D2).
|
|
23
25
|
* - `resolveCallerBillingCustomerId` — the customer id the hub vouches for
|
|
24
26
|
* in `GET /v1/hub-sign-in/me` (#7961, spec 7894 D13): that same Person's,
|
|
25
27
|
* only while its `userId` is the caller.
|
|
26
28
|
* - `setPersonPlanOffering` — the plan's only writer (#7877 D8), called by
|
|
27
|
-
* the Admin's plan route and
|
|
29
|
+
* the Admin's plan route and `releaseHeldCheckoutPlan`.
|
|
30
|
+
* - `releaseHeldCheckoutPlan` and `checkoutPlanKey` — the checkout plan
|
|
31
|
+
* rule (#7877 D10, #8793 D1–D4). A spoke's checkout lead never writes a
|
|
32
|
+
* plan: it holds it (`lead-routing.ts`), because its email is whatever
|
|
33
|
+
* the buyer typed at Stripe Checkout. The hold is released once, at the
|
|
34
|
+
* Person's first member-path link to the user whose VERIFIED email is the
|
|
35
|
+
* Person's own, into a blank plan only, and only when every held purchase
|
|
36
|
+
* under that email names the same plan key.
|
|
28
37
|
* - the contact details (#8435): `findOrCreatePerson` fills BLANK contact
|
|
29
38
|
* fields of an uncurated Person (D4); `updatePersonContact` (the
|
|
30
39
|
* operator's save), `ensureMemberPerson` and `setOwnPersonContact` (the
|
|
@@ -53,7 +62,8 @@
|
|
|
53
62
|
*
|
|
54
63
|
* The base-tree `lead-routing.ts` imports this file, so it ships in every
|
|
55
64
|
* build and must never reach the paid import closure: it imports
|
|
56
|
-
* `lib/prisma.js`, `verified-email.js`
|
|
65
|
+
* `lib/prisma.js`, `verified-email.js`, `planOfferingSchema` from the
|
|
66
|
+
* base-tree kernel `@working-theory/validation` and types only — never
|
|
57
67
|
* `lead-promotion.ts`, `owner-workspaces.ts`, `lead-accept-user.ts` (whose
|
|
58
68
|
* find-or-create shape it mirrors) or `lib/tenant-db.ts`.
|
|
59
69
|
* `packages/create-ailk/__tests__/paid-module-exclusions.test.ts` holds this
|
|
@@ -62,7 +72,11 @@
|
|
|
62
72
|
* ## Tenancy
|
|
63
73
|
*
|
|
64
74
|
* Every `Person` statement is scoped by a server-resolved `workspaceId`;
|
|
65
|
-
* there is no list read here (the reads are
|
|
75
|
+
* there is no `Person` list read here (the reads are
|
|
76
|
+
* `tenantDb(tenant).people`). The one list read is `releaseHeldCheckoutPlan`'s
|
|
77
|
+
* (#8793 D3): the newest 51 `spokeCheckout` leads of a Person just linked to
|
|
78
|
+
* the caller, by `(workspaceId, personId)`, the workspace the member read
|
|
79
|
+
* resolved.
|
|
66
80
|
* `recordCheckoutPerson`'s two other reads are by a server-minted
|
|
67
81
|
* `FlowCheckout` id from a signature-verified webhook, matched on that
|
|
68
82
|
* session's own `stripeSessionId`, then that row's lead scoped by the row's
|
|
@@ -84,7 +98,7 @@
|
|
|
84
98
|
|
|
85
99
|
import type { Prisma } from "@working-theory/database";
|
|
86
100
|
import type { Id } from "@working-theory/ids";
|
|
87
|
-
import type
|
|
101
|
+
import { planOfferingSchema, type PersonContactPatch } from "@working-theory/validation";
|
|
88
102
|
|
|
89
103
|
import { prisma } from "../lib/prisma.js";
|
|
90
104
|
|
|
@@ -455,6 +469,29 @@ function usableCustomerId(raw: string | null | undefined): string | null {
|
|
|
455
469
|
: null;
|
|
456
470
|
}
|
|
457
471
|
|
|
472
|
+
/** A Stripe customer id: `cus_` and letters or digits only, no separator. */
|
|
473
|
+
const STRIPE_CUSTOMER_ID = /^cus_[A-Za-z0-9]+$/;
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The Stripe Dashboard link to one customer (#9349 DD5). Pure: the caller
|
|
477
|
+
* reads the mode. `null` unless `customerId` is a well-formed `cus_` id of
|
|
478
|
+
* at most 255 characters, so a stored value can never add a path segment,
|
|
479
|
+
* a query or a fragment to the fixed Dashboard host.
|
|
480
|
+
*/
|
|
481
|
+
export function stripeCustomerDashboardUrl(
|
|
482
|
+
customerId: string,
|
|
483
|
+
mode: "test" | "live",
|
|
484
|
+
): string | null {
|
|
485
|
+
if (
|
|
486
|
+
customerId.length > MAX_CUSTOMER_ID_LENGTH ||
|
|
487
|
+
!STRIPE_CUSTOMER_ID.test(customerId)
|
|
488
|
+
) {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
const segment = mode === "test" ? "test/customers" : "customers";
|
|
492
|
+
return `https://dashboard.stripe.com/${segment}/${customerId}`;
|
|
493
|
+
}
|
|
494
|
+
|
|
458
495
|
// ─── findOrCreatePerson ───────────────────────────────────────────────────────
|
|
459
496
|
|
|
460
497
|
/** The one delegate this file writes through — the shared client or a transaction's. */
|
|
@@ -872,6 +909,14 @@ export async function resolveCheckoutPerson(input: {
|
|
|
872
909
|
* every other stamp outcome, and a store error, is `null` too, logged by its
|
|
873
910
|
* kind only. Never throws.
|
|
874
911
|
*
|
|
912
|
+
* A fresh `stamped` link — and only that — releases the Person's held
|
|
913
|
+
* checkout plan (#8793 D2, `releaseHeldCheckoutPlan`): the caller has just
|
|
914
|
+
* proven they own the email the purchases were made under. A Person linked
|
|
915
|
+
* earlier, a refused stamp and every other caller of `stampPersonUser` (the
|
|
916
|
+
* Inbox accept above all) release nothing. The release never changes the
|
|
917
|
+
* answer; an `ambiguous` or `internal_error` release is logged by its kind
|
|
918
|
+
* only, as `{ event: "checkout-plan-release", outcome }`.
|
|
919
|
+
*
|
|
875
920
|
* @param workspaceId the SERVER-RESOLVED workspace (the calling spoke's).
|
|
876
921
|
* @param userId the authenticated caller.
|
|
877
922
|
* @param verifiedEmail the caller's verified address, read by the caller.
|
|
@@ -902,7 +947,19 @@ export async function resolveMemberPersonId(
|
|
|
902
947
|
if (!byEmail) return null;
|
|
903
948
|
|
|
904
949
|
const outcome = await stampPersonUser(workspaceId, byEmail.id, userId);
|
|
905
|
-
if (outcome === "stamped"
|
|
950
|
+
if (outcome === "stamped") {
|
|
951
|
+
const release = await releaseHeldCheckoutPlan(
|
|
952
|
+
workspaceId,
|
|
953
|
+
byEmail.id,
|
|
954
|
+
userId,
|
|
955
|
+
normalizePersonEmail(verifiedEmail),
|
|
956
|
+
);
|
|
957
|
+
if (release === "ambiguous" || release === "internal_error") {
|
|
958
|
+
log?.warn({ event: "checkout-plan-release", outcome: release }, "[person] held checkout plan not released");
|
|
959
|
+
}
|
|
960
|
+
return byEmail.id;
|
|
961
|
+
}
|
|
962
|
+
if (outcome === "already_linked") return byEmail.id;
|
|
906
963
|
log?.warn({ event: "member-person-stamp", outcome }, "[person] member link refused");
|
|
907
964
|
return null;
|
|
908
965
|
}
|
|
@@ -943,17 +1000,28 @@ export type SetPlanOfferingOutcome = "set" | "not_found" | "internal_error";
|
|
|
943
1000
|
|
|
944
1001
|
/**
|
|
945
1002
|
* Set — or, with `null`, clear — a Person's plan: the ONE writer of
|
|
946
|
-
* `Person.planOffering
|
|
947
|
-
*
|
|
1003
|
+
* `Person.planOffering`, called by the Admin's plan route and the held
|
|
1004
|
+
* checkout plan's release (#8793). One `updateMany` scoped by the
|
|
1005
|
+
* server-resolved workspace; no row → `not_found`. Never throws.
|
|
1006
|
+
*
|
|
1007
|
+
* With a `guard` (#8793 D5, the release's own write) the statement also
|
|
1008
|
+
* requires a blank plan and the Person still linked to `guard.linkedUserId`,
|
|
1009
|
+
* so a plan already set — or one the operator PUTs at the same moment — wins
|
|
1010
|
+
* and the answer is `not_found`. Without one it is the operator's overwrite.
|
|
948
1011
|
*/
|
|
949
1012
|
export async function setPersonPlanOffering(
|
|
950
1013
|
workspaceId: string,
|
|
951
1014
|
personId: string,
|
|
952
1015
|
planOffering: string | null,
|
|
1016
|
+
guard?: { linkedUserId: string },
|
|
953
1017
|
): Promise<SetPlanOfferingOutcome> {
|
|
954
1018
|
try {
|
|
955
1019
|
const { count } = await prisma.person.updateMany({
|
|
956
|
-
where: {
|
|
1020
|
+
where: {
|
|
1021
|
+
id: personId,
|
|
1022
|
+
workspaceId,
|
|
1023
|
+
...(guard !== undefined && { planOffering: null, userId: guard.linkedUserId }),
|
|
1024
|
+
},
|
|
957
1025
|
data: { planOffering },
|
|
958
1026
|
});
|
|
959
1027
|
return count === 0 ? "not_found" : "set";
|
|
@@ -962,6 +1030,76 @@ export async function setPersonPlanOffering(
|
|
|
962
1030
|
}
|
|
963
1031
|
}
|
|
964
1032
|
|
|
1033
|
+
// ─── The held checkout plan (#8793 D3, D4) ───────────────────────────────────
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* A spoke checkout's plan key: the offering minus a leading `<site>_` (when
|
|
1037
|
+
* the site is a string), answered only when `planOfferingSchema` — the key
|
|
1038
|
+
* the operator's PUT accepts — admits it; else `null`. Shared by the
|
|
1039
|
+
* checkout's hold (`lead-routing.ts`) and the release below.
|
|
1040
|
+
*/
|
|
1041
|
+
export function checkoutPlanKey(offering: unknown, site: unknown): string | null {
|
|
1042
|
+
if (typeof offering !== "string") return null;
|
|
1043
|
+
const prefix = typeof site === "string" ? `${site}_` : null;
|
|
1044
|
+
const key = prefix !== null && offering.startsWith(prefix) ? offering.slice(prefix.length) : offering;
|
|
1045
|
+
return planOfferingSchema.safeParse(key).success ? key : null;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
export type HeldPlanOutcome = "set" | "none" | "ambiguous" | "kept" | "internal_error";
|
|
1049
|
+
|
|
1050
|
+
/** The most held purchases a release weighs; one more is `ambiguous`. */
|
|
1051
|
+
const HELD_PURCHASES_READ = 50;
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Release a Person's held checkout plan (#8793 D3) — called only by
|
|
1055
|
+
* `resolveMemberPersonId` on a fresh `stamped` link, so `verifiedEmail` (the
|
|
1056
|
+
* caller's, normalized) is the Person's own. The Person's marked spoke
|
|
1057
|
+
* checkout leads (`Lead.spokeCheckout`, newest 51) under that email are taken
|
|
1058
|
+
* as a SET: one distinct plan key is written to a blank plan through the
|
|
1059
|
+
* guarded `setPersonPlanOffering` (`set`, or `kept` when the guard matched
|
|
1060
|
+
* nothing — a plan was already there, or the Person is no longer linked to
|
|
1061
|
+
* `userId`); no key is `none`; more than one key, or more than 50 leads, is
|
|
1062
|
+
* `ambiguous` and writes nothing — the operator sets the plan. So a
|
|
1063
|
+
* stranger's purchase under the owner's email can deny the automatic plan,
|
|
1064
|
+
* never choose it (SEC-8793-A). A store error is `internal_error`. Never
|
|
1065
|
+
* throws, and logs nothing: the caller logs the kind.
|
|
1066
|
+
*/
|
|
1067
|
+
export async function releaseHeldCheckoutPlan(
|
|
1068
|
+
workspaceId: string,
|
|
1069
|
+
personId: string,
|
|
1070
|
+
userId: string,
|
|
1071
|
+
verifiedEmail: string,
|
|
1072
|
+
): Promise<HeldPlanOutcome> {
|
|
1073
|
+
try {
|
|
1074
|
+
const rows = await prisma.lead.findMany({
|
|
1075
|
+
where: { workspaceId, personId, spokeCheckout: true },
|
|
1076
|
+
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
|
1077
|
+
take: HELD_PURCHASES_READ + 1,
|
|
1078
|
+
select: { email: true, payload: true },
|
|
1079
|
+
});
|
|
1080
|
+
if (rows.length > HELD_PURCHASES_READ) return "ambiguous";
|
|
1081
|
+
|
|
1082
|
+
const keys = new Set<string>();
|
|
1083
|
+
for (const row of rows) {
|
|
1084
|
+
// The purchase read's own rule: a purchase is its Person's only under
|
|
1085
|
+
// the Person's own email.
|
|
1086
|
+
if (normalizePersonEmail(row.email) !== verifiedEmail) continue;
|
|
1087
|
+
const payload = (row.payload ?? {}) as Record<string, unknown>;
|
|
1088
|
+
const key = checkoutPlanKey(payload["offering"], payload["site"]);
|
|
1089
|
+
if (key !== null) keys.add(key);
|
|
1090
|
+
}
|
|
1091
|
+
if (keys.size > 1) return "ambiguous";
|
|
1092
|
+
const [key] = keys;
|
|
1093
|
+
if (key === undefined) return "none";
|
|
1094
|
+
|
|
1095
|
+
const written = await setPersonPlanOffering(workspaceId, personId, key, { linkedUserId: userId });
|
|
1096
|
+
if (written === "set") return "set";
|
|
1097
|
+
return written === "not_found" ? "kept" : "internal_error";
|
|
1098
|
+
} catch {
|
|
1099
|
+
return "internal_error";
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
965
1103
|
// ─── The two human edits of the contact (#8435 D9, D11) ───────────────────────
|
|
966
1104
|
|
|
967
1105
|
export type SavedContact = {
|
|
@@ -5,15 +5,29 @@
|
|
|
5
5
|
* (`services/magnet-completion.ts`) read the same list `GET …/plan-offerings`
|
|
6
6
|
* serves.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* first page per key wins, at most 200; `name` is the page title. Off the
|
|
11
|
-
* database content adapter only the default workspace has content
|
|
12
|
-
* (`workspaceContentAdapter`), so every other workspace reads `[]`.
|
|
8
|
+
* Two sources (#8934, spec
|
|
9
|
+
* docs/specs/8934-re-source-the-plan-offerings-spec.md D1–D4):
|
|
13
10
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* - A connected workspace with the `commerce` claim lists its Offerings
|
|
12
|
+
* shown set (`getWorkspaceShownSet`, read from the database and never from
|
|
13
|
+
* Stripe — contract T7): one item per sellable Price, in the reader's
|
|
14
|
+
* order, `key` the Price's full lookup key (G5), `name` the product name
|
|
15
|
+
* and its billing word, `type` the product's group. The page list is not
|
|
16
|
+
* consulted.
|
|
17
|
+
* - An unclaimed workspace, or one the reader answers `null` for (no
|
|
18
|
+
* workspace, no connection, no selection), lists its OWN Service then
|
|
19
|
+
* Product pages: `key` is the page slug's last segment when it is an
|
|
20
|
+
* offering key (else the page is skipped), the first page per key wins, at
|
|
21
|
+
* most 200; `name` is the page title. Off the database content adapter only
|
|
22
|
+
* the default workspace has content (`workspaceContentAdapter`), so every
|
|
23
|
+
* other workspace reads `[]`.
|
|
24
|
+
*
|
|
25
|
+
* Never throws: a failed read answers `{ failed }` with a fixed word or the
|
|
26
|
+
* error's class name only, so a caller can tell "none listed" from "could not
|
|
27
|
+
* tell" — a failure is never a pass. `LicenseUndetermined` (the licence could
|
|
28
|
+
* not be read, so the Offerings services may not be called — 8876 D10),
|
|
29
|
+
* `OfferingsSnapshotExpired` and `OfferingsSnapshotEmpty` (the rows say what
|
|
30
|
+
* is shown, but no current snapshot says which keys it sells).
|
|
17
31
|
*
|
|
18
32
|
* Base tree (SEC-8757-D): no `oss: false` marker and no `MODULE_SLICES`
|
|
19
33
|
* entry, because the never-gate lead route reaches it.
|
|
@@ -24,36 +38,88 @@ import {
|
|
|
24
38
|
type PlanOfferingItem,
|
|
25
39
|
} from "@working-theory/validation";
|
|
26
40
|
|
|
41
|
+
import { resolveCommerceClaim } from "../lib/require-commerce-claim.js";
|
|
42
|
+
|
|
43
|
+
import { getWorkspaceShownSet } from "./offerings/index.js";
|
|
27
44
|
import { workspaceContentAdapter } from "./public-site-resolver.js";
|
|
28
45
|
|
|
29
|
-
/**
|
|
46
|
+
/** `planOfferingItemSchema.name`'s bound, in UTF-16 units. */
|
|
47
|
+
const NAME_MAX = 200;
|
|
48
|
+
|
|
49
|
+
/** The workspace's offerings as plan keys (D3). */
|
|
30
50
|
export async function listPlanOfferings(
|
|
31
51
|
workspaceId: string,
|
|
32
52
|
): Promise<PlanOfferingItem[] | { failed: string }> {
|
|
33
53
|
try {
|
|
34
|
-
const
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (!planOfferingSchema.safeParse(key).success || name === "" || seen.has(key)) continue;
|
|
51
|
-
seen.add(key);
|
|
52
|
-
offerings.push({ key, name, type });
|
|
53
|
-
}
|
|
54
|
-
return offerings;
|
|
54
|
+
const claim = await resolveCommerceClaim();
|
|
55
|
+
if (claim === "undetermined") return { failed: "LicenseUndetermined" };
|
|
56
|
+
if (claim === "unclaimed") return await pageOfferings(workspaceId);
|
|
57
|
+
const shown = await getWorkspaceShownSet(workspaceId);
|
|
58
|
+
if (shown === null) return await pageOfferings(workspaceId);
|
|
59
|
+
if (shown.state === "expired") return { failed: "OfferingsSnapshotExpired" };
|
|
60
|
+
if (shown.state === "empty") return { failed: "OfferingsSnapshotEmpty" };
|
|
61
|
+
return collect(
|
|
62
|
+
shown.offerings.flatMap((offering) =>
|
|
63
|
+
offering.prices.map((price) => ({
|
|
64
|
+
key: price.lookupKey,
|
|
65
|
+
name: planOfferingName(offering.name, price.interval),
|
|
66
|
+
type: offering.ailkGroup,
|
|
67
|
+
})),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
55
70
|
} catch (error) {
|
|
56
71
|
// The error's class name only — never its message, which may name a slug.
|
|
57
72
|
return { failed: error instanceof Error ? error.name : typeof error };
|
|
58
73
|
}
|
|
59
74
|
}
|
|
75
|
+
|
|
76
|
+
/** The workspace's Service then Product pages as plan keys (D4, #8514 D9). */
|
|
77
|
+
async function pageOfferings(workspaceId: string): Promise<PlanOfferingItem[]> {
|
|
78
|
+
const adapter = await workspaceContentAdapter(workspaceId);
|
|
79
|
+
if (!adapter) return [];
|
|
80
|
+
const [services, products] = await Promise.all([
|
|
81
|
+
adapter.listPages("Service"),
|
|
82
|
+
adapter.listPages("Product"),
|
|
83
|
+
]);
|
|
84
|
+
const typed = [
|
|
85
|
+
...services.map((page) => ({ page, type: "service" as const })),
|
|
86
|
+
...products.map((page) => ({ page, type: "product" as const })),
|
|
87
|
+
];
|
|
88
|
+
return collect(
|
|
89
|
+
typed.map(({ page, type }) => ({
|
|
90
|
+
key: page.slug.split("/").filter(Boolean).pop() ?? "",
|
|
91
|
+
name: page.title.trim().slice(0, NAME_MAX),
|
|
92
|
+
type,
|
|
93
|
+
})),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The guards both sources share: a valid key, a name, each key once, at most 200. */
|
|
98
|
+
function collect(candidates: PlanOfferingItem[]): PlanOfferingItem[] {
|
|
99
|
+
const offerings: PlanOfferingItem[] = [];
|
|
100
|
+
const seen = new Set<string>();
|
|
101
|
+
for (const { key, name, type } of candidates) {
|
|
102
|
+
if (offerings.length >= PLAN_OFFERINGS_MAX) break;
|
|
103
|
+
if (!planOfferingSchema.safeParse(key).success || name === "" || seen.has(key)) continue;
|
|
104
|
+
seen.add(key);
|
|
105
|
+
offerings.push({ key, name, type });
|
|
106
|
+
}
|
|
107
|
+
return offerings;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* `<product name> (<word>)`, the word from the Price's interval (D2). Whole
|
|
112
|
+
* code points are dropped from the end of the product name until the name
|
|
113
|
+
* fits `NAME_MAX` units, so a surrogate pair is never split; the word is
|
|
114
|
+
* always kept.
|
|
115
|
+
*/
|
|
116
|
+
function planOfferingName(productName: string, interval: "month" | "year" | null): string {
|
|
117
|
+
const word = interval === "month" ? "Monthly" : interval === "year" ? "Annual" : "One-time";
|
|
118
|
+
const suffix = ` (${word})`;
|
|
119
|
+
let name = "";
|
|
120
|
+
for (const point of productName) {
|
|
121
|
+
if (name.length + point.length + suffix.length > NAME_MAX) break;
|
|
122
|
+
name += point;
|
|
123
|
+
}
|
|
124
|
+
return name + suffix;
|
|
125
|
+
}
|