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,325 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* POST /v1/billing/plan-checkout
|
|
4
|
+
*
|
|
5
|
+
* The Billing tab's Purchase plan (#9067 D3, D13; spec
|
|
6
|
+
* `docs/specs/9067-the-billing-tab-s-plan-spec.md`): starts a subscription
|
|
7
|
+
* Stripe Checkout for the caller's OWN Organization's plan, on the
|
|
8
|
+
* deployment's Stripe account, sandbox-only like the Organization portal.
|
|
9
|
+
* The Organization is ALWAYS `request.tenant.organizationId`. The session
|
|
10
|
+
* creates its own customer (no `buyer`, no `customerId`, so no
|
|
11
|
+
* `ailk_user_id`), and the webhook binds that customer to the Organization
|
|
12
|
+
* only because this route recorded the session's id (D13).
|
|
13
|
+
*
|
|
14
|
+
* Check order — each step refuses `{ ok: false, error: { code, message } }`
|
|
15
|
+
* or passes to the next, so a refusal answers before the work it guards:
|
|
16
|
+
*
|
|
17
|
+
* 1. tenant 401 unauthorized
|
|
18
|
+
* 2. `billing-manage` 403 forbidden — before any lookup or budget
|
|
19
|
+
* 3. write budget 429 rate_limited (10/h per Organization and per person)
|
|
20
|
+
* 4. commerce claim 409 purchase_unavailable · 503 license_undetermined
|
|
21
|
+
* 5. body 400 validation_failed
|
|
22
|
+
* 6. return URL 400 return_url_refused · 500 config-error
|
|
23
|
+
* 7. no customer; a plan tier 409 billing_customer_exists · 409 plan_not_purchasable
|
|
24
|
+
* 8. Stripe key 500 stripe-not-configured · 500 live-mode-forbidden
|
|
25
|
+
* 7b. one payable session 409 purchase_pending · 502 checkout-unavailable
|
|
26
|
+
* 9. create 500 plan_price_missing · 500 config-error · 502 checkout-unavailable
|
|
27
|
+
* 10. a Checkout URL 502 checkout-unavailable (the session expired)
|
|
28
|
+
* 9b. record (compare-and-set) 409 purchase_pending · 502 (the new session expired)
|
|
29
|
+
*
|
|
30
|
+
* PII/secret-min: no response body or log line carries the return URL, an
|
|
31
|
+
* Organization or Stripe id, or a Stripe error message — errors are logged
|
|
32
|
+
* by `name` only.
|
|
33
|
+
*
|
|
34
|
+
* MCP parity: owner-only purchase, and `billing-manage` is never
|
|
35
|
+
* agent-grantable, so no twin could succeed. Recorded in
|
|
36
|
+
* `tests/parity/README.md` § Routes with no tool, by decision.
|
|
37
|
+
*/
|
|
38
|
+
import {
|
|
39
|
+
can,
|
|
40
|
+
planCheckoutRequestSchema,
|
|
41
|
+
planCheckoutResponseSchema,
|
|
42
|
+
planPurchaseLookupKey,
|
|
43
|
+
} from "@working-theory/validation";
|
|
44
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
45
|
+
|
|
46
|
+
import { config } from "../../config/index.js";
|
|
47
|
+
import {
|
|
48
|
+
LICENSE_UNDETERMINED_RETRY_AFTER_SECONDS,
|
|
49
|
+
resolveCommerceClaim,
|
|
50
|
+
} from "../../lib/require-commerce-claim.js";
|
|
51
|
+
import { siteStamp } from "../../lib/site.js";
|
|
52
|
+
import {
|
|
53
|
+
assertSandboxKey,
|
|
54
|
+
CheckoutModeMismatchError,
|
|
55
|
+
createCheckoutSession,
|
|
56
|
+
expireCheckoutSession,
|
|
57
|
+
PLATFORM_TERMS_OF_SERVICE,
|
|
58
|
+
retrieveCheckoutSession,
|
|
59
|
+
StripeConfigError,
|
|
60
|
+
UnknownProductCodeError,
|
|
61
|
+
UnknownSessionError,
|
|
62
|
+
} from "../../lib/stripe.js";
|
|
63
|
+
import { chargeWriteBudget } from "../../middleware/rate-limit.js";
|
|
64
|
+
import {
|
|
65
|
+
bindOrgPlanCheckout,
|
|
66
|
+
getOrgBillingIdentity,
|
|
67
|
+
readOrgPlanCheckoutSession,
|
|
68
|
+
resolveEntitlements,
|
|
69
|
+
setOrgPlanCheckoutSession,
|
|
70
|
+
} from "../../services/entitlements.js";
|
|
71
|
+
|
|
72
|
+
/** Route-level body bound (D3 step 5). */
|
|
73
|
+
const BODY_LIMIT = 4096;
|
|
74
|
+
const WRITE_BUDGET_SCOPE = "plancheckout";
|
|
75
|
+
const WRITE_BUDGET_WINDOW_MS = 60 * 60 * 1000;
|
|
76
|
+
const PLAN_CHECKOUTS_PER_HOUR = 10;
|
|
77
|
+
/** The quantity range the buyer picks on Checkout (sites). */
|
|
78
|
+
const SITES_MIN = 1;
|
|
79
|
+
const SITES_MAX = 50;
|
|
80
|
+
const NO_STORE = "private, no-store";
|
|
81
|
+
const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1"]);
|
|
82
|
+
|
|
83
|
+
function refuse(reply: FastifyReply, status: number, code: string, message: string) {
|
|
84
|
+
return reply.status(status).send({ ok: false, error: { code, message } });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function checkoutUnavailable(reply: FastifyReply) {
|
|
88
|
+
return refuse(reply, 502, "checkout-unavailable", "The purchase is temporarily unavailable");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* D3 step 6 — a return URL the Admin may hand Stripe: canonical (its own
|
|
93
|
+
* parse's `href`, which refuses CR, LF, tab, backslash and every other
|
|
94
|
+
* re-spelling), no userinfo, query or fragment (the Admin sends none, and a
|
|
95
|
+
* `{CHECKOUT_SESSION_ID}` template would leak the session id), no `//` path,
|
|
96
|
+
* `https:` (or `http:` on a local host), on a trusted origin.
|
|
97
|
+
*/
|
|
98
|
+
function isAcceptableReturnUrl(value: string, trusted: readonly string[]): boolean {
|
|
99
|
+
let url: URL;
|
|
100
|
+
try {
|
|
101
|
+
url = new URL(value);
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
if (url.href !== value) return false;
|
|
106
|
+
if (url.username !== "" || url.password !== "" || url.hash !== "" || value.includes("#")) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (url.search !== "" || value.includes("?") || url.pathname.startsWith("//")) return false;
|
|
110
|
+
const secure =
|
|
111
|
+
url.protocol === "https:" || (url.protocol === "http:" && LOCAL_HOSTNAMES.has(url.hostname));
|
|
112
|
+
return secure && trusted.includes(url.origin);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Expire a session this route opened and will not hand out; a failure is logged by name. */
|
|
116
|
+
async function expireQuietly(request: FastifyRequest, sessionId: string): Promise<void> {
|
|
117
|
+
try {
|
|
118
|
+
await expireCheckoutSession(sessionId);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
request.log.error(
|
|
121
|
+
{ errName: err instanceof Error ? err.name : "unknown" },
|
|
122
|
+
"[billing] plan checkout expire failed",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function planCheckoutHandler(
|
|
128
|
+
request: FastifyRequest,
|
|
129
|
+
reply: FastifyReply,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
// 1. Tenant — the preHandler denies an unresolvable tenant first.
|
|
132
|
+
const tenant = request.tenant;
|
|
133
|
+
if (!tenant) return refuse(reply, 401, "unauthorized", "Authentication required");
|
|
134
|
+
|
|
135
|
+
// 2. Permission — before any lookup, budget or body read.
|
|
136
|
+
if (!can(tenant.role, "billing-manage")) {
|
|
137
|
+
request.log.warn(
|
|
138
|
+
{ method: request.method, path: request.url, status: 403 },
|
|
139
|
+
"[billing] plan checkout denied; insufficient role",
|
|
140
|
+
);
|
|
141
|
+
return refuse(reply, 403, "forbidden", "Forbidden");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 3. Write budget — every request that got this far is counted.
|
|
145
|
+
const budget = await chargeWriteBudget({
|
|
146
|
+
scope: WRITE_BUDGET_SCOPE,
|
|
147
|
+
windowMs: WRITE_BUDGET_WINDOW_MS,
|
|
148
|
+
now: Date.now(),
|
|
149
|
+
charges: [
|
|
150
|
+
{ key: `org,${tenant.organizationId}`, limit: PLAN_CHECKOUTS_PER_HOUR },
|
|
151
|
+
{ key: `user,${tenant.userId}`, limit: PLAN_CHECKOUTS_PER_HOUR },
|
|
152
|
+
],
|
|
153
|
+
warn: (obj, msg) => request.log.warn(obj, msg),
|
|
154
|
+
});
|
|
155
|
+
if (!budget.ok) {
|
|
156
|
+
reply.header("Retry-After", String(budget.retryAfterSeconds));
|
|
157
|
+
return refuse(reply, 429, "rate_limited", "Too many purchase attempts; try again later");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 4. Commerce claim — the webhook that binds the customer serves only with it.
|
|
161
|
+
const claim = await resolveCommerceClaim();
|
|
162
|
+
if (claim === "undetermined") {
|
|
163
|
+
reply.header("Retry-After", String(LICENSE_UNDETERMINED_RETRY_AFTER_SECONDS));
|
|
164
|
+
return refuse(reply, 503, "license_undetermined", "The licence could not be read; try again later");
|
|
165
|
+
}
|
|
166
|
+
if (claim !== "claimed") {
|
|
167
|
+
return refuse(reply, 409, "purchase_unavailable", "Purchasing a plan is not available on this site");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 5. Body.
|
|
171
|
+
const body = planCheckoutRequestSchema.safeParse(request.body);
|
|
172
|
+
if (!body.success) return refuse(reply, 400, "validation_failed", "Invalid request body");
|
|
173
|
+
const { returnUrl } = body.data;
|
|
174
|
+
|
|
175
|
+
// 6. Return URL — never logged.
|
|
176
|
+
if (config.TRUSTED_ORIGINS.length === 0) {
|
|
177
|
+
request.log.error({ outcome: "no_trusted_origins" }, "[billing] plan checkout misconfigured");
|
|
178
|
+
return refuse(reply, 500, "config-error", "The purchase is misconfigured");
|
|
179
|
+
}
|
|
180
|
+
if (!isAcceptableReturnUrl(returnUrl, config.TRUSTED_ORIGINS)) {
|
|
181
|
+
return refuse(reply, 400, "return_url_refused", "The return address is not allowed");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 7. Not already a customer; a purchasable tier.
|
|
185
|
+
const [identity, entitlements] = await Promise.all([
|
|
186
|
+
getOrgBillingIdentity(tenant.organizationId),
|
|
187
|
+
resolveEntitlements(tenant.organizationId),
|
|
188
|
+
]);
|
|
189
|
+
if (identity.billingCustomerId != null) {
|
|
190
|
+
return refuse(reply, 409, "billing_customer_exists", "This organization already has a plan");
|
|
191
|
+
}
|
|
192
|
+
const productCode = planPurchaseLookupKey(entitlements.limits.static.tier);
|
|
193
|
+
if (productCode === null) {
|
|
194
|
+
return refuse(reply, 409, "plan_not_purchasable", "This plan cannot be purchased here");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 8. Stripe key — sandbox-only, as the Organization portal.
|
|
198
|
+
const stripeSecretKey = process.env["STRIPE_SECRET_KEY"];
|
|
199
|
+
if (!stripeSecretKey) {
|
|
200
|
+
return refuse(reply, 500, "stripe-not-configured", "Stripe is not configured");
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
assertSandboxKey(stripeSecretKey);
|
|
204
|
+
} catch {
|
|
205
|
+
return refuse(reply, 500, "live-mode-forbidden", "Live Stripe keys are not permitted");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 7b. One payable session at a time (D13).
|
|
209
|
+
const earlier = await readOrgPlanCheckoutSession(tenant.organizationId);
|
|
210
|
+
if (earlier !== null) {
|
|
211
|
+
let status: Awaited<ReturnType<typeof retrieveCheckoutSession>>["status"] | null;
|
|
212
|
+
let customer: string | null = null;
|
|
213
|
+
try {
|
|
214
|
+
const retrieved = await retrieveCheckoutSession({ sessionId: earlier });
|
|
215
|
+
status = retrieved.status;
|
|
216
|
+
customer = retrieved.customer;
|
|
217
|
+
} catch (err) {
|
|
218
|
+
if (!(err instanceof UnknownSessionError)) {
|
|
219
|
+
request.log.error(
|
|
220
|
+
{ errName: err instanceof Error ? err.name : "unknown" },
|
|
221
|
+
"[billing] plan checkout read failed",
|
|
222
|
+
);
|
|
223
|
+
return checkoutUnavailable(reply);
|
|
224
|
+
}
|
|
225
|
+
status = null;
|
|
226
|
+
}
|
|
227
|
+
if (status === "complete") {
|
|
228
|
+
// The webhook's bind never landed (a lost event, a swallowed database
|
|
229
|
+
// error). A completed session carries its customer, so bind from it
|
|
230
|
+
// here, under the same guard the webhook uses; a bound Organization
|
|
231
|
+
// then reads exactly as step 7 reads one. Only a session with no
|
|
232
|
+
// customer yet stays pending.
|
|
233
|
+
if (customer !== null) {
|
|
234
|
+
let bound: boolean;
|
|
235
|
+
try {
|
|
236
|
+
bound = await bindOrgPlanCheckout(tenant.organizationId, earlier, customer);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
request.log.error(
|
|
239
|
+
{ errName: err instanceof Error ? err.name : "unknown" },
|
|
240
|
+
"[billing] plan checkout bind failed",
|
|
241
|
+
);
|
|
242
|
+
return checkoutUnavailable(reply);
|
|
243
|
+
}
|
|
244
|
+
if (bound) {
|
|
245
|
+
request.log.info({ outcome: "org_bound_from_completed_session" }, "[billing] plan checkout bound");
|
|
246
|
+
return refuse(reply, 409, "billing_customer_exists", "This organization already has a plan");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return refuse(reply, 409, "purchase_pending", "A purchase is already being confirmed");
|
|
250
|
+
}
|
|
251
|
+
if (status === "open") {
|
|
252
|
+
try {
|
|
253
|
+
await expireCheckoutSession(earlier);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
request.log.error(
|
|
256
|
+
{ errName: err instanceof Error ? err.name : "unknown" },
|
|
257
|
+
"[billing] plan checkout expire failed",
|
|
258
|
+
);
|
|
259
|
+
return checkoutUnavailable(reply);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 9. Start.
|
|
265
|
+
const stamp = { organizationId: tenant.organizationId, ...siteStamp() };
|
|
266
|
+
let created: { sessionId: string; url: string };
|
|
267
|
+
try {
|
|
268
|
+
created = await createCheckoutSession({
|
|
269
|
+
productCode,
|
|
270
|
+
successUrl: returnUrl,
|
|
271
|
+
cancelUrl: returnUrl,
|
|
272
|
+
mode: "subscription",
|
|
273
|
+
adjustableQuantity: { enabled: true, minimum: SITES_MIN, maximum: SITES_MAX },
|
|
274
|
+
metadata: stamp,
|
|
275
|
+
subscriptionMetadata: { ...stamp },
|
|
276
|
+
termsOfService: PLATFORM_TERMS_OF_SERVICE,
|
|
277
|
+
});
|
|
278
|
+
} catch (err) {
|
|
279
|
+
const errName = err instanceof Error ? err.name : "unknown";
|
|
280
|
+
request.log.error({ errName }, "[billing] plan checkout create failed");
|
|
281
|
+
if (err instanceof UnknownProductCodeError || err instanceof CheckoutModeMismatchError) {
|
|
282
|
+
return refuse(reply, 500, "plan_price_missing", "The plan's price is not set up");
|
|
283
|
+
}
|
|
284
|
+
if (err instanceof StripeConfigError) {
|
|
285
|
+
return refuse(reply, 500, "config-error", "The purchase is misconfigured");
|
|
286
|
+
}
|
|
287
|
+
return checkoutUnavailable(reply);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 10. A Checkout URL only — anything else is never handed out.
|
|
291
|
+
const answer = planCheckoutResponseSchema.safeParse({ url: created.url });
|
|
292
|
+
if (!answer.success) {
|
|
293
|
+
request.log.error({ outcome: "not_checkout_url" }, "[billing] plan checkout answer refused");
|
|
294
|
+
await expireQuietly(request, created.sessionId);
|
|
295
|
+
return checkoutUnavailable(reply);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 9b. Record it — no payable session is left unrecorded.
|
|
299
|
+
let recorded: boolean;
|
|
300
|
+
try {
|
|
301
|
+
recorded = await setOrgPlanCheckoutSession(tenant.organizationId, earlier, created.sessionId);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
request.log.error(
|
|
304
|
+
{ errName: err instanceof Error ? err.name : "unknown" },
|
|
305
|
+
"[billing] plan checkout record failed",
|
|
306
|
+
);
|
|
307
|
+
await expireQuietly(request, created.sessionId);
|
|
308
|
+
return checkoutUnavailable(reply);
|
|
309
|
+
}
|
|
310
|
+
if (!recorded) {
|
|
311
|
+
await expireQuietly(request, created.sessionId);
|
|
312
|
+
return refuse(reply, 409, "purchase_pending", "A purchase is already being confirmed");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
request.log.info(
|
|
316
|
+
{ method: request.method, path: request.url, status: 200 },
|
|
317
|
+
"[billing] plan checkout created",
|
|
318
|
+
);
|
|
319
|
+
return reply.status(200).header("Cache-Control", NO_STORE).send({ ok: true, data: answer.data });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Registers the route beside the billing routes, in the same tenant scope (D3). */
|
|
323
|
+
export function registerPlanCheckoutRoute(fastify: FastifyInstance): void {
|
|
324
|
+
fastify.post("/v1/billing/plan-checkout", { bodyLimit: BODY_LIMIT }, planCheckoutHandler);
|
|
325
|
+
}
|
|
@@ -40,6 +40,17 @@ jest.mock("../../../lib/prisma.js", () => ({
|
|
|
40
40
|
},
|
|
41
41
|
}));
|
|
42
42
|
|
|
43
|
+
// #8931 — the route now asks the Offerings service which path a request
|
|
44
|
+
// takes before the env checks; every case in this suite is an env-path case,
|
|
45
|
+
// so the service answers `env` for both the POST and the GET.
|
|
46
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
47
|
+
jest.mock("../../../services/offerings/checkout-authorization.js", () => ({
|
|
48
|
+
resolveOfferingsCheckoutPath: jest.fn(async () => ({ kind: "env" })),
|
|
49
|
+
readOfferingsCheckoutSession: jest.fn(async () => ({ kind: "env" })),
|
|
50
|
+
startOfferingsCheckout: jest.fn(),
|
|
51
|
+
OFFERINGS_CHECKOUT_PER_WORKSPACE_PER_MINUTE: 60,
|
|
52
|
+
}));
|
|
53
|
+
|
|
43
54
|
import { assertSandboxKey } from "@working-theory/stripe-client";
|
|
44
55
|
|
|
45
56
|
import { config } from "../../../config/index.js";
|