Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web-next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"@naap/types": "*",
"@naap/ui": "*",
"@naap/utils": "*",
"@pymthouse/builder-sdk": "0.6.0",
"@pymthouse/builder-sdk": "0.6.3",
"@vercel/blob": "^2.2.0",
"ably": "^2.18.0",
"dompurify": "^3.3.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,87 @@ describe('generic billing route — flag ON', () => {
);
expect(res.status).toBe(400);
});

it('delegates GET plans to the adapter', async () => {
const adapter = makeAdapter({
getPlans: vi.fn(async () => [{ id: 'plan_pro', bundles: [] }]),
});
setResolvedAdapter(adapter);
const res = await GET(
req('http://localhost/api/v1/billing/pymthouse/plans'),
params('pymthouse', ['plans']),
);
expect(res.status).toBe(200);
expect(adapter.getPlans).toHaveBeenCalled();
const json = await res.json();
expect(json.data.plans).toEqual([{ id: 'plan_pro', bundles: [] }]);
});

it('delegates POST subscribe to the adapter', async () => {
const subscribe = vi.fn(async () => ({
checkoutUrl: 'https://checkout.stripe.com/c/test',
subscriptionRef: 'sub_1',
}));
const adapter = makeAdapter({ subscribe });
setResolvedAdapter(adapter);
const res = await POST(
new NextRequest('http://localhost/api/v1/billing/pymthouse/subscribe', {
method: 'POST',
headers: {
cookie: 'naap_auth_token=tok',
'content-type': 'application/json',
},
body: JSON.stringify({ planId: 'plan_pro' }),
}),
params('pymthouse', ['subscribe']),
);
expect(res.status).toBe(200);
expect(subscribe).toHaveBeenCalledWith({
planId: 'plan_pro',
externalUserId: 'user-1',
});
const json = await res.json();
expect(json.data.checkoutUrl).toContain('checkout.stripe.com');
});

it('501 when subscribe is not implemented on the adapter', async () => {
setResolvedAdapter(makeAdapter());
const res = await POST(
new NextRequest('http://localhost/api/v1/billing/pymthouse/subscribe', {
method: 'POST',
headers: {
cookie: 'naap_auth_token=tok',
'content-type': 'application/json',
},
body: JSON.stringify({ planId: 'plan_pro' }),
}),
params('pymthouse', ['subscribe']),
);
expect(res.status).toBe(501);
});

it('maps PmtHouseError 409 from subscribe to CONFLICT', async () => {
const { PmtHouseError } = await import('@pymthouse/builder-sdk');
const subscribe = vi.fn(async () => {
throw new PmtHouseError(
'Customer already has an active subscription; retry checkout or change plan',
{ status: 409 },
);
});
setResolvedAdapter(makeAdapter({ subscribe }));
const res = await POST(
new NextRequest('http://localhost/api/v1/billing/pymthouse/subscribe', {
method: 'POST',
headers: {
cookie: 'naap_auth_token=tok',
'content-type': 'application/json',
},
body: JSON.stringify({ planId: 'plan_pro' }),
}),
params('pymthouse', ['subscribe']),
);
expect(res.status).toBe(409);
const json = await res.json();
expect(json.error?.code).toBe('CONFLICT');
});
});
111 changes: 111 additions & 0 deletions apps/web-next/src/app/api/v1/billing/[provider]/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
* Generic billing provider routing (NAAP-A).
*
* GET /api/v1/billing/{provider}/usage
* GET /api/v1/billing/{provider}/plans
* POST /api/v1/billing/{provider}/token
* POST /api/v1/billing/{provider}/subscribe
*
* Delegates to the BillingProviderAdapter registry instead of any hardcoded
* provider. Gated behind the `provider_adapters` flag (default OFF): when OFF this
Expand All @@ -20,6 +22,7 @@ import { validateCSRF } from '@/lib/api/csrf';
import { enforceRateLimit } from '@/lib/api/rate-limit';
import { error, errors, getAuthToken, success } from '@/lib/api/response';
import { isFeatureEnabled } from '@/lib/feature-flags';
import { PmtHouseError } from '@pymthouse/builder-sdk';
import { AdapterNotImplementedError, type BillingProviderAdapter } from '@/lib/billing/adapter';
import { resolveBillingProviderAdapterDetailed } from '@/lib/billing/registry-db';

