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
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file plan-offerings.test.ts
|
|
3
|
+
*
|
|
4
|
+
* The plan offerings re-sourced from the Offerings shown set (#8934, spec
|
|
5
|
+
* docs/specs/8934-re-source-the-plan-offerings-spec.md D1–D6; AC-1, AC-3,
|
|
6
|
+
* AC-4, AC-5): a connected, claimed workspace lists one item per sellable
|
|
7
|
+
* Price in its shown set, keyed by the Price's full lookup key; an unconnected
|
|
8
|
+
* or unclaimed workspace keeps the page list; a licence or snapshot that
|
|
9
|
+
* cannot be read answers `{ failed }`; and the three consumers accept a
|
|
10
|
+
* listed key with no change to their own code.
|
|
11
|
+
*
|
|
12
|
+
* Runs over `createInMemoryPrisma()` and #8876's `seedOfferings`. External
|
|
13
|
+
* dependency boundaries mocked:
|
|
14
|
+
* - apps/api/src/lib/prisma — the in-memory, where-clause-honouring double.
|
|
15
|
+
* Its `lead.count` is arranged per test: the purchase email's cap reads
|
|
16
|
+
* with `mode: "insensitive"`, a filter the double does not model.
|
|
17
|
+
* - @working-theory/license — `checkLicense` per case; a keyless storage
|
|
18
|
+
* file (`absent`).
|
|
19
|
+
* - @working-theory/stripe-client — touching any export throws.
|
|
20
|
+
* - @working-theory/email — the operator notification and purchase email.
|
|
21
|
+
* - public-site-resolver's `workspaceContentAdapter` — a fixture adapter
|
|
22
|
+
* stands in for the workspace's content store.
|
|
23
|
+
*/
|
|
24
|
+
import { inspect } from "node:util";
|
|
25
|
+
|
|
26
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); the in-memory double is the repo-standard where-honouring test double for it.
|
|
27
|
+
jest.mock("../../lib/prisma.js", () =>
|
|
28
|
+
jest
|
|
29
|
+
.requireActual<typeof import("../../__tests__/factories/in-memory-prisma")>(
|
|
30
|
+
"../../__tests__/factories/in-memory-prisma",
|
|
31
|
+
)
|
|
32
|
+
.createInMemoryPrisma(),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const mockCheckLicense = jest.fn();
|
|
36
|
+
jest.mock("@working-theory/license", () => ({
|
|
37
|
+
checkLicense: (...args: unknown[]) => mockCheckLicense(...args),
|
|
38
|
+
readStoredJwtState: () => Promise.resolve({ state: "absent" }),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
jest.mock(
|
|
42
|
+
"@working-theory/stripe-client",
|
|
43
|
+
() =>
|
|
44
|
+
new Proxy(
|
|
45
|
+
{},
|
|
46
|
+
{
|
|
47
|
+
get: (_target, key) => {
|
|
48
|
+
if (key === "__esModule") return true;
|
|
49
|
+
throw new Error(`@working-theory/stripe-client.${String(key)} was touched`);
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const mockSendLeadNotification = jest.fn();
|
|
56
|
+
const mockSendPurchaseConfirmationEmail = jest.fn();
|
|
57
|
+
jest.mock("@working-theory/email", () => ({
|
|
58
|
+
sendLeadNotification: (...args: unknown[]) => mockSendLeadNotification(...args),
|
|
59
|
+
sendPurchaseConfirmationEmail: (...args: unknown[]) =>
|
|
60
|
+
mockSendPurchaseConfirmationEmail(...args),
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
const mockWorkspaceContentAdapter = jest.fn();
|
|
64
|
+
// eslint-disable-next-line no-restricted-syntax -- the content adapter seam: a fixture adapter stands in for the workspace's content store
|
|
65
|
+
jest.mock("../public-site-resolver.js", () => ({
|
|
66
|
+
...jest.requireActual("../public-site-resolver.js"),
|
|
67
|
+
workspaceContentAdapter: (...args: unknown[]) => mockWorkspaceContentAdapter(...args),
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
import { logger } from "@working-theory/observability";
|
|
71
|
+
import {
|
|
72
|
+
parseLookupKey,
|
|
73
|
+
planOfferingItemSchema,
|
|
74
|
+
planOfferingSchema,
|
|
75
|
+
planOfferingsResponseSchema,
|
|
76
|
+
} from "@working-theory/validation";
|
|
77
|
+
|
|
78
|
+
import { prisma } from "../../lib/prisma.js";
|
|
79
|
+
import { routeLead } from "../lead-routing";
|
|
80
|
+
import { magnetCompletionFor } from "../magnet-completion";
|
|
81
|
+
import {
|
|
82
|
+
FIXTURE_ORG,
|
|
83
|
+
FIXTURE_ORG_2_WS,
|
|
84
|
+
FIXTURE_WS,
|
|
85
|
+
FIXTURE_WS_2,
|
|
86
|
+
OFFERINGS_FIXTURE_CATALOG,
|
|
87
|
+
seedOfferings,
|
|
88
|
+
} from "../offerings/__tests__/fixtures.js";
|
|
89
|
+
import { normalizeCatalog, type CatalogPrice, type CatalogProduct } from "../offerings/index.js";
|
|
90
|
+
import { resolveMemberPersonId } from "../person";
|
|
91
|
+
import { listPlanOfferings } from "../plan-offerings";
|
|
92
|
+
|
|
93
|
+
type Row = Record<string, unknown>;
|
|
94
|
+
// The in-memory double's own surface (`__db`, `__calls`) and its recorded
|
|
95
|
+
// delegates are not on the PrismaClient type the import carries, so it is
|
|
96
|
+
// named here once.
|
|
97
|
+
// eslint-disable-next-line no-restricted-syntax -- justified above
|
|
98
|
+
const store = prisma as unknown as {
|
|
99
|
+
__db: Record<string, Row[]>;
|
|
100
|
+
__calls: Array<{ model: string; op: string }>;
|
|
101
|
+
lead: Record<string, jest.Mock>;
|
|
102
|
+
workspaceOfferings: Record<string, jest.Mock>;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const CLAIMED = { tier: "pro", plan: null, features: ["commerce"], expiresAt: null, revoked: false };
|
|
106
|
+
const FREE = { tier: "free", plan: null, features: [], expiresAt: null, revoked: false };
|
|
107
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
108
|
+
|
|
109
|
+
/** D1's table for the seed. */
|
|
110
|
+
const SHOWN_SET_ITEMS = [
|
|
111
|
+
{ key: "basic_once", name: "Basic (One-time)", type: "plan" },
|
|
112
|
+
{ key: "growth_monthly", name: "Growth (Monthly)", type: "plan" },
|
|
113
|
+
{ key: "growth_yearly", name: "Growth (Annual)", type: "plan" },
|
|
114
|
+
{ key: "seo-audit_monthly", name: "SEO audit (Monthly)", type: "addon" },
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
// The route suite's fixture pages and their keying (#8514 D9).
|
|
118
|
+
const page = (slug: string, title: string) => ({ slug, title });
|
|
119
|
+
const SERVICES = [
|
|
120
|
+
page("services/retainer", "Monthly retainer"),
|
|
121
|
+
page("services/Bad Key!", "Not a key"),
|
|
122
|
+
page("audit", "Brand audit"),
|
|
123
|
+
];
|
|
124
|
+
const PRODUCTS = [page("products/starter-kit", "Starter kit"), page("retainer", "Duplicate retainer")];
|
|
125
|
+
const PAGE_ITEMS = [
|
|
126
|
+
{ key: "retainer", name: "Monthly retainer", type: "service" },
|
|
127
|
+
{ key: "audit", name: "Brand audit", type: "service" },
|
|
128
|
+
{ key: "starter-kit", name: "Starter kit", type: "product" },
|
|
129
|
+
];
|
|
130
|
+
const fixtureAdapter = () => ({
|
|
131
|
+
listPages: jest.fn(async (type: string) =>
|
|
132
|
+
type === "Service" ? SERVICES : type === "Product" ? PRODUCTS : [],
|
|
133
|
+
),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const OFFERINGS_MODELS = ["stripeConnection", "organizationOfferings", "workspaceOfferings"];
|
|
137
|
+
const offeringsReads = () => store.__calls.filter((c) => OFFERINGS_MODELS.includes(c.model));
|
|
138
|
+
|
|
139
|
+
const enabledRow = () =>
|
|
140
|
+
store.__db["organizationOfferings"]!.find((r) => r["organizationId"] === FIXTURE_ORG)!;
|
|
141
|
+
const offeringsRow = () =>
|
|
142
|
+
store.__db["workspaceOfferings"]!.find((r) => r["workspaceId"] === FIXTURE_WS)!;
|
|
143
|
+
const interactions = () => (store.__db["interaction"] ??= []);
|
|
144
|
+
const people = () => (store.__db["person"] ??= []);
|
|
145
|
+
|
|
146
|
+
// ─── Logs: no line carries Stripe text (AC-5) ───────────────────────────────
|
|
147
|
+
|
|
148
|
+
const EMOJI_NAME = "\u{1F600}".repeat(200);
|
|
149
|
+
const FORBIDDEN_LOG_TEXT = [
|
|
150
|
+
...OFFERINGS_FIXTURE_CATALOG.products.flatMap((p) => [
|
|
151
|
+
p.name,
|
|
152
|
+
...(p.description ? [p.description] : []),
|
|
153
|
+
...(p.marketing_features ?? []).map((f) => f.name),
|
|
154
|
+
]),
|
|
155
|
+
EMOJI_NAME.slice(0, 20),
|
|
156
|
+
"prod_",
|
|
157
|
+
"price_",
|
|
158
|
+
];
|
|
159
|
+
const LOGGER_METHODS = ["error", "warn", "info", "debug"] as const;
|
|
160
|
+
const CONSOLE_METHODS = ["log", "info", "warn", "error", "debug"] as const;
|
|
161
|
+
let spies: jest.SpyInstance[] = [];
|
|
162
|
+
|
|
163
|
+
function leakedLines(): string[] {
|
|
164
|
+
return spies
|
|
165
|
+
.flatMap((spy) => spy.mock.calls.map((args) => inspect(args, { depth: 8 })))
|
|
166
|
+
.filter((line) => FORBIDDEN_LOG_TEXT.some((text) => line.includes(text)));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── The lead route (AC-3) ───────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
const BUYER = "buyer@x.io";
|
|
172
|
+
const U = "user_00000000-0000-7000-8000-000000008934";
|
|
173
|
+
let keySeq = 0;
|
|
174
|
+
const sourceMeta = { ip: "127.0.0.1", userAgent: "jest", receivedAt: new Date().toISOString(), viaSpoke: true };
|
|
175
|
+
|
|
176
|
+
/** A marked spoke checkout lead (#8793's shape) for `offering`, from slug site `wendi`. */
|
|
177
|
+
async function checkoutLead(offering: string): Promise<void> {
|
|
178
|
+
keySeq += 1;
|
|
179
|
+
await routeLead(
|
|
180
|
+
{
|
|
181
|
+
kind: "submit",
|
|
182
|
+
siteId: FIXTURE_WS,
|
|
183
|
+
source: "web",
|
|
184
|
+
email: BUYER,
|
|
185
|
+
payload: { site: "wendi", page: "checkout", offering, amountTotal: 900, currency: "usd" },
|
|
186
|
+
},
|
|
187
|
+
sourceMeta,
|
|
188
|
+
{ idempotencyKey: `cs_live_h8934Plan${keySeq}` }, // gitleaks:allow -- a synthetic Checkout Session id shape, not a credential
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The newest purchase message's body. */
|
|
193
|
+
const lastPurchaseBody = () => String(interactions().at(-1)?.["body"]);
|
|
194
|
+
const GENERIC = /^Thanks for your purchase\./;
|
|
195
|
+
|
|
196
|
+
const ENV_KEYS = [
|
|
197
|
+
"DATABASE_URL",
|
|
198
|
+
"NODE_ENV",
|
|
199
|
+
"AILK_LICENSE_KEY",
|
|
200
|
+
"AILK_LEAD_NOTIFICATION_TO",
|
|
201
|
+
"AILK_LEAD_NOTIFICATION_FROM",
|
|
202
|
+
"AILK_DELIVERABLE_EMAIL_FROM",
|
|
203
|
+
] as const;
|
|
204
|
+
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
205
|
+
|
|
206
|
+
beforeEach(async () => {
|
|
207
|
+
for (const key of Object.keys(store.__db)) delete store.__db[key];
|
|
208
|
+
saved = {};
|
|
209
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
210
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
211
|
+
delete process.env.NODE_ENV;
|
|
212
|
+
delete process.env.AILK_LICENSE_KEY;
|
|
213
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
214
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
215
|
+
process.env.AILK_DELIVERABLE_EMAIL_FROM = "orders@example.com";
|
|
216
|
+
mockCheckLicense.mockReset().mockResolvedValue(CLAIMED);
|
|
217
|
+
mockWorkspaceContentAdapter.mockReset().mockImplementation(async () => fixtureAdapter());
|
|
218
|
+
mockSendLeadNotification.mockReset().mockResolvedValue({ ok: true, messageId: "m" });
|
|
219
|
+
mockSendPurchaseConfirmationEmail.mockReset().mockResolvedValue({ ok: true, messageId: "m2" });
|
|
220
|
+
store.lead["count"]!.mockReset().mockResolvedValue(1);
|
|
221
|
+
await seedOfferings(prisma);
|
|
222
|
+
store.__calls.length = 0;
|
|
223
|
+
spies = [
|
|
224
|
+
...LOGGER_METHODS.map((m) => jest.spyOn(logger, m).mockImplementation(() => undefined as never)),
|
|
225
|
+
...CONSOLE_METHODS.map((m) => jest.spyOn(console, m).mockImplementation(() => undefined)),
|
|
226
|
+
];
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
afterEach(() => {
|
|
230
|
+
const leaked = leakedLines();
|
|
231
|
+
for (const spy of spies) spy.mockRestore();
|
|
232
|
+
for (const k of ENV_KEYS) {
|
|
233
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
234
|
+
else process.env[k] = saved[k];
|
|
235
|
+
}
|
|
236
|
+
// AC-5: across every case, no log or console line carries a product name,
|
|
237
|
+
// description, feature string, or a `prod_` or `price_` id.
|
|
238
|
+
expect(leaked).toEqual([]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("a connected workspace lists its shown set's lookup keys, and only those (AC-1)", () => {
|
|
242
|
+
it("one item per sellable Price, keyed by its full lookup key, with no page and no Stripe call", async () => {
|
|
243
|
+
const offerings = await listPlanOfferings(FIXTURE_WS);
|
|
244
|
+
expect(offerings).toEqual(SHOWN_SET_ITEMS);
|
|
245
|
+
if (!Array.isArray(offerings)) throw new Error("expected a list");
|
|
246
|
+
for (const { key } of offerings) {
|
|
247
|
+
expect(parseLookupKey(key)).not.toBeNull();
|
|
248
|
+
expect(planOfferingSchema.safeParse(key).success).toBe(true);
|
|
249
|
+
}
|
|
250
|
+
expect(() => planOfferingsResponseSchema.parse({ offerings })).not.toThrow();
|
|
251
|
+
expect(mockWorkspaceContentAdapter).not.toHaveBeenCalled();
|
|
252
|
+
// The bidi add-on is in the catalog but neither enabled nor shown.
|
|
253
|
+
expect(offerings.map((o) => o.key)).not.toContain("audit-plus_monthly");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("a product switched off in the Organization's enabled set is absent on the next call", async () => {
|
|
257
|
+
const { snapshot, snapshotAt } = offeringsRow();
|
|
258
|
+
enabledRow()["enabledProductIds"] = (enabledRow()["enabledProductIds"] as string[]).filter(
|
|
259
|
+
(id) => id !== "prod_fixture_seo_audit",
|
|
260
|
+
);
|
|
261
|
+
expect(await listPlanOfferings(FIXTURE_WS)).toEqual(
|
|
262
|
+
SHOWN_SET_ITEMS.filter((item) => item.key !== "seo-audit_monthly"),
|
|
263
|
+
);
|
|
264
|
+
expect(offeringsRow()["snapshot"]).toBe(snapshot);
|
|
265
|
+
expect(offeringsRow()["snapshotAt"]).toBe(snapshotAt);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("a product switched off in the workspace's shown set is absent on the next call", async () => {
|
|
269
|
+
const { snapshot, snapshotAt } = offeringsRow();
|
|
270
|
+
offeringsRow()["shownProductIds"] = (offeringsRow()["shownProductIds"] as string[]).filter(
|
|
271
|
+
(id) => id !== "prod_fixture_growth",
|
|
272
|
+
);
|
|
273
|
+
expect(await listPlanOfferings(FIXTURE_WS)).toEqual(
|
|
274
|
+
SHOWN_SET_ITEMS.filter((item) => !item.key.startsWith("growth_")),
|
|
275
|
+
);
|
|
276
|
+
expect(offeringsRow()["snapshot"]).toBe(snapshot);
|
|
277
|
+
expect(offeringsRow()["snapshotAt"]).toBe(snapshotAt);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("an empty shown set lists none: a fact, not a failure", async () => {
|
|
281
|
+
offeringsRow()["shownProductIds"] = [];
|
|
282
|
+
expect(await listPlanOfferings(FIXTURE_WS)).toEqual([]);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("the consumers accept a listed key with no change to their own code (AC-3)", () => {
|
|
287
|
+
it("(a) the purchase message names a listed lookup key, and only a listed one", async () => {
|
|
288
|
+
await checkoutLead("growth_monthly");
|
|
289
|
+
expect(interactions()).toHaveLength(1);
|
|
290
|
+
expect(lastPurchaseBody()).toContain("Growth (Monthly)");
|
|
291
|
+
|
|
292
|
+
await checkoutLead("growth_quarterly");
|
|
293
|
+
expect(lastPurchaseBody()).toMatch(GENERIC);
|
|
294
|
+
// A page the adapter lists is not a connected workspace's offering.
|
|
295
|
+
await checkoutLead("retainer");
|
|
296
|
+
expect(lastPurchaseBody()).toMatch(GENERIC);
|
|
297
|
+
expect(interactions()).toHaveLength(3);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("(b) the held plan released at the verified first link is a key the list names", async () => {
|
|
301
|
+
await checkoutLead("growth_monthly");
|
|
302
|
+
(store.__db["user"] ??= []).push({ id: U, email: BUYER, emailVerified: true });
|
|
303
|
+
const personId = await resolveMemberPersonId(FIXTURE_WS, U, BUYER);
|
|
304
|
+
const buyer = people().find((p) => p["id"] === personId)!;
|
|
305
|
+
expect(buyer["planOffering"]).toBe("growth_monthly");
|
|
306
|
+
const offerings = await listPlanOfferings(FIXTURE_WS);
|
|
307
|
+
if (!Array.isArray(offerings)) throw new Error("expected a list");
|
|
308
|
+
expect(offerings.find((o) => o.key === buyer["planOffering"])?.name).toBe("Growth (Monthly)");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("(c) a checkout magnet completes on an unconnected workspace's page key, and degrades to lead on a connected one", async () => {
|
|
312
|
+
const magnet = (workspaceId: string, checkoutOffering: string): Row => ({
|
|
313
|
+
id: `flow_${workspaceId.slice(-4)}`,
|
|
314
|
+
workspaceId,
|
|
315
|
+
siteId: workspaceId,
|
|
316
|
+
slug: "buy",
|
|
317
|
+
status: "live",
|
|
318
|
+
title: "Buy",
|
|
319
|
+
config: {},
|
|
320
|
+
completion: "checkout",
|
|
321
|
+
checkoutOffering,
|
|
322
|
+
});
|
|
323
|
+
store.__db["flowDefinition"] = [magnet(FIXTURE_ORG_2_WS, "retainer"), magnet(FIXTURE_WS, "growth")];
|
|
324
|
+
const noTarget = () => Promise.resolve(null);
|
|
325
|
+
expect(await magnetCompletionFor(FIXTURE_ORG_2_WS, "buy", noTarget)).toMatchObject({
|
|
326
|
+
next: { completion: "checkout", checkoutOffering: "retainer" },
|
|
327
|
+
});
|
|
328
|
+
expect(await magnetCompletionFor(FIXTURE_WS, "buy", noTarget)).toBeNull();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("(d) a licence or snapshot that cannot be read is { failed }, never a pass", () => {
|
|
332
|
+
const failures: Array<[string, () => Promise<void>, string]> = [
|
|
333
|
+
[
|
|
334
|
+
"an undetermined licence",
|
|
335
|
+
async () => {
|
|
336
|
+
mockCheckLicense.mockReset().mockRejectedValue(new Error("licence read failed"));
|
|
337
|
+
},
|
|
338
|
+
"LicenseUndetermined",
|
|
339
|
+
],
|
|
340
|
+
[
|
|
341
|
+
"a snapshot 25 hours old",
|
|
342
|
+
async () => {
|
|
343
|
+
for (const key of Object.keys(store.__db)) delete store.__db[key];
|
|
344
|
+
await seedOfferings(prisma, { now: new Date(Date.now() - 25 * HOUR_MS) });
|
|
345
|
+
},
|
|
346
|
+
"OfferingsSnapshotExpired",
|
|
347
|
+
],
|
|
348
|
+
[
|
|
349
|
+
"an unreadable connection",
|
|
350
|
+
async () => {
|
|
351
|
+
store.__db["stripeConnection"]![0]!["mode"] = "prod";
|
|
352
|
+
},
|
|
353
|
+
"OfferingsSnapshotEmpty",
|
|
354
|
+
],
|
|
355
|
+
[
|
|
356
|
+
"a selection read that rejects",
|
|
357
|
+
async () => {
|
|
358
|
+
store.workspaceOfferings["findUnique"]!.mockRejectedValueOnce(new Error("db down"));
|
|
359
|
+
},
|
|
360
|
+
"Error",
|
|
361
|
+
],
|
|
362
|
+
];
|
|
363
|
+
|
|
364
|
+
it.each(failures)("%s", async (_label, arm, failed) => {
|
|
365
|
+
await arm();
|
|
366
|
+
expect(await listPlanOfferings(FIXTURE_WS)).toEqual({ failed });
|
|
367
|
+
await arm();
|
|
368
|
+
await checkoutLead("growth_monthly");
|
|
369
|
+
expect(lastPurchaseBody()).toMatch(GENERIC);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("an undetermined licence reads no Offerings or workspace row", async () => {
|
|
373
|
+
mockCheckLicense.mockReset().mockRejectedValue(new Error("licence read failed"));
|
|
374
|
+
store.__calls.length = 0;
|
|
375
|
+
expect(await listPlanOfferings(FIXTURE_WS)).toEqual({ failed: "LicenseUndetermined" });
|
|
376
|
+
expect(store.__calls.filter((c) => [...OFFERINGS_MODELS, "workspace"].includes(c.model))).toEqual([]);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe("an unconnected or unclaimed workspace keeps the page list (AC-4)", () => {
|
|
382
|
+
it.each([
|
|
383
|
+
["a selection with no connection", FIXTURE_ORG_2_WS],
|
|
384
|
+
["a connection with no selection", FIXTURE_WS_2],
|
|
385
|
+
["an unknown workspace", "wksp_01920000-8934-7000-8000-0000000000ff"],
|
|
386
|
+
])("%s answers the page list", async (_label, workspaceId) => {
|
|
387
|
+
expect(await listPlanOfferings(workspaceId)).toEqual(PAGE_ITEMS);
|
|
388
|
+
expect(mockWorkspaceContentAdapter).toHaveBeenCalledWith(workspaceId);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("an unclaimed, connected workspace answers the page list and reads no Offerings row", async () => {
|
|
392
|
+
mockCheckLicense.mockReset().mockResolvedValue(FREE);
|
|
393
|
+
expect(await listPlanOfferings(FIXTURE_WS)).toEqual(PAGE_ITEMS);
|
|
394
|
+
expect(mockWorkspaceContentAdapter).toHaveBeenCalledWith(FIXTURE_WS);
|
|
395
|
+
expect(offeringsReads()).toEqual([]);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
describe("names and the item schema (AC-5)", () => {
|
|
400
|
+
it("a 200-code-point emoji name keeps its billing word, fits 200 units and splits no surrogate", async () => {
|
|
401
|
+
const product = {
|
|
402
|
+
id: "prod_fixture_emoji",
|
|
403
|
+
active: true,
|
|
404
|
+
name: EMOJI_NAME,
|
|
405
|
+
description: null,
|
|
406
|
+
marketing_features: [],
|
|
407
|
+
unit_label: null,
|
|
408
|
+
metadata: { ailk_group: "plan" },
|
|
409
|
+
} as CatalogProduct;
|
|
410
|
+
const price = {
|
|
411
|
+
id: "price_fixture_emoji_monthly",
|
|
412
|
+
active: true,
|
|
413
|
+
product: "prod_fixture_emoji",
|
|
414
|
+
lookup_key: "emoji_monthly",
|
|
415
|
+
type: "recurring",
|
|
416
|
+
recurring: { interval: "month", interval_count: 1, usage_type: "licensed" },
|
|
417
|
+
billing_scheme: "per_unit",
|
|
418
|
+
unit_amount: 100,
|
|
419
|
+
currency: "usd",
|
|
420
|
+
} as CatalogPrice;
|
|
421
|
+
const [emoji] = normalizeCatalog({ products: [product], prices: [price] }).offerings;
|
|
422
|
+
expect(emoji?.name).toBe(EMOJI_NAME);
|
|
423
|
+
enabledRow()["enabledProductIds"] = ["prod_fixture_emoji"];
|
|
424
|
+
offeringsRow()["shownProductIds"] = ["prod_fixture_emoji"];
|
|
425
|
+
offeringsRow()["snapshot"] = { v: 1, offerings: [emoji] };
|
|
426
|
+
|
|
427
|
+
const offerings = await listPlanOfferings(FIXTURE_WS);
|
|
428
|
+
if (!Array.isArray(offerings)) throw new Error(`expected a list, got ${offerings.failed}`);
|
|
429
|
+
const [item] = offerings;
|
|
430
|
+
expect(item?.key).toBe("emoji_monthly");
|
|
431
|
+
expect(item?.name.endsWith(" (Monthly)")).toBe(true);
|
|
432
|
+
expect(item!.name.length).toBeLessThanOrEqual(200);
|
|
433
|
+
expect(item!.name).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/);
|
|
434
|
+
expect(planOfferingItemSchema.safeParse(item).success).toBe(true);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("the item schema takes the Offerings groups beside the page types", () => {
|
|
438
|
+
const item = (type: string) => ({ key: "growth_monthly", name: "Growth (Monthly)", type });
|
|
439
|
+
for (const type of ["service", "product", "plan", "addon"]) {
|
|
440
|
+
expect(planOfferingItemSchema.safeParse(item(type)).success).toBe(true);
|
|
441
|
+
}
|
|
442
|
+
expect(planOfferingItemSchema.safeParse(item("offering")).success).toBe(false);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
@@ -387,7 +387,7 @@ export async function sendCompletionEmail(
|
|
|
387
387
|
|
|
388
388
|
export async function fulfillDeliverable(
|
|
389
389
|
input: FulfillDeliverableInput,
|
|
390
|
-
): Promise<
|
|
390
|
+
): Promise<{ grantId: string } | undefined> {
|
|
391
391
|
const {
|
|
392
392
|
siteId,
|
|
393
393
|
flowSlug,
|
|
@@ -403,7 +403,7 @@ export async function fulfillDeliverable(
|
|
|
403
403
|
// the call on this being present; re-checking here is defense in depth,
|
|
404
404
|
// never load-bearing for the "no block → no call" AC4 regression floor.
|
|
405
405
|
const block = configSnapshot.deliverables;
|
|
406
|
-
if (!block) return;
|
|
406
|
+
if (!block) return undefined;
|
|
407
407
|
|
|
408
408
|
const logCtx = { siteId, flowSlug, sessionId };
|
|
409
409
|
|
|
@@ -412,7 +412,7 @@ export async function fulfillDeliverable(
|
|
|
412
412
|
const entry = resolveEntry(block.assets, segment);
|
|
413
413
|
if (!entry) {
|
|
414
414
|
logOutcome(logCtx, "no_entry");
|
|
415
|
-
return;
|
|
415
|
+
return undefined;
|
|
416
416
|
}
|
|
417
417
|
|
|
418
418
|
// FEAT-074 U4b (#4718) D4 — the `playbook:` virtual asset: a per-lead
|
|
@@ -439,7 +439,7 @@ export async function fulfillDeliverable(
|
|
|
439
439
|
});
|
|
440
440
|
if (!instance) {
|
|
441
441
|
logOutcome(logCtx, "playbook_missing");
|
|
442
|
-
return;
|
|
442
|
+
return undefined;
|
|
443
443
|
}
|
|
444
444
|
assetId = entry.assetKey;
|
|
445
445
|
} else {
|
|
@@ -464,6 +464,13 @@ export async function fulfillDeliverable(
|
|
|
464
464
|
assetId = resolvedAssetId;
|
|
465
465
|
grantWorkspaceId = workspaceId;
|
|
466
466
|
} else {
|
|
467
|
+
// #8848 D3 (SEC-8848-B) — a `deliverable` flow resolves against the
|
|
468
|
+
// Asset store only: it is new, so it has no legacy key to keep, and the
|
|
469
|
+
// legacy store also holds the founders' signup CSVs.
|
|
470
|
+
if (configSnapshot.deliverable !== undefined) {
|
|
471
|
+
logOutcome(logCtx, "asset_missing");
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
467
474
|
// #6310 D2/D3 — legacy fallback. A missing row (config references an
|
|
468
475
|
// un-uploaded key) → same skip-and-log posture as before.
|
|
469
476
|
// #6257 — the LEGACY store's read, scoped by the same resolved
|
|
@@ -476,7 +483,7 @@ export async function fulfillDeliverable(
|
|
|
476
483
|
|
|
477
484
|
if (!asset) {
|
|
478
485
|
logOutcome(logCtx, "asset_missing");
|
|
479
|
-
return;
|
|
486
|
+
return undefined;
|
|
480
487
|
}
|
|
481
488
|
assetId = asset.id;
|
|
482
489
|
}
|
|
@@ -527,6 +534,7 @@ export async function fulfillDeliverable(
|
|
|
527
534
|
defaultLocale: configSnapshot.defaultLocale,
|
|
528
535
|
copy: block.copy,
|
|
529
536
|
});
|
|
537
|
+
return { grantId: grant.id };
|
|
530
538
|
}
|
|
531
539
|
|
|
532
540
|
// ─── #3998 D3 — resend: reuses the existing grant id, never mints ───────────────
|
|
@@ -15,15 +15,24 @@
|
|
|
15
15
|
* foundation fills `features` + `limits.static` (tier + seat count) and returns
|
|
16
16
|
* `limits.metered` as the reserved empty shape FEAT-047 (#3679) fills — this
|
|
17
17
|
* spec builds NO metering engine (spec D5).
|
|
18
|
+
*
|
|
19
|
+
* #9067 D5 — the Billing tab's plan card also reads here: the licence expiry,
|
|
20
|
+
* the Organization's one open plan Checkout, and the customer's billing
|
|
21
|
+
* summary. That summary is the one Stripe read this resolver makes, through
|
|
22
|
+
* `lib/stripe.ts`.
|
|
18
23
|
*/
|
|
19
|
-
import type
|
|
24
|
+
import { checkLicense, type Entitlements } from "@working-theory/license";
|
|
25
|
+
import { logger } from "@working-theory/observability";
|
|
20
26
|
import {
|
|
21
27
|
MAGNET_KIND_MIN_RANK,
|
|
28
|
+
orgBillingDetailsSchema,
|
|
22
29
|
planRank,
|
|
23
30
|
type FlowConfig,
|
|
31
|
+
type OrgBillingDetails,
|
|
24
32
|
} from "@working-theory/validation";
|
|
25
33
|
|
|
26
34
|
import { prisma } from "../lib/prisma.js";
|
|
35
|
+
import { readCustomerBillingSummary } from "../lib/stripe.js";
|
|
27
36
|
|
|
28
37
|
/**
|
|
29
38
|
* Feature flags unlocked per tier. Free unlocks none; every paid tier unlocks
|
|
@@ -180,3 +189,150 @@ export async function isBillingCustomerAnOrganization(
|
|
|
180
189
|
});
|
|
181
190
|
return org !== null;
|
|
182
191
|
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* #9067 D5 — the deployment licence's expiry, as an ISO string, or `null`
|
|
195
|
+
* for a keyless, expired or invalid licence. The trial state's date
|
|
196
|
+
* (C2). Never throws: a failed read is `null`.
|
|
197
|
+
*/
|
|
198
|
+
export async function resolveLicenseExpiresAt(): Promise<string | null> {
|
|
199
|
+
try {
|
|
200
|
+
const license = await checkLicense();
|
|
201
|
+
return license.expiresAt?.toISOString() ?? null;
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* #9067 D13 — the one plan Checkout this Organization has open, or `null`.
|
|
209
|
+
* Server-side only: the session id is never served. An unknown
|
|
210
|
+
* Organization reads `null`.
|
|
211
|
+
*/
|
|
212
|
+
export async function readOrgPlanCheckoutSession(
|
|
213
|
+
organizationId: string,
|
|
214
|
+
): Promise<string | null> {
|
|
215
|
+
const org = await prisma.organization.findUnique({
|
|
216
|
+
where: { id: organizationId },
|
|
217
|
+
select: { planCheckoutSessionId: true },
|
|
218
|
+
});
|
|
219
|
+
return org?.planCheckoutSessionId ?? null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* #9067 D3 step 9b — record the plan Checkout the route just opened, as one
|
|
224
|
+
* compare-and-set: only while the column still holds `expected` and no
|
|
225
|
+
* customer is bound. Answers whether one row moved; throws on a database
|
|
226
|
+
* error.
|
|
227
|
+
*/
|
|
228
|
+
export async function setOrgPlanCheckoutSession(
|
|
229
|
+
organizationId: string,
|
|
230
|
+
expected: string | null,
|
|
231
|
+
next: string,
|
|
232
|
+
): Promise<boolean> {
|
|
233
|
+
const { count } = await prisma.organization.updateMany({
|
|
234
|
+
where: { id: organizationId, planCheckoutSessionId: expected, billingCustomerId: null },
|
|
235
|
+
data: { planCheckoutSessionId: next },
|
|
236
|
+
});
|
|
237
|
+
return count === 1;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* #9067 7b (code review) — bind the Organization to the customer a COMPLETED
|
|
242
|
+
* plan Checkout created, when the webhook's bind never landed (a lost event,
|
|
243
|
+
* a database error the webhook swallowed). The same guarded compare-and-set
|
|
244
|
+
* the webhook runs: only while the column still holds `sessionId` and no
|
|
245
|
+
* customer is bound, and it clears the session. Answers whether one row
|
|
246
|
+
* moved; throws on a database error.
|
|
247
|
+
*/
|
|
248
|
+
export async function bindOrgPlanCheckout(
|
|
249
|
+
organizationId: string,
|
|
250
|
+
sessionId: string,
|
|
251
|
+
customerId: string,
|
|
252
|
+
): Promise<boolean> {
|
|
253
|
+
const { count } = await prisma.organization.updateMany({
|
|
254
|
+
where: { id: organizationId, planCheckoutSessionId: sessionId, billingCustomerId: null },
|
|
255
|
+
data: { billingProvider: "stripe", billingCustomerId: customerId, planCheckoutSessionId: null },
|
|
256
|
+
});
|
|
257
|
+
return count === 1;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** How long one customer's summary is reused, a failure included (#9067 NFR-1). */
|
|
261
|
+
export const ORG_BILLING_SUMMARY_TTL_MS = 60_000;
|
|
262
|
+
/** The most customers held at once; the oldest entry is evicted at the cap. */
|
|
263
|
+
export const ORG_BILLING_SUMMARY_CACHE_MAX = 2_000;
|
|
264
|
+
/** The longest one summary read may take before the keys are left absent (NFR-2). */
|
|
265
|
+
export const ORG_BILLING_SUMMARY_TIMEOUT_MS = 3_000;
|
|
266
|
+
|
|
267
|
+
type OrgBillingSummaryKeys = Partial<Pick<OrgBillingDetails, "renewsAt" | "sites" | "paymentMethod">>;
|
|
268
|
+
|
|
269
|
+
/** Per customer: the settled keys and when they expire. Insertion order is age. */
|
|
270
|
+
const orgBillingSummaryCache = new Map<string, { keys: OrgBillingSummaryKeys; expiresAt: number }>();
|
|
271
|
+
/** Per customer: the one read in flight, joined by every concurrent miss. */
|
|
272
|
+
const orgBillingSummaryInFlight = new Map<string, Promise<OrgBillingSummaryKeys>>();
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* #9067 D5 — the Organization customer's renewal date, site count and card,
|
|
276
|
+
* from the cache when fresh, else one read shared by every concurrent caller
|
|
277
|
+
* of the same customer. The member read's wrapper (`services/subscription.ts`),
|
|
278
|
+
* rebuilt here because importing it would be a cycle. A throw or a timeout
|
|
279
|
+
* answers `{}` (every key absent) and is cached like a success; a value
|
|
280
|
+
* outside `orgBillingDetailsSchema`'s bounds is `null`.
|
|
281
|
+
*/
|
|
282
|
+
export async function readOrgBillingSummary(customerId: string): Promise<OrgBillingSummaryKeys> {
|
|
283
|
+
const cached = orgBillingSummaryCache.get(customerId);
|
|
284
|
+
if (cached && cached.expiresAt > Date.now()) return cached.keys;
|
|
285
|
+
const pending = orgBillingSummaryInFlight.get(customerId);
|
|
286
|
+
if (pending) return pending;
|
|
287
|
+
|
|
288
|
+
const read = settleOrgBillingSummary(customerId).then((keys) => {
|
|
289
|
+
orgBillingSummaryCache.delete(customerId);
|
|
290
|
+
if (orgBillingSummaryCache.size >= ORG_BILLING_SUMMARY_CACHE_MAX) {
|
|
291
|
+
const oldest = orgBillingSummaryCache.keys().next().value;
|
|
292
|
+
if (oldest !== undefined) orgBillingSummaryCache.delete(oldest);
|
|
293
|
+
}
|
|
294
|
+
orgBillingSummaryCache.set(customerId, {
|
|
295
|
+
keys,
|
|
296
|
+
expiresAt: Date.now() + ORG_BILLING_SUMMARY_TTL_MS,
|
|
297
|
+
});
|
|
298
|
+
return keys;
|
|
299
|
+
});
|
|
300
|
+
// Cleared however the read ends, so a rejection is never served forever.
|
|
301
|
+
void read.finally(() => orgBillingSummaryInFlight.delete(customerId)).catch(() => undefined);
|
|
302
|
+
orgBillingSummaryInFlight.set(customerId, read);
|
|
303
|
+
return read;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function settleOrgBillingSummary(customerId: string): Promise<OrgBillingSummaryKeys> {
|
|
307
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
308
|
+
try {
|
|
309
|
+
const timedOut = new Promise<"timeout">((resolve) => {
|
|
310
|
+
timer = setTimeout(() => resolve("timeout"), ORG_BILLING_SUMMARY_TIMEOUT_MS);
|
|
311
|
+
});
|
|
312
|
+
const summary = await Promise.race([readCustomerBillingSummary(customerId), timedOut]);
|
|
313
|
+
if (summary === "timeout") throw new Error("timeout");
|
|
314
|
+
const { shape } = orgBillingDetailsSchema;
|
|
315
|
+
const renewsAt = shape.renewsAt.safeParse(summary.renewsAt);
|
|
316
|
+
const sites = shape.sites.safeParse(summary.sites);
|
|
317
|
+
const paymentMethod = shape.paymentMethod.safeParse(summary.paymentMethod);
|
|
318
|
+
return {
|
|
319
|
+
renewsAt: renewsAt.success ? (renewsAt.data ?? null) : null,
|
|
320
|
+
sites: sites.success ? (sites.data ?? null) : null,
|
|
321
|
+
paymentMethod: paymentMethod.success ? (paymentMethod.data ?? null) : null,
|
|
322
|
+
};
|
|
323
|
+
} catch {
|
|
324
|
+
logger.warn(
|
|
325
|
+
{ route: "billing", outcome: "summary_unavailable" },
|
|
326
|
+
"[billing] organization billing summary unavailable",
|
|
327
|
+
);
|
|
328
|
+
return {};
|
|
329
|
+
} finally {
|
|
330
|
+
clearTimeout(timer);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Test seam: forget every cached and in-flight Organization summary. */
|
|
335
|
+
export function __resetOrgBillingSummaryCache(): void {
|
|
336
|
+
orgBillingSummaryCache.clear();
|
|
337
|
+
orgBillingSummaryInFlight.clear();
|
|
338
|
+
}
|