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:
parent
0e880fd40d
commit
0352ea5383
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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' });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user