Expand Down Expand Up @@ -104,6 +107,18 @@ function mapAdapterError(
log('warn', event, { provider, correlationId, reason: 'not_implemented', method: e.method });
return error('NOT_IMPLEMENTED', 'Operation not supported by this provider', 501);
}
if (e instanceof PmtHouseError) {
log('warn', event, { provider, correlationId, reason: 'checkout', status: e.status });
if (e.status === 400) return errors.badRequest(e.message);
if (e.status === 403) return errors.forbidden(e.message);
// pymthouse checkout (Builder POST …/billing/checkout) returns 409 when the
// customer already has an active subscription or needs a PM before change —
// pass through so callers can switch plans / complete Checkout instead of
// treating it as a transient outage.
if (e.status === 409) return errors.conflict(e.message);
if (e.status === 503) return errors.serviceUnavailable(e.message);
return errors.serviceUnavailable(e.message || 'Checkout failed');
}
const errorType = e instanceof Error ? e.name : 'UnknownError';
log('error', event, { provider, correlationId, errorType });
return errors.serviceUnavailable('Billing provider request failed');
Expand Down Expand Up @@ -218,6 +233,100 @@ async function handleToken(ctx: RouteCtx): Promise<NextResponse> {
}
}

async function handlePlans(ctx: RouteCtx): Promise<NextResponse> {
const { provider, adapter, correlationId } = ctx;
try {
const plans = await adapter.getPlans();
log('info', 'billing.adapter.plans', {
provider,
correlationId,
status: 200,
planCount: plans.length,
});
return noStore(success({ plans }));
} catch (e) {
return noStore(mapAdapterError(e, provider, correlationId, 'billing.adapter.plans'));
}
}

async function handleSubscribe(ctx: RouteCtx): Promise<NextResponse> {
const { request, provider, adapter, correlationId, user } = ctx;

const csrfError = validateCSRF(request);
if (csrfError) return csrfError;

const rateLimited = enforceRateLimit(request, {
keyPrefix: `billing-subscribe:${provider}:${user.id}`,
windowMs: RATE_LIMIT_WINDOW_MS,
maxRequests: RATE_LIMIT_MAX_PER_USER,
});
if (rateLimited) return rateLimited;

if (typeof adapter.subscribe !== 'function') {
return noStore(
mapAdapterError(
new AdapterNotImplementedError(adapter.slug, 'subscribe'),
provider,
correlationId,
'billing.adapter.subscribe',
),
);
}

let rawBody: unknown = {};
try {
rawBody = await request.json();
} catch {
rawBody = {};
}
if (!rawBody || typeof rawBody !== 'object') {
return noStore(errors.badRequest('Request body must be a JSON object'));
}
const body = rawBody as Record<string, unknown>;
const planId = typeof body.planId === 'string' ? body.planId.trim() : '';
if (!planId) return noStore(errors.badRequest('planId is required'));

const externalUserIdRaw =
typeof body.externalUserId === 'string' ? body.externalUserId.trim() : '';
const externalUserId = externalUserIdRaw || user.id;
if (!externalUserId) {
return noStore(errors.badRequest('externalUserId is required'));
}

const successUrl =
typeof body.successUrl === 'string' && /^https?:\/\//i.test(body.successUrl.trim())
? body.successUrl.trim()
: undefined;
const cancelUrl =
typeof body.cancelUrl === 'string' && /^https?:\/\//i.test(body.cancelUrl.trim())
? body.cancelUrl.trim()
: undefined;
if (body.successUrl != null && !successUrl) {
return noStore(errors.badRequest('successUrl must be an http(s) URL'));
}
if (body.cancelUrl != null && !cancelUrl) {
return noStore(errors.badRequest('cancelUrl must be an http(s) URL'));
}

try {
const result = await adapter.subscribe({
planId,
externalUserId,
...(successUrl ? { successUrl } : {}),
...(cancelUrl ? { cancelUrl } : {}),
});
log('info', 'billing.adapter.subscribe', { provider, correlationId, status: 200 });
return noStore(
success({
checkoutUrl: result.checkoutUrl,
...(result.subscriptionRef ? { subscriptionRef: result.subscriptionRef } : {}),
}),
);
} catch (e) {
return noStore(mapAdapterError(e, provider, correlationId, 'billing.adapter.subscribe'));
}
}

