Skip to content

Commit 034f10c

Browse files
0skiTrigger.dev RepoOps
authored andcommitted
feat(plugins,billing): read an org's monthly usage, credits and per-project usage from Metronome
Billing plugin interface: add monthly usage (priced usage per day and per usage type, machine, region and environment, plus the month's credits) and per-project usage reads, and a way to clear an organization's cached current-month usage. The open-source fallback reports these reads as disabled and ignores the clear. Mono-RevId: e54c4dc8e9097a8337902fb8ece57729dda20302
1 parent 5300f47 commit 034f10c

8 files changed

Lines changed: 595 additions & 0 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type {
2+
BillingController,
3+
BillingMonthlyUsage,
4+
BillingProjectUsage,
5+
BillingUsageError,
6+
} from "@trigger.dev/billing";
7+
import { tryCatch } from "@trigger.dev/core/utils";
8+
import type { ResultAsync } from "neverthrow";
9+
import { logger } from "~/services/logger.server";
10+
11+
type BillingUsageResult<T> =
12+
| { ok: true; value: T }
13+
| { ok: false; error: BillingUsageError; retryable: boolean };
14+
15+
export type BillingUsageProject = {
16+
id: string;
17+
name: string;
18+
slug: string;
19+
externalRef: string;
20+
deleted: boolean;
21+
};
22+
23+
type BillingOrganizationProjectUsage = BillingProjectUsage & {
24+
projects: BillingUsageProject[];
25+
};
26+
27+
export type BillingUsageService = {
28+
getMonthlyUsage(
29+
organizationId: string,
30+
month: string
31+
): Promise<BillingUsageResult<BillingMonthlyUsage>>;
32+
getProjectUsage(
33+
organizationId: string,
34+
month: string
35+
): Promise<BillingUsageResult<BillingOrganizationProjectUsage>>;
36+
};
37+
38+
export type FindBillingUsageProjects = (
39+
organizationId: string,
40+
projectIds: string[]
41+
) => Promise<BillingUsageProject[]>;
42+
43+
export type BillingUsageDependencies = {
44+
enabled: boolean;
45+
controller: Pick<BillingController, "getMonthlyUsage" | "getProjectUsage">;
46+
findProjects: FindBillingUsageProjects;
47+
};
48+
49+
const RETRYABLE_ERRORS: ReadonlySet<BillingUsageError> = new Set<BillingUsageError>([
50+
"rate_limited",
51+
"upstream_unavailable",
52+
]);
53+
54+
function failure<T>(error: BillingUsageError): BillingUsageResult<T> {
55+
return { ok: false, error, retryable: RETRYABLE_ERRORS.has(error) };
56+
}
57+
58+
function settle<T>(result: ResultAsync<T, BillingUsageError>): Promise<BillingUsageResult<T>> {
59+
return result.match(
60+
(value): BillingUsageResult<T> => ({ ok: true, value }),
61+
(error) => failure<T>(error)
62+
);
63+
}
64+
65+
async function lookUpProjects(
66+
organizationId: string,
67+
projectIds: string[],
68+
findProjects: FindBillingUsageProjects
69+
): Promise<BillingUsageProject[]> {
70+
if (projectIds.length === 0) return [];
71+
72+
const [error, projects] = await tryCatch(findProjects(organizationId, projectIds));
73+
if (error) {
74+
logger.error("Billing usage project lookup failed", { organizationId, error: error.message });
75+
return [];
76+
}
77+
return projects;
78+
}
79+
80+
export function createBillingUsageService(deps: BillingUsageDependencies): BillingUsageService {
81+
return {
82+
async getMonthlyUsage(organizationId, month) {
83+
if (!deps.enabled) return failure("feature_disabled");
84+
return settle(deps.controller.getMonthlyUsage({ organizationId, month }));
85+
},
86+
async getProjectUsage(organizationId, month) {
87+
if (!deps.enabled) return failure("feature_disabled");
88+
const usage = await settle(deps.controller.getProjectUsage({ organizationId, month }));
89+
if (!usage.ok) return usage;
90+
const projects = await lookUpProjects(
91+
organizationId,
92+
usage.value.projectIds,
93+
deps.findProjects
94+
);
95+
return { ok: true, value: { ...usage.value, projects } };
96+
},
97+
};
98+
}

‎apps/webapp/app/services/provisionBillingCustomer.server.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ function harness(
5656
calls.push(params);
5757
return outcomes[Math.min(calls.length - 1, outcomes.length - 1)]();
5858
},
59+
getMonthlyUsage() {
60+
return errAsync("feature_disabled");
61+
},
62+
getProjectUsage() {
63+
return errAsync("feature_disabled");
64+
},
65+
clearCurrentMonthUsage() {
66+
return okAsync(undefined);
67+
},
5968
};
6069

