From a4245454b24513f206a5e49b551b51cdb3e28ac3 Mon Sep 17 00:00:00 2001 From: vklimontovich Date: Thu, 21 May 2026 16:07:50 -0400 Subject: [PATCH] feat(billing): bill workspaces under a parent workspace A workspace can now bill under a parent. The child inherits the parent's plan and its billing page links to the parent; the parent's billing page sums events and syncs across the whole group. The parent-child relation and consolidation logic live in ee-api (newjitsuee schema). Links are managed from a new admin UI page. Only one level of nesting is allowed and free workspaces cannot be joined. --- .../components/Billing/BillingManager.tsx | 35 +-- .../components/Billing/use-events-usage.ts | 74 +++++- webapps/console/lib/schema/index.ts | 4 + .../[workspaceId]/settings/billing/index.tsx | 20 ++ webapps/ee-api/components/AdminLayout.tsx | 1 + webapps/ee-api/lib/billing-hierarchy.ts | 115 +++++++++ webapps/ee-api/lib/stripe.ts | 36 +++ .../ee-api/pages/api/admin/billing-parents.ts | 49 ++++ webapps/ee-api/pages/api/billing/settings.ts | 59 +++++ webapps/ee-api/pages/billing-parents.tsx | 218 ++++++++++++++++++ webapps/ee-api/prisma/schema.prisma | 13 ++ 11 files changed, 597 insertions(+), 27 deletions(-) create mode 100644 webapps/ee-api/lib/billing-hierarchy.ts create mode 100644 webapps/ee-api/pages/api/admin/billing-parents.ts create mode 100644 webapps/ee-api/pages/billing-parents.tsx diff --git a/webapps/console/components/Billing/BillingManager.tsx b/webapps/console/components/Billing/BillingManager.tsx index 96a2b5ff7..a4f776bc8 100644 --- a/webapps/console/components/Billing/BillingManager.tsx +++ b/webapps/console/components/Billing/BillingManager.tsx @@ -10,7 +10,7 @@ import { Check, ChevronRight, Edit2, ExternalLink, Info, XCircle } from "lucide- import styles from "./BillingManager.module.css"; import { useQuery } from "@tanstack/react-query"; import { ErrorCard } from "../GlobalError/GlobalError"; -import { useEventsUsage } from "./use-events-usage"; +import { useConsolidatedUsage } from "./use-events-usage"; import { upgradeRequired } from "./copy"; import { JitsuButton } from "../JitsuButton/JitsuButton"; import dayjs from "dayjs"; @@ -64,7 +64,7 @@ const EventsUsageSection: React.FC<{}> = () => { assertTrue(billing.enabled); assertFalse(billing.loading, "Billing must be loaded before using UsageSection component"); - const { isLoading, error, usage, throttle } = useEventsUsage(); + const { isLoading, error, usage, throttle } = useConsolidatedUsage(); if (isLoading) { return ; @@ -219,29 +219,8 @@ const ConnectorUsageSection: React.FC<{}> = () => { const billing = useBilling(); assertTrue(billing.enabled, "Billing is not enabled"); assertFalse(billing.loading, "Billing must be loaded before using CurrentSubscription component"); - const workspace = useWorkspace(); - const user = useUser(); - let periodStart: Date; - let periodEnd: Date; - if (billing.settings?.currentPeriod) { - periodEnd = new Date(billing.settings?.currentPeriod.end); - periodStart = new Date(billing.settings?.currentPeriod.start); - } else { - periodStart = dayjs().utc().startOf("month").toDate(); - periodEnd = dayjs().utc().endOf("month").add(-1, "millisecond").toDate(); - } - const { isLoading, error, data } = useQuery( - ["connector usage", workspace.id], - async () => { - const report = await rpc( - `/api/${workspace.id}/reports/sync-stat?start=${periodStart.toISOString()}&end=${dayjs(periodEnd) - .subtract(1, "millisecond") - .toISOString()}` - ); - return report; - }, - { retry: false, cacheTime: 0, staleTime: 0 } - ); + + const { isLoading, error, usage } = useConsolidatedUsage(); if (isLoading) { return ; @@ -249,7 +228,11 @@ const ConnectorUsageSection: React.FC<{}> = () => { return ; } - const activeSyncs = data.activeSyncs; + assertDefined(usage, "Data should be defined"); + + const periodStart = usage.periodStart; + const periodEnd = usage.periodEnd; + const activeSyncs = usage.syncs; const maxActiveSyncs = billing.settings.dailyActiveSyncs || 1; const percentage = activeSyncs / maxActiveSyncs; diff --git a/webapps/console/components/Billing/use-events-usage.ts b/webapps/console/components/Billing/use-events-usage.ts index 9ee23b08e..53502a8a5 100644 --- a/webapps/console/components/Billing/use-events-usage.ts +++ b/webapps/console/components/Billing/use-events-usage.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { assertFalse, assertTrue, rpc } from "juava"; -import { useWorkspace } from "../../lib/context"; +import { useUser, useWorkspace } from "../../lib/context"; import { useBilling } from "./BillingProvider"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; @@ -91,3 +91,75 @@ export function useEventsUsage(opts?: { skipSubscribed?: boolean; cacheSeconds?: : undefined, }; } + +export type ConsolidatedUsage = Usage & { + /** Active syncs across the billing group over the period. */ + syncs: number; + /** Workspaces summed — 1 for a standalone workspace, >1 for a billing parent. */ + memberCount: number; +}; + +export type UseConsolidatedUsageRes = { + isLoading: boolean; + error?: any; + throttle?: number; + usage?: ConsolidatedUsage; +}; + +/** + * Usage for the billing page. Reads consolidated events + syncs for the whole + * billing group from `/ee/billing/settings?withUsage=true` — for a billing + * parent that sums the parent and all its children; for a standalone workspace + * it is just that workspace. + */ +export function useConsolidatedUsage(): UseConsolidatedUsageRes { + const workspace = useWorkspace(); + const user = useUser(); + const billing = useBilling(); + assertTrue(billing.enabled, "Billing is not enabled"); + assertFalse(billing.loading, "Billing must be loaded before using usage hook"); + + const throttle = workspace.featuresEnabled + ?.filter(f => f.startsWith("throttle")) + ?.map(parseThrottle) + ?.find(t => t); + + const { isLoading, error, data } = useQuery( + ["consolidated usage", workspace.id, billing.settings.planId], + async () => { + const resp = await rpc( + `/api/${workspace.id}/ee/billing/settings?withUsage=true&email=${encodeURIComponent(user.email)}` + ); + return resp.usage as { + events: number; + syncs: number; + periodStart: string; + periodEnd: string; + memberCount: number; + }; + }, + { retry: false, cacheTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000 } + ); + + let usage: ConsolidatedUsage | undefined; + if (data) { + const periodStart = new Date(data.periodStart); + const periodEnd = new Date(data.periodEnd); + const periodDuration = dayjs(new Date()).diff(dayjs(periodStart), "day"); + const projection = + periodDuration > 0 + ? (data.events / periodDuration) * dayjs(periodEnd).diff(dayjs(periodStart), "day") + : undefined; + usage = { + periodStart, + periodEnd, + events: data.events, + syncs: data.syncs, + memberCount: data.memberCount, + projectionByTheEndOfPeriod: projection, + maxAllowedDestinatonEvents: billing.settings.destinationEvensPerMonth, + usagePercentage: data.events / billing.settings.destinationEvensPerMonth, + }; + } + return { isLoading, error, throttle, usage }; +} diff --git a/webapps/console/lib/schema/index.ts b/webapps/console/lib/schema/index.ts index c3af7b63c..0b55f737f 100644 --- a/webapps/console/lib/schema/index.ts +++ b/webapps/console/lib/schema/index.ts @@ -62,6 +62,10 @@ export const BillingSettings = z.object({ futureSubscriptionDate: z.string().optional(), profileBuilderEnabled: z.boolean().default(false).optional(), isLegacyPlan: z.boolean().default(false).optional(), + //set when this workspace bills under a parent workspace. The plan above is the + //parent's plan; the /settings/billing page links to the parent instead of + //showing its own billing UI. + billingParent: z.object({ id: z.string(), name: z.string(), slug: z.string().nullish() }).optional(), }); export type BillingSettings = z.infer; diff --git a/webapps/console/pages/[workspaceId]/settings/billing/index.tsx b/webapps/console/pages/[workspaceId]/settings/billing/index.tsx index 496c2e76d..2a0d726a3 100644 --- a/webapps/console/pages/[workspaceId]/settings/billing/index.tsx +++ b/webapps/console/pages/[workspaceId]/settings/billing/index.tsx @@ -3,6 +3,7 @@ import { useBilling } from "../../../../components/Billing/BillingProvider"; import { Alert, Skeleton } from "antd"; import { ChevronLeft } from "lucide-react"; import React from "react"; +import Link from "next/link"; import { BillingManager } from "../../../../components/Billing/BillingManager"; import { WJitsuButton } from "../../../../components/JitsuButton/JitsuButton"; @@ -55,6 +56,25 @@ const BillingComponent: React.FC<{}> = () => { /> ); + } else if (billing.settings.billingParent) { + const parent = billing.settings.billingParent; + return ( +
+ + This workspace bills under {parent.name}. Its plan, invoices and usage are managed there.{" "} + + Open {parent.name}'s billing page + + + } + /> +
+ ); } return ; diff --git a/webapps/ee-api/components/AdminLayout.tsx b/webapps/ee-api/components/AdminLayout.tsx index e408e8b4f..24e56f366 100644 --- a/webapps/ee-api/components/AdminLayout.tsx +++ b/webapps/ee-api/components/AdminLayout.tsx @@ -7,6 +7,7 @@ import { useAuth } from "./AuthProvider"; const navItems: { href: string; label: string }[] = [ { href: "/", label: "Billing" }, + { href: "/billing-parents", label: "Billing Parents" }, { href: "/admin-workspaces", label: "Admin Workspaces" }, { href: "/email", label: "Email" }, ]; diff --git a/webapps/ee-api/lib/billing-hierarchy.ts b/webapps/ee-api/lib/billing-hierarchy.ts new file mode 100644 index 000000000..3a16b2548 --- /dev/null +++ b/webapps/ee-api/lib/billing-hierarchy.ts @@ -0,0 +1,115 @@ +import { pg, prisma } from "./services"; +import { getServerLog } from "./log"; + +const log = getServerLog("billing-hierarchy"); + +export type WorkspaceRef = { id: string; name: string; slug: string | null }; + +export type BillingGroup = { + /** standalone — bills on its own; parent — has children; child — bills under a parent. */ + role: "standalone" | "parent" | "child"; + /** Set when role === "child". */ + parent?: WorkspaceRef; + /** + * Workspaces whose usage is consolidated when this workspace's billing page is + * viewed: just itself for standalone/child, itself + all children for a parent. + */ + memberWorkspaceIds: string[]; +}; + +export type BillingParentLink = { child: WorkspaceRef; parent: WorkspaceRef; createdAt: string }; + +async function getWorkspaceRef(workspaceId: string): Promise { + const row = ( + await pg.query(`select id, name, slug from newjitsu."Workspace" where id = $1 and deleted = false`, [workspaceId]) + ).rows[0]; + return row ? { id: row.id, name: row.name, slug: row.slug } : undefined; +} + +/** The billing parent of a workspace, or undefined if it bills on its own. */ +export async function getBillingParent(workspaceId: string): Promise { + const link = await prisma.workspaceBillingParent.findUnique({ where: { workspaceId } }); + return link ? getWorkspaceRef(link.parentWorkspaceId) : undefined; +} + +/** Ids of all workspaces that bill under the given workspace. */ +export async function getBillingChildren(workspaceId: string): Promise { + const links = await prisma.workspaceBillingParent.findMany({ + where: { parentWorkspaceId: workspaceId }, + select: { workspaceId: true }, + }); + return links.map(l => l.workspaceId); +} + +/** Resolve how a workspace bills and which workspaces its usage consolidates. */ +export async function getBillingGroup(workspaceId: string): Promise { + const parent = await getBillingParent(workspaceId); + if (parent) { + return { role: "child", parent, memberWorkspaceIds: [workspaceId] }; + } + const children = await getBillingChildren(workspaceId); + if (children.length > 0) { + return { role: "parent", memberWorkspaceIds: [workspaceId, ...children] }; + } + return { role: "standalone", memberWorkspaceIds: [workspaceId] }; +} + +/** + * Link `workspaceId` to bill under `parentWorkspaceId`. + * + * Enforces the structural rules: no self-link, both workspaces must exist, and + * only one level of nesting (a parent cannot itself be a child, and a workspace + * that already has children cannot become a child). The free-plan rule is + * checked by the caller — see `isWorkspaceOnFreePlan` in lib/stripe.ts. + */ +export async function setBillingParent(workspaceId: string, parentWorkspaceId: string): Promise { + if (workspaceId === parentWorkspaceId) { + throw new Error("A workspace cannot be its own billing parent"); + } + if (!(await getWorkspaceRef(workspaceId))) { + throw new Error(`Workspace ${workspaceId} not found`); + } + if (!(await getWorkspaceRef(parentWorkspaceId))) { + throw new Error(`Parent workspace ${parentWorkspaceId} not found`); + } + const parentsParent = await prisma.workspaceBillingParent.findUnique({ where: { workspaceId: parentWorkspaceId } }); + if (parentsParent) { + throw new Error( + `Workspace ${parentWorkspaceId} already bills under ${parentsParent.parentWorkspaceId} — only one level of nesting is allowed` + ); + } + const childChildren = await getBillingChildren(workspaceId); + if (childChildren.length > 0) { + throw new Error( + `Workspace ${workspaceId} is itself a billing parent of ${childChildren.length} workspace(s) — only one level of nesting is allowed` + ); + } + await prisma.workspaceBillingParent.upsert({ + where: { workspaceId }, + create: { workspaceId, parentWorkspaceId }, + update: { parentWorkspaceId }, + }); + log.atInfo().log(`Workspace ${workspaceId} now bills under ${parentWorkspaceId}`); +} + +/** Remove a workspace's billing parent, so it bills on its own again. */ +export async function removeBillingParent(workspaceId: string): Promise { + await prisma.workspaceBillingParent.deleteMany({ where: { workspaceId } }); + log.atInfo().log(`Workspace ${workspaceId} no longer bills under a parent`); +} + +/** All billing-parent links, newest first, resolved to workspace names/slugs. */ +export async function listBillingParentLinks(): Promise { + const links = await prisma.workspaceBillingParent.findMany({ orderBy: { createdAt: "desc" } }); + if (links.length === 0) { + return []; + } + const ids = [...new Set(links.flatMap(l => [l.workspaceId, l.parentWorkspaceId]))]; + const rows = (await pg.query(`select id, name, slug from newjitsu."Workspace" where id = any($1)`, [ids])).rows; + const byId = new Map(rows.map(r => [r.id, { id: r.id, name: r.name, slug: r.slug }])); + return links.flatMap(l => { + const child = byId.get(l.workspaceId); + const parent = byId.get(l.parentWorkspaceId); + return child && parent ? [{ child, parent, createdAt: l.createdAt.toISOString() }] : []; + }); +} diff --git a/webapps/ee-api/lib/stripe.ts b/webapps/ee-api/lib/stripe.ts index 0a84a2b58..f41e31074 100644 --- a/webapps/ee-api/lib/stripe.ts +++ b/webapps/ee-api/lib/stripe.ts @@ -1,5 +1,6 @@ import Stripe from "stripe"; import { store } from "./services"; +import { getBillingParent } from "./billing-hierarchy"; import { assertDefined, assertTrue, getLog, requireDefined } from "juava"; import { omit } from "lodash"; import assert from "assert"; @@ -95,6 +96,25 @@ export async function getOrCreateCurrentSubscription( isLegacyPlan?: boolean; subscriptionStatus: SubscriptionStatus; }> { + // If the workspace bills under a parent, return the parent's subscription so + // the child inherits the parent's plan. Only one level of nesting is allowed + // (see lib/billing-hierarchy.ts), so the recursion resolves in one step. + const billingParent = await getBillingParent(workspaceId); + if (billingParent) { + const parent = await getOrCreateCurrentSubscription(billingParent.id, () => { + throw new Error( + `Billing parent ${billingParent.id} has no Stripe customer while resolving child workspace ${workspaceId}` + ); + }); + return { + ...parent, + subscriptionStatus: { + ...parent.subscriptionStatus, + billingParent: { id: billingParent.id, name: billingParent.name, slug: billingParent.slug }, + }, + }; + } + let stripeOptions: StripeDataTableEntry = await store.getTable(stripeDataTable).get(workspaceId); if (!stripeOptions) { const email = userEmail(); @@ -295,6 +315,22 @@ export function getStripeObjectTag() { return (process.env.STRIPE_OBJECT_TAG as string) || "jitsu2.0"; } +/** + * Read-only check of whether a workspace is on the free plan. Used to keep free + * workspaces out of billing groups. Does not create a Stripe customer. + */ +export async function isWorkspaceOnFreePlan(workspaceId: string): Promise { + const opts: StripeDataTableEntry = await store.getTable(stripeDataTable).get(workspaceId); + if (!opts) { + return true; + } + if (opts.customBilling || opts.noRestrictions) { + return false; + } + const plan = await getActivePlan(opts.stripeCustomerId); + return !plan || plan.planId === "free"; +} + export async function getAvailableProducts(opts: { custom?: boolean } = {}): Promise { const stripeObjectTag = getStripeObjectTag(); let allProducts = []; diff --git a/webapps/ee-api/pages/api/admin/billing-parents.ts b/webapps/ee-api/pages/api/admin/billing-parents.ts new file mode 100644 index 000000000..a34b6cdc8 --- /dev/null +++ b/webapps/ee-api/pages/api/admin/billing-parents.ts @@ -0,0 +1,49 @@ +import { withFirebaseAdminAuth } from "../../../lib/route-helpers"; +import { listBillingParentLinks, removeBillingParent, setBillingParent } from "../../../lib/billing-hierarchy"; +import { isWorkspaceOnFreePlan } from "../../../lib/stripe"; + +/** + * Admin CRUD for billing-parent links (workspace bills under a parent). + * + * - GET — list every link + * - POST { workspaceId, parentWorkspaceId } — link a child under a parent + * - DELETE { workspaceId } — unlink a child + */ +export default withFirebaseAdminAuth(async (req, res) => { + if (req.method === "GET") { + return { links: await listBillingParentLinks() }; + } + + if (req.method === "POST") { + const { workspaceId, parentWorkspaceId } = req.body || {}; + if (!workspaceId || !parentWorkspaceId) { + res.status(400).json({ ok: false, error: "workspaceId and parentWorkspaceId are required" }); + return; + } + try { + if (await isWorkspaceOnFreePlan(workspaceId)) { + throw new Error( + "The child workspace is on the free plan — only paid workspaces can be joined to a billing group" + ); + } + await setBillingParent(workspaceId, parentWorkspaceId); + } catch (e: any) { + res.status(400).json({ ok: false, error: e?.message || "Failed to link workspaces" }); + return; + } + return { ok: true }; + } + + if (req.method === "DELETE") { + const { workspaceId } = req.body || {}; + if (!workspaceId) { + res.status(400).json({ ok: false, error: "workspaceId is required" }); + return; + } + await removeBillingParent(workspaceId); + return { ok: true }; + } + + res.status(405).json({ ok: false, error: `Method ${req.method} not allowed` }); + return; +}); diff --git a/webapps/ee-api/pages/api/billing/settings.ts b/webapps/ee-api/pages/api/billing/settings.ts index 333ce754c..04aeaa518 100644 --- a/webapps/ee-api/pages/api/billing/settings.ts +++ b/webapps/ee-api/pages/api/billing/settings.ts @@ -3,15 +3,34 @@ import { auth } from "../../../lib/auth"; import { requireDefined } from "juava"; import { withErrorHandler } from "../../../lib/route-helpers"; import { SubscriptionStatus, getOrCreateCurrentSubscription } from "../../../lib/stripe"; +import { getBillingGroup } from "../../../lib/billing-hierarchy"; +import { getEventsReport } from "../report/workspace-stat"; +import { pg } from "../../../lib/services"; import { getServerLog } from "../../../lib/log"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; + +dayjs.extend(utc); const log = getServerLog("/api/billing/settings"); +/** Consolidated usage of a billing group over the current billing period. */ +export type GroupUsage = { + events: number; + syncs: number; + periodStart: string; + periodEnd: string; + /** Number of workspaces summed — 1 for a standalone workspace. */ + memberCount: number; +}; + export type SuccessfullResponse = { ok: true; stripeCustomerId: string; subscriptionStatus: SubscriptionStatus; noRestrictions: boolean; + /** Present only when the request carries `?withUsage=true`. */ + usage?: GroupUsage; }; export type ErrorResponse = { @@ -19,6 +38,42 @@ export type ErrorResponse = { error: string; }; +/** + * Sum events and active syncs across a workspace's billing group for the current + * billing period. For a standalone workspace the group is just itself; for a + * billing parent it is the parent plus all of its children. + */ +async function getGroupUsage(workspaceId: string, subscription: SubscriptionStatus): Promise { + const period = subscription.currentPeriod; + const periodStart = period ? new Date(period.start) : dayjs().utc().startOf("month").toDate(); + const periodEnd = period ? new Date(period.end) : dayjs().utc().endOf("month").toDate(); + const start = periodStart.toISOString(); + const end = periodEnd.toISOString(); + + const { memberWorkspaceIds } = await getBillingGroup(workspaceId); + + const [eventReports, syncRes] = await Promise.all([ + Promise.all(memberWorkspaceIds.map(id => getEventsReport({ start, end, granularity: "day", workspaceId: id }))), + pg.query( + `select count(distinct sync."fromId" || sync."toId") as "activeSyncs" + from newjitsu.source_task task + join newjitsu."ConfigurationObjectLink" sync on task.sync_id = sync."id" + where (task.status = 'SUCCESS' OR task.status = 'PARTIAL') + and sync."workspaceId" = any($1) + and started_at >= $2 and started_at < $3`, + [memberWorkspaceIds, start, end] + ), + ]); + + return { + events: eventReports.flat().reduce((sum, row) => sum + row.events, 0), + syncs: Number(syncRes.rows[0]?.activeSyncs ?? 0), + periodStart: start, + periodEnd: end, + memberCount: memberWorkspaceIds.length, + }; +} + const handler = async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === "OPTIONS") { //allowing requests from everywhere since our tokens are short-lived @@ -41,10 +96,14 @@ const handler = async function handler(req: NextApiRequest, res: NextApiResponse requireDefined(req.query.email as string, `email is required`) ); + const usage = + req.query.withUsage === "true" ? await getGroupUsage(workspaceId, customer.subscriptionStatus) : undefined; + return res.status(200).json({ ok: true, ...customer, noRestrictions: !!customer.noRestrictions, + ...(usage ? { usage } : {}), }); }; diff --git a/webapps/ee-api/pages/billing-parents.tsx b/webapps/ee-api/pages/billing-parents.tsx new file mode 100644 index 000000000..e6b724e40 --- /dev/null +++ b/webapps/ee-api/pages/billing-parents.tsx @@ -0,0 +1,218 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { App, Button, Card, Table } from "antd"; +import { LinkOutlined, ReloadOutlined } from "@ant-design/icons"; +import { AdminLayout } from "../components/AdminLayout"; +import { RequireAdmin } from "../components/RequireAdmin"; +import { WorkspaceSelector } from "../components/WorkspaceSelector"; +import { useAuth } from "../components/AuthProvider"; + +/** Best-effort error message from a non-OK API response. */ +async function readError(resp: Response): Promise { + try { + const body = await resp.json(); + if (body?.error) { + return typeof body.error === "string" ? body.error : JSON.stringify(body.error); + } + } catch { + // fall through + } + return `HTTP ${resp.status}`; +} + +type WorkspaceRef = { id: string; name: string; slug: string | null }; +type BillingParentLink = { child: WorkspaceRef; parent: WorkspaceRef; createdAt: string }; + +const WorkspaceCell: React.FC<{ workspace: WorkspaceRef }> = ({ workspace }) => ( +
+ {workspace.name} + {workspace.slug && {workspace.slug}} + {workspace.id} +
+); + +const SectionLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +
{children}
+); + +function BillingParentsPage() { + const { authFetch } = useAuth(); + const { message, modal } = App.useApp(); + const [childId, setChildId] = useState(); + const [parentId, setParentId] = useState(); + const [links, setLinks] = useState([]); + const [loading, setLoading] = useState(false); + const [linking, setLinking] = useState(false); + const [tick, setTick] = useState(0); + + const reload = useCallback(() => setTick(t => t + 1), []); + + useEffect(() => { + let cancelled = false; + setLoading(true); + (async () => { + try { + const resp = await authFetch("/api/admin/billing-parents"); + if (!resp.ok) { + throw new Error(await readError(resp)); + } + const { links } = await resp.json(); + if (!cancelled) { + setLinks(links || []); + } + } catch (e: any) { + if (!cancelled) { + message.error(`Failed to load billing-parent links: ${e?.message}`); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + })(); + return () => { + cancelled = true; + }; + }, [authFetch, message, tick]); + + const link = async () => { + if (!childId || !parentId) { + return; + } + setLinking(true); + try { + const resp = await authFetch("/api/admin/billing-parents", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workspaceId: childId, parentWorkspaceId: parentId }), + }); + if (!resp.ok) { + throw new Error(await readError(resp)); + } + message.success("Billing-parent link created"); + setChildId(undefined); + setParentId(undefined); + reload(); + } catch (e: any) { + message.error(`Failed to link workspaces: ${e?.message}`); + } finally { + setLinking(false); + } + }; + + const unlink = (row: BillingParentLink) => { + modal.confirm({ + title: "Remove billing-parent link?", + content: `${row.child.name} will bill on its own again instead of under ${row.parent.name}.`, + okText: "Unlink", + okButtonProps: { danger: true }, + onOk: async () => { + const resp = await authFetch("/api/admin/billing-parents", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workspaceId: row.child.id }), + }); + if (!resp.ok) { + const error = await readError(resp); + message.error(`Failed to unlink: ${error}`); + throw new Error(error); + } + message.success("Billing-parent link removed"); + reload(); + }, + }); + }; + + const columns = [ + { + title: "Child workspace", + key: "child", + render: (_: unknown, row: BillingParentLink) => , + }, + { + title: "Bills under (parent)", + key: "parent", + render: (_: unknown, row: BillingParentLink) => , + }, + { + title: "Linked", + dataIndex: "createdAt", + width: 190, + render: (t: string) => (t ? new Date(t).toLocaleString() : "—"), + }, + { + title: "", + key: "action", + width: 100, + render: (_: unknown, row: BillingParentLink) => ( + + ), + }, + ]; + + return ( +
+

Billing Parents

+

+ Link a workspace to bill under a parent. The child inherits the parent's plan, and the parent's + billing page consolidates the whole group. Only one level of nesting is allowed; free workspaces cannot be + joined. +

+ + +
+
+ Child workspace + +
+
+ Parent workspace + +
+ +
+
+ + } onClick={reload}> + Refresh + + } + > + row.child.id} + dataSource={links} + columns={columns} + pagination={{ pageSize: 15, hideOnSinglePage: true }} + locale={{ emptyText: "No billing-parent links yet" }} + /> + + + ); +} + +export default function BillingParentsPageRoute() { + return ( + + + + + + ); +} diff --git a/webapps/ee-api/prisma/schema.prisma b/webapps/ee-api/prisma/schema.prisma index 81e24db07..e6c622d3b 100644 --- a/webapps/ee-api/prisma/schema.prisma +++ b/webapps/ee-api/prisma/schema.prisma @@ -45,6 +45,19 @@ model StatCache { @@schema("newjitsuee") } +/// Billing hierarchy: a child workspace bills under parentWorkspaceId. Only one +/// level of nesting is allowed (a parent cannot itself have a parent) — that is +/// enforced in lib/billing-hierarchy.ts, not by the schema. +model WorkspaceBillingParent { + workspaceId String @id + parentWorkspaceId String + createdAt DateTime @default(now()) + + @@index([parentWorkspaceId]) + @@map("workspace_billing_parent") + @@schema("newjitsuee") +} + /// DEAD TABLE — modeled here only so `prisma db push` does not drop it. /// It is a hand-managed table that used to feed the `newjitsu.backup_connections` /// view. That view has been removed, and S3 backup connections are now built in