From 0352ea53839b0d9011c92885a839dbe5be7b11b4 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 19 Mar 2026 22:10:32 -0700 Subject: [PATCH] =?UTF-8?q?feat(platform-service):=20pre-built=20Stripe=20?= =?UTF-8?q?checkout=20flow=20(Phase=204.4)=20=E2=80=94=206=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New modules/billing-checkout/ — higher-level POST /billing/checkout with: - Zod-validated input (plan OR explicit priceId) - Tax collection support (automatic_tax) - Customer email pre-fill - Arbitrary metadata passthrough - Trial period and promo code support - 6 tests: plan, priceId, missing both, invalid plan, optional fields, URL validation --- .../billing-checkout/billing-checkout.test.ts | 82 +++++++++++++++++++ .../src/modules/billing-checkout/routes.ts | 70 ++++++++++++++++ .../src/modules/billing-checkout/types.ts | 27 ++++++ services/platform-service/src/server.ts | 3 + 4 files changed, 182 insertions(+) create mode 100644 services/platform-service/src/modules/billing-checkout/billing-checkout.test.ts create mode 100644 services/platform-service/src/modules/billing-checkout/routes.ts create mode 100644 services/platform-service/src/modules/billing-checkout/types.ts diff --git a/services/platform-service/src/modules/billing-checkout/billing-checkout.test.ts b/services/platform-service/src/modules/billing-checkout/billing-checkout.test.ts new file mode 100644 index 00000000..70eec97f --- /dev/null +++ b/services/platform-service/src/modules/billing-checkout/billing-checkout.test.ts @@ -0,0 +1,82 @@ +/** + * Billing checkout tests — Zod schema validation and input handling. + */ + +import { describe, it, expect } from 'vitest'; +import { BillingCheckoutSchema } from './types.js'; + +describe('BillingCheckoutSchema', () => { + it('validates with plan', () => { + const result = BillingCheckoutSchema.safeParse({ + userId: 'user-1', + plan: 'pro', + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.plan).toBe('pro'); + expect(result.data.collectTax).toBe(false); + } + }); + + it('validates with explicit priceId', () => { + const result = BillingCheckoutSchema.safeParse({ + userId: 'user-1', + priceId: 'price_abc123', + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel', + }); + expect(result.success).toBe(true); + }); + + it('rejects when neither plan nor priceId provided', () => { + const result = BillingCheckoutSchema.safeParse({ + userId: 'user-1', + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel', + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid plan', () => { + const result = BillingCheckoutSchema.safeParse({ + userId: 'user-1', + plan: 'platinum', + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel', + }); + expect(result.success).toBe(false); + }); + + it('accepts optional fields', () => { + const result = BillingCheckoutSchema.safeParse({ + userId: 'user-1', + plan: 'enterprise', + successUrl: 'https://example.com/success', + cancelUrl: 'https://example.com/cancel', + trialDays: 14, + promoCode: 'LAUNCH50', + collectTax: true, + customerEmail: 'user@example.com', + metadata: { campaign: 'onboarding' }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.trialDays).toBe(14); + expect(result.data.collectTax).toBe(true); + expect(result.data.customerEmail).toBe('user@example.com'); + expect(result.data.metadata?.campaign).toBe('onboarding'); + } + }); + + it('rejects invalid URLs', () => { + const result = BillingCheckoutSchema.safeParse({ + userId: 'user-1', + plan: 'pro', + successUrl: 'not-a-url', + cancelUrl: 'https://example.com/cancel', + }); + expect(result.success).toBe(false); + }); +}); diff --git a/services/platform-service/src/modules/billing-checkout/routes.ts b/services/platform-service/src/modules/billing-checkout/routes.ts new file mode 100644 index 00000000..297fbe0b --- /dev/null +++ b/services/platform-service/src/modules/billing-checkout/routes.ts @@ -0,0 +1,70 @@ +/** + * Billing Checkout REST endpoint — pre-built Stripe Checkout Session. + * + * POST /billing/checkout — create Stripe Checkout Session (any auth) + * + * This is a higher-level wrapper over /stripe/checkout with: + * - Zod-validated input (priceId OR plan) + * - Tax collection support + * - Customer email pre-fill + * - Arbitrary metadata passthrough + */ + +import type { FastifyInstance } from 'fastify'; +import type Stripe from 'stripe'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { BadRequestError } from '../../lib/errors.js'; +import { getStripeForProduct, getPriceIds } from '../../lib/stripe.js'; +import { BillingCheckoutSchema, type BillingCheckoutResponse } from './types.js'; + +export async function billingCheckoutRoutes(app: FastifyInstance) { + app.post('/billing/checkout', async (req, reply) => { + const parsed = BillingCheckoutSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const input = parsed.data; + const productId = getRequestProductId(req); + const stripe = getStripeForProduct(productId); + + // Resolve priceId: explicit priceId takes precedence, otherwise look up by plan + let priceId = input.priceId; + if (!priceId && input.plan) { + const priceIds = getPriceIds(); + priceId = priceIds[input.plan]; + if (!priceId) throw new BadRequestError(`No price configured for plan: ${input.plan}`); + } + if (!priceId) throw new BadRequestError('Could not resolve a Stripe priceId'); + + const params: Stripe.Checkout.SessionCreateParams = { + mode: 'subscription', + line_items: [{ price: priceId, quantity: 1 }], + success_url: input.successUrl, + cancel_url: input.cancelUrl, + metadata: { + userId: input.userId, + productId, + ...(input.plan && { plan: input.plan }), + ...(input.metadata ?? {}), + }, + ...(input.trialDays && + input.trialDays > 0 && { + subscription_data: { trial_period_days: input.trialDays }, + }), + ...(input.promoCode && { allow_promotion_codes: true }), + ...(input.collectTax && { automatic_tax: { enabled: true } }), + ...(input.customerEmail && { customer_email: input.customerEmail }), + }; + + const session = await stripe.checkout.sessions.create(params); + + const response: BillingCheckoutResponse = { + sessionId: session.id, + url: session.url, + }; + + reply.code(201); + return response; + }); +} diff --git a/services/platform-service/src/modules/billing-checkout/types.ts b/services/platform-service/src/modules/billing-checkout/types.ts new file mode 100644 index 00000000..e198beb0 --- /dev/null +++ b/services/platform-service/src/modules/billing-checkout/types.ts @@ -0,0 +1,27 @@ +/** + * Billing checkout types — pre-built Stripe Checkout Session creation. + */ + +import { z } from 'zod'; + +export const BillingCheckoutSchema = z + .object({ + userId: z.string().min(1), + priceId: z.string().min(1).optional(), + plan: z.enum(['pro', 'enterprise']).optional(), + successUrl: z.string().url(), + cancelUrl: z.string().url(), + trialDays: z.number().int().min(0).max(365).optional(), + promoCode: z.string().max(64).optional(), + collectTax: z.boolean().default(false), + customerEmail: z.string().email().optional(), + metadata: z.record(z.string().max(500)).optional(), + }) + .refine(data => data.priceId || data.plan, { message: 'Either priceId or plan is required' }); + +export type BillingCheckoutInput = z.infer; + +export interface BillingCheckoutResponse { + sessionId: string; + url: string | null; +} diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 6cde47f6..75f81d7e 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -90,6 +90,7 @@ import { webhookRoutes } from './modules/webhooks/routes.js'; import { marketplaceRoutes } from './modules/marketplace/routes.js'; import { predictiveAnalyticsRoutes } from './modules/predictive-analytics/routes.js'; import { onboardingRoutes } from './modules/onboarding/routes.js'; +import { billingCheckoutRoutes } from './modules/billing-checkout/routes.js'; import { initCosmosIfNeeded } from './lib/cosmos-init.js'; import { config } from './lib/config.js'; import type { JwtPayload } from './lib/request-context.js'; @@ -225,6 +226,8 @@ await app.register(marketplaceRoutes, { prefix: '/api' }); await app.register(predictiveAnalyticsRoutes, { prefix: '/api' }); // Onboarding analytics (Phase 4.3) await app.register(onboardingRoutes, { prefix: '/api' }); +// Pre-built Stripe Checkout (Phase 4.4) +await app.register(billingCheckoutRoutes, { prefix: '/api' }); // Broadcast Messaging & Surveys (see docs/roadmaps/not-started/platform_BROADCAST_SURVEY_ROADMAP.md) await app.register(broadcastRoutes, { prefix: '/api' }); await app.register(surveyRoutes, { prefix: '/api' });