create-ailk 0.22.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 +149 -5
- package/component-catalog.md +7 -6
- package/dist/add-module.js +11 -9
- 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 +7 -5
- package/dist/derive-architecture.js +9 -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 +3 -2
- package/dist/lib/scaffold-render-sweep.js +5 -8
- package/dist/module-architecture.js +98 -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 +38 -1
- package/dist/sync-routes.js +184 -11
- 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 +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 +77 -16
- 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__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- 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__/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/spec.ts +10 -7
- 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 +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/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/index.ts +17 -14
- package/templates/apps/api/src/routes/webhooks/README.md +66 -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 +91 -17
- package/templates/apps/api/src/server.ts +140 -51
- 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 +374 -44
- 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/deliverable-fulfillment.ts +13 -5
- 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 +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 +124 -9
- package/templates/apps/api/src/services/plan-offerings.ts +96 -30
- package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
- 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 +10 -31
- package/templates/apps/web/CLAUDE.md +315 -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__/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 +6 -2
- 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 +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]/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 +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 +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 +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/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 +31 -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 +31 -1
- 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/package.json +1 -1
- package/templates/database/seed.ts +30 -2
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- 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
|
@@ -59,27 +59,47 @@
|
|
|
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.
|
|
83
103
|
*
|
|
84
104
|
* PURCHASE EMAIL AND INBOX MESSAGE (#8757 D14). After this request's own
|
|
85
105
|
* write of a lead marked `spokeCheckout` with a Person — so never on a
|
|
@@ -136,12 +156,12 @@ import { sendLeadNotification, sendPurchaseConfirmationEmail } from "@working-th
|
|
|
136
156
|
import { logger } from "@working-theory/observability";
|
|
137
157
|
import {
|
|
138
158
|
accountInvitePath,
|
|
139
|
-
|
|
159
|
+
isConsentLeadSource,
|
|
140
160
|
LEAD_SITE_MARKER_KEY,
|
|
141
161
|
leadInputSchema,
|
|
162
|
+
parseLookupKey,
|
|
142
163
|
PURCHASE_EMAIL_DEFAULTS,
|
|
143
164
|
PURCHASE_EMAILS_PER_RECIPIENT_PER_DAY,
|
|
144
|
-
planOfferingSchema,
|
|
145
165
|
stripPlainText,
|
|
146
166
|
} from "@working-theory/validation";
|
|
147
167
|
|
|
@@ -158,10 +178,10 @@ import {
|
|
|
158
178
|
} from "./consent.js";
|
|
159
179
|
import { enrichLead } from "./enrichment-shim.js";
|
|
160
180
|
import {
|
|
181
|
+
checkoutPlanKey,
|
|
161
182
|
findOrCreatePerson,
|
|
162
183
|
normalizeCapturedContact,
|
|
163
184
|
normalizeTelephone,
|
|
164
|
-
setPersonPlanOffering,
|
|
165
185
|
} from "./person.js";
|
|
166
186
|
import type { LeadContact, PersonContact } from "./person.js";
|
|
167
187
|
import { listPlanOfferings } from "./plan-offerings.js";
|
|
@@ -232,6 +252,14 @@ export type RouteLeadOptions = {
|
|
|
232
252
|
* from the caller, never the body. Read for a marked checkout lead only.
|
|
233
253
|
*/
|
|
234
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;
|
|
235
263
|
};
|
|
236
264
|
|
|
237
265
|
export type RouteLeadResult =
|
|
@@ -307,9 +335,11 @@ async function findLeadByKey(
|
|
|
307
335
|
}
|
|
308
336
|
|
|
309
337
|
/**
|
|
310
|
-
* A
|
|
311
|
-
*
|
|
312
|
-
*
|
|
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.
|
|
313
343
|
*
|
|
314
344
|
* A checkout lead's `payload.termsAccepted` (#8778) is left out too: a spoke
|
|
315
345
|
* updated between a stored attempt and Stripe's redelivery sends the key the
|
|
@@ -321,7 +351,7 @@ function comparablePayload(payload: unknown, source: string): unknown {
|
|
|
321
351
|
return payload;
|
|
322
352
|
}
|
|
323
353
|
const record = payload as Record<string, unknown>;
|
|
324
|
-
if (
|
|
354
|
+
if (isConsentLeadSource(source)) {
|
|
325
355
|
const { consent: _serverWritten, ...rest } = record;
|
|
326
356
|
return rest;
|
|
327
357
|
}
|
|
@@ -335,9 +365,9 @@ function comparablePayload(payload: unknown, source: string): unknown {
|
|
|
335
365
|
/**
|
|
336
366
|
* A stored row is a replay only when it carries this request's exact `email`
|
|
337
367
|
* and `source` and a deep-equal `payload` (key order ignored, SEC-7869-3);
|
|
338
|
-
* any other row is a conflict (#7869 D4). A
|
|
339
|
-
* `payload.consent` is not compared (#8730), nor a
|
|
340
|
-
* `payload.termsAccepted` (#8778).
|
|
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).
|
|
341
371
|
*/
|
|
342
372
|
function classifyKeyedRow(row: KeyedRow, input: SubmitInput): KeyedOutcome {
|
|
343
373
|
return row.email === input.email &&
|
|
@@ -431,6 +461,9 @@ async function resolveLeadPersonId(input: SubmitInput, contact?: LeadContact): P
|
|
|
431
461
|
});
|
|
432
462
|
}
|
|
433
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
|
+
|
|
434
467
|
/** A LIVE-mode Checkout Session id (#7877 D10, SEC-7877-C). */
|
|
435
468
|
const LIVE_CHECKOUT_SESSION_KEY = /^cs_live_[A-Za-z0-9]{1,250}$/;
|
|
436
469
|
const SITE_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
@@ -443,7 +476,7 @@ function checkoutPlanCandidate(
|
|
|
443
476
|
input: SubmitInput,
|
|
444
477
|
idempotencyKey: string | undefined,
|
|
445
478
|
viaSpoke: boolean,
|
|
446
|
-
):
|
|
479
|
+
): CheckoutCandidate | null {
|
|
447
480
|
if (viaSpoke !== true) return null;
|
|
448
481
|
if (idempotencyKey === undefined || !LIVE_CHECKOUT_SESSION_KEY.test(idempotencyKey)) {
|
|
449
482
|
return null;
|
|
@@ -466,8 +499,35 @@ function checkoutPlanCandidate(
|
|
|
466
499
|
return { offering, site };
|
|
467
500
|
}
|
|
468
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
|
+
|
|
505
|
+
/**
|
|
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.
|
|
511
|
+
*/
|
|
512
|
+
function offeringsCheckoutCandidate(
|
|
513
|
+
input: SubmitInput,
|
|
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
|
+
|
|
469
529
|
/** The candidate's offering minus a leading `<site>_` (#7877 D10). */
|
|
470
|
-
function stripSitePrefix(candidate:
|
|
530
|
+
function stripSitePrefix(candidate: CheckoutCandidate): string {
|
|
471
531
|
const prefix = `${candidate.site}_`;
|
|
472
532
|
return candidate.offering.startsWith(prefix)
|
|
473
533
|
? candidate.offering.slice(prefix.length)
|
|
@@ -475,31 +535,18 @@ function stripSitePrefix(candidate: { offering: string; site: string }): string
|
|
|
475
535
|
}
|
|
476
536
|
|
|
477
537
|
/**
|
|
478
|
-
*
|
|
479
|
-
*
|
|
480
|
-
*
|
|
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.
|
|
481
543
|
*/
|
|
482
|
-
|
|
544
|
+
function applyCheckoutPlan(
|
|
483
545
|
input: SubmitInput,
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
):
|
|
487
|
-
|
|
488
|
-
let outcome: "set" | "skipped" | "failed";
|
|
489
|
-
// The operator's PUT accepts the same keys (`planOfferingSchema`).
|
|
490
|
-
if (!planOfferingSchema.safeParse(offering).success) {
|
|
491
|
-
outcome = "skipped";
|
|
492
|
-
} else {
|
|
493
|
-
try {
|
|
494
|
-
const result = await setPersonPlanOffering(input.siteId, personId, offering);
|
|
495
|
-
outcome = result === "set" ? "set" : "failed";
|
|
496
|
-
} catch {
|
|
497
|
-
outcome = "failed";
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
const line = { event: "checkout-plan", outcome, siteId: input.siteId };
|
|
501
|
-
if (outcome === "failed") logger.error(line, "checkout plan failed");
|
|
502
|
-
else logger.info(line, "checkout plan");
|
|
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");
|
|
503
550
|
}
|
|
504
551
|
|
|
505
552
|
// ─── Purchase email and Inbox message (#8757 D14) ────────────────────────────
|
|
@@ -653,7 +700,7 @@ async function sendPurchaseEmail(
|
|
|
653
700
|
async function recordPurchase(
|
|
654
701
|
input: SubmitInput,
|
|
655
702
|
personId: string,
|
|
656
|
-
candidate:
|
|
703
|
+
candidate: CheckoutCandidate,
|
|
657
704
|
accountOrigin: string | undefined,
|
|
658
705
|
): Promise<void> {
|
|
659
706
|
// One guard around the whole step: it runs inside `routeLead`'s persist
|
|
@@ -738,13 +785,17 @@ async function persistToDb(
|
|
|
738
785
|
relayed = false,
|
|
739
786
|
contact?: LeadContact,
|
|
740
787
|
accountOrigin?: string,
|
|
788
|
+
offeringsCheckout = false,
|
|
741
789
|
): Promise<PersistResult> {
|
|
742
790
|
if (input.kind === "submit") {
|
|
743
791
|
const personId = await resolveLeadPersonId(input, contact);
|
|
744
792
|
// #8185 D2 — the plan rule's own predicate (pure: input, key, flag),
|
|
745
793
|
// evaluated before the create so its result can be stored. A relayed
|
|
746
|
-
// request is never a spoke's server call (D3).
|
|
747
|
-
|
|
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);
|
|
748
799
|
const spokeCheckout = candidate !== null && relayed !== true;
|
|
749
800
|
let row: DbRow;
|
|
750
801
|
try {
|
|
@@ -790,15 +841,18 @@ async function persistToDb(
|
|
|
790
841
|
if (!winner) throw err;
|
|
791
842
|
return { ok: "keyed", outcome: classifyKeyedRow(winner, input) };
|
|
792
843
|
}
|
|
793
|
-
// #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.
|
|
794
845
|
if (candidate !== null && personId !== null) {
|
|
795
|
-
await applyCheckoutPlan(input, personId, candidate);
|
|
796
846
|
// #8757 D14 — the marked lead only: live, unrelayed, this write.
|
|
797
847
|
if (spokeCheckout) {
|
|
848
|
+
// #8793 D1 — the plan is held, never written here.
|
|
849
|
+
applyCheckoutPlan(input, candidate);
|
|
798
850
|
await recordPurchase(input, personId, candidate, accountOrigin);
|
|
799
|
-
// #8778 — a marked lead's key is its live Checkout Session id
|
|
800
|
-
|
|
801
|
-
|
|
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);
|
|
802
856
|
}
|
|
803
857
|
}
|
|
804
858
|
}
|
|
@@ -1165,6 +1219,7 @@ export async function routeLead(
|
|
|
1165
1219
|
sourceMeta.relayed === true,
|
|
1166
1220
|
input.kind === "submit" ? options.contact : undefined,
|
|
1167
1221
|
input.kind === "submit" ? options.accountOrigin : undefined,
|
|
1222
|
+
input.kind === "submit" && options.offeringsCheckout === true,
|
|
1168
1223
|
);
|
|
1169
1224
|
// #7869 D5 — a lost race answers like the lookup above: no capture
|
|
1170
1225
|
// record, no notification.
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* services/offerings/catalog.ts — the one normalizer (#8876 spec D5, AC-5):
|
|
3
|
+
* text normalization (T1), the product marker (G6), sellable Prices (G3) and
|
|
4
|
+
* one plan per Product (G4), in D5's order, one bad Product never failing the
|
|
5
|
+
* catalog.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
import { offeringsSnapshotProductSchema } from "@working-theory/validation";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
isOfferingsProduct,
|
|
14
|
+
normalizeCatalog,
|
|
15
|
+
type CatalogPrice,
|
|
16
|
+
type CatalogProduct,
|
|
17
|
+
} from "../catalog.js";
|
|
18
|
+
import {
|
|
19
|
+
EXPECTED_FIXTURE_HELD,
|
|
20
|
+
EXPECTED_FIXTURE_OFFERINGS,
|
|
21
|
+
OFFERINGS_FIXTURE_CATALOG,
|
|
22
|
+
} from "./fixtures.js";
|
|
23
|
+
|
|
24
|
+
function product(id: string, patch: Record<string, unknown> = {}): CatalogProduct {
|
|
25
|
+
return {
|
|
26
|
+
id,
|
|
27
|
+
active: true,
|
|
28
|
+
name: "Growth",
|
|
29
|
+
description: null,
|
|
30
|
+
marketing_features: [],
|
|
31
|
+
unit_label: null,
|
|
32
|
+
metadata: { ailk_group: "plan" },
|
|
33
|
+
...patch,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function price(id: string, productId: string, patch: Record<string, unknown> = {}): CatalogPrice {
|
|
38
|
+
return {
|
|
39
|
+
id,
|
|
40
|
+
active: true,
|
|
41
|
+
product: productId,
|
|
42
|
+
lookup_key: "growth_monthly",
|
|
43
|
+
type: "recurring",
|
|
44
|
+
recurring: { interval: "month", interval_count: 1, usage_type: "licensed" },
|
|
45
|
+
billing_scheme: "per_unit",
|
|
46
|
+
unit_amount: 4900,
|
|
47
|
+
currency: "usd",
|
|
48
|
+
...patch,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Normalize one Product with its Prices. */
|
|
53
|
+
function one(p: CatalogProduct, prices: CatalogPrice[]) {
|
|
54
|
+
return normalizeCatalog({ products: [p], prices });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("normalizeCatalog (#8876 D5, AC-5)", () => {
|
|
58
|
+
it("answers the fixture's offerings and held list exactly, in D5's order", () => {
|
|
59
|
+
expect(normalizeCatalog(OFFERINGS_FIXTURE_CATALOG)).toEqual({
|
|
60
|
+
offerings: EXPECTED_FIXTURE_OFFERINGS,
|
|
61
|
+
held: EXPECTED_FIXTURE_HELD,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("is pure: the same input answers the same output and is not mutated", () => {
|
|
66
|
+
const input = structuredClone(OFFERINGS_FIXTURE_CATALOG);
|
|
67
|
+
expect(normalizeCatalog(input)).toEqual(normalizeCatalog(OFFERINGS_FIXTURE_CATALOG));
|
|
68
|
+
expect(input).toEqual(OFFERINGS_FIXTURE_CATALOG);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("text (T1)", () => {
|
|
72
|
+
it("strips bidi overrides, zero-width characters and a CR from a name", () => {
|
|
73
|
+
const { offerings } = one(product("prod_a", { name: "Growth\r plan" }), [price("price_a", "prod_a")]);
|
|
74
|
+
expect(offerings[0]!.name).toBe("Growth plan");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("answers the NFC form of a decomposed character", () => {
|
|
78
|
+
const { offerings } = one(product("prod_a", { name: "Café" }), [price("price_a", "prod_a")]);
|
|
79
|
+
expect(offerings[0]!.name).toBe("Café");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("keeps a newline in the description only", () => {
|
|
83
|
+
const { offerings } = one(
|
|
84
|
+
product("prod_a", {
|
|
85
|
+
name: "Two\nlines",
|
|
86
|
+
description: " First\nsecond
third ",
|
|
87
|
+
marketing_features: [{ name: "a\nb" }],
|
|
88
|
+
}),
|
|
89
|
+
[price("price_a", "prod_a")],
|
|
90
|
+
);
|
|
91
|
+
expect(offerings[0]!.name).toBe("Twolines");
|
|
92
|
+
expect(offerings[0]!.description).toBe("First\nsecondthird");
|
|
93
|
+
expect(offerings[0]!.marketingFeatures).toEqual(["ab"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("cuts a 2001-code-point description to 2000", () => {
|
|
97
|
+
const { offerings } = one(product("prod_a", { description: "x".repeat(2001) }), [price("price_a", "prod_a")]);
|
|
98
|
+
expect([...offerings[0]!.description!]).toHaveLength(2000);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("keeps a name of 200 astral-plane emoji whole, and it parses", () => {
|
|
102
|
+
const name = "😀".repeat(200);
|
|
103
|
+
const { offerings } = one(product("prod_a", { name }), [price("price_a", "prod_a")]);
|
|
104
|
+
expect(offerings[0]!.name).toBe(name);
|
|
105
|
+
expect(offeringsSnapshotProductSchema.safeParse(offerings[0]).success).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("reads a non-string as null and an empty text as null", () => {
|
|
109
|
+
const { offerings } = one(
|
|
110
|
+
product("prod_a", { description: 42, unit_label: " ", metadata: { ailk_group: "plan", ailk_note: {} } }),
|
|
111
|
+
[price("price_a", "prod_a")],
|
|
112
|
+
);
|
|
113
|
+
expect(offerings[0]).toMatchObject({ description: null, unitLabel: null, note: null });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("never reads images, url, nickname, statement_descriptor or other metadata", () => {
|
|
117
|
+
const { offerings } = one(
|
|
118
|
+
product("prod_a", {
|
|
119
|
+
images: ["https://evil.example/x.png"],
|
|
120
|
+
url: "https://evil.example",
|
|
121
|
+
metadata: { ailk_group: "plan", other: "secret-metadata" },
|
|
122
|
+
} as Record<string, unknown>),
|
|
123
|
+
[price("price_a", "prod_a", { nickname: "Nick", statement_descriptor: "STMT" } as Record<string, unknown>)],
|
|
124
|
+
);
|
|
125
|
+
const text = JSON.stringify(offerings);
|
|
126
|
+
for (const leaked of ["evil.example", "secret-metadata", "Nick", "STMT", "images", "url", "nickname"]) {
|
|
127
|
+
expect(text).not.toContain(leaked);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("the marker (G6)", () => {
|
|
133
|
+
it.each([
|
|
134
|
+
["no ailk_group", {}],
|
|
135
|
+
["ailk_group Plan", { ailk_group: "Plan" }],
|
|
136
|
+
["ailk_group other", { ailk_group: "other" }],
|
|
137
|
+
])("holds %s group_invalid", (_label, metadata) => {
|
|
138
|
+
expect(one(product("prod_a", { metadata }), [price("price_a", "prod_a")])).toEqual({
|
|
139
|
+
offerings: [],
|
|
140
|
+
held: [{ productId: "prod_a", name: "Growth", reason: "group_invalid" }],
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("holds an empty name name_missing", () => {
|
|
145
|
+
expect(one(product("prod_a", { name: " " }), [price("price_a", "prod_a")]).held).toEqual([
|
|
146
|
+
{ productId: "prod_a", name: "", reason: "name_missing" },
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("isOfferingsProduct is true for any non-empty ailk_group, Plan included", () => {
|
|
151
|
+
expect(isOfferingsProduct(product("prod_a", { metadata: { ailk_group: "Plan" } }))).toBe(true);
|
|
152
|
+
expect(isOfferingsProduct(product("prod_a", { metadata: { ailk_group: "addon" } }))).toBe(true);
|
|
153
|
+
expect(isOfferingsProduct(product("prod_a", { metadata: { ailk_group: "" } }))).toBe(false);
|
|
154
|
+
expect(isOfferingsProduct(product("prod_a", { metadata: {} }))).toBe(false);
|
|
155
|
+
expect(isOfferingsProduct(product("prod_a", { metadata: null }))).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("keeps ailk_order of 1–4 digits and sorts anything else last", () => {
|
|
159
|
+
const products = [
|
|
160
|
+
product("prod_a", { name: "A", metadata: { ailk_group: "plan", ailk_order: "12345" } }),
|
|
161
|
+
product("prod_b", { name: "B", metadata: { ailk_group: "plan", ailk_order: "2" } }),
|
|
162
|
+
product("prod_c", { name: "C", metadata: { ailk_group: "plan", ailk_order: 1 } }),
|
|
163
|
+
];
|
|
164
|
+
const prices = [
|
|
165
|
+
price("price_a", "prod_a", { lookup_key: "a_monthly" }),
|
|
166
|
+
price("price_b", "prod_b", { lookup_key: "b_monthly" }),
|
|
167
|
+
price("price_c", "prod_c", { lookup_key: "c_monthly" }),
|
|
168
|
+
];
|
|
169
|
+
const { offerings } = normalizeCatalog({ products, prices });
|
|
170
|
+
expect(offerings.map((o) => [o.productId, o.ailkOrder])).toEqual([
|
|
171
|
+
["prod_b", 2],
|
|
172
|
+
["prod_a", null],
|
|
173
|
+
["prod_c", null],
|
|
174
|
+
]);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("sellable Prices (G3) and one plan per Product (G4)", () => {
|
|
179
|
+
it.each([
|
|
180
|
+
["one sellable and one growth_quarterly Price", [
|
|
181
|
+
price("price_a", "prod_a"),
|
|
182
|
+
price("price_b", "prod_a", { lookup_key: "growth_quarterly", recurring: { interval: "month", interval_count: 3 } }),
|
|
183
|
+
]],
|
|
184
|
+
["a metered Price", [price("price_a", "prod_a", { recurring: { interval: "month", interval_count: 1, usage_type: "metered" } })]],
|
|
185
|
+
["a tiered Price", [price("price_a", "prod_a", { billing_scheme: "tiered", unit_amount: null })]],
|
|
186
|
+
["a period that disagrees with the Price", [price("price_a", "prod_a", { recurring: { interval: "year", interval_count: 1 } })]],
|
|
187
|
+
["no active Price", [price("price_a", "prod_a", { active: false })]],
|
|
188
|
+
["no Price at all", []],
|
|
189
|
+
["a null unit_amount", [price("price_a", "prod_a", { unit_amount: null })]],
|
|
190
|
+
["an uppercase currency", [price("price_a", "prod_a", { currency: "USD" })]],
|
|
191
|
+
["two Prices of one period", [
|
|
192
|
+
price("price_a", "prod_a"),
|
|
193
|
+
price("price_b", "prod_a", { lookup_key: "growth_monthly" }),
|
|
194
|
+
]],
|
|
195
|
+
["two plans on one Product", [
|
|
196
|
+
price("price_a", "prod_a"),
|
|
197
|
+
price("price_b", "prod_a", { lookup_key: "basic_yearly", recurring: { interval: "year", interval_count: 1 } }),
|
|
198
|
+
]],
|
|
199
|
+
])("holds a Product with %s needs_lookup_key", (_label, prices) => {
|
|
200
|
+
expect(one(product("prod_a"), prices as CatalogPrice[]).held).toEqual([
|
|
201
|
+
{ productId: "prod_a", name: "Growth", reason: "needs_lookup_key" },
|
|
202
|
+
]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("ignores an inactive Price beside a sellable one", () => {
|
|
206
|
+
const { offerings } = one(product("prod_a"), [
|
|
207
|
+
price("price_a", "prod_a"),
|
|
208
|
+
price("price_b", "prod_a", { active: false, lookup_key: "growth_quarterly" }),
|
|
209
|
+
]);
|
|
210
|
+
expect(offerings[0]!.prices.map((p) => p.priceId)).toEqual(["price_a"]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("holds both Products carrying one plan plan_conflict", () => {
|
|
214
|
+
const { offerings, held } = normalizeCatalog({
|
|
215
|
+
products: [product("prod_a"), product("prod_b", { name: "Growth two" })],
|
|
216
|
+
prices: [
|
|
217
|
+
price("price_a", "prod_a"),
|
|
218
|
+
price("price_b", "prod_b", { lookup_key: "growth_yearly", recurring: { interval: "year", interval_count: 1 } }),
|
|
219
|
+
],
|
|
220
|
+
});
|
|
221
|
+
expect(offerings).toEqual([]);
|
|
222
|
+
expect(held).toEqual([
|
|
223
|
+
{ productId: "prod_a", name: "Growth", reason: "plan_conflict" },
|
|
224
|
+
{ productId: "prod_b", name: "Growth two", reason: "plan_conflict" },
|
|
225
|
+
]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("reads an expanded product object on a Price", () => {
|
|
229
|
+
const { offerings } = one(product("prod_a"), [price("price_a", "prod_a", { product: { id: "prod_a" } })]);
|
|
230
|
+
expect(offerings).toHaveLength(1);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("leaves out an inactive Product and an id that is not a Stripe id", () => {
|
|
234
|
+
expect(
|
|
235
|
+
normalizeCatalog({
|
|
236
|
+
products: [product("prod_a", { active: false }), product("prod a"), { ...product("prod_x"), id: 42 }],
|
|
237
|
+
prices: [price("price_a", "prod_a")],
|
|
238
|
+
}),
|
|
239
|
+
).toEqual({ offerings: [], held: [] });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("holds a Product whose offering fails its parse invalid, and every other still normalizes", () => {
|
|
243
|
+
// Nothing the normalizer keeps fails the schema today, so the per-Product
|
|
244
|
+
// parse is driven by a schema that refuses one Product.
|
|
245
|
+
const original = offeringsSnapshotProductSchema.safeParse.bind(offeringsSnapshotProductSchema);
|
|
246
|
+
const spy = jest
|
|
247
|
+
.spyOn(offeringsSnapshotProductSchema, "safeParse")
|
|
248
|
+
.mockImplementation((value: unknown) =>
|
|
249
|
+
(value as { productId?: string }).productId === "prod_a"
|
|
250
|
+
? ({ success: false } as never)
|
|
251
|
+
: original(value),
|
|
252
|
+
);
|
|
253
|
+
try {
|
|
254
|
+
const { offerings, held } = normalizeCatalog({
|
|
255
|
+
products: [product("prod_a"), product("prod_b", { name: "Basic" })],
|
|
256
|
+
prices: [price("price_a", "prod_a"), price("price_b", "prod_b", { lookup_key: "basic_monthly" })],
|
|
257
|
+
});
|
|
258
|
+
expect(held).toEqual([{ productId: "prod_a", name: "Growth", reason: "invalid" }]);
|
|
259
|
+
expect(offerings.map((o) => o.productId)).toEqual(["prod_b"]);
|
|
260
|
+
} finally {
|
|
261
|
+
spy.mockRestore();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("counts a Product id the source answers twice once", () => {
|
|
266
|
+
const { offerings, held } = normalizeCatalog({
|
|
267
|
+
products: [product("prod_a"), product("prod_a", { name: "Growth again" })],
|
|
268
|
+
prices: [price("price_a", "prod_a")],
|
|
269
|
+
});
|
|
270
|
+
expect(offerings.map((o) => [o.productId, o.name])).toEqual([["prod_a", "Growth"]]);
|
|
271
|
+
expect(held).toEqual([]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("counts a Price id the source answers twice once", () => {
|
|
275
|
+
const { offerings } = one(product("prod_a"), [price("price_a", "prod_a"), price("price_a", "prod_a")]);
|
|
276
|
+
expect(offerings[0]!.prices.map((p) => p.priceId)).toEqual(["price_a"]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("reads no more than the catalog caps", () => {
|
|
280
|
+
const products = Array.from({ length: 1001 }, (_, i) =>
|
|
281
|
+
product(`prod_${i}`, { name: `P${i}`, metadata: {} }),
|
|
282
|
+
);
|
|
283
|
+
const { held } = normalizeCatalog({ products, prices: [] });
|
|
284
|
+
expect(held).toHaveLength(1000);
|
|
285
|
+
expect(held.some((h) => h.productId === "prod_1000")).toBe(false);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("splits a lookup key only through parseLookupKey (AC-9)", () => {
|
|
290
|
+
const source = readFileSync(join(__dirname, "..", "catalog.ts"), "utf8");
|
|
291
|
+
expect(source).toContain("parseLookupKey(");
|
|
292
|
+
expect(source).not.toMatch(/split\(\s*["']_["']\s*\)/);
|
|
293
|
+
expect(source).not.toMatch(/monthly\|yearly/);
|
|
294
|
+
});
|
|
295
|
+
});
|