6170
const deps: NewOrgProvisionDependencies = {
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import type {
2+
BillingMonthlyUsage,
3+
BillingProjectUsage,
4+
BillingUsageError,
5+
} from "@trigger.dev/billing";
6+
import { errAsync, okAsync, type ResultAsync } from "neverthrow";
7+
import { describe, expect, it, vi } from "vitest";
8+
import {
9+
createBillingUsageService,
10+
type BillingUsageProject,
11+
type FindBillingUsageProjects,
12+
} from "~/services/billingUsage.server";
13+
14+
const monthlyUsage: BillingMonthlyUsage = {
15+
organizationId: "org_1",
16+
fetchedAt: "2026-10-14T12:00:00.000Z",
17+
month: "2026-10",
18+
isCurrentMonth: true,
19+
availableFrom: "2026-09-04",
20+
creditType: { id: "usd", name: "USD (cents)", currency: { code: "USD", minorUnitsPerUnit: 100 } },
21+
total: 600,
22+
creditsApplied: 300,
23+
creditsGranted: 5000,
24+
credits: [
25+
{
26+
id: "com_pro",
27+
name: "Pro monthly credit",
28+
kind: "credit",
29+
amount: 5000,
30+
applied: 300,
31+
startsAt: "2026-10-01T00:00:00Z",
32+
endsAt: "2026-11-01T00:00:00Z",
33+
},
34+
],
35+
days: [{ date: "2026-10-13", total: 600 }],
36+
lines: [],
37+
};
38+
39+
const projectUsage: BillingProjectUsage = {
40+
organizationId: "org_1",
41+
fetchedAt: "2026-10-14T12:00:00.000Z",
42+
month: "2026-10",
43+
isCurrentMonth: true,
44+
truncated: false,
45+
projectIds: ["proj_a", "proj_gone"],
46+
days: [],
47+
lines: [],
48+
};
49+
50+
const projectA: BillingUsageProject = {
51+
id: "proj_a",
52+
name: "Alpha",
53+
slug: "alpha",
54+
externalRef: "proj_alpharef",
55+
deleted: false,
56+
};
57+
58+
function controller(
59+
outcome: {
60+
monthly?: () => ResultAsync<BillingMonthlyUsage, BillingUsageError>;
61+
projects?: () => ResultAsync<BillingProjectUsage, BillingUsageError>;
62+
} = {}
63+
) {
64+
return {
65+
getMonthlyUsage: vi.fn(outcome.monthly ?? (() => okAsync(monthlyUsage))),
66+
getProjectUsage: vi.fn(outcome.projects ?? (() => okAsync(projectUsage))),
67+
};
68+
}
69+
70+
const findNoProjects: FindBillingUsageProjects = async () => [];
71+
72+
describe("createBillingUsageService", () => {
73+
it("returns the plugin's usage for the requested month", async () => {
74+
const plugin = controller();
75+
const service = createBillingUsageService({
76+
enabled: true,
77+
controller: plugin,
78+
findProjects: findNoProjects,
79+
});
80+
81+
await expect(service.getMonthlyUsage("org_1", "2026-10")).resolves.toEqual({
82+
ok: true,
83+
value: monthlyUsage,
84+
});
85+
expect(plugin.getMonthlyUsage).toHaveBeenCalledWith({
86+
organizationId: "org_1",
87+
month: "2026-10",
88+
});
89+
});
90+
91+
it("does not call the plugin when billing v2 is disabled", async () => {
92+
const plugin = controller();
93+
const service = createBillingUsageService({
94+
enabled: false,
95+
controller: plugin,
96+
findProjects: findNoProjects,
97+
});
98+
99+
await expect(service.getMonthlyUsage("org_1", "2026-10")).resolves.toEqual({
100+
ok: false,
101+
error: "feature_disabled",
102+
retryable: false,
103+
});
104+
await expect(service.getProjectUsage("org_1", "2026-10")).resolves.toMatchObject({
105+
ok: false,
106+
error: "feature_disabled",
107+
});
108+
expect(plugin.getMonthlyUsage).not.toHaveBeenCalled();
109+
expect(plugin.getProjectUsage).not.toHaveBeenCalled();
110+
});
111+
112+
it("marks a rate-limited read as retryable", async () => {
113+
const service = createBillingUsageService({
114+
enabled: true,
115+
findProjects: findNoProjects,
116+
controller: controller({ monthly: () => errAsync("rate_limited") }),
117+
});
118+
119+
await expect(service.getMonthlyUsage("org_1", "2026-10")).resolves.toEqual({
120+
ok: false,
121+
error: "rate_limited",
122+
retryable: true,
123+
});
124+
});
125+
126+
it("marks an unknown customer as not retryable", async () => {
127+
const service = createBillingUsageService({
128+
enabled: true,
129+
findProjects: findNoProjects,
130+
controller: controller({ monthly: () => errAsync("customer_not_found") }),
131+
});
132+
133+
await expect(service.getMonthlyUsage("org_1", "2026-10")).resolves.toEqual({
134+
ok: false,
135+
error: "customer_not_found",
136+
retryable: false,
137+
});
138+
});
139+
140+
it("adds the org's projects to project usage", async () => {
141+
const findProjects = vi.fn<FindBillingUsageProjects>(async () => [projectA]);
142+
const plugin = controller();
143+
const service = createBillingUsageService({ enabled: true, controller: plugin, findProjects });
144+
145+
const result = await service.getProjectUsage("org_1", "2026-10");
146+
147+
expect(plugin.getProjectUsage).toHaveBeenCalledWith({
148+
organizationId: "org_1",
149+
month: "2026-10",
150+
});
151+
expect(findProjects).toHaveBeenCalledWith("org_1", ["proj_a", "proj_gone"]);
152+
expect(result).toEqual({ ok: true, value: { ...projectUsage, projects: [projectA] } });
153+
});
154+
155+
it("skips the project lookup when no project has usage", async () => {
156+
const findProjects = vi.fn<FindBillingUsageProjects>(async () => []);
157+
const service = createBillingUsageService({
158+
enabled: true,
159+
findProjects,
160+
controller: controller({ projects: () => okAsync({ ...projectUsage, projectIds: [] }) }),
161+
});
162+
163+
await expect(service.getProjectUsage("org_1", "2026-10")).resolves.toMatchObject({
164+
ok: true,
165+
value: { projects: [] },
166+
});
167+
expect(findProjects).not.toHaveBeenCalled();
168+
});
169+
170+
it("keeps the usage without project names when the project lookup fails", async () => {
171+
const service = createBillingUsageService({
172+
enabled: true,
173+
controller: controller(),
174+
findProjects: async () => {
175+
throw new Error("db down");
176+
},
177+
});
178+
179+
await expect(service.getProjectUsage("org_1", "2026-10")).resolves.toEqual({
180+
ok: true,
181+
value: { ...projectUsage, projects: [] },
182+
});
183+
});
184+
185+
it("does not look up projects when the plugin read fails", async () => {
186+
const findProjects = vi.fn<FindBillingUsageProjects>(async () => []);
187+
const service = createBillingUsageService({
188+
enabled: true,
189+
findProjects,
190+
controller: controller({ projects: () => errAsync("rate_limited") }),
191+
});
192+
193+
await expect(service.getProjectUsage("org_1", "2026-10")).resolves.toEqual({
194+
ok: false,
195+
error: "rate_limited",
196+
retryable: true,
197+
});
198+
expect(findProjects).not.toHaveBeenCalled();
199+
});
200+
});

‎internal-packages/billing/src/fallback.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import type {
22
BillingController,
33
BillingCustomer,
44
BillingCustomerError,
5+
BillingMonthlyUsage,
6+
BillingProjectUsage,
7+
BillingUsageError,
58
ProvisionBillingCustomerResult,
69
ProvisionFreePlanContractResult,
710
} from "@trigger.dev/plugins";
@@ -29,4 +32,16 @@ class BillingFallbackController implements BillingController {
2932
provisionFreePlanContract(): ResultAsync<ProvisionFreePlanContractResult, BillingCustomerError> {
3033
return errAsync("feature_disabled");
3134
}
35+
36+
getMonthlyUsage(): ResultAsync<BillingMonthlyUsage, BillingUsageError> {
37+
return errAsync("feature_disabled");
38+
}
39+
40+
getProjectUsage(): ResultAsync<BillingProjectUsage, BillingUsageError> {
41+
return errAsync("feature_disabled");
42+
}
43+
44+
clearCurrentMonthUsage(): ResultAsync<void, never> {
45+
return okAsync(undefined);
46+
}
3247
}

0 commit comments

Comments
 (0)