diff --git a/ee/apps/den-api/src/env.ts b/ee/apps/den-api/src/env.ts index 3f74edbdd..dc011886b 100644 --- a/ee/apps/den-api/src/env.ts +++ b/ee/apps/den-api/src/env.ts @@ -97,8 +97,13 @@ const EnvSchema = z.object({ STRIPE_SECRET_KEY: z.string().optional(), STRIPE_WEBHOOK_SECRET: z.string().optional(), STRIPE_INFERENCE_PRICE_ID: z.string().optional(), + STRIPE_ORG_SEATS_PRICE_ID: z.string().optional(), STRIPE_BILLING_SUCCESS_URL: z.string().optional(), STRIPE_BILLING_CANCEL_URL: z.string().optional(), + OPENWORK_BILLING_PROVIDER: z.enum(["disabled", "simulated", "stripe"]).optional(), + FEATURE_BILLING_ORG_SEATS: z.string().optional(), + FEATURE_BILLING_INFERENCE: z.string().optional(), + FEATURE_BILLING_ENFORCEMENT: z.string().optional(), }).superRefine((value, ctx) => { const inferredMode = value.DB_MODE ?? (value.DATABASE_URL ? "mysql" : "planetscale") @@ -152,6 +157,12 @@ const betterAuthTrustedOrigins = splitCsv(parsed.DEN_BETTER_AUTH_TRUSTED_ORIGINS const polarFeatureGateEnabled = (parsed.POLAR_FEATURE_GATE_ENABLED ?? "false").toLowerCase() === "true" +function envFlag(value: string | undefined, defaultValue: boolean) { + const normalized = value?.trim().toLowerCase() + if (!normalized) return defaultValue + return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on" +} + const devMode = (parsed.OPENWORK_DEV_MODE ?? "0").trim() === "1" const port = Number(parsed.PORT ?? "8790") @@ -224,9 +235,14 @@ export const env = { openRouterManagementApiKey: optionalString(parsed.OPENROUTER_MANAGEMENT_API_KEY), openRouterWorkspaceId: optionalString(parsed.OPENROUTER_WORKSPACE_ID), stripe: { + billingProvider: parsed.OPENWORK_BILLING_PROVIDER ?? "stripe", + orgSeatsEnabled: envFlag(parsed.FEATURE_BILLING_ORG_SEATS, false), + inferenceEnabled: envFlag(parsed.FEATURE_BILLING_INFERENCE, true), + enforcementEnabled: envFlag(parsed.FEATURE_BILLING_ENFORCEMENT, false), secretKey: optionalString(parsed.STRIPE_SECRET_KEY), webhookSecret: optionalString(parsed.STRIPE_WEBHOOK_SECRET), inferencePriceId: optionalString(parsed.STRIPE_INFERENCE_PRICE_ID), + orgSeatsPriceId: optionalString(parsed.STRIPE_ORG_SEATS_PRICE_ID), billingSuccessUrl: optionalString(parsed.STRIPE_BILLING_SUCCESS_URL), billingCancelUrl: optionalString(parsed.STRIPE_BILLING_CANCEL_URL), }, diff --git a/ee/apps/den-api/src/orgs.ts b/ee/apps/den-api/src/orgs.ts index 1100d31b6..3de62f58d 100644 --- a/ee/apps/den-api/src/orgs.ts +++ b/ee/apps/den-api/src/orgs.ts @@ -12,6 +12,7 @@ import { import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid" import { db } from "./db.js" import { runPostOrganizationMemberChangeHooks } from "./organization-member-hooks.js" +import { getOrgSeatEntitlement } from "./stripe-billing.js" import { DEFAULT_ORGANIZATION_LIMITS, normalizeOrganizationMetadata, serializeOrganizationMetadata } from "./organization-limits.js" import { denDefaultDynamicOrganizationRoles, denOrganizationStaticRoles } from "./organization-access.js" import { ensureDefaultDesktopPolicyForOrganization } from "./desktop-policies.js" @@ -432,6 +433,13 @@ async function acceptInvitation(invitation: InvitationRow, userId: UserId) { const existingMember = existingMemberRows[0] ?? null let member = existingMember + if (!member && !invitedMember) { + const seatEntitlement = await getOrgSeatEntitlement(invitation.organizationId) + if (!seatEntitlement.allowed) { + throw new Error("org_seat_limit_reached") + } + } + if (!member && invitedMember) { await db .update(MemberTable) diff --git a/ee/apps/den-api/src/routes/org/billing.ts b/ee/apps/den-api/src/routes/org/billing.ts index 16b5dbf82..1abad7134 100644 --- a/ee/apps/den-api/src/routes/org/billing.ts +++ b/ee/apps/den-api/src/routes/org/billing.ts @@ -2,8 +2,8 @@ import type { Hono } from "hono" import { describeRoute } from "hono-openapi" import { z } from "zod" import { getCloudWorkerBillingStatus } from "../../billing/polar.js" -import { createInferenceCheckoutSession, createInferencePortalSession, getOrgBillingSummary } from "../../stripe-billing.js" -import { requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js" +import { createInferenceCheckoutSession, createInferencePortalSession, createOrgSeatsCheckoutSession, createOrgSeatsPortalSession, getActiveMemberCountForBilling, getOrgBillingSummary, upsertSimulatedSubscription } from "../../stripe-billing.js" +import { jsonValidator, requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js" import { forbiddenSchema, jsonResponse, unauthorizedSchema } from "../../openapi.js" import { getRequiredUserEmail } from "../../user.js" import { env } from "../../env.js" @@ -13,6 +13,12 @@ import { ensureOwner } from "./shared.js" const stripeBillingResponseSchema = z.object({}).passthrough().meta({ ref: "OrgStripeBillingResponse" }) const stripeCheckoutResponseSchema = z.object({ url: z.string() }).meta({ ref: "OrgStripeCheckoutResponse" }) const stripePortalResponseSchema = z.object({ url: z.string() }).meta({ ref: "OrgStripePortalResponse" }) +const orgSeatsCheckoutSchema = z.object({ quantity: z.number().int().min(1).max(500) }) +const simulatedBillingUpdateSchema = z.object({ + product: z.enum(["org_seats", "inference"]), + quantity: z.number().int().min(1).max(500).optional(), + status: z.enum(["active", "trialing", "past_due", "canceled", "unpaid"]).default("active"), +}) function getRequestOrigin(c: { req: { raw: Request } }) { const url = new URL(c.req.raw.url) @@ -111,6 +117,69 @@ export function registerOrgBillingRoutes { + const permission = ensureOwner(c) + if (!permission.ok) return c.json(permission.response, 403) + const user = c.get("user") + const email = getRequiredUserEmail(user) + if (!email) return c.json({ error: "user_email_required" }, 400) + const payload = c.get("organizationContext") + if (!env.stripe.inferenceEnabled) return c.json({ error: "billing_product_disabled" }, 404) + if (env.stripe.billingProvider === "simulated") { + await upsertSimulatedSubscription({ organizationId: payload.organization.id, orgMemberId: payload.currentMember.id, product: "inference", quantity: 1, status: "active" }) + return c.json({ url: billingReturnUrl(c) }) + } + const session = await createInferenceCheckoutSession({ + organizationId: payload.organization.id, + orgMemberId: payload.currentMember.id, + email, + name: user.name ?? email, + successUrl: checkoutSuccessUrl(c), + cancelUrl: checkoutCancelUrl(c), + }) + return c.json({ url: session.url }) + }, + ) + + app.post( + "/v1/billing/org-seats/checkout", + requireUserMiddleware, + resolveOrganizationContextMiddleware, + jsonValidator(orgSeatsCheckoutSchema), + async (c) => { + const permission = ensureOwner(c) + if (!permission.ok) return c.json(permission.response, 403) + const user = c.get("user") + const email = getRequiredUserEmail(user) + if (!email) return c.json({ error: "user_email_required" }, 400) + const payload = c.get("organizationContext") + if (!env.stripe.orgSeatsEnabled) return c.json({ error: "billing_product_disabled" }, 404) + const input = c.req.valid("json") + const activeMembers = await getActiveMemberCountForBilling(payload.organization.id) + if (input.quantity < activeMembers) { + return c.json({ error: "quantity_below_active_members", activeMembers, message: `Choose at least ${activeMembers} seats for the active members in this organization.` }, 400) + } + if (env.stripe.billingProvider === "simulated") { + await upsertSimulatedSubscription({ organizationId: payload.organization.id, orgMemberId: payload.currentMember.id, product: "org_seats", quantity: input.quantity, status: "active" }) + return c.json({ url: billingReturnUrl(c) }) + } + const session = await createOrgSeatsCheckoutSession({ + organizationId: payload.organization.id, + orgMemberId: payload.currentMember.id, + email, + name: user.name ?? email, + quantity: input.quantity, + successUrl: checkoutSuccessUrl(c), + cancelUrl: checkoutCancelUrl(c), + }) + return c.json({ url: session.url }) + }, + ) + app.post( "/v1/billing/stripe/portal", describeRoute({ @@ -138,4 +207,53 @@ export function registerOrgBillingRoutes { + const permission = ensureOwner(c) + if (!permission.ok) return c.json(permission.response, 403) + const payload = c.get("organizationContext") + if (env.stripe.billingProvider === "simulated") return c.json({ url: billingReturnUrl(c) }) + const session = await createInferencePortalSession({ organizationId: payload.organization.id, returnUrl: billingReturnUrl(c) }) + return c.json({ url: session.url }) + }, + ) + + app.post( + "/v1/billing/org-seats/portal", + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const permission = ensureOwner(c) + if (!permission.ok) return c.json(permission.response, 403) + const payload = c.get("organizationContext") + if (env.stripe.billingProvider === "simulated") return c.json({ url: billingReturnUrl(c) }) + const session = await createOrgSeatsPortalSession({ organizationId: payload.organization.id, returnUrl: billingReturnUrl(c) }) + return c.json({ url: session.url }) + }, + ) + + app.post( + "/v1/billing/simulated/subscription", + requireUserMiddleware, + resolveOrganizationContextMiddleware, + jsonValidator(simulatedBillingUpdateSchema), + async (c) => { + if (env.stripe.billingProvider !== "simulated") return c.json({ error: "billing_simulator_disabled" }, 404) + const permission = ensureOwner(c) + if (!permission.ok) return c.json(permission.response, 403) + const payload = c.get("organizationContext") + const input = c.req.valid("json") + const activeMembers = await getActiveMemberCountForBilling(payload.organization.id) + const quantity = input.product === "org_seats" ? input.quantity ?? activeMembers : 1 + if (input.product === "org_seats" && quantity < activeMembers) { + return c.json({ error: "quantity_below_active_members", activeMembers }, 400) + } + await upsertSimulatedSubscription({ organizationId: payload.organization.id, orgMemberId: payload.currentMember.id, product: input.product, quantity, status: input.status }) + return c.json(await getOrgBillingSummary({ organizationId: payload.organization.id, includePortalUrl: true, returnUrl: billingReturnUrl(c) })) + }, + ) } diff --git a/ee/apps/den-api/src/routes/org/core.ts b/ee/apps/den-api/src/routes/org/core.ts index 075b96d7a..71fb9f0c5 100644 --- a/ee/apps/den-api/src/routes/org/core.ts +++ b/ee/apps/den-api/src/routes/org/core.ts @@ -252,6 +252,12 @@ export function registerOrgCoreRoutes(["active", "trialing"]) const EXPIRED_STATUSES = new Set(["past_due", "canceled", "unpaid", "incomplete_expired", "expired"]) @@ -41,6 +43,21 @@ function requireInferencePriceId() { return env.stripe.inferencePriceId } +function requireOrgSeatsPriceId() { + if (!env.stripe.orgSeatsPriceId) { + throw new Error("stripe_org_seats_price_id_missing") + } + return env.stripe.orgSeatsPriceId +} + +function productPriceId(product: BillingProduct) { + return product === ORG_SEATS_SUBSCRIPTION_TYPE ? requireOrgSeatsPriceId() : requireInferencePriceId() +} + +function productMetadataName(product: BillingProduct) { + return product === ORG_SEATS_SUBSCRIPTION_TYPE ? "openwork_team_seats" : "openwork_models" +} + function fromUnixSeconds(value: number | null | undefined) { return typeof value === "number" ? new Date(value * 1000) : null } @@ -72,9 +89,13 @@ function firstSubscriptionItem(subscription: Stripe.Subscription) { function getSubscriptionMetadata(subscription: Stripe.Subscription) { const orgId = subscription.metadata.org_id?.trim() ?? "" const orgMemberId = subscription.metadata.created_by_org_member_id?.trim() ?? "" + const subscriptionType = subscription.metadata.subscription_type?.trim() === ORG_SEATS_SUBSCRIPTION_TYPE + ? ORG_SEATS_SUBSCRIPTION_TYPE + : INFERENCE_SUBSCRIPTION_TYPE return { organizationId: orgId || null, orgMemberId: orgMemberId || null, + subscriptionType, } } @@ -90,18 +111,26 @@ export async function getActiveMemberCountForBilling(organizationId: OrgId) { return activeMemberCount(organizationId) } -async function findInferenceSubscriptionByOrg(organizationId: OrgId) { +async function findSubscriptionByOrg(organizationId: OrgId, product: BillingProduct) { return db .select() .from(OrgSubscriptionTable) .where(and( eq(OrgSubscriptionTable.organization_id, organizationId), - eq(OrgSubscriptionTable.type, INFERENCE_SUBSCRIPTION_TYPE), + eq(OrgSubscriptionTable.type, product), )) .limit(1) .then((rows) => rows[0] ?? null) } +async function findInferenceSubscriptionByOrg(organizationId: OrgId) { + return findSubscriptionByOrg(organizationId, INFERENCE_SUBSCRIPTION_TYPE) +} + +async function findOrgSeatsSubscriptionByOrg(organizationId: OrgId) { + return findSubscriptionByOrg(organizationId, ORG_SEATS_SUBSCRIPTION_TYPE) +} + async function findInferenceSubscriptionByStripeId(stripeSubscriptionId: string) { return db .select() @@ -157,7 +186,7 @@ export async function upsertInferenceSubscriptionFromStripe(subscription: Stripe id: createDenTypeId("orgSubscription"), organization_id: metadata.organizationId as OrgId, created_by_org_membership_id: metadata.orgMemberId as MemberId | null, - type: INFERENCE_SUBSCRIPTION_TYPE, + type: metadata.subscriptionType, status, stripe_customer_id: customerIdFromSubscription(subscription), stripe_subscription_id: subscription.id, @@ -179,6 +208,7 @@ export async function upsertInferenceSubscriptionFromStripe(subscription: Stripe created_by_org_membership_id: values.created_by_org_membership_id, status: values.status, stripe_customer_id: values.stripe_customer_id, + stripe_subscription_id: values.stripe_subscription_id, stripe_price_id: values.stripe_price_id, stripe_subscription_item_id: values.stripe_subscription_item_id, quantity: values.quantity, @@ -192,7 +222,7 @@ export async function upsertInferenceSubscriptionFromStripe(subscription: Stripe }, }) - if (EXPIRED_STATUSES.has(status)) { + if (metadata.subscriptionType === INFERENCE_SUBSCRIPTION_TYPE && EXPIRED_STATUSES.has(status)) { await setInferenceEnabled({ organizationId: metadata.organizationId as OrgId, enabled: false }) } @@ -242,16 +272,18 @@ export async function findOrCreateStripeCustomer(input: { return customer.id } -export async function createInferenceCheckoutSession(input: { +async function createCheckoutSession(input: { + product: BillingProduct organizationId: OrgId orgMemberId: MemberId email: string name: string + quantity?: number successUrl: string cancelUrl: string }) { - const priceId = requireInferencePriceId() - const quantity = Math.max(1, await activeMemberCount(input.organizationId)) + const priceId = productPriceId(input.product) + const quantity = Math.max(1, input.quantity ?? await activeMemberCount(input.organizationId)) const customer = await findOrCreateStripeCustomer({ organizationId: input.organizationId, email: input.email, @@ -259,7 +291,7 @@ export async function createInferenceCheckoutSession(input: { metadata: { org_id: input.organizationId, created_by_org_member_id: input.orgMemberId, - openwork_product: "openwork_models", + openwork_product: productMetadataName(input.product), }, }) return stripe().checkout.sessions.create({ @@ -273,21 +305,52 @@ export async function createInferenceCheckoutSession(input: { metadata: { org_id: input.organizationId, created_by_org_member_id: input.orgMemberId, - openwork_product: "openwork_models", + openwork_product: productMetadataName(input.product), }, subscription_data: { metadata: { org_id: input.organizationId, created_by_org_member_id: input.orgMemberId, - openwork_product: "openwork_models", - subscription_type: INFERENCE_SUBSCRIPTION_TYPE, + openwork_product: productMetadataName(input.product), + subscription_type: input.product, }, }, }) } +export async function createInferenceCheckoutSession(input: { + organizationId: OrgId + orgMemberId: MemberId + email: string + name: string + successUrl: string + cancelUrl: string +}) { + return createCheckoutSession({ ...input, product: INFERENCE_SUBSCRIPTION_TYPE }) +} + +export async function createOrgSeatsCheckoutSession(input: { + organizationId: OrgId + orgMemberId: MemberId + email: string + name: string + quantity: number + successUrl: string + cancelUrl: string +}) { + return createCheckoutSession({ ...input, product: ORG_SEATS_SUBSCRIPTION_TYPE }) +} + export async function createInferencePortalSession(input: { organizationId: OrgId; returnUrl: string }) { - const row = await findInferenceSubscriptionByOrg(input.organizationId) + return createPortalSession({ organizationId: input.organizationId, returnUrl: input.returnUrl, product: INFERENCE_SUBSCRIPTION_TYPE }) +} + +export async function createOrgSeatsPortalSession(input: { organizationId: OrgId; returnUrl: string }) { + return createPortalSession({ organizationId: input.organizationId, returnUrl: input.returnUrl, product: ORG_SEATS_SUBSCRIPTION_TYPE }) +} + +async function createPortalSession(input: { organizationId: OrgId; returnUrl: string; product: BillingProduct }) { + const row = await findSubscriptionByOrg(input.organizationId, input.product) if (!row?.stripe_customer_id) { throw new Error("stripe_customer_missing") } @@ -297,41 +360,135 @@ export async function createInferencePortalSession(input: { organizationId: OrgI }) } -export async function getOrgBillingSummary(input: { organizationId: OrgId; includePortalUrl?: boolean; returnUrl: string }) { - const row = await findInferenceSubscriptionByOrg(input.organizationId) - const memberCount = await activeMemberCount(input.organizationId) +function productEnabled(product: BillingProduct) { + if (env.stripe.billingProvider === "disabled") return false + return product === ORG_SEATS_SUBSCRIPTION_TYPE ? env.stripe.orgSeatsEnabled : env.stripe.inferenceEnabled +} + +function productConfigured(product: BillingProduct) { + if (!productEnabled(product)) return false + if (env.stripe.billingProvider === "disabled") return false + if (env.stripe.billingProvider === "simulated") return true + return Boolean(env.stripe.secretKey && (product === ORG_SEATS_SUBSCRIPTION_TYPE ? env.stripe.orgSeatsPriceId : env.stripe.inferencePriceId)) +} + +function productUnitAmount(product: BillingProduct) { + return product === ORG_SEATS_SUBSCRIPTION_TYPE ? 2000 : 1000 +} + +function serializeSubscription(row: Awaited>) { + return row ? { + id: row.id, + status: row.status, + stripeCustomerId: row.stripe_customer_id, + stripeSubscriptionId: row.stripe_subscription_id, + quantity: row.quantity, + currentPeriodStart: row.current_period_start?.toISOString() ?? null, + currentPeriodEnd: row.current_period_end?.toISOString() ?? null, + cancelAtPeriodEnd: row.cancel_at_period_end, + } : null +} + +async function getBillingProductSummary(input: { organizationId: OrgId; product: BillingProduct; includePortalUrl?: boolean; returnUrl: string }) { + const row = await findSubscriptionByOrg(input.organizationId, input.product) + const usedSeats = await activeMemberCount(input.organizationId) const hasActiveSubscription = Boolean(row && ACTIVE_STATUSES.has(row.status)) let portalUrl: string | null = null - if (input.includePortalUrl && row?.stripe_customer_id) { + if (input.includePortalUrl && row?.stripe_customer_id && env.stripe.billingProvider === "stripe") { try { - portalUrl = (await createInferencePortalSession({ organizationId: input.organizationId, returnUrl: input.returnUrl })).url + portalUrl = (await createPortalSession({ organizationId: input.organizationId, returnUrl: input.returnUrl, product: input.product })).url } catch (error) { - console.warn("[stripe-billing] failed to create billing portal session", error) + console.warn(`[stripe-billing] failed to create ${input.product} billing portal session`, error) } } return { - stripe: { - configured: Boolean(env.stripe.secretKey && env.stripe.inferencePriceId), - priceId: env.stripe.inferencePriceId ?? null, - unitAmount: 1000, - currency: "usd", - interval: "month", - memberCount, - hasActiveSubscription, - portalUrl, - subscription: row ? { - id: row.id, - status: row.status, - stripeCustomerId: row.stripe_customer_id, - stripeSubscriptionId: row.stripe_subscription_id, - quantity: row.quantity, - currentPeriodStart: row.current_period_start?.toISOString() ?? null, - currentPeriodEnd: row.current_period_end?.toISOString() ?? null, - cancelAtPeriodEnd: row.cancel_at_period_end, - } : null, - }, + enabled: productEnabled(input.product), + configured: productConfigured(input.product), + provider: env.stripe.billingProvider, + priceId: input.product === ORG_SEATS_SUBSCRIPTION_TYPE ? env.stripe.orgSeatsPriceId ?? null : env.stripe.inferencePriceId ?? null, + unitAmount: productUnitAmount(input.product), + currency: "usd", + interval: "month", + usedSeats, + purchasedSeats: input.product === ORG_SEATS_SUBSCRIPTION_TYPE && row ? row.quantity : null, + memberCount: usedSeats, + hasActiveSubscription, + portalUrl, + enforceSeats: input.product === ORG_SEATS_SUBSCRIPTION_TYPE && env.stripe.enforcementEnabled && productConfigured(input.product), + subscription: serializeSubscription(row), + } +} + +export async function getOrgBillingSummary(input: { organizationId: OrgId; includePortalUrl?: boolean; returnUrl: string }) { + const inference = await getBillingProductSummary({ ...input, product: INFERENCE_SUBSCRIPTION_TYPE }) + const orgSeats = await getBillingProductSummary({ ...input, product: ORG_SEATS_SUBSCRIPTION_TYPE }) + + return { + provider: env.stripe.billingProvider, + products: { inference, orgSeats }, + stripe: inference, + } +} + +export async function getOrgSeatEntitlement(organizationId: OrgId) { + const orgSeats = await getBillingProductSummary({ organizationId, product: ORG_SEATS_SUBSCRIPTION_TYPE, returnUrl: "" }) + if (!orgSeats.enforceSeats) { + return { allowed: true, reason: "billing_not_enforced" as const, ...orgSeats } + } + if (!orgSeats.hasActiveSubscription) { + return { allowed: false, reason: "subscription_inactive" as const, ...orgSeats } + } + if (orgSeats.purchasedSeats !== null && orgSeats.usedSeats >= orgSeats.purchasedSeats) { + return { allowed: false, reason: "seat_limit_reached" as const, ...orgSeats } } + return { allowed: true, reason: "has_available_seat" as const, ...orgSeats } +} + +export async function upsertSimulatedSubscription(input: { organizationId: OrgId; orgMemberId: MemberId | null; product: BillingProduct; quantity: number; status: OrgSubscriptionStatusValue }) { + if (env.stripe.billingProvider !== "simulated") { + throw new Error("billing_simulator_disabled") + } + const now = new Date() + const values = { + id: createDenTypeId("orgSubscription"), + organization_id: input.organizationId, + created_by_org_membership_id: input.orgMemberId, + type: input.product, + status: input.status, + stripe_customer_id: `sim_cus_${input.organizationId}`, + stripe_subscription_id: `sim_sub_${input.organizationId}_${input.product}`, + stripe_price_id: input.product === ORG_SEATS_SUBSCRIPTION_TYPE ? "sim_org_seats" : "sim_inference", + stripe_subscription_item_id: `sim_item_${input.organizationId}_${input.product}`, + quantity: Math.max(1, input.quantity), + current_period_start: now, + current_period_end: new Date(now.getTime() + 1000 * 60 * 60 * 24 * 30), + cancel_at_period_end: false, + canceled_at: input.status === "canceled" ? now : null, + ended_at: null, + last_event_id: `sim_evt_${Date.now()}`, + created_at: now, + updated_at: now, + } + await db.insert(OrgSubscriptionTable).values(values).onDuplicateKeyUpdate({ + set: { + created_by_org_membership_id: values.created_by_org_membership_id, + status: values.status, + stripe_customer_id: values.stripe_customer_id, + stripe_subscription_id: values.stripe_subscription_id, + stripe_price_id: values.stripe_price_id, + stripe_subscription_item_id: values.stripe_subscription_item_id, + quantity: values.quantity, + current_period_start: values.current_period_start, + current_period_end: values.current_period_end, + cancel_at_period_end: values.cancel_at_period_end, + canceled_at: values.canceled_at, + ended_at: values.ended_at, + last_event_id: values.last_event_id, + updated_at: now, + }, + }) + return findSubscriptionByOrg(input.organizationId, input.product) } export async function syncInferenceSubscriptionQuantityAfterMemberChange(input: { organizationId: OrgId; memberCount: number }) { @@ -363,7 +520,7 @@ export async function handleStripeWebhook(input: { payload: string; signature: s const subscription = await stripe().subscriptions.retrieve(session.subscription) await upsertInferenceSubscriptionFromStripe(subscription, event.id) const metadata = getSubscriptionMetadata(subscription) - if (metadata.organizationId && ACTIVE_STATUSES.has(subscriptionStatus(subscription.status))) { + if (metadata.subscriptionType === INFERENCE_SUBSCRIPTION_TYPE && metadata.organizationId && ACTIVE_STATUSES.has(subscriptionStatus(subscription.status))) { await setInferenceEnabled({ organizationId: metadata.organizationId as OrgId, enabled: true }) } } @@ -387,7 +544,9 @@ export async function handleStripeWebhook(input: { payload: string; signature: s .update(OrgSubscriptionTable) .set({ status: "expired", last_event_id: event.id, updated_at: new Date() }) .where(eq(OrgSubscriptionTable.id, row.id)) - await setInferenceEnabled({ organizationId: row.organization_id, enabled: false }) + if (row.type === INFERENCE_SUBSCRIPTION_TYPE) { + await setInferenceEnabled({ organizationId: row.organization_id, enabled: false }) + } } } break diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/billing-dashboard-screen.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/billing-dashboard-screen.tsx index 90e7c21df..70c498078 100644 --- a/ee/apps/den-web/app/(den)/dashboard/_components/billing-dashboard-screen.tsx +++ b/ee/apps/den-web/app/(den)/dashboard/_components/billing-dashboard-screen.tsx @@ -8,15 +8,20 @@ import { DashboardPageTemplate } from "../../_components/ui/dashboard-page-templ import { useDenFlow } from "../../_providers/den-flow-provider"; import { useOrgDashboard } from "../_providers/org-dashboard-provider"; -type StripeBilling = { +type BillingProduct = { + enabled: boolean; configured: boolean; + provider: "disabled" | "simulated" | "stripe"; priceId: string | null; unitAmount: number; currency: string; interval: string; + usedSeats: number; + purchasedSeats: number | null; memberCount: number; hasActiveSubscription: boolean; portalUrl: string | null; + enforceSeats: boolean; subscription: { status: string; quantity: number; @@ -25,41 +30,61 @@ type StripeBilling = { } | null; }; +type BillingSummary = { + provider: "disabled" | "simulated" | "stripe"; + products: { + inference: BillingProduct; + orgSeats: BillingProduct; + }; +}; + type PolarBilling = { hasActivePlan: boolean; portalUrl: string | null; - subscription: { - status: string; - } | null; + subscription: { status: string } | null; }; -function parseStripeBilling(payload: unknown): StripeBilling | null { - if (!payload || typeof payload !== "object" || !("billing" in payload)) return null; - const billing = (payload as { billing?: unknown }).billing; - if (!billing || typeof billing !== "object" || !("stripe" in billing)) return null; - const stripe = (billing as { stripe?: unknown }).stripe; - if (!stripe || typeof stripe !== "object") return null; - const value = stripe as Partial; +function parseProduct(value: unknown): BillingProduct | null { + if (!value || typeof value !== "object") return null; + const product = value as Partial; return { - configured: value.configured === true, - priceId: typeof value.priceId === "string" ? value.priceId : null, - unitAmount: typeof value.unitAmount === "number" ? value.unitAmount : 1000, - currency: typeof value.currency === "string" ? value.currency : "usd", - interval: typeof value.interval === "string" ? value.interval : "month", - memberCount: typeof value.memberCount === "number" ? value.memberCount : 0, - hasActiveSubscription: value.hasActiveSubscription === true, - portalUrl: typeof value.portalUrl === "string" ? value.portalUrl : null, - subscription: value.subscription && typeof value.subscription === "object" + enabled: product.enabled === true, + configured: product.configured === true, + provider: product.provider === "disabled" || product.provider === "simulated" || product.provider === "stripe" ? product.provider : "stripe", + priceId: typeof product.priceId === "string" ? product.priceId : null, + unitAmount: typeof product.unitAmount === "number" ? product.unitAmount : 1000, + currency: typeof product.currency === "string" ? product.currency : "usd", + interval: typeof product.interval === "string" ? product.interval : "month", + usedSeats: typeof product.usedSeats === "number" ? product.usedSeats : typeof product.memberCount === "number" ? product.memberCount : 0, + purchasedSeats: typeof product.purchasedSeats === "number" ? product.purchasedSeats : null, + memberCount: typeof product.memberCount === "number" ? product.memberCount : 0, + hasActiveSubscription: product.hasActiveSubscription === true, + portalUrl: typeof product.portalUrl === "string" ? product.portalUrl : null, + enforceSeats: product.enforceSeats === true, + subscription: product.subscription && typeof product.subscription === "object" ? { - status: typeof value.subscription.status === "string" ? value.subscription.status : "unknown", - quantity: typeof value.subscription.quantity === "number" ? value.subscription.quantity : 0, - currentPeriodEnd: typeof value.subscription.currentPeriodEnd === "string" ? value.subscription.currentPeriodEnd : null, - cancelAtPeriodEnd: value.subscription.cancelAtPeriodEnd === true, + status: typeof product.subscription.status === "string" ? product.subscription.status : "unknown", + quantity: typeof product.subscription.quantity === "number" ? product.subscription.quantity : 0, + currentPeriodEnd: typeof product.subscription.currentPeriodEnd === "string" ? product.subscription.currentPeriodEnd : null, + cancelAtPeriodEnd: product.subscription.cancelAtPeriodEnd === true, } : null, }; } +function parseBillingSummary(payload: unknown): BillingSummary | null { + if (!payload || typeof payload !== "object" || !("billing" in payload)) return null; + const billing = (payload as { billing?: unknown }).billing; + if (!billing || typeof billing !== "object") return null; + const value = billing as { provider?: unknown; products?: unknown; stripe?: unknown }; + const products = value.products && typeof value.products === "object" ? value.products as { inference?: unknown; orgSeats?: unknown } : null; + const inference = parseProduct(products?.inference ?? value.stripe); + if (!inference) return null; + const orgSeats = parseProduct(products?.orgSeats) ?? { ...inference, enabled: false, configured: false, hasActiveSubscription: false, purchasedSeats: null, subscription: null }; + const provider = value.provider === "disabled" || value.provider === "simulated" || value.provider === "stripe" ? value.provider : inference.provider; + return { provider, products: { inference, orgSeats } }; +} + function parsePolarBilling(payload: unknown): PolarBilling | null { if (!payload || typeof payload !== "object" || !("billing" in payload)) return null; const billing = (payload as { billing?: unknown }).billing; @@ -70,170 +95,185 @@ function parsePolarBilling(payload: unknown): PolarBilling | null { return { hasActivePlan: value.hasActivePlan === true, portalUrl: typeof value.portalUrl === "string" ? value.portalUrl : null, - subscription: value.subscription && typeof value.subscription === "object" - ? { - status: typeof value.subscription.status === "string" ? value.subscription.status : "active", - } - : null, + subscription: value.subscription && typeof value.subscription === "object" ? { status: typeof value.subscription.status === "string" ? value.subscription.status : "active" } : null, }; } +function productStatus(product: BillingProduct | null) { + return product?.subscription ? formatSubscriptionStatus(product.subscription.status) : "Not subscribed"; +} + export function BillingDashboardScreen() { const { sessionHydrated, user } = useDenFlow(); const { orgContext } = useOrgDashboard(); - const [stripeBilling, setStripeBilling] = useState(null); + const [billing, setBilling] = useState(null); const [polarBilling, setPolarBilling] = useState(null); - const [stripeBusy, setStripeBusy] = useState(false); - const [stripeActionBusy, setStripeActionBusy] = useState<"checkout" | "portal" | null>(null); - const [stripeError, setStripeError] = useState(null); + const [busy, setBusy] = useState(false); + const [actionBusy, setActionBusy] = useState(null); + const [error, setError] = useState(null); + const [seatQuantity, setSeatQuantity] = useState(10); const isOwner = orgContext?.currentMember.isOwner === true; + const orgSeats = billing?.products.orgSeats ?? null; + const inference = billing?.products.inference ?? null; + const simulated = billing?.provider === "simulated"; - async function refreshStripeBilling(quiet = false) { - setStripeBusy(true); - if (!quiet) setStripeError(null); + async function refreshBilling(quiet = false) { + setBusy(true); + if (!quiet) setError(null); try { const { response, payload } = await requestJson("/v1/billing", { method: "GET" }, 12000); - if (!response.ok) throw new Error(getErrorMessage(payload, `Stripe billing lookup failed (${response.status}).`)); - const parsed = parseStripeBilling(payload); - if (!parsed) throw new Error("Stripe billing response was incomplete."); - setStripeBilling(parsed); + if (!response.ok) throw new Error(getErrorMessage(payload, `Billing lookup failed (${response.status}).`)); + const parsed = parseBillingSummary(payload); + if (!parsed) throw new Error("Billing response was incomplete."); + setBilling(parsed); setPolarBilling(parsePolarBilling(payload)); + const nextQuantity = parsed.products.orgSeats.purchasedSeats ?? Math.max(10, parsed.products.orgSeats.usedSeats || orgContext?.members.length || 1); + setSeatQuantity(nextQuantity); return parsed; - } catch (error) { - if (!quiet) setStripeError(error instanceof Error ? error.message : "Could not load Stripe billing."); + } catch (nextError) { + if (!quiet) setError(nextError instanceof Error ? nextError.message : "Could not load billing."); return null; } finally { - setStripeBusy(false); + setBusy(false); } } useEffect(() => { if (!sessionHydrated || !user) return; - void refreshStripeBilling(true); + void refreshBilling(true); }, [sessionHydrated, user, orgContext?.organization.id]); - async function startStripeCheckout() { - setStripeActionBusy("checkout"); - setStripeError(null); + async function openUrlFromResponse(path: string, init: RequestInit, busyKey: string) { + setActionBusy(busyKey); + setError(null); try { - const { response, payload } = await requestJson("/v1/billing/stripe/checkout", { method: "POST" }, 12000); - if (!response.ok) throw new Error(getErrorMessage(payload, `Checkout failed (${response.status}).`)); + const { response, payload } = await requestJson(path, init, 12000); + if (!response.ok) throw new Error(getErrorMessage(payload, `Billing action failed (${response.status}).`)); const url = payload && typeof payload === "object" && "url" in payload && typeof payload.url === "string" ? payload.url : null; - if (!url) throw new Error("Checkout response did not include a URL."); - window.location.href = url; - } catch (error) { - setStripeError(error instanceof Error ? error.message : "Could not start Stripe checkout."); + if (!url) throw new Error("Billing response did not include a URL."); + if (simulated) { + await refreshBilling(true); + } else { + window.location.href = url; + } + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Billing action failed."); } finally { - setStripeActionBusy(null); + setActionBusy(null); } } - async function openStripePortal() { - setStripeActionBusy("portal"); - setStripeError(null); + async function updateSimulated(product: "org_seats" | "inference", status: "active" | "past_due" | "canceled", quantity?: number) { + setActionBusy(`${product}:${status}`); + setError(null); try { - const { response, payload } = await requestJson("/v1/billing/stripe/portal", { method: "POST" }, 12000); - if (!response.ok) throw new Error(getErrorMessage(payload, `Billing portal failed (${response.status}).`)); - const url = payload && typeof payload === "object" && "url" in payload && typeof payload.url === "string" ? payload.url : null; - if (!url) throw new Error("Billing portal response did not include a URL."); - window.location.href = url; - } catch (error) { - setStripeError(error instanceof Error ? error.message : "Could not open Stripe billing portal."); + const { response, payload } = await requestJson("/v1/billing/simulated/subscription", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ product, status, quantity }), + }, 12000); + if (!response.ok) throw new Error(getErrorMessage(payload, `Simulated billing update failed (${response.status}).`)); + const parsed = parseBillingSummary({ billing: payload }); + if (parsed) setBilling(parsed); + await refreshBilling(true); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Simulated billing update failed."); } finally { - setStripeActionBusy(null); + setActionBusy(null); } } const showPolar = polarBilling?.hasActivePlan === true && Boolean(polarBilling.portalUrl); - const stripePrice = formatMoneyMinor(stripeBilling?.unitAmount ?? 1000, stripeBilling?.currency ?? "usd"); + const seatsPrice = formatMoneyMinor(orgSeats?.unitAmount ?? 2000, orgSeats?.currency ?? "usd"); + const inferencePrice = formatMoneyMinor(inference?.unitAmount ?? 1000, inference?.currency ?? "usd"); + const minSeats = Math.max(1, orgSeats?.usedSeats ?? orgContext?.members.length ?? 1); return ( - {stripeError ? ( -
- {stripeError} -
- ) : null} - - {isOwner ? null : ( -
- Only workspace owners can start checkout or open billing portals. Other members can view the current billing state. -
- )} + {error ?
{error}
: null} + {!isOwner ?
Only workspace owners can start checkout or open billing portals. Other members can view the current billing state.
: null} + {simulated ?
Simulated billing is enabled. These controls exercise the full billing UI without Stripe.
: null} {showPolar ? (
-
+

Polar

Cloud worker plan

-

- Your existing Polar subscription is {formatSubscriptionStatus(polarBilling?.subscription?.status ?? "active").toLowerCase()}. -

+

Your existing Polar subscription is {formatSubscriptionStatus(polarBilling?.subscription?.status ?? "active").toLowerCase()}.

- {polarBilling?.portalUrl ? ( - - Open Polar portal - - ) : null} + {polarBilling?.portalUrl ? Open Polar portal : null}
) : null} -
-
-
-

Stripe

-

OpenWork Models

-

- Model access is billed at $10/user/month -

-
- void refreshStripeBilling(false)}> - Refresh - -
- -
-
-

Price

-

{stripePrice}/user/month

-
-
-

Active members

-

{stripeBilling?.memberCount ?? orgContext?.members.length ?? 0}

-
-
-

Status

-

- {stripeBilling?.hasActiveSubscription ? formatSubscriptionStatus(stripeBilling.subscription?.status ?? "active") : "Not subscribed"} -

-
-
+
+ void refreshBilling(false)}>Refresh +
- {stripeBilling?.hasActiveSubscription ? ( -
- - Manage subscription - -
- ) : ( -
-
-

Subscribe to enable OpenWork Models

+
+ {orgSeats?.enabled ? ( +
+

Team Seats

+

Hosted org access

+

Invite teammates into your hosted Den org. Model usage is billed separately.

+
+ + +
- - Subscribe with Stripe - -
- )} -
+ +
+ {orgSeats.hasActiveSubscription ? simulated ? void updateSimulated("org_seats", "active", seatQuantity) : void openUrlFromResponse("/v1/billing/org-seats/portal", { method: "POST" }, "org-seats:portal")}>{simulated ? "Update simulated seats" : "Manage seats"} : null} + {!orgSeats.hasActiveSubscription ? void openUrlFromResponse("/v1/billing/org-seats/checkout", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ quantity: seatQuantity }) }, "org-seats:checkout")}>Buy Team Seats : null} + {simulated && orgSeats.hasActiveSubscription ? void updateSimulated("org_seats", "canceled", seatQuantity)}>Cancel simulated seats : null} + {simulated && orgSeats.hasActiveSubscription ? void updateSimulated("org_seats", "past_due", seatQuantity)}>Simulate failed payment : null} +
+ + ) : null} + + {inference?.enabled ? ( +
+

OpenWork Models

+

Hosted inference

+

Use OpenWork-hosted model access without bringing your own provider key.

+
+ + + +
+
+ {inference.hasActiveSubscription ? simulated ? void updateSimulated("inference", "active", 1) : void openUrlFromResponse("/v1/billing/inference/portal", { method: "POST" }, "inference:portal")}>{simulated ? "Refresh simulated models" : "Manage models"} : null} + {!inference.hasActiveSubscription ? void openUrlFromResponse("/v1/billing/inference/checkout", { method: "POST" }, "inference:checkout")}>Enable OpenWork Models : null} + {simulated && inference.hasActiveSubscription ? void updateSimulated("inference", "canceled", 1)}>Cancel simulated models : null} +
+
+ ) : null} + + + {!orgSeats?.enabled && !inference?.enabled ? ( +
+ Billing is disabled for this deployment. Team seats are unlimited and Stripe is not required. +
+ ) : null}
); } + +function Metric(props: { label: string; value: string }) { + return ( +
+

{props.label}

+

{props.value}

+
+ ); +} diff --git a/ee/apps/den-web/next.config.js b/ee/apps/den-web/next.config.js index 5ea6ece01..2bc7b752d 100644 --- a/ee/apps/den-web/next.config.js +++ b/ee/apps/den-web/next.config.js @@ -8,4 +8,13 @@ const nextConfig = { outputFileTracingRoot: path.join(__dirname, "../../.."), }; +const allowedDevOrigins = (process.env.DEN_WEB_ALLOWED_DEV_ORIGINS || "") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); + +if (allowedDevOrigins.length > 0) { + nextConfig.allowedDevOrigins = allowedDevOrigins; +} + module.exports = nextConfig; diff --git a/ee/packages/den-db/src/schema/subscriptions.ts b/ee/packages/den-db/src/schema/subscriptions.ts index 0928427b6..0c022e864 100644 --- a/ee/packages/den-db/src/schema/subscriptions.ts +++ b/ee/packages/den-db/src/schema/subscriptions.ts @@ -3,7 +3,7 @@ import { boolean, index, int, mysqlEnum, mysqlTable, timestamp, uniqueIndex, var import { denTypeIdColumn, timestamps } from "../columns" import { MemberTable, OrganizationTable } from "./org" -export const OrgSubscriptionType = ["inference"] as const +export const OrgSubscriptionType = ["inference", "org_seats"] as const export const OrgSubscriptionStatus = [ "incomplete", "incomplete_expired",