billing.ts305 lines · main
1import { Hono } from 'hono';
2import { Webhook, WebhookVerificationError } from 'standardwebhooks';
3import { z } from 'zod';
4
5import { env } from '../env.js';
6import { userRateLimit } from '../middleware/rate-limit.js';
7import { requireAuth } from '../middleware/session.js';
8import type { AppEnv } from '../types/app-env.js';
9import {
10 checkVatWithVies,
11 configuredPlans,
12 createCheckout,
13 createCustomerPortalSession,
14 getSubscriptionForOrg,
15 getTierForOrg,
16 upsertSubscriptionFromPolar,
17} from '../services/billing.js';
18import { log } from '../lib/logger.js';
19import { audit } from '../services/audit.js';
20import { getDefaultOrgForUser } from '../services/orgs.js';
21
22const checkoutSchema = z.object({
23 tier: z.enum(['pro', 'team']),
24 successURL: z.string().url(),
25});
26
27const 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.
34const 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
54export const billingRouter = new Hono<AppEnv>();
55
56billingRouter.use('/v1/billing/tier', requireAuth());
57billingRouter.use('/v1/billing/checkout', requireAuth());
58billingRouter.use('/v1/billing/plans', requireAuth());
59billingRouter.use('/v1/billing/subscription', requireAuth());
60billingRouter.use('/v1/billing/portal', requireAuth());
61billingRouter.use('/v1/billing/vat/check', requireAuth());
62
63billingRouter.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
70billingRouter.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 */
89billingRouter.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
95billingRouter.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 */
132billingRouter.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
142billingRouter.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 */
172billingRouter.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
291function 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}