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 }) => (
+
+ 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.
+
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