From a8f941603a43b8246e8d8eaae582f40011836e81 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Mon, 19 Oct 2026 11:40:20 +0300 Subject: [PATCH] fix(api): billing portal answers 404 when a team has no Polar customer --- apps/api/test/billing-portal.test.ts | 81 ++++++++++++++++++++++++++++ packages/core/src/billing/polar.ts | 23 ++++++-- 2 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 apps/api/test/billing-portal.test.ts diff --git a/apps/api/test/billing-portal.test.ts b/apps/api/test/billing-portal.test.ts new file mode 100644 index 00000000..69128a88 --- /dev/null +++ b/apps/api/test/billing-portal.test.ts @@ -0,0 +1,81 @@ +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; + +import { AccessToken } from '@nestri/core/access-token/index'; +import { Polar } from '@nestri/core/billing/polar'; +import { Fixtures } from '@nestri/core/db/fixtures'; +import { testDb } from '@nestri/core/db/test'; +import { Env } from '@nestri/core/env'; +import { Identifier } from '@nestri/core/id'; + +import { app } from '../app/index'; +import './setup'; + +const sql = testDb(); +const createdUserIds: string[] = []; +const realFetch = globalThis.fetch; + +async function person(label: string) { + const owner = await Fixtures.owner(label); + createdUserIds.push(owner.userId); + const pat = await AccessToken.create({ + id: Identifier.ascending('accessToken'), + ownerUserId: owner.userId, + teamId: null, + name: label + }); + return { authorization: `Bearer ${pat.token}` }; +} + +function configure() { + Env.init({ + NODE_ENV: 'test', + FRONTEND_URL: 'http://localhost:5173', + POLAR_ACCESS_TOKEN: 'polar_test', + POLAR_PRODUCT_ID: 'prod_test' + }); + Polar.reset(); +} + +/** Stand in for Polar only; every other request goes where it was going. */ +function polarAnswers(status: number, body: unknown) { + globalThis.fetch = (async (input: Request | string | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + if (url.includes('polar.sh')) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }); + } + return realFetch(input as never, init); + }) as typeof fetch; +} + +afterEach(() => { + globalThis.fetch = realFetch; + Polar.reset(); +}); + +afterAll(async () => { + if (createdUserIds.length > 0) { + await sql`delete from "user" where id in ${sql(createdUserIds)}`; + } +}); + +describe('GET /billing/portal', () => { + test('a team Polar has never heard of gets a 404, not a 500', async () => { + const headers = await person('portal-none'); + configure(); + polarAnswers(404, { error: 'ResourceNotFound', detail: 'Customer not found' }); + const res = await app.request('/billing/portal', { headers }); + expect(res.status).toBe(404); + expect((await res.json()).message).toBe('This team has no billing yet'); + }); + + test('an unknown external id answered with a 422 is the same 404', async () => { + const headers = await person('portal-422'); + configure(); + polarAnswers(422, { detail: [{ loc: ['body'], msg: 'Customer does not exist', type: 'x' }] }); + const res = await app.request('/billing/portal', { headers }); + expect(res.status).toBe(404); + }); +}); diff --git a/packages/core/src/billing/polar.ts b/packages/core/src/billing/polar.ts index 705c9300..0a65cd49 100644 --- a/packages/core/src/billing/polar.ts +++ b/packages/core/src/billing/polar.ts @@ -207,10 +207,25 @@ export namespace Polar { * them, which is the one thing this integration exists to avoid. */ export const portal = fn(z.object({ teamId: z.string() }), async (input) => { - const session = await client().customerSessions.create({ - externalCustomerId: input.teamId - }); - return { url: session.customerPortalUrl }; + try { + const session = await client().customerSessions.create({ + externalCustomerId: input.teamId + }); + return { url: session.customerPortalUrl }; + } catch (err) { + // A team that never checked out has no customer on their side, and + // they refuse the session (404, or 422 for an unknown external id). + // That is not our failure, so it must not surface as a 500. + const status = (err as { statusCode?: number } | null)?.statusCode; + if (status === 404 || status === 422) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'This team has no billing yet' + ); + } + throw err; + } }); /** The plan and status a team is on, as far as the provider is concerned. */