Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 9 additions & 26 deletions webapps/console/components/Billing/BillingManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <Skeleton active paragraph={{ rows: 1, width: "100%" }} title={false} />;
Expand Down Expand Up @@ -219,37 +219,20 @@ 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 <Skeleton active paragraph={{ rows: 1, width: "100%" }} title={false} />;
} else if (error) {
return <ErrorCard error={error} />;
}

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;

Expand Down
74 changes: 73 additions & 1 deletion webapps/console/components/Billing/use-events-usage.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 };
}
4 changes: 4 additions & 0 deletions webapps/console/lib/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof BillingSettings>;
Expand Down
20 changes: 20 additions & 0 deletions webapps/console/pages/[workspaceId]/settings/billing/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -55,6 +56,25 @@ const BillingComponent: React.FC<{}> = () => {
/>
</div>
);
} else if (billing.settings.billingParent) {
const parent = billing.settings.billingParent;
return (
<div>
<Alert
showIcon
type="info"
message="Billing is managed by a parent workspace"
description={
<>
This workspace bills under <b>{parent.name}</b>. Its plan, invoices and usage are managed there.{" "}
<Link className="text-primary" href={`/${parent.slug ?? parent.id}/settings/billing`}>
Open {parent.name}'s billing page
</Link>
</>
}
/>
</div>
);
}

return <BillingManager />;
Expand Down
1 change: 1 addition & 0 deletions webapps/ee-api/components/AdminLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];
Expand Down
115 changes: 115 additions & 0 deletions webapps/ee-api/lib/billing-hierarchy.ts
Original file line number Diff line number Diff line change
@@ -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<WorkspaceRef | undefined> {
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<WorkspaceRef | undefined> {
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<string[]> {
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<BillingGroup> {
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<void> {
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<void> {
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<BillingParentLink[]> {
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<string, WorkspaceRef>(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() }] : [];
});
}
36 changes: 36 additions & 0 deletions webapps/ee-api/lib/stripe.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<boolean> {
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<Stripe.Product[]> {
const stripeObjectTag = getStripeObjectTag();
let allProducts = [];
Expand Down
Loading