billing.ts365 lines · main
1import { brivenError } from '@briven/shared';
2import { and, eq, isNull } from 'drizzle-orm';
3
4import { getDb } from '../db/client.js';
5import { orgMembers, projects, subscriptions, users, type SubscriptionStatus } from '../db/schema.js';
6import { env } from '../env.js';
7import { log } from '../lib/logger.js';
8import type { ProjectTier } from '../db/schema.js';
9import { invalidatePolarCustomerCache } from '../workers/polar-meter-push.js';
10import { publishEvent } from './outbound-webhooks.js';
11import { invalidateTierCache } from './tiers.js';
12
13/**
14 * Polar.sh integration.
15 *
16 * Tier mapping is env-driven: BRIVEN_POLAR_PRO_PRODUCT_ID and
17 * BRIVEN_POLAR_TEAM_PRODUCT_ID are the UUIDs of the Polar products that
18 * correspond to each tier. Webhooks look up the tier by matching the
19 * product_id from Polar's payload against these env values. Checkout calls
20 * pass the same UUIDs to Polar in the `products: [...]` body.
21 *
22 * Webhook expected payloads (subset we care about):
23 * - subscription.created
24 * - subscription.updated (status / currentPeriodEnd changes)
25 * - subscription.canceled
26 */
27
28type CheckoutableTier = 'pro' | 'team';
29
30export interface PlanConfig {
31 tier: CheckoutableTier;
32 productId: string;
33}
34
35/**
36 * Plans currently configured for checkout. Returns an empty array when no
37 * product UUIDs are set — the settings page reads this to decide whether
38 * the upgrade buttons are live.
39 */
40export function configuredPlans(): PlanConfig[] {
41 const out: PlanConfig[] = [];
42 if (env.BRIVEN_POLAR_PRO_PRODUCT_ID) {
43 out.push({ tier: 'pro', productId: env.BRIVEN_POLAR_PRO_PRODUCT_ID });
44 }
45 if (env.BRIVEN_POLAR_TEAM_PRODUCT_ID) {
46 out.push({ tier: 'team', productId: env.BRIVEN_POLAR_TEAM_PRODUCT_ID });
47 }
48 return out;
49}
50
51function tierForProductId(productId: string): ProjectTier {
52 if (productId === env.BRIVEN_POLAR_PRO_PRODUCT_ID) return 'pro';
53 if (productId === env.BRIVEN_POLAR_TEAM_PRODUCT_ID) return 'team';
54 return 'free';
55}
56
57export type VatCheck =
58 | {
59 state: 'valid';
60 countryCode: string;
61 vatNumber: string;
62 name: string | null;
63 address: string | null;
64 }
65 | { state: 'invalid'; reason: string }
66 | { state: 'unverifiable'; reason: string };
67
68/**
69 * Validate an EU VAT number against the VIES REST API. Used by both the
70 * interactive settings form (debounced per-keystroke) and the authoritative
71 * check that happens on PATCH /v1/me before we lock the field.
72 *
73 * VIES is known to be flaky per-country — an outage from (say) Germany's
74 * registry returns 'unverifiable'. Callers decide whether to block or not;
75 * the settings save path lets unverifiable saves through without setting
76 * vat_verified_at so the user can retry later.
77 */
78export async function checkVatWithVies(raw: string): Promise<VatCheck> {
79 const cleaned = raw.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
80 if (cleaned.length < 4) return { state: 'invalid', reason: 'too_short' };
81 const countryCode = cleaned.slice(0, 2);
82 const vatNumber = cleaned.slice(2);
83 if (!/^[A-Z]{2}$/.test(countryCode)) return { state: 'invalid', reason: 'bad_country' };
84 try {
85 const res = await fetch(
86 'https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number',
87 {
88 method: 'POST',
89 headers: { 'content-type': 'application/json' },
90 body: JSON.stringify({ countryCode, vatNumber }),
91 signal: AbortSignal.timeout(6000),
92 },
93 );
94 if (!res.ok) return { state: 'unverifiable', reason: `vies_http_${res.status}` };
95 // VIES response fields (live):
96 // valid: true | false
97 // userError: string present when VIES itself errored (e.g. registry down)
98 // name / address: "---" when not disclosed; real string when valid
99 const data = (await res.json()) as {
100 valid?: boolean;
101 userError?: string;
102 name?: string | null;
103 address?: string | null;
104 };
105 if (data.userError && data.userError !== 'VALID') {
106 return { state: 'unverifiable', reason: data.userError };
107 }
108 if (data.valid === true) {
109 return {
110 state: 'valid',
111 countryCode,
112 vatNumber,
113 name: data.name && data.name !== '---' ? data.name : null,
114 address: data.address && data.address !== '---' ? data.address : null,
115 };
116 }
117 if (data.valid === false) {
118 return { state: 'invalid', reason: 'not_registered' };
119 }
120 return { state: 'unverifiable', reason: 'vies_ambiguous' };
121 } catch (err) {
122 return { state: 'unverifiable', reason: err instanceof Error ? err.name : 'vies_error' };
123 }
124}
125
126export async function upsertSubscriptionFromPolar(input: {
127 polarSubscriptionId: string;
128 polarCustomerId: string;
129 polarProductId: string;
130 orgId: string;
131 status: SubscriptionStatus;
132 currentPeriodEnd: Date | null;
133 canceledAt: Date | null;
134}): Promise<void> {
135 const tier = tierForProductId(input.polarProductId);
136 const db = getDb();
137 // Unique index on subscriptions.org_id (one sub per org). New subs for
138 // the same org replace the stale row — a fresh checkout after cancel
139 // cleanly overwrites the prior polar_subscription_id.
140 await db
141 .insert(subscriptions)
142 .values({
143 id: `sub_${input.polarSubscriptionId}`,
144 orgId: input.orgId,
145 polarSubscriptionId: input.polarSubscriptionId,
146 polarCustomerId: input.polarCustomerId,
147 tier,
148 status: input.status,
149 currentPeriodEnd: input.currentPeriodEnd,
150 canceledAt: input.canceledAt,
151 })
152 .onConflictDoUpdate({
153 target: subscriptions.orgId,
154 set: {
155 polarSubscriptionId: input.polarSubscriptionId,
156 polarCustomerId: input.polarCustomerId,
157 tier,
158 status: input.status,
159 currentPeriodEnd: input.currentPeriodEnd,
160 canceledAt: input.canceledAt,
161 updatedAt: new Date(),
162 },
163 });
164
165 // Propagate the org-level tier change down to every active project
166 // owned by that org. Without this, projects.tier stays 'free' even
167 // after a successful Polar subscription — the rate-limit middleware
168 // reads projects.tier directly. Suspended-or-past-due subscriptions
169 // collapse back to 'free' here.
170 const effectiveTier: ProjectTier =
171 input.status === 'canceled' || input.status === 'past_due' ? 'free' : tier;
172 const updated = await db
173 .update(projects)
174 .set({ tier: effectiveTier, updatedAt: new Date() })
175 .where(and(eq(projects.orgId, input.orgId), isNull(projects.deletedAt)))
176 .returning({ id: projects.id });
177 for (const row of updated) {
178 invalidateTierCache(row.id);
179 // The polar customer id may have flipped on this checkout — drop the
180 // cached lookup so the next meter push picks up the fresh id without
181 // waiting on the 5-min TTL.
182 invalidatePolarCustomerCache(row.id);
183 // Notify outbound subscribers. Errors swallowed — the Polar sync is
184 // load-bearing; webhook notification is opportunistic.
185 void publishEvent({
186 projectId: row.id,
187 eventType: 'tier.changed',
188 payload: {
189 projectId: row.id,
190 tier: effectiveTier,
191 polarStatus: input.status,
192 changedAt: new Date().toISOString(),
193 },
194 }).catch((err: unknown) => {
195 log.warn('outbound_publish_failed', {
196 event: 'tier.changed',
197 projectId: row.id,
198 message: err instanceof Error ? err.message : String(err),
199 });
200 });
201 }
202
203 log.info('subscription_synced', {
204 polarSubscriptionId: input.polarSubscriptionId,
205 orgId: input.orgId,
206 tier,
207 effectiveTier,
208 status: input.status,
209 projectsRetiered: updated.length,
210 });
211}
212
213/**
214 * Founder comp: orgs owned by one of these emails run on the Team tier for
215 * free — flndrn doesn't pay for flndrn's own apps. Keyed on the owner's
216 * email (not org/project id) so the comp survives org or project
217 * re-creation. Applied inside getTierForOrg so it flows to BOTH the
218 * /billing "current plan" display AND the tier stamped onto newly-created
219 * projects (services/projects.ts reads getTierForOrg at create time).
220 */
221const COMPED_OWNER_EMAILS = new Set(['flndrn@hotmail.com']);
222
223export async function getTierForOrg(orgId: string): Promise<ProjectTier> {
224 const db = getDb();
225 if (COMPED_OWNER_EMAILS.size > 0) {
226 const [owner] = await db
227 .select({ email: users.email })
228 .from(orgMembers)
229 .innerJoin(users, eq(users.id, orgMembers.userId))
230 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.role, 'owner')))
231 .limit(1);
232 if (owner && COMPED_OWNER_EMAILS.has(owner.email.toLowerCase())) {
233 return 'team';
234 }
235 }
236 const [row] = await db
237 .select({ tier: subscriptions.tier, status: subscriptions.status })
238 .from(subscriptions)
239 .where(eq(subscriptions.orgId, orgId))
240 .limit(1);
241 if (!row) return 'free';
242 if (row.status === 'canceled' || row.status === 'past_due') return 'free';
243 return row.tier;
244}
245
246export interface SubscriptionSummary {
247 tier: ProjectTier;
248 status: SubscriptionStatus | 'free';
249 currentPeriodEnd: string | null;
250 canceledAt: string | null;
251 polarCustomerId: string | null;
252}
253
254export async function getSubscriptionForOrg(orgId: string): Promise<SubscriptionSummary> {
255 const db = getDb();
256 // Effective tier folds in the founder comp (see getTierForOrg) so a comped
257 // org without a paid Polar subscription still surfaces its real tier on the
258 // /billing "current plan" panel, not just on the rate-limit path.
259 const effectiveTier = await getTierForOrg(orgId);
260 const [row] = await db
261 .select()
262 .from(subscriptions)
263 .where(eq(subscriptions.orgId, orgId))
264 .limit(1);
265 if (!row) {
266 return {
267 tier: effectiveTier,
268 status: effectiveTier === 'free' ? 'free' : 'active',
269 currentPeriodEnd: null,
270 canceledAt: null,
271 polarCustomerId: null,
272 };
273 }
274 return {
275 tier: row.status === 'canceled' || row.status === 'past_due' ? effectiveTier : row.tier,
276 status: row.status,
277 currentPeriodEnd: row.currentPeriodEnd?.toISOString() ?? null,
278 canceledAt: row.canceledAt?.toISOString() ?? null,
279 polarCustomerId: row.polarCustomerId,
280 };
281}
282
283/**
284 * Open a Polar customer portal session the user can use to manage cards,
285 * invoices, and cancellation on Polar's hosted UI. Throws PolarNotConfigured
286 * when the access token is missing, or NotFoundError if the user has no
287 * `polar_customer_id` yet (i.e. they've never checked out).
288 */
289export async function createCustomerPortalSession(
290 polarCustomerId: string,
291 returnURL: string,
292): Promise<{ url: string }> {
293 if (!env.BRIVEN_POLAR_ACCESS_TOKEN) throw new PolarNotConfigured();
294 const res = await fetch(`${env.BRIVEN_POLAR_API_BASE}/v1/customer-sessions/`, {
295 method: 'POST',
296 headers: {
297 authorization: `Bearer ${env.BRIVEN_POLAR_ACCESS_TOKEN}`,
298 'content-type': 'application/json',
299 },
300 body: JSON.stringify({
301 customer_id: polarCustomerId,
302 return_url: returnURL,
303 }),
304 });
305 if (!res.ok) {
306 const body = await res.text().catch(() => '');
307 throw new brivenError('polar_error', `polar portal failed: ${body}`, { status: 502 });
308 }
309 const parsed = (await res.json()) as { customer_portal_url?: string };
310 if (!parsed.customer_portal_url) {
311 throw new brivenError('polar_error', 'polar did not return a portal url', { status: 502 });
312 }
313 return { url: parsed.customer_portal_url };
314}
315
316export class PolarNotConfigured extends brivenError {
317 constructor() {
318 super('polar_not_configured', 'billing is not configured', { status: 503 });
319 this.name = 'PolarNotConfigured';
320 }
321}
322
323export class UnknownTier extends brivenError {
324 constructor(tier: string) {
325 super('unknown_tier', `tier '${tier}' has no configured polar product`, { status: 400 });
326 this.name = 'UnknownTier';
327 }
328}
329
330export async function createCheckout(input: {
331 orgId: string;
332 createdByUserId: string;
333 email: string;
334 tier: CheckoutableTier;
335 successURL: string;
336}): Promise<{ url: string }> {
337 if (!env.BRIVEN_POLAR_ACCESS_TOKEN) throw new PolarNotConfigured();
338 const plan = configuredPlans().find((p) => p.tier === input.tier);
339 if (!plan) throw new UnknownTier(input.tier);
340
341 const res = await fetch(`${env.BRIVEN_POLAR_API_BASE}/v1/checkouts/`, {
342 method: 'POST',
343 headers: {
344 authorization: `Bearer ${env.BRIVEN_POLAR_ACCESS_TOKEN}`,
345 'content-type': 'application/json',
346 },
347 body: JSON.stringify({
348 products: [plan.productId],
349 customer_email: input.email,
350 success_url: input.successURL,
351 // orgId = billing-owning entity; createdByUserId = audit trail of
352 // which user clicked the button inside that org.
353 metadata: {
354 orgId: input.orgId,
355 createdByUserId: input.createdByUserId,
356 tier: input.tier,
357 },
358 }),
359 });
360 if (!res.ok) {
361 const body = await res.text().catch(() => '');
362 throw new brivenError('polar_error', `polar checkout failed: ${body}`, { status: 502 });
363 }
364 return (await res.json()) as { url: string };
365}