billing.ts365 lines · main
| 1 | import { brivenError } from '@briven/shared'; |
| 2 | import { and, eq, isNull } from 'drizzle-orm'; |
| 3 | |
| 4 | import { getDb } from '../db/client.js'; |
| 5 | import { orgMembers, projects, subscriptions, users, type SubscriptionStatus } from '../db/schema.js'; |
| 6 | import { env } from '../env.js'; |
| 7 | import { log } from '../lib/logger.js'; |
| 8 | import type { ProjectTier } from '../db/schema.js'; |
| 9 | import { invalidatePolarCustomerCache } from '../workers/polar-meter-push.js'; |
| 10 | import { publishEvent } from './outbound-webhooks.js'; |
| 11 | import { 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 | |
| 28 | type CheckoutableTier = 'pro' | 'team'; |
| 29 | |
| 30 | export 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 | */ |
| 40 | export 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 | |
| 51 | function 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 | |
| 57 | export 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 | */ |
| 78 | export 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 | |
| 126 | export 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 | */ |
| 221 | const COMPED_OWNER_EMAILS = new Set(['flndrn@hotmail.com']); |
| 222 | |
| 223 | export 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 | |
| 246 | export interface SubscriptionSummary { |
| 247 | tier: ProjectTier; |
| 248 | status: SubscriptionStatus | 'free'; |
| 249 | currentPeriodEnd: string | null; |
| 250 | canceledAt: string | null; |
| 251 | polarCustomerId: string | null; |
| 252 | } |
| 253 | |
| 254 | export 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 | */ |
| 289 | export 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 | |
| 316 | export class PolarNotConfigured extends brivenError { |
| 317 | constructor() { |
| 318 | super('polar_not_configured', 'billing is not configured', { status: 503 }); |
| 319 | this.name = 'PolarNotConfigured'; |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | export 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 | |
| 330 | export 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 | } |