diff --git a/apps/web-next/package.json b/apps/web-next/package.json index d48269505..7727846db 100644 --- a/apps/web-next/package.json +++ b/apps/web-next/package.json @@ -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", diff --git a/apps/web-next/src/app/api/v1/billing/[provider]/[...path]/route.test.ts b/apps/web-next/src/app/api/v1/billing/[provider]/[...path]/route.test.ts index e4a09a29f..49c3af56e 100644 --- a/apps/web-next/src/app/api/v1/billing/[provider]/[...path]/route.test.ts +++ b/apps/web-next/src/app/api/v1/billing/[provider]/[...path]/route.test.ts @@ -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'); + }); }); diff --git a/apps/web-next/src/app/api/v1/billing/[provider]/[...path]/route.ts b/apps/web-next/src/app/api/v1/billing/[provider]/[...path]/route.ts index 0c74c35a6..1db8bbde5 100644 --- a/apps/web-next/src/app/api/v1/billing/[provider]/[...path]/route.ts +++ b/apps/web-next/src/app/api/v1/billing/[provider]/[...path]/route.ts @@ -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 @@ -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'; @@ -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'); @@ -218,6 +233,100 @@ async function handleToken(ctx: RouteCtx): Promise { } } +async function handlePlans(ctx: RouteCtx): Promise { + 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 { + 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; + 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, @@ -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) { diff --git a/apps/web-next/src/app/api/v1/teams/[teamId]/subscriptions/route.test.ts b/apps/web-next/src/app/api/v1/teams/[teamId]/subscriptions/route.test.ts index 2ba6d1d2e..0cd336555 100644 --- a/apps/web-next/src/app/api/v1/teams/[teamId]/subscriptions/route.test.ts +++ b/apps/web-next/src/app/api/v1/teams/[teamId]/subscriptions/route.test.ts @@ -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, @@ -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; select: unknown }) => ({ id: 'sub-1', teamId: data.teamId, @@ -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(); + }); }); diff --git a/apps/web-next/src/app/api/v1/teams/[teamId]/subscriptions/route.ts b/apps/web-next/src/app/api/v1/teams/[teamId]/subscriptions/route.ts index 095ab774d..6b7d841b7 100644 --- a/apps/web-next/src/app/api/v1/teams/[teamId]/subscriptions/route.ts +++ b/apps/web-next/src/app/api/v1/teams/[teamId]/subscriptions/route.ts @@ -28,6 +28,8 @@ import { parseCreateSubscriptionBody, toSubscriptionView, } from '@/lib/billing/subscription-catalog'; +import { PmtHouseError } from '@pymthouse/builder-sdk'; +import { buildAdapterForProviderInstance } from '@/lib/billing/provider-instance'; interface RouteParams { params: Promise<{ teamId: string }>; @@ -138,7 +140,14 @@ export async function POST(request: NextRequest, { params }: RouteParams): Promi // The instance must exist + be enabled (tenant-neutral catalog row). const instance = await prisma.providerInstance.findUnique({ where: { id: input.providerInstanceId }, - select: { id: true, enabled: true }, + select: { + id: true, + enabled: true, + adapterType: true, + slug: true, + config: true, + secretRef: true, + }, }); if (!instance || !instance.enabled) { return noStore(errors.badRequest('Unknown or disabled provider instance')); @@ -161,6 +170,42 @@ export async function POST(request: NextRequest, { params }: RouteParams): Promi ); } + // When a plan is selected and the provider implements subscribe, start + // checkout before persisting the NaaP subscription row so a failed + // checkout never leaves an orphan local sub. + let checkoutUrl: string | undefined; + let subscriptionRef: string | undefined; + if (input.providerPlanId) { + const adapter = await buildAdapterForProviderInstance({ + id: instance.id, + adapterType: instance.adapterType, + slug: instance.slug, + config: instance.config, + secretRef: instance.secretRef, + enabled: instance.enabled, + }); + if (adapter && typeof adapter.subscribe === 'function') { + try { + const checkout = await adapter.subscribe({ + planId: input.providerPlanId, + externalUserId: accountId, + ...(input.successUrl ? { successUrl: input.successUrl } : {}), + ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}), + }); + checkoutUrl = checkout.checkoutUrl; + subscriptionRef = checkout.subscriptionRef; + } catch (err) { + if (err instanceof PmtHouseError) { + if (err.status === 400) return noStore(errors.badRequest(err.message)); + if (err.status === 403) return noStore(errors.forbidden(err.message)); + if (err.status === 409) return noStore(errors.conflict(err.message)); + return noStore(errors.serviceUnavailable(err.message || 'Checkout failed')); + } + throw err; + } + } + } + const created = await prisma.subscription.create({ data: { teamId, @@ -179,9 +224,16 @@ export async function POST(request: NextRequest, { params }: RouteParams): Promi correlationId, subscriptionId: created.id, providerInstanceId: created.providerInstanceId, + checkoutStarted: Boolean(checkoutUrl), }); - return noStore(success({ subscription: toSubscriptionView(created) })); + return noStore( + success({ + subscription: toSubscriptionView(created), + ...(checkoutUrl ? { checkoutUrl } : {}), + ...(subscriptionRef ? { subscriptionRef } : {}), + }), + ); } catch (err) { log('error', 'subscriptions.create.error', { correlationId, diff --git a/apps/web-next/src/lib/billing/adapter.ts b/apps/web-next/src/lib/billing/adapter.ts index 84c632eab..ee89ed1e8 100644 --- a/apps/web-next/src/lib/billing/adapter.ts +++ b/apps/web-next/src/lib/billing/adapter.ts @@ -151,6 +151,23 @@ export interface MintSignerSessionInput { email?: string; } +/** Input for optional provider subscribe / checkout (BPP plan → paid sub). */ +export interface SubscribeInput { + planId: string; + /** Provider external-user / account id (NaaP `billingAccountRef.accountId`). */ + externalUserId: string; + successUrl?: string; + cancelUrl?: string; +} + +/** Result of starting a provider checkout / subscribe flow. */ +export interface SubscribeResult { + /** Stripe (or provider) Checkout URL the end user must open. */ + checkoutUrl: string; + /** Opaque provider subscription pointer when the provider returns one. */ + subscriptionRef?: string; +} + /** * The provider-neutral adapter SPI. Every method maps to a BPP seam. */ @@ -167,6 +184,13 @@ export interface BillingProviderAdapter { /** BPP ④ — plan catalogue. */ getPlans(): Promise; + /** + * Start a paid subscribe / payment-method checkout for a plan. + * OPTIONAL — providers without an M2M checkout path omit it; callers treat + * absence as "local binding only" (no redirect). + */ + subscribe?(input: SubscribeInput): Promise; + /** BPP usage/telemetry — per-user usage rollup for one external user. */ getUsageForExternalUser(input: UsageForExternalUserInput): Promise; diff --git a/apps/web-next/src/lib/billing/pymthouse-adapter.test.ts b/apps/web-next/src/lib/billing/pymthouse-adapter.test.ts index 598cb6e4f..1f41d5f35 100644 --- a/apps/web-next/src/lib/billing/pymthouse-adapter.test.ts +++ b/apps/web-next/src/lib/billing/pymthouse-adapter.test.ts @@ -6,6 +6,7 @@ const fetchUsageForExternalUser = vi.fn(); const getUsage = vi.fn(); const getUserSubscription = vi.fn(); const listBillingProducts = vi.fn(); +const createBillingCheckout = vi.fn(); const getSignerRouting = vi.fn(); const createPymthouseApiKey = vi.fn(); const globalSignerExchangeConfig = vi.fn(); @@ -17,6 +18,7 @@ vi.mock('@/lib/pymthouse-client', () => ({ getUsage, getUserSubscription, listBillingProducts, + createBillingCheckout, getSignerRouting, }), globalSignerExchangeConfig: () => globalSignerExchangeConfig(), @@ -131,6 +133,78 @@ describe('PymthouseAdapter.validate (BPP ② live capabilities, flag-gated)', () }); }); +describe('PymthouseAdapter.getPlans + subscribe', () => { + it('getPlans maps listBillingProducts into BPP plans', async () => { + listBillingProducts.mockResolvedValue({ + apiVersion: 2, + products: [ + { + id: 'plan_pro', + name: 'Pro', + type: 'subscription', + status: 'active', + priceAmount: '29.00', + priceCurrency: 'USD', + allowance: { billingCycle: 'monthly' }, + capabilities: [{ pipeline: 'text-to-image', modelId: 'flux-dev' }], + }, + { + id: 'plan_draft', + name: 'Draft', + status: 'draft', + capabilities: [], + }, + ], + }); + + const plans = await adapter.getPlans(); + expect(listBillingProducts).toHaveBeenCalled(); + expect(plans).toEqual([ + { + id: 'plan_pro', + name: 'Pro', + price: { amount: 29, interval: 'month', currency: 'USD' }, + bundles: [{ capability: 'text-to-image:flux-dev' }], + }, + ]); + }); + + it('subscribe calls SDK createBillingCheckout on the client', async () => { + const instanceCheckout = vi.fn().mockResolvedValue({ + checkoutUrl: 'https://checkout.stripe.com/c/pay_test', + subscriptionId: 'sub_om_9', + }); + const a = new PymthouseAdapter({ + client: { createBillingCheckout: instanceCheckout } as never, + isConfigured: () => true, + }); + + const result = await a.subscribe({ + planId: 'plan_pro', + externalUserId: 'acct_user_1', + successUrl: 'https://naap.example/ok', + }); + + expect(instanceCheckout).toHaveBeenCalledWith({ + planId: 'plan_pro', + externalUserId: 'acct_user_1', + successUrl: 'https://naap.example/ok', + }); + expect(result).toEqual({ + checkoutUrl: 'https://checkout.stripe.com/c/pay_test', + subscriptionRef: 'sub_om_9', + }); + }); + + it('subscribe when not configured throws AdapterNotImplementedError', async () => { + const a = new PymthouseAdapter({ isConfigured: () => false }); + await expect( + a.subscribe({ planId: 'plan_pro', externalUserId: 'acct_1' }), + ).rejects.toBeInstanceOf(AdapterNotImplementedError); + expect(createBillingCheckout).not.toHaveBeenCalled(); + }); +}); + describe('PymthouseAdapter per-instance client (P0, zero regression)', () => { it('default constructor → talks to the global-env client singleton (today\'s behavior)', async () => { getUsage.mockResolvedValue({ byUser: [] }); diff --git a/apps/web-next/src/lib/billing/pymthouse-adapter.ts b/apps/web-next/src/lib/billing/pymthouse-adapter.ts index 8d7da3f6b..9a71d0fca 100644 --- a/apps/web-next/src/lib/billing/pymthouse-adapter.ts +++ b/apps/web-next/src/lib/billing/pymthouse-adapter.ts @@ -4,8 +4,9 @@ * Wraps the existing `getPmtHouseServerClient()` BEHIND the BillingProviderAdapter * SPI. This is the ONLY place that may import the pymthouse client; all other NaaP * code goes through the adapter + registry. Methods the NaaP side does not yet - * support (BPP validate/plans/curation/manifest — PYMT-3/5/7 pending) throw - * AdapterNotImplementedError rather than fabricating a response. + * support (BPP curation/manifest — PYMT-7 pending) throw + * AdapterNotImplementedError rather than fabricating a response. BPP ④ + * `getPlans` and optional `subscribe` (checkout) are implemented. */ import 'server-only'; @@ -40,10 +41,13 @@ import { type ProviderSpendScope, type SignerSessionEndpoint, type SignerSessionToken, + type SubscribeInput, + type SubscribeResult, type UsageForExternalUserInput, type ValidateContext, type ValidateResult, } from './adapter'; +import { mapBillingProductsToPlans } from './pymthouse-plans'; export const PYMTHOUSE_ADAPTER_SLUG = 'pymthouse'; @@ -130,8 +134,40 @@ export class PymthouseAdapter implements BillingProviderAdapter { }; } + /** + * BPP ④ — live plan catalogue from pymthouse `GET …/plans` (SDK + * `listBillingProducts`). Active products only; capability bundles are + * taxonomy-normalized to `":"`. + */ async getPlans(): Promise { - throw new AdapterNotImplementedError(this.slug, 'getPlans'); + const { products } = await this.client().listBillingProducts(); + return mapBillingProductsToPlans(products ?? []); + } + + /** + * Start pymthouse end-user checkout via SDK `createBillingCheckout` + * (`POST …/apps/{clientId}/billing/checkout`). Returns the Stripe Checkout + * URL; the provider creates or changes the OpenMeter subscription before + * returning (existing Starter is changed in-place — creating a second sub + * 409s on Konnect). Upstream 409s (active paid sub / PM required) propagate + * as {@link PmtHouseError} for the BFF to map to HTTP CONFLICT. + */ + async subscribe(input: SubscribeInput): Promise { + if (!this.isConfigured()) { + throw new AdapterNotImplementedError(this.slug, 'subscribe'); + } + const result = await this.client().createBillingCheckout({ + planId: input.planId, + externalUserId: input.externalUserId, + ...(input.successUrl ? { successUrl: input.successUrl } : {}), + ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}), + }); + return { + checkoutUrl: result.checkoutUrl, + ...(result.subscriptionId + ? { subscriptionRef: result.subscriptionId } + : {}), + }; } async getUsageForExternalUser(input: UsageForExternalUserInput): Promise { diff --git a/apps/web-next/src/lib/billing/pymthouse-plans.test.ts b/apps/web-next/src/lib/billing/pymthouse-plans.test.ts new file mode 100644 index 000000000..cd12221d9 --- /dev/null +++ b/apps/web-next/src/lib/billing/pymthouse-plans.test.ts @@ -0,0 +1,54 @@ +/** @vitest-environment node */ + +import { describe, expect, it } from 'vitest'; + +import { mapBillingProductToPlan, mapBillingProductsToPlans } from './pymthouse-plans'; + +describe('mapBillingProductToPlan', () => { + it('maps active subscription products to BPP plans', () => { + const plan = mapBillingProductToPlan({ + id: 'plan_pro', + name: 'Pro', + type: 'subscription', + status: 'active', + priceAmount: '29.00', + priceCurrency: 'usd', + allowance: { billingCycle: 'monthly' }, + capabilities: [ + { pipeline: 'text-to-image', modelId: 'flux-dev' }, + { pipeline: 'live-video-to-video', modelId: 'scope' }, + ], + }); + expect(plan).toEqual({ + id: 'plan_pro', + name: 'Pro', + price: { amount: 29, interval: 'month', currency: 'USD' }, + bundles: [ + { capability: 'text-to-image:flux-dev' }, + { capability: 'live-video-to-video:scope' }, + ], + }); + }); + + it('returns null for blank id', () => { + expect(mapBillingProductToPlan({ id: ' ' })).toBeNull(); + }); +}); + +describe('mapBillingProductsToPlans', () => { + it('skips inactive products by default', () => { + const plans = mapBillingProductsToPlans([ + { id: 'plan_active', status: 'active', name: 'A', capabilities: [] }, + { id: 'plan_draft', status: 'draft', name: 'D', capabilities: [] }, + ]); + expect(plans.map((p) => p.id)).toEqual(['plan_active']); + }); + + it('can include inactive when requested', () => { + const plans = mapBillingProductsToPlans( + [{ id: 'plan_draft', status: 'draft', name: 'D', capabilities: [] }], + { includeInactive: true }, + ); + expect(plans).toHaveLength(1); + }); +}); diff --git a/apps/web-next/src/lib/billing/pymthouse-plans.ts b/apps/web-next/src/lib/billing/pymthouse-plans.ts new file mode 100644 index 000000000..81a0c663d --- /dev/null +++ b/apps/web-next/src/lib/billing/pymthouse-plans.ts @@ -0,0 +1,95 @@ +/** + * Map pymthouse `listBillingProducts` rows onto the BPP ④ {@link Plan} shape. + * Pure helpers — no I/O — so the adapter + unit tests share one mapper. + */ + +import { normalizeProviderCapabilities } from '@/lib/capabilities/taxonomy'; + +import type { Plan } from './adapter'; + +/** Subset of SDK `BillingProduct` the mapper needs (avoids a hard type import). */ +export interface PymthouseBillingProductLike { + id: string; + name?: string | null; + type?: string | null; + status?: string | null; + priceAmount?: string | null; + priceCurrency?: string | null; + allowance?: { billingCycle?: string | null } | null; + capabilities?: ReadonlyArray<{ + pipeline?: string | null; + modelId?: string | null; + }> | null; +} + +function mapInterval( + product: PymthouseBillingProductLike, +): 'month' | 'year' | 'once' | null { + const type = (product.type ?? '').trim().toLowerCase(); + if (type === 'one_time' || type === 'once' || type === 'credit') { + return 'once'; + } + const cycle = (product.allowance?.billingCycle ?? '').trim().toLowerCase(); + if (cycle === 'yearly' || cycle === 'annual' || cycle === 'year') return 'year'; + if (cycle === 'monthly' || cycle === 'month' || cycle === 'weekly' || cycle === 'daily') { + // BPP only allows month|year|once; weekly/daily coerce to month. + return 'month'; + } + if (type === 'subscription') return 'month'; + return null; +} + +function mapPrice(product: PymthouseBillingProductLike): Plan['price'] | undefined { + const interval = mapInterval(product); + if (!interval) return undefined; + const raw = product.priceAmount?.trim() ?? ''; + const amount = Number(raw); + if (!Number.isFinite(amount) || amount < 0) return undefined; + const currencyRaw = (product.priceCurrency ?? 'USD').trim().toUpperCase(); + const currency = /^[A-Z]{3}$/.test(currencyRaw) ? currencyRaw : 'USD'; + return { amount, interval, currency }; +} + +function mapBundles(product: PymthouseBillingProductLike): Plan['bundles'] { + const raw: string[] = []; + for (const cap of product.capabilities ?? []) { + const pipeline = cap.pipeline?.trim(); + const modelId = cap.modelId?.trim(); + if (pipeline && modelId) raw.push(`${pipeline}:${modelId}`); + } + return normalizeProviderCapabilities(raw).map((capability) => ({ capability })); +} + +/** Map one pymthouse product to a BPP Plan. Returns null when id is blank. */ +export function mapBillingProductToPlan(product: PymthouseBillingProductLike): Plan | null { + const id = typeof product.id === 'string' ? product.id.trim() : ''; + if (!id) return null; + const name = typeof product.name === 'string' ? product.name.trim() : ''; + const price = mapPrice(product); + return { + id, + ...(name ? { name } : {}), + ...(price ? { price } : {}), + bundles: mapBundles(product), + }; +} + +/** + * Map a product list to BPP plans. Skips blank ids. By default only `active` + * products are included (checkout rejects non-active targets). + */ +export function mapBillingProductsToPlans( + products: ReadonlyArray, + opts?: { includeInactive?: boolean }, +): Plan[] { + const out: Plan[] = []; + for (const product of products) { + if (!opts?.includeInactive) { + const status = (product.status ?? '').trim().toLowerCase(); + if (status && status !== 'active') continue; + } + const plan = mapBillingProductToPlan(product); + if (plan) out.push(plan); + } + return out; +} diff --git a/apps/web-next/src/lib/billing/registry-db-instance.test.ts b/apps/web-next/src/lib/billing/registry-db-instance.test.ts index ff538da77..f3140bbd3 100644 --- a/apps/web-next/src/lib/billing/registry-db-instance.test.ts +++ b/apps/web-next/src/lib/billing/registry-db-instance.test.ts @@ -31,6 +31,10 @@ vi.mock('@naap/crypto', () => ({ vi.mock('@pymthouse/builder-sdk/config', () => ({ isPymthouseConfigured: () => true, + getBuilderApiV1BaseFromIssuerUrl: (issuerUrl: string) => { + const u = new URL(issuerUrl); + return `${u.origin}/api/v1`; + }, })); import { prisma } from '@/lib/db'; diff --git a/apps/web-next/src/lib/billing/subscription-catalog.test.ts b/apps/web-next/src/lib/billing/subscription-catalog.test.ts index b0b3a3325..6573e436c 100644 --- a/apps/web-next/src/lib/billing/subscription-catalog.test.ts +++ b/apps/web-next/src/lib/billing/subscription-catalog.test.ts @@ -96,6 +96,8 @@ describe('parseCreateSubscriptionBody', () => { providerPlanId: null, accountId: null, appId: null, + successUrl: null, + cancelUrl: null, }); }); @@ -112,4 +114,25 @@ describe('parseCreateSubscriptionBody', () => { expect(r.value.accountId).toBe('acct_7'); expect(r.value.appId).toBe('storyboard'); }); + + it('accepts http(s) checkout redirect URLs', () => { + const r = parseCreateSubscriptionBody({ + providerInstanceId: 'inst-1', + successUrl: 'https://naap.example/ok', + cancelUrl: 'http://localhost:3000/cancel', + }); + expect(r.ok).toBe(true); + if (!r.ok) throw new Error('expected ok'); + expect(r.value.successUrl).toBe('https://naap.example/ok'); + expect(r.value.cancelUrl).toBe('http://localhost:3000/cancel'); + }); + + it('rejects non-http checkout redirect URLs', () => { + expect( + parseCreateSubscriptionBody({ + providerInstanceId: 'inst-1', + successUrl: 'javascript:alert(1)', + }).ok, + ).toBe(false); + }); }); diff --git a/apps/web-next/src/lib/billing/subscription-catalog.ts b/apps/web-next/src/lib/billing/subscription-catalog.ts index a08670fc8..450b858d0 100644 --- a/apps/web-next/src/lib/billing/subscription-catalog.ts +++ b/apps/web-next/src/lib/billing/subscription-catalog.ts @@ -135,6 +135,9 @@ export interface CreateSubscriptionInput { /** Caller-supplied provider account pointer; null ⇒ derive from team binding. */ accountId: string | null; appId: string | null; + /** Optional Checkout redirect URLs when the provider supports subscribe. */ + successUrl: string | null; + cancelUrl: string | null; } export type ParseResult = { ok: true; value: T } | { ok: false; error: string }; @@ -147,10 +150,18 @@ function optionalTrimmed(value: unknown, max = 256): string | null { return v.slice(0, max); } +function optionalUrl(value: unknown, max = 2048): string | null { + const v = optionalTrimmed(value, max); + if (!v) return null; + if (!/^https?:\/\//i.test(v)) return null; + return v; +} + /** * Validate a create-subscription body. `providerInstanceId` is required; - * `providerPlanId`, `accountId`, and `appId` are optional. Returns a typed - * error string (never throws) so the route can 400 with a clear message. + * `providerPlanId`, `accountId`, `appId`, and checkout URLs are optional. + * Returns a typed error string (never throws) so the route can 400 with a + * clear message. */ export function parseCreateSubscriptionBody(body: unknown): ParseResult { if (!body || typeof body !== 'object') { @@ -161,6 +172,12 @@ export function parseCreateSubscriptionBody(body: unknown): ParseResult=20" + } + }, "apps/web-next/node_modules/@types/mime-types": { "version": "3.0.1", "dev": true, @@ -32450,7 +32462,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -32468,7 +32479,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -32486,7 +32496,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -32504,7 +32513,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -32522,7 +32530,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -32540,7 +32547,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -32558,7 +32564,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -32576,7 +32581,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -32594,7 +32598,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -32612,7 +32615,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -32630,7 +32632,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -32648,7 +32649,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -32666,7 +32666,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -32684,7 +32683,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -32702,7 +32700,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -32720,7 +32717,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -32738,7 +32734,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -32756,7 +32751,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -32774,7 +32768,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -32792,7 +32785,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -32810,7 +32802,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -32828,7 +32819,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -32846,7 +32836,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -32864,7 +32853,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -32882,7 +32870,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -32900,7 +32887,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -32961,7 +32947,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -33078,7 +33063,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4",