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 { marketplaceRoutes } from './modules/marketplace/routes.js';
|
||||||
import { predictiveAnalyticsRoutes } from './modules/predictive-analytics/routes.js';
|
import { predictiveAnalyticsRoutes } from './modules/predictive-analytics/routes.js';
|
||||||
import { onboardingRoutes } from './modules/onboarding/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 { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||||
import { config } from './lib/config.js';
|
import { config } from './lib/config.js';
|
||||||
import type { JwtPayload } from './lib/request-context.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' });
|
await app.register(predictiveAnalyticsRoutes, { prefix: '/api' });
|
||||||
// Onboarding analytics (Phase 4.3)
|
// Onboarding analytics (Phase 4.3)
|
||||||
await app.register(onboardingRoutes, { prefix: '/api' });
|
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)
|
// Broadcast Messaging & Surveys (see docs/roadmaps/not-started/platform_BROADCAST_SURVEY_ROADMAP.md)
|
||||||
await app.register(broadcastRoutes, { prefix: '/api' });
|
await app.register(broadcastRoutes, { prefix: '/api' });
|
||||||
await app.register(surveyRoutes, { prefix: '/api' });
|
await app.register(surveyRoutes, { prefix: '/api' });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user