async function resolve(
request: NextRequest,
ctx: Params,
Expand Down Expand Up @@ -276,7 +385,9 @@ async function resolve(
};

if (method === 'GET' && op === 'usage') return handleUsage(routeCtx);
if (method === 'GET' && op === 'plans') return handlePlans(routeCtx);
if (method === 'POST' && op === 'token') return handleToken(routeCtx);
if (method === 'POST' && op === 'subscribe') return handleSubscribe(routeCtx);

return noStore(errors.notFound('Billing operation'));
} catch (err) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ const prisma = vi.hoisted(() => ({
}));
vi.mock('@/lib/db', () => ({ prisma }));

const buildAdapterForProviderInstance = vi.fn();
vi.mock('@/lib/billing/provider-instance', () => ({
buildAdapterForProviderInstance: (...a: unknown[]) => buildAdapterForProviderInstance(...a),
}));

function req(init?: { method?: string; body?: unknown }): NextRequest {
return new NextRequest('http://localhost/x', {
method: init?.method,
Expand All @@ -42,8 +47,16 @@ beforeEach(() => {
validateSession.mockResolvedValue({ id: 'user-1' });
validateTeamAccess.mockResolvedValue({ team: { id: 'team-1' }, member: { role: 'admin' } });
prisma.subscription.findMany.mockResolvedValue([]);
prisma.providerInstance.findUnique.mockResolvedValue({ id: 'inst-1', enabled: true });
prisma.providerInstance.findUnique.mockResolvedValue({
id: 'inst-1',
enabled: true,
adapterType: 'pymthouse',
slug: 'pymthouse',
config: {},
secretRef: 'vault:x',
});
prisma.team.findUnique.mockResolvedValue({ billingAccountId: 'acct_team_1' });
buildAdapterForProviderInstance.mockResolvedValue(undefined);
prisma.subscription.create.mockImplementation(async ({ data, select: _s }: { data: Record<string, unknown>; select: unknown }) => ({
id: 'sub-1',
teamId: data.teamId,
Expand Down Expand Up @@ -139,4 +152,73 @@ describe('POST create (flag ON)', () => {
expect(res.status).toBe(200);
expect(prisma.subscription.create.mock.calls[0][0].data.accountId).toBe('acct_custom');
});

it('starts provider checkout when providerPlanId is set and adapter.subscribe exists', async () => {
const subscribe = vi.fn().mockResolvedValue({
checkoutUrl: 'https://checkout.stripe.com/c/test',
subscriptionRef: 'sub_om_1',
});
buildAdapterForProviderInstance.mockResolvedValue({ subscribe });

const res = await POST(
req({
method: 'POST',
body: {
providerInstanceId: 'inst-1',
providerPlanId: 'plan_pro',
successUrl: 'https://naap.example/ok',
},
}),
params('team-1'),
);
expect(res.status).toBe(200);
expect(subscribe).toHaveBeenCalledWith({
planId: 'plan_pro',
externalUserId: 'acct_team_1',
successUrl: 'https://naap.example/ok',
});
const json = await res.json();
expect(json.data.checkoutUrl).toBe('https://checkout.stripe.com/c/test');
expect(json.data.subscriptionRef).toBe('sub_om_1');
expect(prisma.subscription.create).toHaveBeenCalled();
});

it('maps checkout 409 to CONFLICT and does not create a local subscription', async () => {
const { PmtHouseError } = await import('@pymthouse/builder-sdk');
buildAdapterForProviderInstance.mockResolvedValue({
subscribe: vi.fn().mockRejectedValue(
new PmtHouseError(
'Customer already has an active subscription; retry checkout or change plan',
{ status: 409 },
),
),
});
const res = await POST(
req({
method: 'POST',
body: { providerInstanceId: 'inst-1', providerPlanId: 'plan_pro' },
}),
params('team-1'),
);
expect(res.status).toBe(409);
const json = await res.json();
expect(json.error?.code).toBe('CONFLICT');
expect(prisma.subscription.create).not.toHaveBeenCalled();
});

it('does not create a local subscription when checkout fails', async () => {
const { PmtHouseError } = await import('@pymthouse/builder-sdk');
buildAdapterForProviderInstance.mockResolvedValue({
subscribe: vi.fn().mockRejectedValue(new PmtHouseError('Plan not found', { status: 400 })),
});
const res = await POST(
req({
method: 'POST',
body: { providerInstanceId: 'inst-1', providerPlanId: 'plan_missing' },
}),
params('team-1'),
);
expect(res.status).toBe(400);
expect(prisma.subscription.create).not.toHaveBeenCalled();
});
});
Loading
Loading