From b72280cf15e992af643d22984fd47cc7400dea97 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Wed, 5 Aug 2026 22:02:01 -0400 Subject: [PATCH 1/5] feat(billing): wire pymthouse getPlans + subscribe checkout Expose BPP plan catalogue and optional M2M checkout through the PymthouseAdapter so agent/BFF callers can list plans and start Stripe Checkout without inventing marketplace UI or last-mile credit APIs. --- .../[provider]/[...path]/route.test.ts | 58 +++++++ .../v1/billing/[provider]/[...path]/route.ts | 106 +++++++++++++ .../[teamId]/subscriptions/route.test.ts | 61 +++++++- .../v1/teams/[teamId]/subscriptions/route.ts | 55 ++++++- apps/web-next/src/lib/billing/adapter.ts | 24 +++ .../src/lib/billing/provider-instance.ts | 18 +++ .../src/lib/billing/pymthouse-adapter.test.ts | 91 +++++++++++ .../src/lib/billing/pymthouse-adapter.ts | 52 ++++++- .../lib/billing/pymthouse-billing-checkout.ts | 146 ++++++++++++++++++ .../src/lib/billing/pymthouse-plans.test.ts | 54 +++++++ .../src/lib/billing/pymthouse-plans.ts | 95 ++++++++++++ .../lib/billing/registry-db-instance.test.ts | 4 + .../lib/billing/subscription-catalog.test.ts | 23 +++ .../src/lib/billing/subscription-catalog.ts | 23 ++- docs/pymthouse-integration.md | 12 +- 15 files changed, 813 insertions(+), 9 deletions(-) create mode 100644 apps/web-next/src/lib/billing/pymthouse-billing-checkout.ts create mode 100644 apps/web-next/src/lib/billing/pymthouse-plans.test.ts create mode 100644 apps/web-next/src/lib/billing/pymthouse-plans.ts 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..bed6b077c 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,62 @@ 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); + }); }); 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..9700f71bd 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 @@ -21,6 +23,7 @@ import { enforceRateLimit } from '@/lib/api/rate-limit'; import { error, errors, getAuthToken, success } from '@/lib/api/response'; import { isFeatureEnabled } from '@/lib/feature-flags'; import { AdapterNotImplementedError, type BillingProviderAdapter } from '@/lib/billing/adapter'; +import { PymthouseCheckoutError } from '@/lib/billing/pymthouse-billing-checkout'; import { resolveBillingProviderAdapterDetailed } from '@/lib/billing/registry-db'; const PROVIDER_ADAPTERS_FLAG = 'provider_adapters'; @@ -104,6 +107,13 @@ 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 PymthouseCheckoutError) { + 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); + 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 +228,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 +380,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..1ca2410e9 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,50 @@ 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('does not create a local subscription when checkout fails', async () => { + const { PymthouseCheckoutError } = await import('@/lib/billing/pymthouse-billing-checkout'); + buildAdapterForProviderInstance.mockResolvedValue({ + subscribe: vi.fn().mockRejectedValue(new PymthouseCheckoutError('Plan not found', 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..e204a117c 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 { buildAdapterForProviderInstance } from '@/lib/billing/provider-instance'; +import { PymthouseCheckoutError } from '@/lib/billing/pymthouse-billing-checkout'; 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,41 @@ 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 PymthouseCheckoutError) { + if (err.status === 400) return noStore(errors.badRequest(err.message)); + if (err.status === 403) return noStore(errors.forbidden(err.message)); + return noStore(errors.serviceUnavailable(err.message || 'Checkout failed')); + } + throw err; + } + } + } + const created = await prisma.subscription.create({ data: { teamId, @@ -179,9 +223,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/provider-instance.ts b/apps/web-next/src/lib/billing/provider-instance.ts index 807b113a3..4a64364e0 100644 --- a/apps/web-next/src/lib/billing/provider-instance.ts +++ b/apps/web-next/src/lib/billing/provider-instance.ts @@ -16,12 +16,14 @@ import 'server-only'; import { decryptV1 } from '@naap/crypto'; +import { getBuilderApiV1BaseFromIssuerUrl } from '@pymthouse/builder-sdk/config'; import { prisma } from '@/lib/db'; import { createPmtHouseClient } from '@/lib/pymthouse-client'; import type { BillingProviderAdapter } from './adapter'; import { PymthouseAdapter, PYMTHOUSE_ADAPTER_SLUG } from './pymthouse-adapter'; +import type { PymthouseBillingCheckoutCreds } from './pymthouse-billing-checkout'; /** Minimal `ProviderInstance` shape the registry needs to build an adapter. */ export interface ProviderInstanceRecord { @@ -117,10 +119,26 @@ export async function buildAdapterForProviderInstance( if (!m2mClientSecret) { return undefined; } + let apiV1Base: string; + try { + apiV1Base = getBuilderApiV1BaseFromIssuerUrl(config.issuerUrl); + } catch { + return undefined; + } + if (!apiV1Base) { + return undefined; + } + const billingCheckoutCreds: PymthouseBillingCheckoutCreds = { + apiV1Base, + publicClientId: config.publicClientId, + m2mClientId: config.m2mClientId, + m2mClientSecret, + }; const client = createPmtHouseClient({ ...config, m2mClientSecret }); return new PymthouseAdapter({ client, isConfigured: () => true, + billingCheckoutCreds, // Per-instance signer-session exchange binds to THIS app's issuer/creds so // the opaque `pmth_…` mint targets the right token endpoint (not global env). signerExchange: { 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..7f67fdae9 100644 --- a/apps/web-next/src/lib/billing/pymthouse-adapter.test.ts +++ b/apps/web-next/src/lib/billing/pymthouse-adapter.test.ts @@ -27,6 +27,17 @@ vi.mock('@/lib/pymthouse-keys-bff', () => ({ createPymthouseApiKey: (input: unknown) => createPymthouseApiKey(input), })); +const createPymthouseBillingCheckout = vi.fn(); +const resolveGlobalPymthouseBillingCheckoutCreds = vi.fn(() => null); +vi.mock('./pymthouse-billing-checkout', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createPymthouseBillingCheckout: (...a: unknown[]) => createPymthouseBillingCheckout(...a), + resolveGlobalPymthouseBillingCheckoutCreds: () => resolveGlobalPymthouseBillingCheckoutCreds(), + }; +}); + // Default: no global PYMTHOUSE_API_KEY → legacy per-user mint path (zero // regression). Tests that exercise the new endpoint pass `apiKeyExchange` // explicitly via the adapter options instead. @@ -57,6 +68,7 @@ beforeEach(() => { vi.clearAllMocks(); resetPymthouseCapabilityCacheForTests(); isFeatureEnabled.mockResolvedValue(false); + resolveGlobalPymthouseBillingCheckoutCreds.mockReturnValue(null); globalSignerExchangeConfig.mockReturnValue({ issuerUrl: 'https://pymthouse.com/api/v1/oidc', m2mClientId: 'm2m_test', @@ -131,6 +143,85 @@ 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 billing checkout with injected creds', async () => { + createPymthouseBillingCheckout.mockResolvedValue({ + checkoutUrl: 'https://checkout.stripe.com/c/pay_test', + subscriptionRef: 'sub_om_9', + }); + const a = new PymthouseAdapter({ + billingCheckoutCreds: { + apiV1Base: 'https://pymthouse.example/api/v1', + publicClientId: 'app_x', + m2mClientId: 'm2m_x', + m2mClientSecret: 'secret_x', + }, + }); + + const result = await a.subscribe({ + planId: 'plan_pro', + externalUserId: 'acct_user_1', + successUrl: 'https://naap.example/ok', + }); + + expect(createPymthouseBillingCheckout).toHaveBeenCalledWith( + expect.objectContaining({ publicClientId: 'app_x' }), + { + 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 without creds throws AdapterNotImplementedError', async () => { + resolveGlobalPymthouseBillingCheckoutCreds.mockReturnValue(null); + await expect( + adapter.subscribe({ planId: 'plan_pro', externalUserId: 'acct_1' }), + ).rejects.toBeInstanceOf(AdapterNotImplementedError); + expect(createPymthouseBillingCheckout).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..86ff7746d 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,18 @@ import { type ProviderSpendScope, type SignerSessionEndpoint, type SignerSessionToken, + type SubscribeInput, + type SubscribeResult, type UsageForExternalUserInput, type ValidateContext, type ValidateResult, } from './adapter'; +import { + createPymthouseBillingCheckout, + resolveGlobalPymthouseBillingCheckoutCreds, + type PymthouseBillingCheckoutCreds, +} from './pymthouse-billing-checkout'; +import { mapBillingProductsToPlans } from './pymthouse-plans'; export const PYMTHOUSE_ADAPTER_SLUG = 'pymthouse'; @@ -74,6 +83,12 @@ export interface PymthouseAdapterOptions { * adapter resolves it lazily from `PYMTHOUSE_API_KEY` (unset ⇒ legacy path). */ apiKeyExchange?: PymthouseApiKeyExchangeConfig; + /** + * M2M creds for `POST …/billing/checkout` (per-instance). When omitted the + * adapter falls back to global `PYMTHOUSE_*` env via + * {@link resolveGlobalPymthouseBillingCheckoutCreds}. + */ + billingCheckoutCreds?: PymthouseBillingCheckoutCreds; } export class PymthouseAdapter implements BillingProviderAdapter { @@ -83,12 +98,14 @@ export class PymthouseAdapter implements BillingProviderAdapter { private readonly isConfiguredOverride?: () => boolean; private readonly signerExchange?: PymthouseSignerExchangeConfig; private readonly apiKeyExchange?: PymthouseApiKeyExchangeConfig; + private readonly billingCheckoutCreds?: PymthouseBillingCheckoutCreds; constructor(options: PymthouseAdapterOptions = {}) { this.clientOverride = options.client; this.isConfiguredOverride = options.isConfigured; this.signerExchange = options.signerExchange; this.apiKeyExchange = options.apiKeyExchange; + this.billingCheckoutCreds = options.billingCheckoutCreds; } /** @@ -130,8 +147,37 @@ 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 (`POST …/billing/checkout`) for a plan. + * Returns the Stripe Checkout URL; the provider creates the OpenMeter + * subscription before returning. + */ + async subscribe(input: SubscribeInput): Promise { + const creds = + this.billingCheckoutCreds ?? resolveGlobalPymthouseBillingCheckoutCreds(); + if (!creds) { + throw new AdapterNotImplementedError(this.slug, 'subscribe'); + } + const result = await createPymthouseBillingCheckout(creds, { + planId: input.planId, + externalUserId: input.externalUserId, + ...(input.successUrl ? { successUrl: input.successUrl } : {}), + ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}), + }); + return { + checkoutUrl: result.checkoutUrl, + ...(result.subscriptionRef ? { subscriptionRef: result.subscriptionRef } : {}), + }; } async getUsageForExternalUser(input: UsageForExternalUserInput): Promise { diff --git a/apps/web-next/src/lib/billing/pymthouse-billing-checkout.ts b/apps/web-next/src/lib/billing/pymthouse-billing-checkout.ts new file mode 100644 index 000000000..b230c4b1f --- /dev/null +++ b/apps/web-next/src/lib/billing/pymthouse-billing-checkout.ts @@ -0,0 +1,146 @@ +/** + * M2M call to pymthouse `POST /apps/{publicClientId}/billing/checkout`. + * + * The published `@pymthouse/builder-sdk` pin does not yet expose this verb, so + * NaaP calls it with the same Basic-auth pattern as discovery-plans. Secrets + * are used only to build the Authorization header and are never logged. + */ + +import 'server-only'; + +import { getPymthouseApiV1Base } from '@/lib/pymthouse-device-initiate'; + +import type { PymthouseDiscoveryPlansCreds } from '@/lib/pymthouse-discovery-plans'; + +export type PymthouseBillingCheckoutCreds = PymthouseDiscoveryPlansCreds; + +export interface CreatePymthouseBillingCheckoutInput { + planId: string; + externalUserId: string; + successUrl?: string; + cancelUrl?: string; +} + +export interface PymthouseBillingCheckoutResult { + checkoutUrl: string; + /** Opaque provider subscription pointer when the provider returns one. */ + subscriptionRef?: string; +} + +/** Provider/HTTP failure from checkout — carries a suggested HTTP status. */ +export class PymthouseCheckoutError extends Error { + readonly status: number; + + constructor( + message: string, + status: number, + ) { + super(message); + this.name = 'PymthouseCheckoutError'; + this.status = status; + } +} + +/** Resolve global-env M2M checkout creds (null when incomplete). */ +export function resolveGlobalPymthouseBillingCheckoutCreds(): PymthouseBillingCheckoutCreds | null { + const apiV1Base = getPymthouseApiV1Base()?.trim() ?? ''; + const publicClientId = + process.env.PYMTHOUSE_PUBLIC_CLIENT_ID?.trim() || + process.env.PMTHOUSE_CLIENT_ID?.trim() || + ''; + const m2mClientId = + process.env.PYMTHOUSE_M2M_CLIENT_ID?.trim() || + process.env.PMTHOUSE_M2M_CLIENT_ID?.trim() || + ''; + const m2mClientSecret = + process.env.PYMTHOUSE_M2M_CLIENT_SECRET?.trim() || + process.env.PMTHOUSE_M2M_CLIENT_SECRET?.trim() || + ''; + if (!apiV1Base || !publicClientId || !m2mClientId || !m2mClientSecret) { + return null; + } + return { apiV1Base, publicClientId, m2mClientId, m2mClientSecret }; +} + +/** + * Start end-user plan checkout on pymthouse. Throws + * {@link PymthouseCheckoutError} on non-2xx / malformed responses. + */ +export async function createPymthouseBillingCheckout( + creds: PymthouseBillingCheckoutCreds, + input: CreatePymthouseBillingCheckoutInput, + signal?: AbortSignal, +): Promise { + const planId = input.planId.trim(); + const externalUserId = input.externalUserId.trim(); + if (!planId || !externalUserId) { + throw new PymthouseCheckoutError('planId and externalUserId are required', 400); + } + + const basic = Buffer.from( + `${creds.m2mClientId}:${creds.m2mClientSecret}`, + 'utf8', + ).toString('base64'); + const url = `${creds.apiV1Base.replace(/\/$/, '')}/apps/${encodeURIComponent(creds.publicClientId)}/billing/checkout`; + + const body: Record = { planId, externalUserId }; + if (input.successUrl?.trim()) body.successUrl = input.successUrl.trim(); + if (input.cancelUrl?.trim()) body.cancelUrl = input.cancelUrl.trim(); + + let res: Response; + try { + res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Basic ${basic}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + signal, + cache: 'no-store', + }); + } catch { + throw new PymthouseCheckoutError('Checkout request failed', 502); + } + + let json: unknown = null; + try { + json = await res.json(); + } catch { + json = null; + } + + if (!res.ok) { + const errMsg = + json && + typeof json === 'object' && + typeof (json as { error?: unknown }).error === 'string' + ? (json as { error: string }).error.trim() + : ''; + const status = res.status >= 400 && res.status < 600 ? res.status : 502; + throw new PymthouseCheckoutError(errMsg || 'Checkout failed', status); + } + + const checkoutUrl = + json && + typeof json === 'object' && + typeof (json as { checkoutUrl?: unknown }).checkoutUrl === 'string' + ? (json as { checkoutUrl: string }).checkoutUrl.trim() + : ''; + if (!checkoutUrl) { + throw new PymthouseCheckoutError('Checkout response missing checkoutUrl', 502); + } + + const subscriptionId = + json && + typeof json === 'object' && + typeof (json as { subscriptionId?: unknown }).subscriptionId === 'string' + ? (json as { subscriptionId: string }).subscriptionId.trim() + : ''; + + return { + checkoutUrl, + ...(subscriptionId ? { subscriptionRef: subscriptionId } : {}), + }; +} 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 Date: Wed, 5 Aug 2026 22:30:45 -0400 Subject: [PATCH 2/5] refactor(billing): use SDK createBillingCheckout for subscribe Drop the hand-rolled M2M checkout helper; adapter.subscribe and error mapping now go through PmtHouseClient / PmtHouseError from builder-sdk. --- apps/web-next/package.json | 2 +- .../v1/billing/[provider]/[...path]/route.ts | 4 +- .../[teamId]/subscriptions/route.test.ts | 4 +- .../v1/teams/[teamId]/subscriptions/route.ts | 4 +- .../src/lib/billing/provider-instance.ts | 18 --- .../src/lib/billing/pymthouse-adapter.test.ts | 49 ++---- .../src/lib/billing/pymthouse-adapter.ts | 29 +--- .../lib/billing/pymthouse-billing-checkout.ts | 146 ------------------ docs/pymthouse-integration.md | 2 +- package-lock.json | 13 +- 10 files changed, 44 insertions(+), 227 deletions(-) delete mode 100644 apps/web-next/src/lib/billing/pymthouse-billing-checkout.ts diff --git a/apps/web-next/package.json b/apps/web-next/package.json index d48269505..75f85cf8a 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": "github:pymthouse/builder-sdk#feat/create-billing-checkout", "@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.ts b/apps/web-next/src/app/api/v1/billing/[provider]/[...path]/route.ts index 9700f71bd..4a1207ae4 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 @@ -22,8 +22,8 @@ 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 { PymthouseCheckoutError } from '@/lib/billing/pymthouse-billing-checkout'; import { resolveBillingProviderAdapterDetailed } from '@/lib/billing/registry-db'; const PROVIDER_ADAPTERS_FLAG = 'provider_adapters'; @@ -107,7 +107,7 @@ 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 PymthouseCheckoutError) { + 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); 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 1ca2410e9..28eebad28 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 @@ -184,9 +184,9 @@ describe('POST create (flag ON)', () => { }); it('does not create a local subscription when checkout fails', async () => { - const { PymthouseCheckoutError } = await import('@/lib/billing/pymthouse-billing-checkout'); + const { PmtHouseError } = await import('@pymthouse/builder-sdk'); buildAdapterForProviderInstance.mockResolvedValue({ - subscribe: vi.fn().mockRejectedValue(new PymthouseCheckoutError('Plan not found', 400)), + subscribe: vi.fn().mockRejectedValue(new PmtHouseError('Plan not found', { status: 400 })), }); const res = await POST( req({ 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 e204a117c..fcdaef57e 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,8 +28,8 @@ import { parseCreateSubscriptionBody, toSubscriptionView, } from '@/lib/billing/subscription-catalog'; +import { PmtHouseError } from '@pymthouse/builder-sdk'; import { buildAdapterForProviderInstance } from '@/lib/billing/provider-instance'; -import { PymthouseCheckoutError } from '@/lib/billing/pymthouse-billing-checkout'; interface RouteParams { params: Promise<{ teamId: string }>; @@ -195,7 +195,7 @@ export async function POST(request: NextRequest, { params }: RouteParams): Promi checkoutUrl = checkout.checkoutUrl; subscriptionRef = checkout.subscriptionRef; } catch (err) { - if (err instanceof PymthouseCheckoutError) { + if (err instanceof PmtHouseError) { if (err.status === 400) return noStore(errors.badRequest(err.message)); if (err.status === 403) return noStore(errors.forbidden(err.message)); return noStore(errors.serviceUnavailable(err.message || 'Checkout failed')); diff --git a/apps/web-next/src/lib/billing/provider-instance.ts b/apps/web-next/src/lib/billing/provider-instance.ts index 4a64364e0..807b113a3 100644 --- a/apps/web-next/src/lib/billing/provider-instance.ts +++ b/apps/web-next/src/lib/billing/provider-instance.ts @@ -16,14 +16,12 @@ import 'server-only'; import { decryptV1 } from '@naap/crypto'; -import { getBuilderApiV1BaseFromIssuerUrl } from '@pymthouse/builder-sdk/config'; import { prisma } from '@/lib/db'; import { createPmtHouseClient } from '@/lib/pymthouse-client'; import type { BillingProviderAdapter } from './adapter'; import { PymthouseAdapter, PYMTHOUSE_ADAPTER_SLUG } from './pymthouse-adapter'; -import type { PymthouseBillingCheckoutCreds } from './pymthouse-billing-checkout'; /** Minimal `ProviderInstance` shape the registry needs to build an adapter. */ export interface ProviderInstanceRecord { @@ -119,26 +117,10 @@ export async function buildAdapterForProviderInstance( if (!m2mClientSecret) { return undefined; } - let apiV1Base: string; - try { - apiV1Base = getBuilderApiV1BaseFromIssuerUrl(config.issuerUrl); - } catch { - return undefined; - } - if (!apiV1Base) { - return undefined; - } - const billingCheckoutCreds: PymthouseBillingCheckoutCreds = { - apiV1Base, - publicClientId: config.publicClientId, - m2mClientId: config.m2mClientId, - m2mClientSecret, - }; const client = createPmtHouseClient({ ...config, m2mClientSecret }); return new PymthouseAdapter({ client, isConfigured: () => true, - billingCheckoutCreds, // Per-instance signer-session exchange binds to THIS app's issuer/creds so // the opaque `pmth_…` mint targets the right token endpoint (not global env). signerExchange: { 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 7f67fdae9..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(), @@ -27,17 +29,6 @@ vi.mock('@/lib/pymthouse-keys-bff', () => ({ createPymthouseApiKey: (input: unknown) => createPymthouseApiKey(input), })); -const createPymthouseBillingCheckout = vi.fn(); -const resolveGlobalPymthouseBillingCheckoutCreds = vi.fn(() => null); -vi.mock('./pymthouse-billing-checkout', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - createPymthouseBillingCheckout: (...a: unknown[]) => createPymthouseBillingCheckout(...a), - resolveGlobalPymthouseBillingCheckoutCreds: () => resolveGlobalPymthouseBillingCheckoutCreds(), - }; -}); - // Default: no global PYMTHOUSE_API_KEY → legacy per-user mint path (zero // regression). Tests that exercise the new endpoint pass `apiKeyExchange` // explicitly via the adapter options instead. @@ -68,7 +59,6 @@ beforeEach(() => { vi.clearAllMocks(); resetPymthouseCapabilityCacheForTests(); isFeatureEnabled.mockResolvedValue(false); - resolveGlobalPymthouseBillingCheckoutCreds.mockReturnValue(null); globalSignerExchangeConfig.mockReturnValue({ issuerUrl: 'https://pymthouse.com/api/v1/oidc', m2mClientId: 'm2m_test', @@ -179,18 +169,14 @@ describe('PymthouseAdapter.getPlans + subscribe', () => { ]); }); - it('subscribe calls billing checkout with injected creds', async () => { - createPymthouseBillingCheckout.mockResolvedValue({ + it('subscribe calls SDK createBillingCheckout on the client', async () => { + const instanceCheckout = vi.fn().mockResolvedValue({ checkoutUrl: 'https://checkout.stripe.com/c/pay_test', - subscriptionRef: 'sub_om_9', + subscriptionId: 'sub_om_9', }); const a = new PymthouseAdapter({ - billingCheckoutCreds: { - apiV1Base: 'https://pymthouse.example/api/v1', - publicClientId: 'app_x', - m2mClientId: 'm2m_x', - m2mClientSecret: 'secret_x', - }, + client: { createBillingCheckout: instanceCheckout } as never, + isConfigured: () => true, }); const result = await a.subscribe({ @@ -199,26 +185,23 @@ describe('PymthouseAdapter.getPlans + subscribe', () => { successUrl: 'https://naap.example/ok', }); - expect(createPymthouseBillingCheckout).toHaveBeenCalledWith( - expect.objectContaining({ publicClientId: 'app_x' }), - { - 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 without creds throws AdapterNotImplementedError', async () => { - resolveGlobalPymthouseBillingCheckoutCreds.mockReturnValue(null); + it('subscribe when not configured throws AdapterNotImplementedError', async () => { + const a = new PymthouseAdapter({ isConfigured: () => false }); await expect( - adapter.subscribe({ planId: 'plan_pro', externalUserId: 'acct_1' }), + a.subscribe({ planId: 'plan_pro', externalUserId: 'acct_1' }), ).rejects.toBeInstanceOf(AdapterNotImplementedError); - expect(createPymthouseBillingCheckout).not.toHaveBeenCalled(); + expect(createBillingCheckout).not.toHaveBeenCalled(); }); }); diff --git a/apps/web-next/src/lib/billing/pymthouse-adapter.ts b/apps/web-next/src/lib/billing/pymthouse-adapter.ts index 86ff7746d..aa772e41a 100644 --- a/apps/web-next/src/lib/billing/pymthouse-adapter.ts +++ b/apps/web-next/src/lib/billing/pymthouse-adapter.ts @@ -47,11 +47,6 @@ import { type ValidateContext, type ValidateResult, } from './adapter'; -import { - createPymthouseBillingCheckout, - resolveGlobalPymthouseBillingCheckoutCreds, - type PymthouseBillingCheckoutCreds, -} from './pymthouse-billing-checkout'; import { mapBillingProductsToPlans } from './pymthouse-plans'; export const PYMTHOUSE_ADAPTER_SLUG = 'pymthouse'; @@ -83,12 +78,6 @@ export interface PymthouseAdapterOptions { * adapter resolves it lazily from `PYMTHOUSE_API_KEY` (unset ⇒ legacy path). */ apiKeyExchange?: PymthouseApiKeyExchangeConfig; - /** - * M2M creds for `POST …/billing/checkout` (per-instance). When omitted the - * adapter falls back to global `PYMTHOUSE_*` env via - * {@link resolveGlobalPymthouseBillingCheckoutCreds}. - */ - billingCheckoutCreds?: PymthouseBillingCheckoutCreds; } export class PymthouseAdapter implements BillingProviderAdapter { @@ -98,14 +87,12 @@ export class PymthouseAdapter implements BillingProviderAdapter { private readonly isConfiguredOverride?: () => boolean; private readonly signerExchange?: PymthouseSignerExchangeConfig; private readonly apiKeyExchange?: PymthouseApiKeyExchangeConfig; - private readonly billingCheckoutCreds?: PymthouseBillingCheckoutCreds; constructor(options: PymthouseAdapterOptions = {}) { this.clientOverride = options.client; this.isConfiguredOverride = options.isConfigured; this.signerExchange = options.signerExchange; this.apiKeyExchange = options.apiKeyExchange; - this.billingCheckoutCreds = options.billingCheckoutCreds; } /** @@ -158,17 +145,15 @@ export class PymthouseAdapter implements BillingProviderAdapter { } /** - * Start pymthouse end-user checkout (`POST …/billing/checkout`) for a plan. - * Returns the Stripe Checkout URL; the provider creates the OpenMeter - * subscription before returning. + * Start pymthouse end-user checkout via SDK `createBillingCheckout` + * (`POST …/billing/checkout`). Returns the Stripe Checkout URL; the + * provider creates the OpenMeter subscription before returning. */ async subscribe(input: SubscribeInput): Promise { - const creds = - this.billingCheckoutCreds ?? resolveGlobalPymthouseBillingCheckoutCreds(); - if (!creds) { + if (!this.isConfigured()) { throw new AdapterNotImplementedError(this.slug, 'subscribe'); } - const result = await createPymthouseBillingCheckout(creds, { + const result = await this.client().createBillingCheckout({ planId: input.planId, externalUserId: input.externalUserId, ...(input.successUrl ? { successUrl: input.successUrl } : {}), @@ -176,7 +161,9 @@ export class PymthouseAdapter implements BillingProviderAdapter { }); return { checkoutUrl: result.checkoutUrl, - ...(result.subscriptionRef ? { subscriptionRef: result.subscriptionRef } : {}), + ...(result.subscriptionId + ? { subscriptionRef: result.subscriptionId } + : {}), }; } diff --git a/apps/web-next/src/lib/billing/pymthouse-billing-checkout.ts b/apps/web-next/src/lib/billing/pymthouse-billing-checkout.ts deleted file mode 100644 index b230c4b1f..000000000 --- a/apps/web-next/src/lib/billing/pymthouse-billing-checkout.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * M2M call to pymthouse `POST /apps/{publicClientId}/billing/checkout`. - * - * The published `@pymthouse/builder-sdk` pin does not yet expose this verb, so - * NaaP calls it with the same Basic-auth pattern as discovery-plans. Secrets - * are used only to build the Authorization header and are never logged. - */ - -import 'server-only'; - -import { getPymthouseApiV1Base } from '@/lib/pymthouse-device-initiate'; - -import type { PymthouseDiscoveryPlansCreds } from '@/lib/pymthouse-discovery-plans'; - -export type PymthouseBillingCheckoutCreds = PymthouseDiscoveryPlansCreds; - -export interface CreatePymthouseBillingCheckoutInput { - planId: string; - externalUserId: string; - successUrl?: string; - cancelUrl?: string; -} - -export interface PymthouseBillingCheckoutResult { - checkoutUrl: string; - /** Opaque provider subscription pointer when the provider returns one. */ - subscriptionRef?: string; -} - -/** Provider/HTTP failure from checkout — carries a suggested HTTP status. */ -export class PymthouseCheckoutError extends Error { - readonly status: number; - - constructor( - message: string, - status: number, - ) { - super(message); - this.name = 'PymthouseCheckoutError'; - this.status = status; - } -} - -/** Resolve global-env M2M checkout creds (null when incomplete). */ -export function resolveGlobalPymthouseBillingCheckoutCreds(): PymthouseBillingCheckoutCreds | null { - const apiV1Base = getPymthouseApiV1Base()?.trim() ?? ''; - const publicClientId = - process.env.PYMTHOUSE_PUBLIC_CLIENT_ID?.trim() || - process.env.PMTHOUSE_CLIENT_ID?.trim() || - ''; - const m2mClientId = - process.env.PYMTHOUSE_M2M_CLIENT_ID?.trim() || - process.env.PMTHOUSE_M2M_CLIENT_ID?.trim() || - ''; - const m2mClientSecret = - process.env.PYMTHOUSE_M2M_CLIENT_SECRET?.trim() || - process.env.PMTHOUSE_M2M_CLIENT_SECRET?.trim() || - ''; - if (!apiV1Base || !publicClientId || !m2mClientId || !m2mClientSecret) { - return null; - } - return { apiV1Base, publicClientId, m2mClientId, m2mClientSecret }; -} - -/** - * Start end-user plan checkout on pymthouse. Throws - * {@link PymthouseCheckoutError} on non-2xx / malformed responses. - */ -export async function createPymthouseBillingCheckout( - creds: PymthouseBillingCheckoutCreds, - input: CreatePymthouseBillingCheckoutInput, - signal?: AbortSignal, -): Promise { - const planId = input.planId.trim(); - const externalUserId = input.externalUserId.trim(); - if (!planId || !externalUserId) { - throw new PymthouseCheckoutError('planId and externalUserId are required', 400); - } - - const basic = Buffer.from( - `${creds.m2mClientId}:${creds.m2mClientSecret}`, - 'utf8', - ).toString('base64'); - const url = `${creds.apiV1Base.replace(/\/$/, '')}/apps/${encodeURIComponent(creds.publicClientId)}/billing/checkout`; - - const body: Record = { planId, externalUserId }; - if (input.successUrl?.trim()) body.successUrl = input.successUrl.trim(); - if (input.cancelUrl?.trim()) body.cancelUrl = input.cancelUrl.trim(); - - let res: Response; - try { - res = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Basic ${basic}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify(body), - signal, - cache: 'no-store', - }); - } catch { - throw new PymthouseCheckoutError('Checkout request failed', 502); - } - - let json: unknown = null; - try { - json = await res.json(); - } catch { - json = null; - } - - if (!res.ok) { - const errMsg = - json && - typeof json === 'object' && - typeof (json as { error?: unknown }).error === 'string' - ? (json as { error: string }).error.trim() - : ''; - const status = res.status >= 400 && res.status < 600 ? res.status : 502; - throw new PymthouseCheckoutError(errMsg || 'Checkout failed', status); - } - - const checkoutUrl = - json && - typeof json === 'object' && - typeof (json as { checkoutUrl?: unknown }).checkoutUrl === 'string' - ? (json as { checkoutUrl: string }).checkoutUrl.trim() - : ''; - if (!checkoutUrl) { - throw new PymthouseCheckoutError('Checkout response missing checkoutUrl', 502); - } - - const subscriptionId = - json && - typeof json === 'object' && - typeof (json as { subscriptionId?: unknown }).subscriptionId === 'string' - ? (json as { subscriptionId: string }).subscriptionId.trim() - : ''; - - return { - checkoutUrl, - ...(subscriptionId ? { subscriptionRef: subscriptionId } : {}), - }; -} diff --git a/docs/pymthouse-integration.md b/docs/pymthouse-integration.md index 7c77368b6..0a47e8ad3 100644 --- a/docs/pymthouse-integration.md +++ b/docs/pymthouse-integration.md @@ -4,7 +4,7 @@ Official Builder API contract: [PymtHouse `docs/builder-api.md`](https://github. Server-to-server calls use the published npm package [`@pymthouse/builder-sdk`](https://www.npmjs.com/package/@pymthouse/builder-sdk) (source: [pymthouse/builder-sdk](https://github.com/pymthouse/builder-sdk)), wrapped in [apps/web-next/src/lib/pymthouse-client.ts](apps/web-next/src/lib/pymthouse-client.ts) with `import "server-only"` so M2M secrets never ship to the browser. -**Dependency pin:** NaaP pins `@pymthouse/builder-sdk` at **`0.6.0`** in [apps/web-next/package.json](../apps/web-next/package.json) (and matching pins in the developer-api plugin packages). Review [builder-sdk releases](https://github.com/pymthouse/builder-sdk/releases) before bumping, run `npm install` at the repo root (or `./bin/start.sh`, which syncs when `package-lock.json` changes), and re-verify billing/OIDC routes after any upgrade. +**Dependency pin:** NaaP pins `@pymthouse/builder-sdk` at **`github:pymthouse/builder-sdk#feat/create-billing-checkout`** (pending `0.6.2` publish) in [apps/web-next/package.json](../apps/web-next/package.json) for `createBillingCheckout` / plans+subscribe. Other packages may still pin `0.6.0`. Review [builder-sdk releases](https://github.com/pymthouse/builder-sdk/releases) before bumping, run `npm install` at the repo root (or `./bin/start.sh`, which syncs when `package-lock.json` changes), and re-verify billing/OIDC routes after any upgrade. ## Plan-builder data (PymtHouse → NaaP) diff --git a/package-lock.json b/package-lock.json index 4e7d7cca6..54cbbcc97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,7 +72,7 @@ "@naap/types": "*", "@naap/ui": "*", "@naap/utils": "*", - "@pymthouse/builder-sdk": "0.6.0", + "@pymthouse/builder-sdk": "github:pymthouse/builder-sdk#feat/create-billing-checkout", "@vercel/blob": "^2.2.0", "ably": "^2.18.0", "dompurify": "^3.3.0", @@ -132,6 +132,17 @@ "dev": true, "license": "Apache-2.0" }, + "apps/web-next/node_modules/@pymthouse/builder-sdk": { + "version": "0.6.2", + "resolved": "git+ssh://git@github.com/pymthouse/builder-sdk.git#08f3f4afdf90aa6d641a26b4f93b2fd8c0ced605", + "license": "MIT", + "dependencies": { + "oauth4webapi": "^3.8.5" + }, + "engines": { + "node": ">=20" + } + }, "apps/web-next/node_modules/@types/mime-types": { "version": "3.0.1", "dev": true, From 5ed3e951f72b9ae082ebd62597c0781582624960 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Wed, 5 Aug 2026 22:46:54 -0400 Subject: [PATCH 3/5] chore(deps): pin @pymthouse/builder-sdk to published 0.6.2 builder-sdk#50 merged and published; replace the git branch pin so createBillingCheckout comes from the npm release. --- apps/web-next/package.json | 2 +- docs/pymthouse-integration.md | 2 +- package-lock.json | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web-next/package.json b/apps/web-next/package.json index 75f85cf8a..1bfbb9f65 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": "github:pymthouse/builder-sdk#feat/create-billing-checkout", + "@pymthouse/builder-sdk": "^0.6.2", "@vercel/blob": "^2.2.0", "ably": "^2.18.0", "dompurify": "^3.3.0", diff --git a/docs/pymthouse-integration.md b/docs/pymthouse-integration.md index 0a47e8ad3..7aba389e9 100644 --- a/docs/pymthouse-integration.md +++ b/docs/pymthouse-integration.md @@ -4,7 +4,7 @@ Official Builder API contract: [PymtHouse `docs/builder-api.md`](https://github. Server-to-server calls use the published npm package [`@pymthouse/builder-sdk`](https://www.npmjs.com/package/@pymthouse/builder-sdk) (source: [pymthouse/builder-sdk](https://github.com/pymthouse/builder-sdk)), wrapped in [apps/web-next/src/lib/pymthouse-client.ts](apps/web-next/src/lib/pymthouse-client.ts) with `import "server-only"` so M2M secrets never ship to the browser. -**Dependency pin:** NaaP pins `@pymthouse/builder-sdk` at **`github:pymthouse/builder-sdk#feat/create-billing-checkout`** (pending `0.6.2` publish) in [apps/web-next/package.json](../apps/web-next/package.json) for `createBillingCheckout` / plans+subscribe. Other packages may still pin `0.6.0`. Review [builder-sdk releases](https://github.com/pymthouse/builder-sdk/releases) before bumping, run `npm install` at the repo root (or `./bin/start.sh`, which syncs when `package-lock.json` changes), and re-verify billing/OIDC routes after any upgrade. +**Dependency pin:** NaaP pins `@pymthouse/builder-sdk` at **`0.6.2`** in [apps/web-next/package.json](../apps/web-next/package.json) (and matching pins in the developer-api plugin packages where applicable). Review [builder-sdk releases](https://github.com/pymthouse/builder-sdk/releases) before bumping, run `npm install` at the repo root (or `./bin/start.sh`, which syncs when `package-lock.json` changes), and re-verify billing/OIDC routes after any upgrade. ## Plan-builder data (PymtHouse → NaaP) diff --git a/package-lock.json b/package-lock.json index 54cbbcc97..b2fbe4d25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,7 +72,7 @@ "@naap/types": "*", "@naap/ui": "*", "@naap/utils": "*", - "@pymthouse/builder-sdk": "github:pymthouse/builder-sdk#feat/create-billing-checkout", + "@pymthouse/builder-sdk": "^0.6.2", "@vercel/blob": "^2.2.0", "ably": "^2.18.0", "dompurify": "^3.3.0", @@ -134,7 +134,8 @@ }, "apps/web-next/node_modules/@pymthouse/builder-sdk": { "version": "0.6.2", - "resolved": "git+ssh://git@github.com/pymthouse/builder-sdk.git#08f3f4afdf90aa6d641a26b4f93b2fd8c0ced605", + "resolved": "https://registry.npmjs.org/@pymthouse/builder-sdk/-/builder-sdk-0.6.2.tgz", + "integrity": "sha512-IXxgOyAqRcnkU+TipBFMpEeL0epMzUCrDWt8jwsftZpnc60y3ZZlPrxb0IbP+DxKIan9HXrJtQJsfz00yV7E9w==", "license": "MIT", "dependencies": { "oauth4webapi": "^3.8.5" From b4245d60ab18b18e3c969762e2b20ed334d6235e Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Wed, 5 Aug 2026 23:12:28 -0400 Subject: [PATCH 4/5] chore(deps): bump @pymthouse/builder-sdk to 0.6.3 Move off the interim 0.6.2 pin to the published Sonar/CI security patch. --- apps/web-next/package.json | 2 +- docs/pymthouse-integration.md | 2 +- package-lock.json | 36 ++++------------------------------- 3 files changed, 6 insertions(+), 34 deletions(-) diff --git a/apps/web-next/package.json b/apps/web-next/package.json index 1bfbb9f65..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.2", + "@pymthouse/builder-sdk": "0.6.3", "@vercel/blob": "^2.2.0", "ably": "^2.18.0", "dompurify": "^3.3.0", diff --git a/docs/pymthouse-integration.md b/docs/pymthouse-integration.md index 7aba389e9..28fc62523 100644 --- a/docs/pymthouse-integration.md +++ b/docs/pymthouse-integration.md @@ -4,7 +4,7 @@ Official Builder API contract: [PymtHouse `docs/builder-api.md`](https://github. Server-to-server calls use the published npm package [`@pymthouse/builder-sdk`](https://www.npmjs.com/package/@pymthouse/builder-sdk) (source: [pymthouse/builder-sdk](https://github.com/pymthouse/builder-sdk)), wrapped in [apps/web-next/src/lib/pymthouse-client.ts](apps/web-next/src/lib/pymthouse-client.ts) with `import "server-only"` so M2M secrets never ship to the browser. -**Dependency pin:** NaaP pins `@pymthouse/builder-sdk` at **`0.6.2`** in [apps/web-next/package.json](../apps/web-next/package.json) (and matching pins in the developer-api plugin packages where applicable). Review [builder-sdk releases](https://github.com/pymthouse/builder-sdk/releases) before bumping, run `npm install` at the repo root (or `./bin/start.sh`, which syncs when `package-lock.json` changes), and re-verify billing/OIDC routes after any upgrade. +**Dependency pin:** NaaP pins `@pymthouse/builder-sdk` at **`0.6.3`** in [apps/web-next/package.json](../apps/web-next/package.json) (and matching pins in the developer-api plugin packages where applicable). Review [builder-sdk releases](https://github.com/pymthouse/builder-sdk/releases) before bumping, run `npm install` at the repo root (or `./bin/start.sh`, which syncs when `package-lock.json` changes), and re-verify billing/OIDC routes after any upgrade. ## Plan-builder data (PymtHouse → NaaP) diff --git a/package-lock.json b/package-lock.json index b2fbe4d25..82b3cd880 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,7 +72,7 @@ "@naap/types": "*", "@naap/ui": "*", "@naap/utils": "*", - "@pymthouse/builder-sdk": "^0.6.2", + "@pymthouse/builder-sdk": "0.6.3", "@vercel/blob": "^2.2.0", "ably": "^2.18.0", "dompurify": "^3.3.0", @@ -133,9 +133,9 @@ "license": "Apache-2.0" }, "apps/web-next/node_modules/@pymthouse/builder-sdk": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@pymthouse/builder-sdk/-/builder-sdk-0.6.2.tgz", - "integrity": "sha512-IXxgOyAqRcnkU+TipBFMpEeL0epMzUCrDWt8jwsftZpnc60y3ZZlPrxb0IbP+DxKIan9HXrJtQJsfz00yV7E9w==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@pymthouse/builder-sdk/-/builder-sdk-0.6.3.tgz", + "integrity": "sha512-ex+qcGknv6gs+FqvmgoQgR5Trwy/yQ7UIO91jlGVPpgwSccFqTunP/Lmi+ABY9Gz1F0xN+U9C6AYOhkFSb/Nsw==", "license": "MIT", "dependencies": { "oauth4webapi": "^3.8.5" @@ -32462,7 +32462,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -32480,7 +32479,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -32498,7 +32496,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -32516,7 +32513,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -32534,7 +32530,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -32552,7 +32547,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -32570,7 +32564,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -32588,7 +32581,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -32606,7 +32598,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -32624,7 +32615,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -32642,7 +32632,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -32660,7 +32649,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -32678,7 +32666,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -32696,7 +32683,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -32714,7 +32700,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -32732,7 +32717,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -32750,7 +32734,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -32768,7 +32751,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -32786,7 +32768,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -32804,7 +32785,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -32822,7 +32802,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -32840,7 +32819,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -32858,7 +32836,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -32876,7 +32853,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -32894,7 +32870,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -32912,7 +32887,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -32973,7 +32947,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -33090,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", From b3e2aa9e706e528ef3fab2abf341f6995491be89 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 7 Aug 2026 14:07:08 -0400 Subject: [PATCH 5/5] fix(billing): pass through pymthouse checkout 409 conflicts Builder checkout (pymthouse#386) returns 409 when a customer already has an active subscription or needs a payment method before plan change. Map that to CONFLICT instead of treating it as a transient 503. --- .../[provider]/[...path]/route.test.ts | 25 +++++++++++++++++++ .../v1/billing/[provider]/[...path]/route.ts | 5 ++++ .../[teamId]/subscriptions/route.test.ts | 23 +++++++++++++++++ .../v1/teams/[teamId]/subscriptions/route.ts | 1 + .../src/lib/billing/pymthouse-adapter.ts | 7 ++++-- docs/pymthouse-integration.md | 2 ++ 6 files changed, 61 insertions(+), 2 deletions(-) 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 bed6b077c..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 @@ -255,4 +255,29 @@ describe('generic billing route — flag ON', () => { ); 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 4a1207ae4..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 @@ -111,6 +111,11 @@ function mapAdapterError( 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'); } 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 28eebad28..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 @@ -183,6 +183,29 @@ describe('POST create (flag ON)', () => { 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({ 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 fcdaef57e..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 @@ -198,6 +198,7 @@ export async function POST(request: NextRequest, { params }: RouteParams): Promi 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; diff --git a/apps/web-next/src/lib/billing/pymthouse-adapter.ts b/apps/web-next/src/lib/billing/pymthouse-adapter.ts index aa772e41a..9a71d0fca 100644 --- a/apps/web-next/src/lib/billing/pymthouse-adapter.ts +++ b/apps/web-next/src/lib/billing/pymthouse-adapter.ts @@ -146,8 +146,11 @@ export class PymthouseAdapter implements BillingProviderAdapter { /** * Start pymthouse end-user checkout via SDK `createBillingCheckout` - * (`POST …/billing/checkout`). Returns the Stripe Checkout URL; the - * provider creates the OpenMeter subscription before returning. + * (`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()) { diff --git a/docs/pymthouse-integration.md b/docs/pymthouse-integration.md index 28fc62523..c431b0845 100644 --- a/docs/pymthouse-integration.md +++ b/docs/pymthouse-integration.md @@ -33,6 +33,8 @@ surface (when `provider_adapters` is ON): | `GET` | `/api/v1/billing/pymthouse/plans` | BPP plan catalogue (`listBillingProducts` → BPP `Plan[]`) | | `POST` | `/api/v1/billing/pymthouse/subscribe` | Start end-user checkout (`planId`, optional `externalUserId` / redirect URLs) → `{ checkoutUrl, subscriptionRef? }` | +Upstream Builder path: SDK `createBillingCheckout` → pymthouse `POST /api/v1/apps/{clientId}/billing/checkout`. That route reuses/changes an existing Starter subscription instead of creating a second Konnect sub. Callers may receive **409 CONFLICT** when the customer already has an active paid subscription (switch via pymthouse `POST …/users/{externalUserId}/subscription/change`) or still needs a payment method before the change can complete — not a transient outage. + Team multi-subscribe (`POST /api/v1/teams/{teamId}/subscriptions` with `providerPlanId`, when `multi_subscription` is ON) calls the same provider checkout before persisting the NaaP subscription row, and returns `checkoutUrl` when the provider supports it. Fallback redirect (no adapter / flag OFF): use `PYMTHOUSE_MARKETPLACE_URL`, or `PMTHOUSE_BASE_URL` (appends `/marketplace`), or `PYMTHOUSE_ISSUER_URL` (marketplace path defaults to `/marketplace` on the non-`api.` host).