billing.ts305 lines · main
| 1 | import { Hono } from 'hono'; |
| 2 | import { Webhook, WebhookVerificationError } from 'standardwebhooks'; |
| 3 | import { z } from 'zod'; |
| 4 | |
| 5 | import { env } from '../env.js'; |
| 6 | import { userRateLimit } from '../middleware/rate-limit.js'; |
| 7 | import { requireAuth } from '../middleware/session.js'; |
| 8 | import type { AppEnv } from '../types/app-env.js'; |
| 9 | import { |
| 10 | checkVatWithVies, |
| 11 | configuredPlans, |
| 12 | createCheckout, |
| 13 | createCustomerPortalSession, |
| 14 | getSubscriptionForOrg, |
| 15 | getTierForOrg, |
| 16 | upsertSubscriptionFromPolar, |
| 17 | } from '../services/billing.js'; |
| 18 | import { log } from '../lib/logger.js'; |
| 19 | import { audit } from '../services/audit.js'; |
| 20 | import { getDefaultOrgForUser } from '../services/orgs.js'; |
| 21 | |
| 22 | const checkoutSchema = z.object({ |
| 23 | tier: z.enum(['pro', 'team']), |
| 24 | successURL: z.string().url(), |
| 25 | }); |
| 26 | |
| 27 | const portalSchema = z.object({ |
| 28 | returnURL: z.string().url(), |
| 29 | }); |
| 30 | |
| 31 | // Polar's webhook payload has evolved — some events carry flat ids |
| 32 | // (`customer_id`, `product_id`) while newer subscription/order events nest |
| 33 | // the full object. Accept both shapes and resolve at read time. |
| 34 | const webhookSchema = z.object({ |
| 35 | type: z.string(), |
| 36 | data: z |
| 37 | .object({ |
| 38 | id: z.string(), |
| 39 | // nullish: Polar sends null (not undefined) for fields that don't |
| 40 | // apply to a given event type — e.g. product.* events have no |
| 41 | // customer_id. Accept both null and undefined. |
| 42 | customer_id: z.string().nullish(), |
| 43 | customer: z.object({ id: z.string() }).passthrough().nullish(), |
| 44 | product_id: z.string().nullish(), |
| 45 | product: z.object({ id: z.string() }).passthrough().nullish(), |
| 46 | status: z.string().nullish(), |
| 47 | current_period_end: z.string().nullish(), |
| 48 | canceled_at: z.string().nullish(), |
| 49 | metadata: z.record(z.string(), z.unknown()).nullish(), |
| 50 | }) |
| 51 | .passthrough(), |
| 52 | }); |
| 53 | |
| 54 | export const billingRouter = new Hono<AppEnv>(); |
| 55 | |
| 56 | billingRouter.use('/v1/billing/tier', requireAuth()); |
| 57 | billingRouter.use('/v1/billing/checkout', requireAuth()); |
| 58 | billingRouter.use('/v1/billing/plans', requireAuth()); |
| 59 | billingRouter.use('/v1/billing/subscription', requireAuth()); |
| 60 | billingRouter.use('/v1/billing/portal', requireAuth()); |
| 61 | billingRouter.use('/v1/billing/vat/check', requireAuth()); |
| 62 | |
| 63 | billingRouter.get('/v1/billing/tier', async (c) => { |
| 64 | const user = c.get('user')!; |
| 65 | const org = await getDefaultOrgForUser(user.id); |
| 66 | const tier = await getTierForOrg(org.id); |
| 67 | return c.json({ tier }); |
| 68 | }); |
| 69 | |
| 70 | billingRouter.get('/v1/billing/subscription', async (c) => { |
| 71 | const user = c.get('user')!; |
| 72 | const org = await getDefaultOrgForUser(user.id); |
| 73 | const summary = await getSubscriptionForOrg(org.id); |
| 74 | return c.json(summary); |
| 75 | }); |
| 76 | |
| 77 | /** |
| 78 | * Live VAT check against the EU VIES REST API. Returns one of: |
| 79 | * valid – VIES confirms it is a registered VAT number |
| 80 | * invalid – VIES confirms it does not exist (or format is bad) |
| 81 | * unverifiable – VIES was unreachable / that country's registry is |
| 82 | * timing out. Caller should let the user proceed but |
| 83 | * flag the number as unverified. |
| 84 | * |
| 85 | * VIES is known to be per-country flaky, so we never block on a transient |
| 86 | * outage — the customer can still check out and we re-check on the next |
| 87 | * profile update. |
| 88 | */ |
| 89 | billingRouter.get('/v1/billing/vat/check', async (c) => { |
| 90 | const raw = c.req.query('id') ?? ''; |
| 91 | const result = await checkVatWithVies(raw); |
| 92 | return c.json(result); |
| 93 | }); |
| 94 | |
| 95 | billingRouter.post('/v1/billing/portal', userRateLimit('billing', 10), async (c) => { |
| 96 | const user = c.get('user')!; |
| 97 | const body = await c.req.json().catch(() => null); |
| 98 | const parsed = portalSchema.safeParse(body); |
| 99 | if (!parsed.success) { |
| 100 | return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400); |
| 101 | } |
| 102 | const org = await getDefaultOrgForUser(user.id); |
| 103 | const summary = await getSubscriptionForOrg(org.id); |
| 104 | if (!summary.polarCustomerId) { |
| 105 | return c.json( |
| 106 | { |
| 107 | code: 'no_customer', |
| 108 | message: 'no paid subscription yet — start one via checkout first', |
| 109 | }, |
| 110 | 404, |
| 111 | ); |
| 112 | } |
| 113 | try { |
| 114 | const result = await createCustomerPortalSession( |
| 115 | summary.polarCustomerId, |
| 116 | parsed.data.returnURL, |
| 117 | ); |
| 118 | return c.json(result); |
| 119 | } catch (err) { |
| 120 | if (err && typeof err === 'object' && 'code' in err) { |
| 121 | const e = err as { code: string; message: string; status?: number }; |
| 122 | return c.json({ code: e.code, message: e.message }, (e.status ?? 500) as never); |
| 123 | } |
| 124 | throw err; |
| 125 | } |
| 126 | }); |
| 127 | |
| 128 | /** |
| 129 | * Plans the user can check out into. Empty array when Polar product UUIDs |
| 130 | * aren't configured — the UI uses this to gate the upgrade buttons. |
| 131 | */ |
| 132 | billingRouter.get('/v1/billing/plans', async (c) => { |
| 133 | const plans = configuredPlans().map((p) => ({ |
| 134 | tier: p.tier, |
| 135 | // The product id is the only identifier the client needs to reason about |
| 136 | // a plan; nothing else about the Polar product leaks through here. |
| 137 | productId: p.productId, |
| 138 | })); |
| 139 | return c.json({ plans }); |
| 140 | }); |
| 141 | |
| 142 | billingRouter.post('/v1/billing/checkout', userRateLimit('billing', 10), async (c) => { |
| 143 | const user = c.get('user')!; |
| 144 | const body = await c.req.json().catch(() => null); |
| 145 | const parsed = checkoutSchema.safeParse(body); |
| 146 | if (!parsed.success) { |
| 147 | return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400); |
| 148 | } |
| 149 | try { |
| 150 | const org = await getDefaultOrgForUser(user.id); |
| 151 | const result = await createCheckout({ |
| 152 | orgId: org.id, |
| 153 | createdByUserId: user.id, |
| 154 | email: user.email, |
| 155 | tier: parsed.data.tier, |
| 156 | successURL: parsed.data.successURL, |
| 157 | }); |
| 158 | return c.json(result); |
| 159 | } catch (err) { |
| 160 | if (err && typeof err === 'object' && 'code' in err) { |
| 161 | const e = err as { code: string; message: string; status?: number }; |
| 162 | return c.json({ code: e.code, message: e.message }, (e.status ?? 500) as never); |
| 163 | } |
| 164 | throw err; |
| 165 | } |
| 166 | }); |
| 167 | |
| 168 | /** |
| 169 | * Polar.sh webhook endpoint. HMAC-validated with BRIVEN_POLAR_WEBHOOK_SECRET. |
| 170 | * Idempotent upserts on subscription row. |
| 171 | */ |
| 172 | billingRouter.post('/v1/billing/webhook', async (c) => { |
| 173 | const secret = env.BRIVEN_POLAR_WEBHOOK_SECRET; |
| 174 | if (!secret) { |
| 175 | return c.json({ code: 'not_configured' }, 503); |
| 176 | } |
| 177 | |
| 178 | const rawBody = await c.req.text(); |
| 179 | |
| 180 | // Polar deviates from the Standard Webhooks spec on secret handling. |
| 181 | // Per polar-python's validate_event (src/polar_sdk/_webhooks/__init__.py), |
| 182 | // the reference implementation is: |
| 183 | // base64_secret = base64.b64encode(secret.encode()).decode() |
| 184 | // webhook = Webhook(base64_secret) |
| 185 | // i.e. base64-encode the FULL secret string (including the `polar_whs_` |
| 186 | // prefix) and hand THAT to the library, which base64-decodes it and |
| 187 | // HMAC's with those raw ASCII bytes. Do NOT strip the prefix. |
| 188 | const encodedSecret = Buffer.from(secret, 'utf8').toString('base64'); |
| 189 | |
| 190 | const wh = new Webhook(encodedSecret); |
| 191 | try { |
| 192 | wh.verify(rawBody, { |
| 193 | 'webhook-id': c.req.header('webhook-id') ?? '', |
| 194 | 'webhook-timestamp': c.req.header('webhook-timestamp') ?? '', |
| 195 | 'webhook-signature': c.req.header('webhook-signature') ?? '', |
| 196 | }); |
| 197 | } catch (err) { |
| 198 | if (err instanceof WebhookVerificationError) { |
| 199 | log.warn('polar_webhook_bad_signature', { message: err.message }); |
| 200 | return c.json({ code: 'bad_signature' }, 401); |
| 201 | } |
| 202 | throw err; |
| 203 | } |
| 204 | |
| 205 | let payload: z.infer<typeof webhookSchema>; |
| 206 | try { |
| 207 | payload = webhookSchema.parse(JSON.parse(rawBody)); |
| 208 | } catch (err) { |
| 209 | log.warn('polar_webhook_bad_payload', { |
| 210 | message: err instanceof Error ? err.message : String(err), |
| 211 | }); |
| 212 | return c.json({ code: 'bad_payload' }, 400); |
| 213 | } |
| 214 | |
| 215 | // Polar fires many event types; we only care about subscription |
| 216 | // lifecycle ones. Everything else gets a 200 so Polar doesn't retry. |
| 217 | const SUBSCRIPTION_EVENTS = new Set([ |
| 218 | 'subscription.created', |
| 219 | 'subscription.active', |
| 220 | 'subscription.updated', |
| 221 | 'subscription.canceled', |
| 222 | ]); |
| 223 | if (!SUBSCRIPTION_EVENTS.has(payload.type)) { |
| 224 | return c.json({ ok: true, ignored: payload.type }); |
| 225 | } |
| 226 | |
| 227 | const data = payload.data; |
| 228 | const customerId = data.customer_id ?? data.customer?.id ?? null; |
| 229 | const productId = data.product_id ?? data.product?.id ?? null; |
| 230 | if (!customerId || !productId) { |
| 231 | log.warn('polar_webhook_missing_ids', { |
| 232 | type: payload.type, |
| 233 | subscriptionId: data.id, |
| 234 | }); |
| 235 | return c.json({ ok: true, ignored: 'missing_ids' }); |
| 236 | } |
| 237 | |
| 238 | const meta = (data.metadata as Record<string, unknown> | null) ?? {}; |
| 239 | // Prefer orgId (the current schema). Legacy events from before migration |
| 240 | // 0010 only carry ownerId — resolve them to the user's personal org so |
| 241 | // redelivery of pre-migration events still lands correctly. |
| 242 | let orgId: string | null = typeof meta.orgId === 'string' ? meta.orgId : null; |
| 243 | if (!orgId) { |
| 244 | const legacyOwnerId = typeof meta.ownerId === 'string' ? meta.ownerId : null; |
| 245 | if (legacyOwnerId) { |
| 246 | try { |
| 247 | const personalOrg = await getDefaultOrgForUser(legacyOwnerId); |
| 248 | orgId = personalOrg.id; |
| 249 | } catch { |
| 250 | // fall through to no_org branch |
| 251 | } |
| 252 | } |
| 253 | } |
| 254 | if (!orgId) { |
| 255 | log.warn('polar_webhook_no_org', { type: payload.type, subscriptionId: data.id }); |
| 256 | return c.json({ ok: true, ignored: 'no_org' }); |
| 257 | } |
| 258 | |
| 259 | const status = data.status ?? (payload.type === 'subscription.canceled' ? 'canceled' : 'active'); |
| 260 | |
| 261 | await upsertSubscriptionFromPolar({ |
| 262 | polarSubscriptionId: data.id, |
| 263 | polarCustomerId: customerId, |
| 264 | polarProductId: productId, |
| 265 | orgId, |
| 266 | status: statusFromPolar(status), |
| 267 | currentPeriodEnd: data.current_period_end ? new Date(data.current_period_end) : null, |
| 268 | canceledAt: data.canceled_at ? new Date(data.canceled_at) : null, |
| 269 | }); |
| 270 | |
| 271 | // Audit-log every subscription lifecycle event so it surfaces in the |
| 272 | // admin event stream alongside email events. PII intentionally |
| 273 | // excluded — we keep subscription id + product id + status only; |
| 274 | // customer identity stays on polar's side. |
| 275 | await audit({ |
| 276 | actorId: null, |
| 277 | projectId: null, |
| 278 | action: `polar.${payload.type}`, |
| 279 | ipHash: null, |
| 280 | userAgent: 'polar-webhook', |
| 281 | metadata: { |
| 282 | subscriptionId: data.id, |
| 283 | productId, |
| 284 | status: statusFromPolar(status), |
| 285 | orgId, |
| 286 | }, |
| 287 | }); |
| 288 | return c.json({ ok: true }); |
| 289 | }); |
| 290 | |
| 291 | function statusFromPolar(raw: string): 'active' | 'past_due' | 'canceled' | 'trialing' { |
| 292 | switch (raw) { |
| 293 | case 'trialing': |
| 294 | return 'trialing'; |
| 295 | case 'past_due': |
| 296 | case 'unpaid': |
| 297 | return 'past_due'; |
| 298 | case 'canceled': |
| 299 | case 'cancelled': |
| 300 | case 'incomplete_expired': |
| 301 | return 'canceled'; |
| 302 | default: |
| 303 | return 'active'; |
| 304 | } |
| 305 | } |