Skip to content

Commit 89bc788

Browse files
feat(den): separate team billing from worker capacity (#1286)
* feat(den): separate team billing from worker capacity * fix(den-web): keep checkout page open for billing * fix(den): bill cloud access at the org level * fix(den): keep billing scoped to the active org Remove legacy user-level Polar fallbacks, sync the session org from the routed dashboard context, and distinguish worker add-on checkout from the base plan.
1 parent d459235 commit 89bc788

File tree

17 files changed

+421
-265
lines changed

17 files changed

+421
-265
lines changed

ee/apps/den-api/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ POLAR_FEATURE_GATE_ENABLED=false
1818
POLAR_ACCESS_TOKEN=
1919
POLAR_PRODUCT_ID=
2020
POLAR_BENEFIT_ID=
21+
POLAR_WORKER_PRODUCT_ID=
22+
POLAR_WORKER_BENEFIT_ID=
2123
POLAR_SUCCESS_URL=
2224
POLAR_RETURN_URL=
2325
DAYTONA_API_KEY=

ee/apps/den-api/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ It carries the full migrated Den API route surface in a foldered Hono structure
1212
pnpm --filter @openwork-ee/den-api dev:local
1313
```
1414

15+
## Billing model
16+
17+
- `POLAR_PRODUCT_ID` / `POLAR_BENEFIT_ID`: base OpenWork Cloud team plan
18+
- `POLAR_WORKER_PRODUCT_ID` / `POLAR_WORKER_BENEFIT_ID`: per-worker add-on product
19+
- The base plan unlocks the shared cloud workspace.
20+
- Workers are counted separately and billed as additional recurring subscriptions.
21+
1522
## Current routes
1623

1724
- `GET /` -> `302 https://openworklabs.com`

ee/apps/den-api/src/billing/polar.ts

Lines changed: 102 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,6 @@ type PolarCustomerSession = {
1515
customer_portal_url?: string
1616
}
1717

18-
type PolarCustomer = {
19-
id?: string
20-
email?: string
21-
external_id?: string | null
22-
}
23-
2418
type PolarListResource<T> = {
2519
items?: T[]
2620
}
@@ -119,6 +113,10 @@ export type CloudWorkerBillingStatus = {
119113
hasActivePlan: boolean
120114
checkoutRequired: boolean
121115
checkoutUrl: string | null
116+
activeWorkerSubscriptions: number
117+
workerCheckoutUrl: string | null
118+
workerCheckoutRequired: boolean
119+
workerPrice: CloudWorkerBillingPrice | null
122120
portalUrl: string | null
123121
price: CloudWorkerBillingPrice | null
124122
subscription: CloudWorkerBillingSubscription | null
@@ -139,6 +137,7 @@ type CloudAccessInput = {
139137
userId: string
140138
email: string
141139
name: string
140+
orgId?: string | null
142141
}
143142

144143
type BillingStatusOptions = {
@@ -163,6 +162,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
163162
return typeof value === "object" && value !== null
164163
}
165164

165+
function getExternalCustomerId(input: CloudAccessInput) {
166+
return input.orgId?.trim() || input.userId
167+
}
168+
166169
async function polarFetch(path: string, init: RequestInit = {}) {
167170
const headers = new Headers(init.headers)
168171
headers.set("Authorization", `Bearer ${env.polar.accessToken}`)
@@ -216,75 +219,21 @@ async function getCustomerStateByExternalId(externalCustomerId: string): Promise
216219
return payload
217220
}
218221

219-
async function getCustomerStateById(customerId: string): Promise<PolarCustomerState | null> {
220-
const encodedCustomerId = encodeURIComponent(customerId)
221-
const { response, payload, text } = await polarFetchJson<PolarCustomerState>(`/v1/customers/${encodedCustomerId}/state`, {
222-
method: "GET",
223-
})
224-
225-
if (response.status === 404) {
226-
return null
227-
}
228-
229-
if (!response.ok) {
230-
throw new Error(`Polar customer state lookup by ID failed (${response.status}): ${text.slice(0, 400)}`)
231-
}
232-
233-
return payload
234-
}
235-
236-
async function getCustomerByEmail(email: string): Promise<PolarCustomer | null> {
237-
const normalizedEmail = email.trim().toLowerCase()
238-
if (!normalizedEmail) {
239-
return null
240-
}
241-
242-
const encodedEmail = encodeURIComponent(normalizedEmail)
243-
const { response, payload, text } = await polarFetchJson<PolarListResource<PolarCustomer>>(`/v1/customers/?email=${encodedEmail}`, {
244-
method: "GET",
245-
})
246-
247-
if (!response.ok) {
248-
throw new Error(`Polar customer lookup by email failed (${response.status}): ${text.slice(0, 400)}`)
249-
}
250-
251-
const customers = payload?.items ?? []
252-
const exact = customers.find((customer) => customer.email?.trim().toLowerCase() === normalizedEmail)
253-
return exact ?? customers[0] ?? null
254-
}
255-
256-
async function linkCustomerExternalId(customer: PolarCustomer, externalCustomerId: string): Promise<void> {
257-
if (!customer.id) {
258-
return
259-
}
260-
261-
if (typeof customer.external_id === "string" && customer.external_id.length > 0) {
262-
return
263-
}
264-
265-
const encodedCustomerId = encodeURIComponent(customer.id)
266-
await polarFetch(`/v1/customers/${encodedCustomerId}`, {
267-
method: "PATCH",
268-
body: JSON.stringify({
269-
external_id: externalCustomerId,
270-
}),
271-
})
272-
}
273-
274-
function hasRequiredBenefit(state: PolarCustomerState | null) {
275-
if (!state?.granted_benefits || !env.polar.benefitId) {
222+
function hasBenefit(state: PolarCustomerState | null, benefitId: string | undefined) {
223+
if (!state?.granted_benefits || !benefitId) {
276224
return false
277225
}
278226

279-
return state.granted_benefits.some((grant) => grant.benefit_id === env.polar.benefitId)
227+
return state.granted_benefits.some((grant) => grant.benefit_id === benefitId)
280228
}
281229

282-
async function createCheckoutSession(input: CloudAccessInput): Promise<string> {
230+
async function createCheckoutSessionForProduct(input: CloudAccessInput, productId: string): Promise<string> {
231+
const externalCustomerId = getExternalCustomerId(input)
283232
const payload = {
284-
products: [env.polar.productId],
233+
products: [productId],
285234
success_url: env.polar.successUrl,
286235
return_url: env.polar.returnUrl,
287-
external_customer_id: input.userId,
236+
external_customer_id: externalCustomerId,
288237
customer_email: input.email,
289238
customer_name: input.name,
290239
}
@@ -325,33 +274,44 @@ async function evaluateCloudWorkerAccess(
325274

326275
assertPaywallConfig()
327276

328-
const externalState = await getCustomerStateByExternalId(input.userId)
329-
if (hasRequiredBenefit(externalState)) {
277+
const externalCustomerId = getExternalCustomerId(input)
278+
const externalState = await getCustomerStateByExternalId(externalCustomerId)
279+
if (hasBenefit(externalState, env.polar.benefitId)) {
330280
return {
331281
featureGateEnabled: true,
332282
hasActivePlan: true,
333283
checkoutUrl: null,
334284
}
335285
}
336286

337-
const customer = await getCustomerByEmail(input.email)
338-
if (customer?.id) {
339-
const emailState = await getCustomerStateById(customer.id)
340-
if (hasRequiredBenefit(emailState)) {
341-
await linkCustomerExternalId(customer, input.userId).catch(() => undefined)
342-
return {
343-
featureGateEnabled: true,
344-
hasActivePlan: true,
345-
checkoutUrl: null,
346-
}
347-
}
348-
}
349-
287+
const productId = env.polar.productId
350288
return {
351289
featureGateEnabled: true,
352290
hasActivePlan: false,
353-
checkoutUrl: options.includeCheckoutUrl ? await createCheckoutSession(input) : null,
291+
checkoutUrl: options.includeCheckoutUrl && productId ? await createCheckoutSessionForProduct(input, productId) : null,
292+
}
293+
}
294+
295+
async function getActiveWorkerSubscriptionCount(input: CloudAccessInput): Promise<number> {
296+
if (!env.polar.workerProductId) {
297+
return 0
298+
}
299+
300+
const subscriptions = await listSubscriptionsByExternalCustomer(getExternalCustomerId(input), {
301+
activeOnly: true,
302+
limit: 100,
303+
productId: env.polar.workerProductId,
304+
})
305+
306+
return subscriptions.filter((subscription) => isActiveSubscriptionStatus(subscription.status)).length
307+
}
308+
309+
async function createWorkerCheckoutSession(input: CloudAccessInput): Promise<string | null> {
310+
if (!env.polar.workerProductId) {
311+
return null
354312
}
313+
314+
return createCheckoutSessionForProduct(input, env.polar.workerProductId)
355315
}
356316

357317
function normalizeRecurringInterval(value: string | null | undefined): string | null {
@@ -419,12 +379,12 @@ async function getSubscriptionById(subscriptionId: string): Promise<PolarSubscri
419379

420380
async function listSubscriptionsByExternalCustomer(
421381
externalCustomerId: string,
422-
options: { activeOnly?: boolean; limit?: number } = {},
382+
options: { activeOnly?: boolean; limit?: number; productId?: string | null } = {},
423383
): Promise<PolarSubscription[]> {
424384
const params = new URLSearchParams()
425385
params.set("external_customer_id", externalCustomerId)
426-
if (env.polar.productId) {
427-
params.set("product_id", env.polar.productId)
386+
if (options.productId) {
387+
params.set("product_id", options.productId)
428388
}
429389
params.set("limit", String(options.limit ?? 1))
430390
params.set("sorting", "-started_at")
@@ -458,21 +418,26 @@ async function listSubscriptionsByExternalCustomer(
458418
}
459419

460420
async function getPrimarySubscriptionForCustomer(externalCustomerId: string): Promise<PolarSubscription | null> {
461-
const active = await listSubscriptionsByExternalCustomer(externalCustomerId, { activeOnly: true, limit: 1 })
421+
const active = await listSubscriptionsByExternalCustomer(externalCustomerId, {
422+
activeOnly: true,
423+
limit: 1,
424+
productId: env.polar.productId,
425+
})
462426
if (active[0]) {
463427
return active[0]
464428
}
465429

466-
const recent = await listSubscriptionsByExternalCustomer(externalCustomerId, { activeOnly: false, limit: 1 })
430+
const recent = await listSubscriptionsByExternalCustomer(externalCustomerId, {
431+
activeOnly: false,
432+
limit: 1,
433+
productId: env.polar.productId,
434+
})
467435
return recent[0] ?? null
468436
}
469437

470438
async function listRecentOrdersByExternalCustomer(externalCustomerId: string, limit = 6): Promise<PolarOrder[]> {
471439
const params = new URLSearchParams()
472440
params.set("external_customer_id", externalCustomerId)
473-
if (env.polar.productId) {
474-
params.set("product_id", env.polar.productId)
475-
}
476441
params.set("limit", String(limit))
477442
params.set("sorting", "-created_at")
478443

@@ -649,6 +614,27 @@ export async function requireCloudWorkerAccess(input: CloudAccessInput): Promise
649614
}
650615
}
651616

617+
export async function requireAdditionalCloudWorkerAccess(input: CloudAccessInput & { ownedWorkerCount: number }): Promise<CloudWorkerAccess> {
618+
if (!env.polar.featureGateEnabled || !env.polar.workerProductId) {
619+
return { allowed: true }
620+
}
621+
622+
const activeWorkerSubscriptions = await getActiveWorkerSubscriptionCount(input)
623+
if (input.ownedWorkerCount < activeWorkerSubscriptions) {
624+
return { allowed: true }
625+
}
626+
627+
const checkoutUrl = await createWorkerCheckoutSession(input)
628+
if (!checkoutUrl) {
629+
throw new Error("Polar worker checkout URL unavailable")
630+
}
631+
632+
return {
633+
allowed: false,
634+
checkoutUrl,
635+
}
636+
}
637+
652638
export async function getCloudWorkerBillingStatus(
653639
input: CloudAccessInput,
654640
options: BillingStatusOptions = {},
@@ -665,6 +651,10 @@ export async function getCloudWorkerBillingStatus(
665651
hasActivePlan: true,
666652
checkoutRequired: false,
667653
checkoutUrl: null,
654+
activeWorkerSubscriptions: 0,
655+
workerCheckoutUrl: null,
656+
workerCheckoutRequired: false,
657+
workerPrice: null,
668658
portalUrl: null,
669659
price: null,
670660
subscription: null,
@@ -676,11 +666,19 @@ export async function getCloudWorkerBillingStatus(
676666
await sendSubscribedToDenEvent(input)
677667
}
678668

669+
const [activeWorkerSubscriptions, workerCheckoutUrl, workerPrice] = await Promise.all([
670+
getActiveWorkerSubscriptionCount(input).catch(() => 0),
671+
evaluation.hasActivePlan && options.includeCheckoutUrl
672+
? createWorkerCheckoutSession(input).catch(() => null)
673+
: Promise.resolve<string | null>(null),
674+
env.polar.workerProductId ? getProductBillingPrice(env.polar.workerProductId).catch(() => null) : Promise.resolve<CloudWorkerBillingPrice | null>(null),
675+
])
676+
679677
const [subscriptionResult, priceResult, portalResult, invoicesResult] = await Promise.all([
680-
getPrimarySubscriptionForCustomer(input.userId).catch(() => null),
678+
getPrimarySubscriptionForCustomer(getExternalCustomerId(input)).catch(() => null),
681679
env.polar.productId ? getProductBillingPrice(env.polar.productId).catch(() => null) : Promise.resolve<CloudWorkerBillingPrice | null>(null),
682-
includePortalUrl ? createCustomerPortalUrl(input.userId).catch(() => null) : Promise.resolve<string | null>(null),
683-
includeInvoices ? listBillingInvoices(input.userId).catch(() => []) : Promise.resolve<CloudWorkerBillingInvoice[]>([]),
680+
includePortalUrl ? createCustomerPortalUrl(getExternalCustomerId(input)).catch(() => null) : Promise.resolve<string | null>(null),
681+
includeInvoices ? listBillingInvoices(getExternalCustomerId(input)).catch(() => []) : Promise.resolve<CloudWorkerBillingInvoice[]>([]),
684682
])
685683

686684
const subscription = toBillingSubscription(subscriptionResult)
@@ -693,6 +691,10 @@ export async function getCloudWorkerBillingStatus(
693691
hasActivePlan: evaluation.hasActivePlan,
694692
checkoutRequired: evaluation.featureGateEnabled && !evaluation.hasActivePlan,
695693
checkoutUrl: evaluation.checkoutUrl,
694+
activeWorkerSubscriptions,
695+
workerCheckoutUrl,
696+
workerCheckoutRequired: evaluation.hasActivePlan && activeWorkerSubscriptions <= 0,
697+
workerPrice,
696698
portalUrl,
697699
price: productPrice ?? toBillingPriceFromSubscription(subscription),
698700
subscription,
@@ -732,24 +734,15 @@ export async function getCloudWorkerAdminBillingStatus(
732734
let paidByBenefit = false
733735

734736
if (env.polar.benefitId) {
735-
const externalState = await getCustomerStateByExternalId(input.userId)
736-
if (hasRequiredBenefit(externalState)) {
737+
const externalCustomerId = getExternalCustomerId(input)
738+
const externalState = await getCustomerStateByExternalId(externalCustomerId)
739+
if (hasBenefit(externalState, env.polar.benefitId)) {
737740
paidByBenefit = true
738741
note = "Benefit granted via external customer id."
739-
} else {
740-
const customer = await getCustomerByEmail(input.email)
741-
if (customer?.id) {
742-
const emailState = await getCustomerStateById(customer.id)
743-
if (hasRequiredBenefit(emailState)) {
744-
paidByBenefit = true
745-
note = "Benefit granted via matching customer email."
746-
await linkCustomerExternalId(customer, input.userId).catch(() => undefined)
747-
}
748-
}
749742
}
750743
}
751744

752-
const subscription = env.polar.productId ? await getPrimarySubscriptionForCustomer(input.userId) : null
745+
const subscription = env.polar.productId ? await getPrimarySubscriptionForCustomer(getExternalCustomerId(input)) : null
753746
const normalizedSubscription = toBillingSubscription(subscription)
754747
const paidBySubscription = isActiveSubscriptionStatus(normalizedSubscription?.status)
755748

@@ -789,9 +782,10 @@ export async function setCloudWorkerSubscriptionCancellation(
789782

790783
assertPaywallConfig()
791784

792-
const activeSubscriptions = await listSubscriptionsByExternalCustomer(input.userId, {
785+
const activeSubscriptions = await listSubscriptionsByExternalCustomer(getExternalCustomerId(input), {
793786
activeOnly: true,
794787
limit: 1,
788+
productId: env.polar.productId,
795789
})
796790
const active = activeSubscriptions[0]
797791
if (!active?.id) {

ee/apps/den-api/src/env.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ const EnvSchema = z.object({
5050
POLAR_ACCESS_TOKEN: z.string().optional(),
5151
POLAR_PRODUCT_ID: z.string().optional(),
5252
POLAR_BENEFIT_ID: z.string().optional(),
53+
POLAR_WORKER_PRODUCT_ID: z.string().optional(),
54+
POLAR_WORKER_BENEFIT_ID: z.string().optional(),
5355
POLAR_SUCCESS_URL: z.string().optional(),
5456
POLAR_RETURN_URL: z.string().optional(),
5557
DAYTONA_API_URL: z.string().optional(),
@@ -208,6 +210,8 @@ export const env = {
208210
accessToken: parsed.POLAR_ACCESS_TOKEN,
209211
productId: parsed.POLAR_PRODUCT_ID,
210212
benefitId: parsed.POLAR_BENEFIT_ID,
213+
workerProductId: parsed.POLAR_WORKER_PRODUCT_ID,
214+
workerBenefitId: parsed.POLAR_WORKER_BENEFIT_ID,
211215
successUrl: parsed.POLAR_SUCCESS_URL,
212216
returnUrl: parsed.POLAR_RETURN_URL,
213217
},

0 commit comments

Comments
 (0)