Skip to content

Commit 5300f47

Browse files
0skiTrigger.dev RepoOps
authored andcommitted
feat(webapp,plugins): provision a free plan contract through the billing plugin when an organization is created
When the billing plugin is enabled and its free plan contracts are turned on, creating an organization also provisions a free plan contract through the plugin, before the organization is returned. Provisioning is idempotent and fails closed: if it can't complete, the new organization is removed and creation fails. With the option off (the default), organization creation behaves as before. Mono-RevId: e9fe47352051c004483603a4006ea9246ed60cba
1 parent 8cb5fd8 commit 5300f47

9 files changed

Lines changed: 140 additions & 35 deletions

File tree

‎apps/webapp/app/env.server.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2839,6 +2839,7 @@ const EnvironmentSchema = z
28392839

28402840
BILLING_PLUGIN_ENABLED: BoolEnv.default(false),
28412841
BILLING_FORCE_FALLBACK: BoolEnv.default(false),
2842+
BILLING_V2_ENABLED: BoolEnv.default(false),
28422843
BILLING_DATABASE_CONNECTION_LIMIT: z.coerce.number().int().default(2),
28432844
})
28442845
.and(GithubAppEnvSchema)

‎apps/webapp/app/services/billingPlugin.server.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const billingPlugin = billing.create({
1717
export function provisionBillingCustomerForOrg(organizationId: string): Promise<void> {
1818
return provisionBillingCustomerForNewOrg(organizationId, {
1919
enabled: billingEnabled,
20+
createFreePlanContract: env.BILLING_V2_ENABLED,
2021
controller: billingPlugin,
2122
deleteOrganization: async (id) => {
2223
await prisma.organization.delete({ where: { id } });

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

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type {
44
BillingCustomerError,
55
ProvisionBillingCustomerParams,
66
ProvisionBillingCustomerResult,
7+
ProvisionFreePlanContractParams,
8+
ProvisionFreePlanContractResult,
79
} from "@trigger.dev/billing";
810
import { errAsync, okAsync, ResultAsync } from "neverthrow";
911
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -14,37 +16,58 @@ import {
1416
type NewOrgProvisionDependencies,
1517
} from "./provisionBillingCustomer.server";
1618

17-
type Outcome = () => ResultAsync<ProvisionBillingCustomerResult, BillingCustomerError>;
19+
type Outcome = () => ResultAsync<ProvisionFreePlanContractResult, BillingCustomerError>;
1820

1921
const created: Outcome = () =>
20-
okAsync({ organizationId: "org_1", billingCustomerId: "cus_1", outcome: "created" as const });
22+
okAsync({
23+
organizationId: "org_1",
24+
billingCustomerId: "cus_1",
25+
contractId: "con_1",
26+
outcome: "created" as const,
27+
});
2128

2229
const never: Outcome = () => new ResultAsync(new Promise(() => {}));
2330

24-
function harness(outcomes: Outcome[], options: { usingPlugin?: () => Promise<boolean> } = {}) {
25-
const calls: ProvisionBillingCustomerParams[] = [];
31+
function harness(
32+
outcomes: Outcome[],
33+
options: {
34+
usingPlugin?: () => Promise<boolean>;
35+
customerOutcomes?: ProvisionBillingCustomerResult["outcome"][];
36+
} = {}
37+
) {
38+
const calls: ProvisionFreePlanContractParams[] = [];
39+
const customerCalls: ProvisionBillingCustomerParams[] = [];
2640
const deleted: string[] = [];
41+
const customerOutcomes: ProvisionBillingCustomerResult["outcome"][] =
42+
options.customerOutcomes ?? ["created"];
2743

2844
const controller: BillingController = {
2945
isUsingPlugin: options.usingPlugin ?? (async () => true),
3046
getCustomer(): ResultAsync<BillingCustomer | null, BillingCustomerError> {
3147
return okAsync(null);
3248
},
3349
provisionCustomer(params) {
50+
customerCalls.push(params);
51+
const outcome =
52+
customerOutcomes[Math.min(customerCalls.length - 1, customerOutcomes.length - 1)];
53+
return okAsync({ organizationId: "org_1", billingCustomerId: "cus_1", outcome });
54+
},
55+
provisionFreePlanContract(params) {
3456
calls.push(params);
3557
return outcomes[Math.min(calls.length - 1, outcomes.length - 1)]();
3658
},
3759
};
3860

3961
const deps: NewOrgProvisionDependencies = {
4062
enabled: true,
63+
createFreePlanContract: true,
4164
controller,
4265
deleteOrganization: async (id) => {
4366
deleted.push(id);
4467
},
4568
};
4669

47-
return { deps, calls, deleted };
70+
return { deps, calls, customerCalls, deleted };
4871
}
4972

5073
function run(deps: NewOrgProvisionDependencies) {
@@ -71,7 +94,7 @@ describe("provisionBillingCustomerForNewOrg", () => {
7194
expect(deleted).toHaveLength(0);
7295
});
7396

74-
it("blocks until the customer is provisioned and keeps the org", async () => {
97+
it("blocks until the customer and free plan contract are provisioned and keeps the org", async () => {
7598
const { deps, calls, deleted } = harness([created]);
7699

77100
expect(await run(deps)).toBe("ok");
@@ -81,12 +104,37 @@ describe("provisionBillingCustomerForNewOrg", () => {
81104
expect(deleted).toHaveLength(0);
82105
});
83106

107+
it("provisions only the customer, without a contract, when free plan contracts are off", async () => {
108+
const { deps, calls, customerCalls, deleted } = harness([created]);
109+
110+
expect(await run({ ...deps, createFreePlanContract: false })).toBe("ok");
111+
expect(calls).toHaveLength(0);
112+
expect(customerCalls).toHaveLength(1);
113+
expect(customerCalls[0].organizationId).toBe("org_1");
114+
expect(deleted).toHaveLength(0);
115+
});
116+
117+
it("retries a customer that is still in progress when free plan contracts are off", async () => {
118+
const { deps, calls, customerCalls, deleted } = harness([created], {
119+
customerOutcomes: ["in_progress", "created"],
120+
});
121+
122+
const result = run({ ...deps, createFreePlanContract: false });
123+
await vi.runAllTimersAsync();
124+
125+
expect(await result).toBe("ok");
126+
expect(calls).toHaveLength(0);
127+
expect(customerCalls).toHaveLength(2);
128+
expect(deleted).toHaveLength(0);
129+
});
130+
84131
it("accepts an org that was already provisioned", async () => {
85132
const { deps, deleted } = harness([
86133
() =>
87134
okAsync({
88135
organizationId: "org_1",
89136
billingCustomerId: "cus_1",
137+
contractId: "con_1",
90138
outcome: "already_provisioned" as const,
91139
}),
92140
]);
@@ -170,24 +218,6 @@ describe("provisionBillingCustomerForNewOrg", () => {
170218
expect(calls).toHaveLength(2);
171219
});
172220

173-
it("retries an in_progress outcome instead of treating it as done", async () => {
174-
const { deps, calls } = harness([
175-
() =>
176-
okAsync({
177-
organizationId: "org_1",
178-
billingCustomerId: null,
179-
outcome: "in_progress" as const,
180-
}),
181-
created,
182-
]);
183-
184-
const settled = run(deps);
185-
await vi.advanceTimersByTimeAsync(200);
186-
187-
expect(await settled).toBe("ok");
188-
expect(calls).toHaveLength(2);
189-
});
190-
191221
it("deletes the org and fails creation once retries are exhausted", async () => {
192222
const { deps, calls, deleted } = harness([() => errAsync("upstream_unavailable")]);
193223

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

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
1-
import type {
2-
BillingController,
3-
BillingCustomerError,
4-
ProvisionBillingCustomerResult,
5-
} from "@trigger.dev/billing";
1+
import type { BillingController, BillingCustomerError } from "@trigger.dev/billing";
62
import { tryCatch } from "@trigger.dev/core/utils";
7-
import type { Result } from "neverthrow";
3+
import type { Result, ResultAsync } from "neverthrow";
84
import { logger } from "~/services/logger.server";
95

106
const PROVISION_DEADLINE_MS = 20_000;
@@ -21,6 +17,7 @@ const NOT_CONFIGURED_POLICY: NotConfiguredPolicy = "fail";
2117

2218
export type NewOrgProvisionDependencies = {
2319
enabled: boolean;
20+
createFreePlanContract: boolean;
2421
controller: BillingController;
2522
deleteOrganization: (organizationId: string) => Promise<void>;
2623
now?: () => number;
@@ -30,7 +27,7 @@ export type NewOrgProvisionDependencies = {
3027

3128
type Budget = { deadlineAt: number; now: () => number; signal: AbortSignal };
3229

33-
type ProvisionAttempt = Result<ProvisionBillingCustomerResult, BillingCustomerError> | "aborted";
30+
type ProvisionAttempt = Result<boolean, BillingCustomerError> | "aborted";
3431

3532
export function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
3633
return new Promise<void>((resolve) => {
@@ -67,7 +64,7 @@ export async function provisionBillingCustomerForNewOrg(
6764
const error = await provisionOrError(organizationId, deps);
6865
if (!error) return;
6966

70-
logger.error("Billing customer provisioning failed; rolling back organization creation", {
67+
logger.error("Billing provisioning failed; rolling back organization creation", {
7168
organizationId,
7269
error,
7370
});
@@ -131,19 +128,19 @@ async function provisionWithRetry(
131128

132129
for (let attempt = 1; attempt <= PROVISION_MAX_ATTEMPTS; attempt++) {
133130
const [thrown, result] = await tryCatch<ProvisionAttempt>(
134-
untilAborted(deps.controller.provisionCustomer({ organizationId, signal }), signal)
131+
untilAborted(provisionOnce(organizationId, deps, signal), signal)
135132
);
136133

137134
if (thrown) {
138-
logger.error("Billing customer provisioning threw", {
135+
logger.error("Billing provisioning threw", {
139136
organizationId,
140137
error: thrown instanceof Error ? thrown.message : thrown,
141138
});
142139
lastError = "internal";
143140
} else if (result === "aborted") {
144141
return "upstream_unavailable";
145142
} else if (result.isOk()) {
146-
if (result.value.outcome !== "in_progress") return undefined;
143+
if (result.value) return undefined;
147144
lastError = "internal";
148145
} else {
149146
if (!RETRYABLE_ERRORS.has(result.error)) return result.error;
@@ -158,3 +155,17 @@ async function provisionWithRetry(
158155

159156
return lastError;
160157
}
158+
159+
// Resolves to whether provisioning is complete; a customer still in progress is retried.
160+
function provisionOnce(
161+
organizationId: string,
162+
deps: NewOrgProvisionDependencies,
163+
signal: AbortSignal
164+
): ResultAsync<boolean, BillingCustomerError> {
165+
if (deps.createFreePlanContract) {
166+
return deps.controller.provisionFreePlanContract({ organizationId, signal }).map(() => true);
167+
}
168+
return deps.controller
169+
.provisionCustomer({ organizationId, signal })
170+
.map((result) => result.outcome !== "in_progress");
171+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
BillingCustomer,
44
BillingCustomerError,
55
ProvisionBillingCustomerResult,
6+
ProvisionFreePlanContractResult,
67
} from "@trigger.dev/plugins";
78
import { errAsync, okAsync, type ResultAsync } from "neverthrow";
89

@@ -24,4 +25,8 @@ class BillingFallbackController implements BillingController {
2425
provisionCustomer(): ResultAsync<ProvisionBillingCustomerResult, BillingCustomerError> {
2526
return errAsync("feature_disabled");
2627
}
28+
29+
provisionFreePlanContract(): ResultAsync<ProvisionFreePlanContractResult, BillingCustomerError> {
30+
return errAsync("feature_disabled");
31+
}
2732
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import type {
66
PluginDatabaseConfig,
77
ProvisionBillingCustomerParams,
88
ProvisionBillingCustomerResult,
9+
ProvisionFreePlanContractParams,
10+
ProvisionFreePlanContractResult,
911
} from "@trigger.dev/plugins";
1012
import { ResultAsync } from "neverthrow";
1113
import { BillingFallback } from "./fallback.js";
@@ -16,6 +18,8 @@ export type {
1618
BillingCustomerError,
1719
ProvisionBillingCustomerParams,
1820
ProvisionBillingCustomerResult,
21+
ProvisionFreePlanContractParams,
22+
ProvisionFreePlanContractResult,
1923
} from "@trigger.dev/plugins";
2024

2125
export type BillingCreateOptions = {
@@ -110,6 +114,12 @@ export class LazyController implements BillingController {
110114
): ResultAsync<ProvisionBillingCustomerResult, BillingCustomerError> {
111115
return this.call((c) => c.provisionCustomer(params));
112116
}
117+
118+
provisionFreePlanContract(
119+
params: ProvisionFreePlanContractParams
120+
): ResultAsync<ProvisionFreePlanContractResult, BillingCustomerError> {
121+
return this.call((c) => c.provisionFreePlanContract(params));
122+
}
113123
}
114124

115125
class Billing {

‎internal-packages/billing/src/loader.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ function makeStubController(overrides: Partial<BillingController> = {}): Billing
2222
outcome: "created" as const,
2323
});
2424
},
25+
provisionFreePlanContract(params) {
26+
return okAsync({
27+
organizationId: params.organizationId,
28+
billingCustomerId: "cus_stub",
29+
contractId: "con_stub",
30+
outcome: "created" as const,
31+
});
32+
},
2533
...overrides,
2634
};
2735
return stub;
@@ -142,6 +150,27 @@ describe("billing loader", () => {
142150
expect(result._unsafeUnwrap()).toBeNull();
143151
});
144152

153+
it("fallback refuses to provision a free plan contract", async () => {
154+
const controller = loader.create({ forceFallback: true });
155+
156+
const result = await controller.provisionFreePlanContract({ organizationId: "org_1" });
157+
expect(result._unsafeUnwrapErr()).toBe("feature_disabled");
158+
});
159+
160+
it("delegates free plan contract provisioning to the plugin", async () => {
161+
const controller = new LazyController({
162+
importer: async () => makePluginModule(makeStubController()),
163+
});
164+
165+
const result = await controller.provisionFreePlanContract({ organizationId: "org_1" });
166+
expect(result._unsafeUnwrap()).toEqual({
167+
organizationId: "org_1",
168+
billingCustomerId: "cus_stub",
169+
contractId: "con_stub",
170+
outcome: "created",
171+
});
172+
});
173+
145174
it("loads the plugin once and reuses it across calls", async () => {
146175
const importer = vi.fn(async () => makePluginModule(makeStubController()));
147176
const controller = new LazyController({ importer });

‎packages/plugins/src/billing.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@ export type ProvisionBillingCustomerParams = {
2828
signal?: AbortSignal;
2929
};
3030

31+
export type ProvisionFreePlanContractParams = {
32+
organizationId: string;
33+
signal?: AbortSignal;
34+
};
35+
36+
export type ProvisionFreePlanContractResult = {
37+
organizationId: string;
38+
billingCustomerId: string;
39+
contractId: string;
40+
outcome: "created" | "already_provisioned";
41+
};
42+
3143
export interface BillingController {
3244
isUsingPlugin(): Promise<boolean>;
3345

@@ -36,6 +48,10 @@ export interface BillingController {
3648
provisionCustomer(
3749
params: ProvisionBillingCustomerParams
3850
): ResultAsync<ProvisionBillingCustomerResult, BillingCustomerError>;
51+
52+
provisionFreePlanContract(
53+
params: ProvisionFreePlanContractParams
54+
): ResultAsync<ProvisionFreePlanContractResult, BillingCustomerError>;
3955
}
4056

4157
export type BillingPluginConfig = {

‎packages/plugins/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,6 @@ export type {
8181
ProvisionBillingCustomerParams,
8282
ProvisionBillingCustomerResult,
8383
ProvisionBillingCustomerOutcome,
84+
ProvisionFreePlanContractParams,
85+
ProvisionFreePlanContractResult,
8486
} from "./billing.js";

0 commit comments

Comments
 (0)