feat(platform-service): pre-built Stripe checkout flow (Phase 4.4) — 6 tests

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
This commit is contained in:
saravanakumardb1 2026-03-19 22:10:32 -07:00
parent 0e880fd40d
commit 0352ea5383
4 changed files with 182 additions and 0 deletions

View File

@ -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);
});
});

View File

@ -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;
});
}

View File

@ -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<typeof BillingCheckoutSchema>;
export interface BillingCheckoutResponse {
sessionId: string;
url: string | null;
}

View File

@ -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' });