@beignet/cli 0.0.26 → 0.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +59 -15
- package/dist/check.d.ts +61 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +164 -0
- package/dist/check.js.map +1 -0
- package/dist/choices.d.ts +8 -0
- package/dist/choices.d.ts.map +1 -1
- package/dist/choices.js +14 -0
- package/dist/choices.js.map +1 -1
- package/dist/db.d.ts +8 -0
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +4 -4
- package/dist/db.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +156 -21
- package/dist/index.js.map +1 -1
- package/dist/inspect.d.ts.map +1 -1
- package/dist/inspect.js +121 -3
- package/dist/inspect.js.map +1 -1
- package/dist/make/inbox.d.ts +63 -0
- package/dist/make/inbox.d.ts.map +1 -0
- package/dist/make/inbox.js +1747 -0
- package/dist/make/inbox.js.map +1 -0
- package/dist/make/payments.d.ts +61 -0
- package/dist/make/payments.d.ts.map +1 -0
- package/dist/make/payments.js +1900 -0
- package/dist/make/payments.js.map +1 -0
- package/dist/make/shared.d.ts +592 -0
- package/dist/make/shared.d.ts.map +1 -0
- package/dist/make/shared.js +2233 -0
- package/dist/make/shared.js.map +1 -0
- package/dist/make/tenancy.d.ts +101 -0
- package/dist/make/tenancy.d.ts.map +1 -0
- package/dist/make/tenancy.js +4409 -0
- package/dist/make/tenancy.js.map +1 -0
- package/dist/make.d.ts +8 -44
- package/dist/make.d.ts.map +1 -1
- package/dist/make.js +57 -3689
- package/dist/make.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +45 -4
- package/dist/mcp.js.map +1 -1
- package/dist/provider-add.d.ts +19 -0
- package/dist/provider-add.d.ts.map +1 -0
- package/dist/provider-add.js +691 -0
- package/dist/provider-add.js.map +1 -0
- package/dist/provider-audit.d.ts.map +1 -1
- package/dist/provider-audit.js +77 -1
- package/dist/provider-audit.js.map +1 -1
- package/dist/registry-edits.d.ts +9 -0
- package/dist/registry-edits.d.ts.map +1 -1
- package/dist/registry-edits.js +32 -0
- package/dist/registry-edits.js.map +1 -1
- package/dist/task.d.ts +2 -0
- package/dist/task.d.ts.map +1 -1
- package/dist/task.js +2 -0
- package/dist/task.js.map +1 -1
- package/dist/templates/agents.d.ts.map +1 -1
- package/dist/templates/agents.js +29 -11
- package/dist/templates/agents.js.map +1 -1
- package/dist/templates/base.d.ts.map +1 -1
- package/dist/templates/base.js +42 -12
- package/dist/templates/base.js.map +1 -1
- package/dist/templates/db/sqlite.d.ts.map +1 -1
- package/dist/templates/db/sqlite.js +7 -1
- package/dist/templates/db/sqlite.js.map +1 -1
- package/dist/templates/server.d.ts.map +1 -1
- package/dist/templates/server.js +44 -37
- package/dist/templates/server.js.map +1 -1
- package/dist/templates/shared.d.ts +5 -0
- package/dist/templates/shared.d.ts.map +1 -1
- package/dist/templates/shared.js +5 -0
- package/dist/templates/shared.js.map +1 -1
- package/package.json +2 -2
- package/skills/app-structure/SKILL.md +1 -1
- package/src/check.ts +246 -0
- package/src/choices.ts +28 -0
- package/src/db.ts +4 -4
- package/src/index.ts +232 -21
- package/src/inspect.ts +168 -2
- package/src/make/inbox.ts +2015 -0
- package/src/make/payments.ts +2182 -0
- package/src/make/shared.ts +3638 -0
- package/src/make/tenancy.ts +4809 -0
- package/src/make.ts +388 -5355
- package/src/mcp.ts +66 -3
- package/src/provider-add.ts +926 -0
- package/src/provider-audit.ts +95 -3
- package/src/registry-edits.ts +47 -0
- package/src/task.ts +8 -10
- package/src/templates/agents.ts +29 -11
- package/src/templates/base.ts +43 -12
- package/src/templates/db/sqlite.ts +7 -1
- package/src/templates/server.ts +44 -37
- package/src/templates/shared.ts +5 -0
- package/src/test-helpers/generated-app.ts +168 -0
|
@@ -0,0 +1,2182 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type BeignetConfig,
|
|
5
|
+
loadBeignetConfig,
|
|
6
|
+
type ResolvedBeignetConfig,
|
|
7
|
+
resolveConfig,
|
|
8
|
+
} from "../config.js";
|
|
9
|
+
import { appendToNamedArray, insertAfterImports } from "../registry-edits.js";
|
|
10
|
+
import {
|
|
11
|
+
addNamedTypeImport,
|
|
12
|
+
aliasModule,
|
|
13
|
+
appendDeferredPortKey,
|
|
14
|
+
appendPickStringLiteralMember,
|
|
15
|
+
appendSharedErrorEntries,
|
|
16
|
+
assertStandardApp,
|
|
17
|
+
type DatabaseName,
|
|
18
|
+
detectResourceDatabase,
|
|
19
|
+
detectResourcePersistence,
|
|
20
|
+
detectUiShell,
|
|
21
|
+
drizzleDialects,
|
|
22
|
+
drizzleRepositoriesPath,
|
|
23
|
+
drizzleSchemaIndexPath,
|
|
24
|
+
fileExists,
|
|
25
|
+
type GeneratedFile,
|
|
26
|
+
infrastructureDir,
|
|
27
|
+
insertTypeProperty,
|
|
28
|
+
type MakeResult,
|
|
29
|
+
paymentsPortWiring,
|
|
30
|
+
planGeneratedFiles,
|
|
31
|
+
type ResourceNames,
|
|
32
|
+
readOptionalFile,
|
|
33
|
+
relativeModule,
|
|
34
|
+
resourceNames,
|
|
35
|
+
resourceSharedErrorsPath,
|
|
36
|
+
seedEntrypointPath,
|
|
37
|
+
seedNames,
|
|
38
|
+
uniqueStrings,
|
|
39
|
+
updateDatabaseProviderPick,
|
|
40
|
+
updateDatabaseResetTables,
|
|
41
|
+
updateDrizzleRepositories,
|
|
42
|
+
updateDrizzleSchemaIndexExports,
|
|
43
|
+
updateInfrastructureDeferredPorts,
|
|
44
|
+
updatePackageJson,
|
|
45
|
+
updatePackageScripts,
|
|
46
|
+
updatePortsIndex,
|
|
47
|
+
updateSeedEntrypoint,
|
|
48
|
+
updateServerRoutes,
|
|
49
|
+
updateTransactionPortsPick,
|
|
50
|
+
wirePortProviders,
|
|
51
|
+
writePlannedGeneratedFile,
|
|
52
|
+
} from "./shared.js";
|
|
53
|
+
|
|
54
|
+
export type MakePaymentsOptions = {
|
|
55
|
+
cwd?: string;
|
|
56
|
+
force?: boolean;
|
|
57
|
+
dryRun?: boolean;
|
|
58
|
+
config?: BeignetConfig;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Result returned by `beignet make payments`.
|
|
63
|
+
*/
|
|
64
|
+
export type MakePaymentsResult = MakeResult;
|
|
65
|
+
|
|
66
|
+
export async function makePayments(
|
|
67
|
+
options: MakePaymentsOptions,
|
|
68
|
+
): Promise<MakePaymentsResult> {
|
|
69
|
+
const targetDir = path.resolve(options.cwd ?? process.cwd());
|
|
70
|
+
const config = options.config
|
|
71
|
+
? resolveConfig(options.config)
|
|
72
|
+
: await loadBeignetConfig(targetDir);
|
|
73
|
+
|
|
74
|
+
await assertStandardApp(targetDir, config);
|
|
75
|
+
|
|
76
|
+
const persistence = await detectResourcePersistence(targetDir, config);
|
|
77
|
+
if (persistence !== "drizzle") {
|
|
78
|
+
throw new Error(
|
|
79
|
+
"beignet make payments expects a Drizzle-backed Beignet app because billing state must be durable. Add a database provider first, then run make payments again.",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const database = await detectResourceDatabase(targetDir, config);
|
|
84
|
+
const ui = await detectUiShell(targetDir, config);
|
|
85
|
+
const generatedFiles = paymentsFiles(config, database, { ui });
|
|
86
|
+
const plannedFiles = await planGeneratedFiles(targetDir, generatedFiles, {
|
|
87
|
+
force: Boolean(options.force),
|
|
88
|
+
});
|
|
89
|
+
const billingNames = resourceNames("billing");
|
|
90
|
+
const billingSeedNames = seedNames("billing/demo-billing-account");
|
|
91
|
+
const plannedBillingUpdates = await updatePaymentsWiring(
|
|
92
|
+
targetDir,
|
|
93
|
+
billingNames,
|
|
94
|
+
config,
|
|
95
|
+
{ dryRun: true, ui },
|
|
96
|
+
);
|
|
97
|
+
const plannedSeedEntrypoint = await updateSeedEntrypoint(
|
|
98
|
+
targetDir,
|
|
99
|
+
billingSeedNames,
|
|
100
|
+
config,
|
|
101
|
+
{ dryRun: true },
|
|
102
|
+
);
|
|
103
|
+
const plannedPaymentsUpdates = (
|
|
104
|
+
await wirePortProviders(
|
|
105
|
+
targetDir,
|
|
106
|
+
config,
|
|
107
|
+
[paymentsPortWiring("make payments")],
|
|
108
|
+
{ dryRun: true },
|
|
109
|
+
)
|
|
110
|
+
).updatedFiles;
|
|
111
|
+
|
|
112
|
+
const createdFiles = plannedFiles
|
|
113
|
+
.filter((file) => file.result === "created")
|
|
114
|
+
.map((file) => file.path);
|
|
115
|
+
const updatedFiles = plannedFiles
|
|
116
|
+
.filter((file) => file.result === "updated")
|
|
117
|
+
.map((file) => file.path);
|
|
118
|
+
const skippedFiles = plannedFiles
|
|
119
|
+
.filter((file) => file.result === "skipped")
|
|
120
|
+
.map((file) => file.path);
|
|
121
|
+
|
|
122
|
+
if (options.dryRun) {
|
|
123
|
+
if (plannedSeedEntrypoint === "created") {
|
|
124
|
+
createdFiles.push(seedEntrypointPath(config));
|
|
125
|
+
}
|
|
126
|
+
if (plannedSeedEntrypoint === "updated") {
|
|
127
|
+
updatedFiles.push(seedEntrypointPath(config));
|
|
128
|
+
}
|
|
129
|
+
if (plannedSeedEntrypoint === "skipped") {
|
|
130
|
+
skippedFiles.push(seedEntrypointPath(config));
|
|
131
|
+
}
|
|
132
|
+
updatedFiles.push(...plannedBillingUpdates, ...plannedPaymentsUpdates);
|
|
133
|
+
} else {
|
|
134
|
+
for (const file of plannedFiles) {
|
|
135
|
+
await writePlannedGeneratedFile(targetDir, file);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const seedEntrypointResult = await updateSeedEntrypoint(
|
|
139
|
+
targetDir,
|
|
140
|
+
billingSeedNames,
|
|
141
|
+
config,
|
|
142
|
+
{ dryRun: false },
|
|
143
|
+
);
|
|
144
|
+
if (seedEntrypointResult === "created") {
|
|
145
|
+
createdFiles.push(seedEntrypointPath(config));
|
|
146
|
+
}
|
|
147
|
+
if (seedEntrypointResult === "updated") {
|
|
148
|
+
updatedFiles.push(seedEntrypointPath(config));
|
|
149
|
+
}
|
|
150
|
+
if (seedEntrypointResult === "skipped") {
|
|
151
|
+
skippedFiles.push(seedEntrypointPath(config));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
updatedFiles.push(
|
|
155
|
+
...(await updatePaymentsWiring(targetDir, billingNames, config, {
|
|
156
|
+
dryRun: false,
|
|
157
|
+
ui,
|
|
158
|
+
})),
|
|
159
|
+
);
|
|
160
|
+
updatedFiles.push(
|
|
161
|
+
...(
|
|
162
|
+
await wirePortProviders(
|
|
163
|
+
targetDir,
|
|
164
|
+
config,
|
|
165
|
+
[paymentsPortWiring("make payments")],
|
|
166
|
+
{ dryRun: false },
|
|
167
|
+
)
|
|
168
|
+
).updatedFiles,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
schemaVersion: 1,
|
|
174
|
+
name: "payments",
|
|
175
|
+
targetDir,
|
|
176
|
+
dryRun: Boolean(options.dryRun),
|
|
177
|
+
files: [
|
|
178
|
+
...generatedFiles.map((file) => file.path),
|
|
179
|
+
seedEntrypointPath(config),
|
|
180
|
+
],
|
|
181
|
+
createdFiles,
|
|
182
|
+
updatedFiles: uniqueStrings(updatedFiles),
|
|
183
|
+
skippedFiles,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Post-generate guidance for `beignet make payments`, shared with the CLI
|
|
189
|
+
* command so the printed steps track what was actually emitted.
|
|
190
|
+
*/
|
|
191
|
+
export function makePaymentsNextStepLines(
|
|
192
|
+
result: Pick<MakePaymentsResult, "files">,
|
|
193
|
+
): string[] {
|
|
194
|
+
const hasPlanUi = result.files.includes("app/(app)/settings/plan/page.tsx");
|
|
195
|
+
|
|
196
|
+
return [
|
|
197
|
+
"The free plan works out of the box; billing accounts without an active subscription resolve to it automatically.",
|
|
198
|
+
"Set BILLING_PRO_PRICE_ID in .env.local to the provider price ID for your pro plan.",
|
|
199
|
+
"Run beignet db generate and beignet db migrate so billing_accounts exists before handling webhooks.",
|
|
200
|
+
'Gate paid actions once usage passes FREE_PLAN_LIMITS: await requireEntitlement(ctx, { entitlement: "todos.create", subject: { type: "tenant", id: tenantId } });',
|
|
201
|
+
...(hasPlanUi
|
|
202
|
+
? [
|
|
203
|
+
"Users manage their plan at /settings/plan; run bun run db:seed to create the demo billing account.",
|
|
204
|
+
]
|
|
205
|
+
: ["Run bun run db:seed to create the demo billing account."]),
|
|
206
|
+
"Use createMemoryPaymentsProvider() for local development, then swap server/providers.ts to stripePaymentsProvider when STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET are configured.",
|
|
207
|
+
"Billing accounts fall back to the user id today; after beignet make tenancy they become workspace-scoped automatically.",
|
|
208
|
+
"Run your app's test, lint, and typecheck commands, then beignet lint and beignet doctor.",
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function updatePaymentsWiring(
|
|
213
|
+
targetDir: string,
|
|
214
|
+
billingNames: ResourceNames,
|
|
215
|
+
config: ResolvedBeignetConfig,
|
|
216
|
+
options: { dryRun: boolean; ui?: boolean },
|
|
217
|
+
): Promise<string[]> {
|
|
218
|
+
const updated = new Set<string>();
|
|
219
|
+
|
|
220
|
+
if (await updatePackageJson(targetDir, { dryRun: options.dryRun })) {
|
|
221
|
+
updated.add("package.json");
|
|
222
|
+
}
|
|
223
|
+
if (
|
|
224
|
+
await updatePackageScripts(
|
|
225
|
+
targetDir,
|
|
226
|
+
{ "db:seed": `bun ${seedEntrypointPath(config)}` },
|
|
227
|
+
{ dryRun: options.dryRun },
|
|
228
|
+
)
|
|
229
|
+
) {
|
|
230
|
+
updated.add("package.json");
|
|
231
|
+
}
|
|
232
|
+
if (
|
|
233
|
+
await updatePortsIndex(targetDir, billingNames, config, {
|
|
234
|
+
...options,
|
|
235
|
+
generationOptions: {
|
|
236
|
+
auth: false,
|
|
237
|
+
tenant: false,
|
|
238
|
+
events: false,
|
|
239
|
+
softDelete: false,
|
|
240
|
+
},
|
|
241
|
+
})
|
|
242
|
+
) {
|
|
243
|
+
updated.add(config.paths.ports);
|
|
244
|
+
}
|
|
245
|
+
if (await updatePaymentsEntitlementsPort(targetDir, config, options)) {
|
|
246
|
+
updated.add(config.paths.ports);
|
|
247
|
+
updated.add(config.paths.infrastructurePorts);
|
|
248
|
+
}
|
|
249
|
+
if (
|
|
250
|
+
await updateInfrastructureDeferredPorts(
|
|
251
|
+
targetDir,
|
|
252
|
+
billingNames,
|
|
253
|
+
config,
|
|
254
|
+
options,
|
|
255
|
+
)
|
|
256
|
+
) {
|
|
257
|
+
updated.add(config.paths.infrastructurePorts);
|
|
258
|
+
}
|
|
259
|
+
if (
|
|
260
|
+
await updateDrizzleSchemaIndexExports(
|
|
261
|
+
targetDir,
|
|
262
|
+
config,
|
|
263
|
+
{ exports: ["billingAccounts"], module: "./billing" },
|
|
264
|
+
options,
|
|
265
|
+
)
|
|
266
|
+
) {
|
|
267
|
+
updated.add(drizzleSchemaIndexPath(config));
|
|
268
|
+
}
|
|
269
|
+
if (
|
|
270
|
+
await updateDrizzleRepositories(targetDir, billingNames, config, options)
|
|
271
|
+
) {
|
|
272
|
+
updated.add(drizzleRepositoriesPath(config));
|
|
273
|
+
}
|
|
274
|
+
if (
|
|
275
|
+
await updateDatabaseProviderPick(targetDir, config, ["billing"], options)
|
|
276
|
+
) {
|
|
277
|
+
updated.add(path.join(infrastructureDir(config), "db/provider.ts"));
|
|
278
|
+
}
|
|
279
|
+
if (await updateDatabaseProviderEntitlements(targetDir, config, options)) {
|
|
280
|
+
updated.add(path.join(infrastructureDir(config), "db/provider.ts"));
|
|
281
|
+
}
|
|
282
|
+
if (
|
|
283
|
+
await updateTransactionPortsPick(
|
|
284
|
+
targetDir,
|
|
285
|
+
config,
|
|
286
|
+
["billing"],
|
|
287
|
+
"make payments",
|
|
288
|
+
options,
|
|
289
|
+
)
|
|
290
|
+
) {
|
|
291
|
+
updated.add(
|
|
292
|
+
path.join(infrastructureDir(config), "db/transaction-ports.ts"),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (
|
|
296
|
+
await updateDatabaseResetTables(
|
|
297
|
+
targetDir,
|
|
298
|
+
config,
|
|
299
|
+
["billing_accounts"],
|
|
300
|
+
options,
|
|
301
|
+
)
|
|
302
|
+
) {
|
|
303
|
+
updated.add(path.join(infrastructureDir(config), "db/reset.ts"));
|
|
304
|
+
}
|
|
305
|
+
if (await updateBillingSharedErrors(targetDir, config, options)) {
|
|
306
|
+
updated.add(resourceSharedErrorsPath(config));
|
|
307
|
+
}
|
|
308
|
+
if (await updateBillingEnv(targetDir, options)) {
|
|
309
|
+
updated.add("lib/env.ts");
|
|
310
|
+
}
|
|
311
|
+
if (await updateBillingEnvExample(targetDir, options)) {
|
|
312
|
+
updated.add(".env.example");
|
|
313
|
+
}
|
|
314
|
+
if (options.ui && (await updateBillingSettingsNav(targetDir, options))) {
|
|
315
|
+
updated.add("components/settings-nav.tsx");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const routesFile = await updateServerRoutes(
|
|
319
|
+
targetDir,
|
|
320
|
+
billingNames,
|
|
321
|
+
config,
|
|
322
|
+
options,
|
|
323
|
+
);
|
|
324
|
+
if (routesFile) updated.add(routesFile);
|
|
325
|
+
|
|
326
|
+
return [...updated];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function updatePaymentsEntitlementsPort(
|
|
330
|
+
targetDir: string,
|
|
331
|
+
config: ResolvedBeignetConfig,
|
|
332
|
+
options: { dryRun: boolean },
|
|
333
|
+
): Promise<boolean> {
|
|
334
|
+
let changed = false;
|
|
335
|
+
const portsFilePath = path.join(targetDir, config.paths.ports);
|
|
336
|
+
const portsOriginal = await readFile(portsFilePath, "utf8");
|
|
337
|
+
let portsNext = addNamedTypeImport(
|
|
338
|
+
portsOriginal,
|
|
339
|
+
"EntitlementsPort",
|
|
340
|
+
"@beignet/core/entitlements",
|
|
341
|
+
);
|
|
342
|
+
if (!/\bentitlements\s*:/.test(portsNext)) {
|
|
343
|
+
portsNext = insertTypeProperty(
|
|
344
|
+
portsNext,
|
|
345
|
+
"AppPorts",
|
|
346
|
+
"entitlements: EntitlementsPort;",
|
|
347
|
+
`Could not find AppPorts in ${config.paths.ports}. Add entitlements: EntitlementsPort; manually, or restore the generated ports file before running make payments.`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
if (portsNext !== portsOriginal) {
|
|
351
|
+
changed = true;
|
|
352
|
+
if (!options.dryRun) await writeFile(portsFilePath, portsNext);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const infrastructureFilePath = path.join(
|
|
356
|
+
targetDir,
|
|
357
|
+
config.paths.infrastructurePorts,
|
|
358
|
+
);
|
|
359
|
+
const infrastructureOriginal = await readFile(infrastructureFilePath, "utf8");
|
|
360
|
+
const infrastructureNext = appendDeferredPortKey(
|
|
361
|
+
infrastructureOriginal,
|
|
362
|
+
"entitlements",
|
|
363
|
+
);
|
|
364
|
+
if (
|
|
365
|
+
infrastructureNext === infrastructureOriginal &&
|
|
366
|
+
!/["']entitlements["']/.test(infrastructureOriginal)
|
|
367
|
+
) {
|
|
368
|
+
throw new Error(
|
|
369
|
+
`Could not find the deferred port list in ${config.paths.infrastructurePorts}. Add "entitlements" manually, or restore the generated infrastructure ports file before running make payments.`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (infrastructureNext !== infrastructureOriginal) {
|
|
373
|
+
changed = true;
|
|
374
|
+
if (!options.dryRun) {
|
|
375
|
+
await writeFile(infrastructureFilePath, infrastructureNext);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return changed;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export async function updateDatabaseProviderEntitlements(
|
|
383
|
+
targetDir: string,
|
|
384
|
+
config: ResolvedBeignetConfig,
|
|
385
|
+
options: { dryRun: boolean },
|
|
386
|
+
): Promise<boolean> {
|
|
387
|
+
const file = path.join(infrastructureDir(config), "db/provider.ts");
|
|
388
|
+
const filePath = path.join(targetDir, file);
|
|
389
|
+
if (!(await fileExists(filePath))) return false;
|
|
390
|
+
|
|
391
|
+
const original = await readFile(filePath, "utf8");
|
|
392
|
+
let next = original;
|
|
393
|
+
const importLine =
|
|
394
|
+
'import { createBillingEntitlements } from "@/features/billing/entitlements";';
|
|
395
|
+
if (!next.includes(importLine)) {
|
|
396
|
+
next = insertAfterImports(next, importLine);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
next = appendPickStringLiteralMember(next, "AppPorts", "entitlements");
|
|
400
|
+
|
|
401
|
+
if (!/\bconst\s+entitlements\s*=/.test(next)) {
|
|
402
|
+
const withEntitlementsConst = next.replace(
|
|
403
|
+
/(\n\s*const repositories = createRepositories\(dbPort\.(?:drizzle|db)\);)/,
|
|
404
|
+
`$1\n\t\tconst entitlements = createBillingEntitlements(repositories.billing);`,
|
|
405
|
+
);
|
|
406
|
+
if (withEntitlementsConst === next) {
|
|
407
|
+
throw new Error(
|
|
408
|
+
`Could not find createRepositories(dbPort.drizzle) in ${file}. Add const entitlements = createBillingEntitlements(repositories.billing) manually, or restore the generated database provider before running make payments.`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
next = withEntitlementsConst;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (!/\bentitlements\s*,/.test(next)) {
|
|
415
|
+
const withEntitlementsPort = next.replace(
|
|
416
|
+
/(\n\s*\.\.\.repositories,\n)/,
|
|
417
|
+
`$1\t\t\tentitlements,\n`,
|
|
418
|
+
);
|
|
419
|
+
if (withEntitlementsPort === next) {
|
|
420
|
+
throw new Error(
|
|
421
|
+
`Could not find the repository spread in ${file}. Add entitlements to providedPorts manually, or restore the generated database provider before running make payments.`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
next = withEntitlementsPort;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (next === original) return false;
|
|
428
|
+
if (!options.dryRun) await writeFile(filePath, next);
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export async function updateBillingSharedErrors(
|
|
433
|
+
targetDir: string,
|
|
434
|
+
config: ResolvedBeignetConfig,
|
|
435
|
+
options: { dryRun: boolean },
|
|
436
|
+
): Promise<boolean> {
|
|
437
|
+
return appendSharedErrorEntries(
|
|
438
|
+
targetDir,
|
|
439
|
+
config,
|
|
440
|
+
[
|
|
441
|
+
{
|
|
442
|
+
name: "BillingAccountNotFound",
|
|
443
|
+
entry: `BillingAccountNotFound: {
|
|
444
|
+
\t\tcode: "BILLING_ACCOUNT_NOT_FOUND",
|
|
445
|
+
\t\tstatus: 404,
|
|
446
|
+
\t\tmessage: "Billing account not found",
|
|
447
|
+
\t},`,
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
name: "IdempotencyConflict",
|
|
451
|
+
entry: `IdempotencyConflict: {
|
|
452
|
+
\t\tcode: "IDEMPOTENCY_CONFLICT",
|
|
453
|
+
\t\tstatus: 409,
|
|
454
|
+
\t\tmessage: "Idempotency key was already used with a different request",
|
|
455
|
+
\t},`,
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
name: "IdempotencyInProgress",
|
|
459
|
+
entry: `IdempotencyInProgress: {
|
|
460
|
+
\t\tcode: "IDEMPOTENCY_IN_PROGRESS",
|
|
461
|
+
\t\tstatus: 409,
|
|
462
|
+
\t\tmessage: "Idempotency key is already being processed",
|
|
463
|
+
\t},`,
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
name: "BillingCheckoutUnavailable",
|
|
467
|
+
entry: `BillingCheckoutUnavailable: {
|
|
468
|
+
\t\tcode: "BILLING_CHECKOUT_UNAVAILABLE",
|
|
469
|
+
\t\tstatus: 502,
|
|
470
|
+
\t\tmessage: "Billing checkout is unavailable",
|
|
471
|
+
\t},`,
|
|
472
|
+
},
|
|
473
|
+
],
|
|
474
|
+
"make payments",
|
|
475
|
+
options,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export async function updateBillingEnv(
|
|
480
|
+
targetDir: string,
|
|
481
|
+
options: { dryRun: boolean },
|
|
482
|
+
): Promise<boolean> {
|
|
483
|
+
const file = "lib/env.ts";
|
|
484
|
+
const filePath = path.join(targetDir, file);
|
|
485
|
+
const original = await readOptionalFile(filePath);
|
|
486
|
+
if (original === undefined) {
|
|
487
|
+
throw new Error(
|
|
488
|
+
`Could not find ${file}. Add BILLING_PRO_PRICE_ID to your env schema manually, or restore the generated env file before running make payments.`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
if (/\bBILLING_PRO_PRICE_ID\b/.test(original)) return false;
|
|
492
|
+
|
|
493
|
+
const match = /\n([\t ]*)LOG_LEVEL:/.exec(original);
|
|
494
|
+
if (!match) {
|
|
495
|
+
throw new Error(
|
|
496
|
+
`Could not find LOG_LEVEL in ${file}. Add BILLING_PRO_PRICE_ID: z.string().min(1).default("price_pro_example") to the createEnv server block manually, or restore the generated env file before running make payments.`,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const entry = `\n${match[1]}BILLING_PRO_PRICE_ID: z.string().min(1).default("price_pro_example"),`;
|
|
501
|
+
const next = `${original.slice(0, match.index)}${entry}${original.slice(
|
|
502
|
+
match.index,
|
|
503
|
+
)}`;
|
|
504
|
+
|
|
505
|
+
if (!options.dryRun) await writeFile(filePath, next);
|
|
506
|
+
return true;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export async function updateBillingEnvExample(
|
|
510
|
+
targetDir: string,
|
|
511
|
+
options: { dryRun: boolean },
|
|
512
|
+
): Promise<boolean> {
|
|
513
|
+
const file = ".env.example";
|
|
514
|
+
const filePath = path.join(targetDir, file);
|
|
515
|
+
const original = await readOptionalFile(filePath);
|
|
516
|
+
if (original === undefined) return false;
|
|
517
|
+
if (/^BILLING_PRO_PRICE_ID=/m.test(original)) return false;
|
|
518
|
+
|
|
519
|
+
const match = /\nLOG_LEVEL=/.exec(original);
|
|
520
|
+
const next = match
|
|
521
|
+
? `${original.slice(0, match.index)}\nBILLING_PRO_PRICE_ID=price_pro_example${original.slice(
|
|
522
|
+
match.index,
|
|
523
|
+
)}`
|
|
524
|
+
: `${original.trimEnd()}\nBILLING_PRO_PRICE_ID=price_pro_example\n`;
|
|
525
|
+
|
|
526
|
+
if (!options.dryRun) await writeFile(filePath, next);
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export async function updateBillingSettingsNav(
|
|
531
|
+
targetDir: string,
|
|
532
|
+
options: { dryRun: boolean },
|
|
533
|
+
): Promise<boolean> {
|
|
534
|
+
const file = "components/settings-nav.tsx";
|
|
535
|
+
const filePath = path.join(targetDir, file);
|
|
536
|
+
const original = await readOptionalFile(filePath);
|
|
537
|
+
if (original === undefined) return false;
|
|
538
|
+
if (original.includes('"/settings/plan"')) return false;
|
|
539
|
+
|
|
540
|
+
const appended = appendToNamedArray(
|
|
541
|
+
original,
|
|
542
|
+
"settingsLinks",
|
|
543
|
+
'{ href: "/settings/plan", label: "Plan" }',
|
|
544
|
+
);
|
|
545
|
+
if (appended.kind === "missing") {
|
|
546
|
+
throw new Error(
|
|
547
|
+
`Could not find const settingsLinks = [ in ${file}. Add { href: "/settings/plan", label: "Plan" } to the settings navigation manually, or restore the generated settings nav before running make payments.`,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
if (appended.kind === "unchanged") return false;
|
|
551
|
+
|
|
552
|
+
if (!options.dryRun) await writeFile(filePath, appended.source);
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function paymentsFiles(
|
|
557
|
+
config: ResolvedBeignetConfig,
|
|
558
|
+
database: DatabaseName,
|
|
559
|
+
options: { ui: boolean } = { ui: false },
|
|
560
|
+
): GeneratedFile[] {
|
|
561
|
+
const uiFiles: GeneratedFile[] = options.ui
|
|
562
|
+
? [
|
|
563
|
+
{
|
|
564
|
+
path: path.join(config.paths.features, "billing/client/queries.ts"),
|
|
565
|
+
content: billingClientQueriesFile(),
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
path: path.join(
|
|
569
|
+
config.paths.features,
|
|
570
|
+
"billing/components/plan-card.tsx",
|
|
571
|
+
),
|
|
572
|
+
content: billingPlanCardFile(),
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
path: path.join(
|
|
576
|
+
config.paths.features,
|
|
577
|
+
"billing/components/billing-actions.tsx",
|
|
578
|
+
),
|
|
579
|
+
content: billingActionsFile(),
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
path: path.join(config.paths.features, "billing/components/index.ts"),
|
|
583
|
+
content: billingComponentsIndexFile(),
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
path: "app/(app)/settings/plan/page.tsx",
|
|
587
|
+
content: billingPlanPageFile(),
|
|
588
|
+
},
|
|
589
|
+
]
|
|
590
|
+
: [];
|
|
591
|
+
|
|
592
|
+
return [
|
|
593
|
+
{
|
|
594
|
+
path: path.join(config.paths.features, "billing/schemas.ts"),
|
|
595
|
+
content: billingSchemasFile(),
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
path: path.join(config.paths.features, "billing/pricing.ts"),
|
|
599
|
+
content: billingPricingFile(),
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
path: path.join(config.paths.features, "billing/entitlements.ts"),
|
|
603
|
+
content: billingEntitlementsFile(),
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
path: path.join(config.paths.features, "billing/ports.ts"),
|
|
607
|
+
content: billingPortsFile(),
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
path: path.join(config.paths.features, "billing/contracts.ts"),
|
|
611
|
+
content: billingContractsFile(),
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
path: path.join(config.paths.features, "billing/use-cases/index.ts"),
|
|
615
|
+
content: billingUseCasesFile(config),
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
path: path.join(config.paths.features, "billing/routes.ts"),
|
|
619
|
+
content: billingRoutesFile(config),
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
path: path.join(config.paths.features, "billing/tests/billing.test.ts"),
|
|
623
|
+
content: billingTestFile(config),
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
path: path.join(
|
|
627
|
+
infrastructureDir(config),
|
|
628
|
+
"billing/drizzle-billing-repository.ts",
|
|
629
|
+
),
|
|
630
|
+
content: billingDrizzleRepositoryFile(config, database),
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
path: path.join(infrastructureDir(config), "db/schema/billing.ts"),
|
|
634
|
+
content: billingDrizzleSchemaFile(database),
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
path: path.join(config.paths.features, "billing/seeds/index.ts"),
|
|
638
|
+
content: billingSeedsFile(config),
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
path: "app/api/webhooks/payments/route.ts",
|
|
642
|
+
content: billingWebhookRouteFile(),
|
|
643
|
+
},
|
|
644
|
+
...uiFiles,
|
|
645
|
+
];
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function billingSchemasFile(): string {
|
|
649
|
+
return `import { z } from "zod";
|
|
650
|
+
|
|
651
|
+
export const billingPlanSchema = z.enum(["free", "pro"]);
|
|
652
|
+
|
|
653
|
+
export const billingStatusSchema = z.enum([
|
|
654
|
+
\t"inactive",
|
|
655
|
+
\t"trialing",
|
|
656
|
+
\t"active",
|
|
657
|
+
\t"past_due",
|
|
658
|
+
\t"canceled",
|
|
659
|
+
]);
|
|
660
|
+
|
|
661
|
+
export const createCheckoutSessionInputSchema = z.object({
|
|
662
|
+
\tplan: billingPlanSchema,
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
export const createCheckoutSessionOutputSchema = z.object({
|
|
666
|
+
\tsessionId: z.string(),
|
|
667
|
+
\turl: z.string().url(),
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
export const createBillingPortalSessionOutputSchema = z.object({
|
|
671
|
+
\turl: z.string().url(),
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
export const billingStatusOutputSchema = z.object({
|
|
675
|
+
\ttenantId: z.string(),
|
|
676
|
+
\tplan: billingPlanSchema,
|
|
677
|
+
\tstatus: billingStatusSchema,
|
|
678
|
+
\tactive: z.boolean(),
|
|
679
|
+
\tprovider: z.string().optional(),
|
|
680
|
+
\tcustomerId: z.string().optional(),
|
|
681
|
+
\tsubscriptionId: z.string().optional(),
|
|
682
|
+
\tcurrentPeriodEnd: z.string().optional(),
|
|
683
|
+
\tcancelAtPeriodEnd: z.boolean().optional(),
|
|
684
|
+
\tcheckoutSessionId: z.string().optional(),
|
|
685
|
+
\tlastEventId: z.string().optional(),
|
|
686
|
+
\tupdatedAt: z.string().optional(),
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
export type BillingPlan = z.infer<typeof billingPlanSchema>;
|
|
690
|
+
export type BillingStatus = z.infer<typeof billingStatusSchema>;
|
|
691
|
+
export type BillingStatusOutput = z.infer<typeof billingStatusOutputSchema>;
|
|
692
|
+
`;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
export function billingPricingFile(): string {
|
|
696
|
+
return `import type {
|
|
697
|
+
\tPaymentCheckoutLineItem,
|
|
698
|
+
\tPaymentCheckoutMode,
|
|
699
|
+
} from "@beignet/core/payments";
|
|
700
|
+
import { env } from "@/lib/env";
|
|
701
|
+
import type { BillingPlan } from "./schemas";
|
|
702
|
+
|
|
703
|
+
export type BillingEntitlement = "todos.create";
|
|
704
|
+
|
|
705
|
+
export type BillingPlanDefinition = {
|
|
706
|
+
\tid: BillingPlan;
|
|
707
|
+
\tlabel: string;
|
|
708
|
+
\tmode: PaymentCheckoutMode;
|
|
709
|
+
\tlineItems: readonly PaymentCheckoutLineItem[];
|
|
710
|
+
\tentitlements: readonly BillingEntitlement[];
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
export const billingPlans = {
|
|
714
|
+
\tfree: {
|
|
715
|
+
\t\tid: "free",
|
|
716
|
+
\t\tlabel: "Free",
|
|
717
|
+
\t\tmode: "subscription",
|
|
718
|
+
\t\tlineItems: [],
|
|
719
|
+
\t\tentitlements: [],
|
|
720
|
+
\t},
|
|
721
|
+
\tpro: {
|
|
722
|
+
\t\tid: "pro",
|
|
723
|
+
\t\tlabel: "Pro",
|
|
724
|
+
\t\tmode: "subscription",
|
|
725
|
+
\t\tlineItems: [{ priceId: env.BILLING_PRO_PRICE_ID, quantity: 1 }],
|
|
726
|
+
\t\tentitlements: ["todos.create"],
|
|
727
|
+
\t},
|
|
728
|
+
} as const satisfies Record<BillingPlan, BillingPlanDefinition>;
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Free-plan quotas. Call \`requireEntitlement\` only once usage reaches these
|
|
732
|
+
* limits, so the free plan stays a fully working tier.
|
|
733
|
+
*/
|
|
734
|
+
export const FREE_PLAN_LIMITS = {
|
|
735
|
+
\ttodos: 100,
|
|
736
|
+
} as const;
|
|
737
|
+
|
|
738
|
+
export function getBillingPlan(plan: BillingPlan): BillingPlanDefinition {
|
|
739
|
+
\treturn billingPlans[plan];
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
export function billingPlanIncludesEntitlement(
|
|
743
|
+
\tplan: BillingPlan,
|
|
744
|
+
\tentitlement: string,
|
|
745
|
+
): entitlement is BillingEntitlement {
|
|
746
|
+
\treturn getBillingPlan(plan).entitlements.includes(
|
|
747
|
+
\t\tentitlement as BillingEntitlement,
|
|
748
|
+
\t);
|
|
749
|
+
}
|
|
750
|
+
`;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export function billingEntitlementsFile(): string {
|
|
754
|
+
return `import {
|
|
755
|
+
\tcreateEntitlements,
|
|
756
|
+
\tdenyEntitlement,
|
|
757
|
+
} from "@beignet/core/entitlements";
|
|
758
|
+
import { isBillingAccountActive, type BillingRepository } from "./ports";
|
|
759
|
+
import { billingPlanIncludesEntitlement } from "./pricing";
|
|
760
|
+
import type { BillingPlan } from "./schemas";
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Billing-backed entitlements.
|
|
764
|
+
*
|
|
765
|
+
* The free plan is a valid tier: a missing billing account or an inactive
|
|
766
|
+
* subscription resolves to the free plan rather than denying everything.
|
|
767
|
+
* Only entitlements outside the resolved plan are denied.
|
|
768
|
+
*/
|
|
769
|
+
export function createBillingEntitlements(billing: BillingRepository) {
|
|
770
|
+
\treturn createEntitlements({
|
|
771
|
+
\t\tasync inspect(input) {
|
|
772
|
+
\t\t\tif (input.subject.type !== "tenant") {
|
|
773
|
+
\t\t\t\treturn denyEntitlement({
|
|
774
|
+
\t\t\t\t\treason: "Billing entitlements require a tenant subject.",
|
|
775
|
+
\t\t\t\t\tcode: "ENTITLEMENT_SUBJECT_UNSUPPORTED",
|
|
776
|
+
\t\t\t\t\tdetails: { subject: input.subject },
|
|
777
|
+
\t\t\t\t});
|
|
778
|
+
\t\t\t}
|
|
779
|
+
|
|
780
|
+
\t\t\tconst account = await billing.findByTenantId(input.subject.id);
|
|
781
|
+
\t\t\tconst plan: BillingPlan = isBillingAccountActive(account)
|
|
782
|
+
\t\t\t\t? account.plan
|
|
783
|
+
\t\t\t\t: "free";
|
|
784
|
+
|
|
785
|
+
\t\t\tif (!billingPlanIncludesEntitlement(plan, input.entitlement)) {
|
|
786
|
+
\t\t\t\treturn denyEntitlement({
|
|
787
|
+
\t\t\t\t\treason:
|
|
788
|
+
\t\t\t\t\t\t"The current billing plan does not include this entitlement.",
|
|
789
|
+
\t\t\t\t\tcode: "ENTITLEMENT_NOT_INCLUDED",
|
|
790
|
+
\t\t\t\t\tdetails: {
|
|
791
|
+
\t\t\t\t\t\tentitlement: input.entitlement,
|
|
792
|
+
\t\t\t\t\t\tplan,
|
|
793
|
+
\t\t\t\t\t\tsubject: input.subject,
|
|
794
|
+
\t\t\t\t\t},
|
|
795
|
+
\t\t\t\t});
|
|
796
|
+
\t\t\t}
|
|
797
|
+
|
|
798
|
+
\t\t\treturn true;
|
|
799
|
+
\t\t},
|
|
800
|
+
\t});
|
|
801
|
+
}
|
|
802
|
+
`;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
export function billingPortsFile(): string {
|
|
806
|
+
return `import type { BillingPlan, BillingStatus } from "./schemas";
|
|
807
|
+
|
|
808
|
+
export type BillingAccount = {
|
|
809
|
+
\ttenantId: string;
|
|
810
|
+
\tplan: BillingPlan;
|
|
811
|
+
\tstatus: BillingStatus;
|
|
812
|
+
\tprovider: string;
|
|
813
|
+
\tcustomerId?: string;
|
|
814
|
+
\tsubscriptionId?: string;
|
|
815
|
+
\tcurrentPeriodEnd?: string;
|
|
816
|
+
\tcancelAtPeriodEnd?: boolean;
|
|
817
|
+
\tcheckoutSessionId?: string;
|
|
818
|
+
\tlastEventId?: string;
|
|
819
|
+
\tupdatedAt: string;
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
export type SaveBillingAccountInput = BillingAccount;
|
|
823
|
+
|
|
824
|
+
export interface BillingRepository {
|
|
825
|
+
\tfindByTenantId(tenantId: string): Promise<BillingAccount | null>;
|
|
826
|
+
\tfindByCustomerId(
|
|
827
|
+
\t\tcustomerId: string,
|
|
828
|
+
\t\tprovider: string,
|
|
829
|
+
\t): Promise<BillingAccount | null>;
|
|
830
|
+
\tsave(input: SaveBillingAccountInput): Promise<BillingAccount>;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
export function isBillingAccountActive(
|
|
834
|
+
\taccount: BillingAccount | null,
|
|
835
|
+
): account is BillingAccount & { status: "active" | "trialing" } {
|
|
836
|
+
\treturn account?.status === "active" || account?.status === "trialing";
|
|
837
|
+
}
|
|
838
|
+
`;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export function billingContractsFile(): string {
|
|
842
|
+
return `import { defineContractGroup } from "@beignet/core/contracts";
|
|
843
|
+
import { z } from "zod";
|
|
844
|
+
import {
|
|
845
|
+
\tbillingStatusOutputSchema,
|
|
846
|
+
\tcreateBillingPortalSessionOutputSchema,
|
|
847
|
+
\tcreateCheckoutSessionInputSchema,
|
|
848
|
+
\tcreateCheckoutSessionOutputSchema,
|
|
849
|
+
} from "@/features/billing/schemas";
|
|
850
|
+
import { errors } from "@/features/shared/errors";
|
|
851
|
+
|
|
852
|
+
const billing = defineContractGroup()
|
|
853
|
+
\t.namespace("billing")
|
|
854
|
+
\t.prefix("/api/billing");
|
|
855
|
+
const checkoutHeadersSchema = z.object({
|
|
856
|
+
\t"idempotency-key": z.string().min(1),
|
|
857
|
+
});
|
|
858
|
+
const checkoutBilling = billing.headers(checkoutHeadersSchema);
|
|
859
|
+
|
|
860
|
+
export const getBillingStatus = billing
|
|
861
|
+
\t.get("/")
|
|
862
|
+
\t.query(z.object({}))
|
|
863
|
+
\t.meta({ auth: "required" })
|
|
864
|
+
\t.errors({ Unauthorized: errors.Unauthorized })
|
|
865
|
+
\t.responses({
|
|
866
|
+
\t\t200: billingStatusOutputSchema,
|
|
867
|
+
\t})
|
|
868
|
+
\t.openapi({ summary: "Get billing status", tags: ["billing"] });
|
|
869
|
+
|
|
870
|
+
export const createCheckoutSession = checkoutBilling
|
|
871
|
+
\t.post("/checkout")
|
|
872
|
+
\t.body(createCheckoutSessionInputSchema)
|
|
873
|
+
\t.meta({
|
|
874
|
+
\t\tauth: "required",
|
|
875
|
+
\t\tidempotency: {
|
|
876
|
+
\t\t\trequired: true,
|
|
877
|
+
\t\t\theader: "idempotency-key",
|
|
878
|
+
\t\t\tscope: "actor-tenant",
|
|
879
|
+
\t\t\tttlSec: 86_400,
|
|
880
|
+
\t\t},
|
|
881
|
+
\t})
|
|
882
|
+
\t.errors({
|
|
883
|
+
\t\tUnauthorized: errors.Unauthorized,
|
|
884
|
+
\t\tIdempotencyConflict: errors.IdempotencyConflict,
|
|
885
|
+
\t\tIdempotencyInProgress: errors.IdempotencyInProgress,
|
|
886
|
+
\t\tBillingCheckoutUnavailable: errors.BillingCheckoutUnavailable,
|
|
887
|
+
\t})
|
|
888
|
+
\t.responses({
|
|
889
|
+
\t\t201: createCheckoutSessionOutputSchema,
|
|
890
|
+
\t})
|
|
891
|
+
\t.openapi({ summary: "Create a checkout session", tags: ["billing"] });
|
|
892
|
+
|
|
893
|
+
export const createBillingPortalSession = billing
|
|
894
|
+
\t.post("/portal")
|
|
895
|
+
\t.body(z.object({}))
|
|
896
|
+
\t.meta({ auth: "required" })
|
|
897
|
+
\t.errors({
|
|
898
|
+
\t\tUnauthorized: errors.Unauthorized,
|
|
899
|
+
\t\tBillingAccountNotFound: errors.BillingAccountNotFound,
|
|
900
|
+
\t})
|
|
901
|
+
\t.responses({
|
|
902
|
+
\t\t200: createBillingPortalSessionOutputSchema,
|
|
903
|
+
\t})
|
|
904
|
+
\t.openapi({ summary: "Create a billing portal session", tags: ["billing"] });
|
|
905
|
+
|
|
906
|
+
export const billingContracts = [
|
|
907
|
+
\tgetBillingStatus,
|
|
908
|
+
\tcreateCheckoutSession,
|
|
909
|
+
\tcreateBillingPortalSession,
|
|
910
|
+
];
|
|
911
|
+
`;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
export function billingUseCasesFile(config: ResolvedBeignetConfig): string {
|
|
915
|
+
return `import {
|
|
916
|
+
\tcreateIdempotencyFingerprint,
|
|
917
|
+
\trunIdempotently,
|
|
918
|
+
} from "@beignet/core/idempotency";
|
|
919
|
+
import type { PaymentWebhookEvent } from "@beignet/core/payments";
|
|
920
|
+
import { requireUser } from "@beignet/core/ports";
|
|
921
|
+
import { z } from "zod";
|
|
922
|
+
import type { AppContext } from "${aliasModule(config.paths.appContext)}";
|
|
923
|
+
import { appError } from "@/features/shared/errors";
|
|
924
|
+
import { env } from "@/lib/env";
|
|
925
|
+
import { useCase } from "@/lib/use-case";
|
|
926
|
+
import { getBillingPlan } from "../pricing";
|
|
927
|
+
import type { BillingAccount } from "../ports";
|
|
928
|
+
import { isBillingAccountActive } from "../ports";
|
|
929
|
+
import {
|
|
930
|
+
\ttype BillingPlan,
|
|
931
|
+
\ttype BillingStatus,
|
|
932
|
+
\tbillingStatusOutputSchema,
|
|
933
|
+
\tcreateBillingPortalSessionOutputSchema,
|
|
934
|
+
\tcreateCheckoutSessionInputSchema,
|
|
935
|
+
\tcreateCheckoutSessionOutputSchema,
|
|
936
|
+
} from "../schemas";
|
|
937
|
+
|
|
938
|
+
type ProviderPayload = Record<string, unknown>;
|
|
939
|
+
|
|
940
|
+
const paymentWebhookEventSchema = z.custom<PaymentWebhookEvent>(
|
|
941
|
+
\t(value): value is PaymentWebhookEvent =>
|
|
942
|
+
\t\ttypeof value === "object" &&
|
|
943
|
+
\t\tvalue !== null &&
|
|
944
|
+
\t\ttypeof (value as PaymentWebhookEvent).id === "string" &&
|
|
945
|
+
\t\ttypeof (value as PaymentWebhookEvent).type === "string" &&
|
|
946
|
+
\t\ttypeof (value as PaymentWebhookEvent).provider === "string",
|
|
947
|
+
);
|
|
948
|
+
const paymentWebhookOutputSchema = z.object({
|
|
949
|
+
\treceived: z.boolean(),
|
|
950
|
+
\taccountUpdated: z.boolean(),
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
function nowIso(): string {
|
|
954
|
+
\treturn new Date().toISOString();
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function record(value: unknown): ProviderPayload {
|
|
958
|
+
\treturn value && typeof value === "object" && !Array.isArray(value)
|
|
959
|
+
\t\t? (value as ProviderPayload)
|
|
960
|
+
\t\t: {};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function stringValue(value: unknown): string | undefined {
|
|
964
|
+
\treturn typeof value === "string" && value.length > 0 ? value : undefined;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function providerId(value: unknown): string | undefined {
|
|
968
|
+
\tif (typeof value === "string") return stringValue(value);
|
|
969
|
+
\tconst payload = record(value);
|
|
970
|
+
\treturn stringValue(payload.id);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function metadataValue(data: ProviderPayload, key: string): string | undefined {
|
|
974
|
+
\treturn stringValue(record(data.metadata)[key]);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function tenantIdFromPayload(data: ProviderPayload): string | undefined {
|
|
978
|
+
\treturn (
|
|
979
|
+
\t\tstringValue(data.client_reference_id) ??
|
|
980
|
+
\t\tmetadataValue(data, "tenantId") ??
|
|
981
|
+
\t\tmetadataValue(data, "tenant_id")
|
|
982
|
+
\t);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function planFromPayload(data: ProviderPayload): BillingPlan {
|
|
986
|
+
\t// Checkout and subscription webhooks are always about a paid plan, so an
|
|
987
|
+
\t// event without plan metadata resolves to pro rather than free.
|
|
988
|
+
\tconst plan = metadataValue(data, "plan");
|
|
989
|
+
\treturn plan === "free" ? "free" : "pro";
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function statusFromPayload(
|
|
993
|
+
\tdata: ProviderPayload,
|
|
994
|
+
\tfallback: BillingStatus,
|
|
995
|
+
): BillingStatus {
|
|
996
|
+
\tconst status = stringValue(data.status);
|
|
997
|
+
\tswitch (status) {
|
|
998
|
+
\t\tcase "active":
|
|
999
|
+
\t\tcase "trialing":
|
|
1000
|
+
\t\tcase "past_due":
|
|
1001
|
+
\t\tcase "canceled":
|
|
1002
|
+
\t\tcase "inactive":
|
|
1003
|
+
\t\t\treturn status;
|
|
1004
|
+
\t\tcase "unpaid":
|
|
1005
|
+
\t\t\treturn "past_due";
|
|
1006
|
+
\t\tcase "incomplete":
|
|
1007
|
+
\t\t\treturn "inactive";
|
|
1008
|
+
\t\tcase "incomplete_expired":
|
|
1009
|
+
\t\t\treturn "canceled";
|
|
1010
|
+
\t\tcase "paused":
|
|
1011
|
+
\t\t\treturn "inactive";
|
|
1012
|
+
\t\tdefault:
|
|
1013
|
+
\t\t\treturn fallback;
|
|
1014
|
+
\t}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function periodEndFromPayload(data: ProviderPayload): string | undefined {
|
|
1018
|
+
\tconst value = data.current_period_end;
|
|
1019
|
+
\treturn typeof value === "number"
|
|
1020
|
+
\t\t? new Date(value * 1000).toISOString()
|
|
1021
|
+
\t\t: stringValue(value);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function cancelAtPeriodEndFromPayload(
|
|
1025
|
+
\tdata: ProviderPayload,
|
|
1026
|
+
): boolean | undefined {
|
|
1027
|
+
\treturn typeof data.cancel_at_period_end === "boolean"
|
|
1028
|
+
\t\t? data.cancel_at_period_end
|
|
1029
|
+
\t\t: undefined;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function billingTenantId(ctx: AppContext): string {
|
|
1033
|
+
\tconst user = requireUser(ctx);
|
|
1034
|
+
\treturn ctx.tenant?.id ?? user.id;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function billingStatusOutput(tenantId: string, account: BillingAccount | null) {
|
|
1038
|
+
\t// A missing account or an inactive subscription resolves to the free plan;
|
|
1039
|
+
\t// the free plan is the default tier, not a locked state.
|
|
1040
|
+
\tconst plan: BillingPlan = isBillingAccountActive(account)
|
|
1041
|
+
\t\t? account.plan
|
|
1042
|
+
\t\t: "free";
|
|
1043
|
+
|
|
1044
|
+
\treturn {
|
|
1045
|
+
\t\ttenantId,
|
|
1046
|
+
\t\tplan,
|
|
1047
|
+
\t\tstatus: account?.status ?? "inactive",
|
|
1048
|
+
\t\tactive: isBillingAccountActive(account),
|
|
1049
|
+
\t\t...(account?.provider ? { provider: account.provider } : {}),
|
|
1050
|
+
\t\t...(account?.customerId ? { customerId: account.customerId } : {}),
|
|
1051
|
+
\t\t...(account?.subscriptionId
|
|
1052
|
+
\t\t\t? { subscriptionId: account.subscriptionId }
|
|
1053
|
+
\t\t\t: {}),
|
|
1054
|
+
\t\t...(account?.currentPeriodEnd
|
|
1055
|
+
\t\t\t? { currentPeriodEnd: account.currentPeriodEnd }
|
|
1056
|
+
\t\t\t: {}),
|
|
1057
|
+
\t\t...(account?.cancelAtPeriodEnd !== undefined
|
|
1058
|
+
\t\t\t? { cancelAtPeriodEnd: account.cancelAtPeriodEnd }
|
|
1059
|
+
\t\t\t: {}),
|
|
1060
|
+
\t\t...(account?.checkoutSessionId
|
|
1061
|
+
\t\t\t? { checkoutSessionId: account.checkoutSessionId }
|
|
1062
|
+
\t\t\t: {}),
|
|
1063
|
+
\t\t...(account?.lastEventId ? { lastEventId: account.lastEventId } : {}),
|
|
1064
|
+
\t\t...(account?.updatedAt ? { updatedAt: account.updatedAt } : {}),
|
|
1065
|
+
\t};
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
async function saveBillingAccount(args: {
|
|
1069
|
+
\texisting: BillingAccount | null;
|
|
1070
|
+
\ttenantId: string;
|
|
1071
|
+
\tprovider: string;
|
|
1072
|
+
\tplan?: BillingPlan;
|
|
1073
|
+
\tstatus?: BillingStatus;
|
|
1074
|
+
\tcustomerId?: string;
|
|
1075
|
+
\tsubscriptionId?: string;
|
|
1076
|
+
\tcurrentPeriodEnd?: string;
|
|
1077
|
+
\tcancelAtPeriodEnd?: boolean;
|
|
1078
|
+
\tcheckoutSessionId?: string;
|
|
1079
|
+
\tlastEventId?: string;
|
|
1080
|
+
\tsave: (account: BillingAccount) => Promise<BillingAccount>;
|
|
1081
|
+
}) {
|
|
1082
|
+
\treturn args.save({
|
|
1083
|
+
\t\ttenantId: args.tenantId,
|
|
1084
|
+
\t\tprovider: args.provider,
|
|
1085
|
+
\t\tplan: args.plan ?? args.existing?.plan ?? "free",
|
|
1086
|
+
\t\tstatus: args.status ?? args.existing?.status ?? "inactive",
|
|
1087
|
+
\t\tcustomerId: args.customerId ?? args.existing?.customerId,
|
|
1088
|
+
\t\tsubscriptionId: args.subscriptionId ?? args.existing?.subscriptionId,
|
|
1089
|
+
\t\tcurrentPeriodEnd:
|
|
1090
|
+
\t\t\targs.currentPeriodEnd ?? args.existing?.currentPeriodEnd,
|
|
1091
|
+
\t\tcancelAtPeriodEnd:
|
|
1092
|
+
\t\t\targs.cancelAtPeriodEnd ?? args.existing?.cancelAtPeriodEnd,
|
|
1093
|
+
\t\tcheckoutSessionId:
|
|
1094
|
+
\t\t\targs.checkoutSessionId ?? args.existing?.checkoutSessionId,
|
|
1095
|
+
\t\tlastEventId: args.lastEventId ?? args.existing?.lastEventId,
|
|
1096
|
+
\t\tupdatedAt: nowIso(),
|
|
1097
|
+
\t});
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
async function applyCheckoutCompleted(
|
|
1101
|
+
\tevent: PaymentWebhookEvent,
|
|
1102
|
+
\tsave: (account: BillingAccount) => Promise<BillingAccount>,
|
|
1103
|
+
\tfindByTenantId: (tenantId: string) => Promise<BillingAccount | null>,
|
|
1104
|
+
) {
|
|
1105
|
+
\tconst data = record(event.data);
|
|
1106
|
+
\tconst tenantId = tenantIdFromPayload(data);
|
|
1107
|
+
\tif (!tenantId) return null;
|
|
1108
|
+
|
|
1109
|
+
\tconst existing = await findByTenantId(tenantId);
|
|
1110
|
+
\treturn saveBillingAccount({
|
|
1111
|
+
\t\texisting,
|
|
1112
|
+
\t\ttenantId,
|
|
1113
|
+
\t\tprovider: event.provider,
|
|
1114
|
+
\t\tplan: planFromPayload(data),
|
|
1115
|
+
\t\tstatus: "active",
|
|
1116
|
+
\t\tcustomerId: providerId(data.customer),
|
|
1117
|
+
\t\tsubscriptionId: providerId(data.subscription),
|
|
1118
|
+
\t\tcheckoutSessionId: stringValue(data.id),
|
|
1119
|
+
\t\tlastEventId: event.id,
|
|
1120
|
+
\t\tsave,
|
|
1121
|
+
\t});
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
async function applySubscriptionEvent(
|
|
1125
|
+
\tevent: PaymentWebhookEvent,
|
|
1126
|
+
\tsave: (account: BillingAccount) => Promise<BillingAccount>,
|
|
1127
|
+
\tfindByCustomerId: (
|
|
1128
|
+
\t\tcustomerId: string,
|
|
1129
|
+
\t\tprovider: string,
|
|
1130
|
+
\t) => Promise<BillingAccount | null>,
|
|
1131
|
+
\tfindByTenantId: (tenantId: string) => Promise<BillingAccount | null>,
|
|
1132
|
+
) {
|
|
1133
|
+
\tconst data = record(event.data);
|
|
1134
|
+
\tconst customerId = providerId(data.customer);
|
|
1135
|
+
\tconst explicitTenantId = tenantIdFromPayload(data);
|
|
1136
|
+
\tconst existing = explicitTenantId
|
|
1137
|
+
\t\t? await findByTenantId(explicitTenantId)
|
|
1138
|
+
\t\t: customerId
|
|
1139
|
+
\t\t\t? await findByCustomerId(customerId, event.provider)
|
|
1140
|
+
\t\t\t: null;
|
|
1141
|
+
\tconst tenantId = explicitTenantId ?? existing?.tenantId;
|
|
1142
|
+
\tif (!tenantId) return null;
|
|
1143
|
+
|
|
1144
|
+
\treturn saveBillingAccount({
|
|
1145
|
+
\t\texisting,
|
|
1146
|
+
\t\ttenantId,
|
|
1147
|
+
\t\tprovider: event.provider,
|
|
1148
|
+
\t\tplan: planFromPayload(data),
|
|
1149
|
+
\t\tstatus:
|
|
1150
|
+
\t\t\tevent.type === "customer.subscription.deleted"
|
|
1151
|
+
\t\t\t\t? "canceled"
|
|
1152
|
+
\t\t\t\t: statusFromPayload(data, existing?.status ?? "active"),
|
|
1153
|
+
\t\tcustomerId,
|
|
1154
|
+
\t\tsubscriptionId: providerId(data.id) ?? existing?.subscriptionId,
|
|
1155
|
+
\t\tcurrentPeriodEnd: periodEndFromPayload(data),
|
|
1156
|
+
\t\tcancelAtPeriodEnd:
|
|
1157
|
+
\t\t\tevent.type === "customer.subscription.deleted"
|
|
1158
|
+
\t\t\t\t? false
|
|
1159
|
+
\t\t\t\t: cancelAtPeriodEndFromPayload(data),
|
|
1160
|
+
\t\tlastEventId: event.id,
|
|
1161
|
+
\t\tsave,
|
|
1162
|
+
\t});
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
async function applyInvoiceSucceeded(
|
|
1166
|
+
\tevent: PaymentWebhookEvent,
|
|
1167
|
+
\tsave: (account: BillingAccount) => Promise<BillingAccount>,
|
|
1168
|
+
\tfindByCustomerId: (
|
|
1169
|
+
\t\tcustomerId: string,
|
|
1170
|
+
\t\tprovider: string,
|
|
1171
|
+
\t) => Promise<BillingAccount | null>,
|
|
1172
|
+
) {
|
|
1173
|
+
\tconst data = record(event.data);
|
|
1174
|
+
\tconst customerId = providerId(data.customer);
|
|
1175
|
+
\tif (!customerId) return null;
|
|
1176
|
+
|
|
1177
|
+
\tconst existing = await findByCustomerId(customerId, event.provider);
|
|
1178
|
+
\tif (!existing) return null;
|
|
1179
|
+
\tif (existing.status === "canceled") return null;
|
|
1180
|
+
|
|
1181
|
+
\treturn saveBillingAccount({
|
|
1182
|
+
\t\texisting,
|
|
1183
|
+
\t\ttenantId: existing.tenantId,
|
|
1184
|
+
\t\tprovider: event.provider,
|
|
1185
|
+
\t\tstatus: "active",
|
|
1186
|
+
\t\tcustomerId,
|
|
1187
|
+
\t\tsubscriptionId: providerId(data.subscription),
|
|
1188
|
+
\t\tcancelAtPeriodEnd: cancelAtPeriodEndFromPayload(data),
|
|
1189
|
+
\t\tlastEventId: event.id,
|
|
1190
|
+
\t\tsave,
|
|
1191
|
+
\t});
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
async function applyInvoiceFailed(
|
|
1195
|
+
\tevent: PaymentWebhookEvent,
|
|
1196
|
+
\tsave: (account: BillingAccount) => Promise<BillingAccount>,
|
|
1197
|
+
\tfindByCustomerId: (
|
|
1198
|
+
\t\tcustomerId: string,
|
|
1199
|
+
\t\tprovider: string,
|
|
1200
|
+
\t) => Promise<BillingAccount | null>,
|
|
1201
|
+
) {
|
|
1202
|
+
\tconst data = record(event.data);
|
|
1203
|
+
\tconst customerId = providerId(data.customer);
|
|
1204
|
+
\tif (!customerId) return null;
|
|
1205
|
+
|
|
1206
|
+
\tconst existing = await findByCustomerId(customerId, event.provider);
|
|
1207
|
+
\tif (!existing) return null;
|
|
1208
|
+
\tif (existing.status === "canceled") return null;
|
|
1209
|
+
|
|
1210
|
+
\treturn saveBillingAccount({
|
|
1211
|
+
\t\texisting,
|
|
1212
|
+
\t\ttenantId: existing.tenantId,
|
|
1213
|
+
\t\tprovider: event.provider,
|
|
1214
|
+
\t\tstatus: "past_due",
|
|
1215
|
+
\t\tcustomerId,
|
|
1216
|
+
\t\tsubscriptionId: providerId(data.subscription),
|
|
1217
|
+
\t\tlastEventId: event.id,
|
|
1218
|
+
\t\tsave,
|
|
1219
|
+
\t});
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
export const getBillingStatusUseCase = useCase
|
|
1223
|
+
\t.query("billing.getStatus")
|
|
1224
|
+
\t.input(z.object({}))
|
|
1225
|
+
\t.output(billingStatusOutputSchema)
|
|
1226
|
+
\t.run(async ({ ctx }) => {
|
|
1227
|
+
\t\tconst tenantId = billingTenantId(ctx);
|
|
1228
|
+
\t\tconst account = await ctx.ports.billing.findByTenantId(tenantId);
|
|
1229
|
+
\t\treturn billingStatusOutput(tenantId, account);
|
|
1230
|
+
\t});
|
|
1231
|
+
|
|
1232
|
+
export const createCheckoutSessionUseCase = useCase
|
|
1233
|
+
\t.command("billing.createCheckoutSession")
|
|
1234
|
+
\t.input(createCheckoutSessionInputSchema)
|
|
1235
|
+
\t.output(createCheckoutSessionOutputSchema)
|
|
1236
|
+
\t.run(async ({ ctx, input }) => {
|
|
1237
|
+
\t\tconst tenantId = billingTenantId(ctx);
|
|
1238
|
+
\t\tconst account = await ctx.ports.billing.findByTenantId(tenantId);
|
|
1239
|
+
\t\tconst plan = getBillingPlan(input.plan);
|
|
1240
|
+
\t\tconst session = await ctx.ports.payments.createCheckoutSession({
|
|
1241
|
+
\t\t\tmode: plan.mode,
|
|
1242
|
+
\t\t\tlineItems: plan.lineItems,
|
|
1243
|
+
\t\t\tsuccessUrl: \`\${env.APP_URL}/settings/plan?checkout=success\`,
|
|
1244
|
+
\t\t\tcancelUrl: \`\${env.APP_URL}/settings/plan?checkout=cancelled\`,
|
|
1245
|
+
\t\t\tcustomerId: account?.customerId,
|
|
1246
|
+
\t\t\tclientReferenceId: tenantId,
|
|
1247
|
+
\t\t\tmetadata: {
|
|
1248
|
+
\t\t\t\ttenantId,
|
|
1249
|
+
\t\t\t\tplan: input.plan,
|
|
1250
|
+
\t\t\t},
|
|
1251
|
+
\t\t});
|
|
1252
|
+
|
|
1253
|
+
\t\tif (!session.url) {
|
|
1254
|
+
\t\t\tthrow appError("BillingCheckoutUnavailable", {
|
|
1255
|
+
\t\t\t\tdetails: { provider: session.provider, sessionId: session.id },
|
|
1256
|
+
\t\t\t});
|
|
1257
|
+
\t\t}
|
|
1258
|
+
|
|
1259
|
+
\t\tawait ctx.ports.billing.save({
|
|
1260
|
+
\t\t\ttenantId,
|
|
1261
|
+
\t\t\tprovider: session.provider,
|
|
1262
|
+
\t\t\tplan: input.plan,
|
|
1263
|
+
\t\t\tstatus: account?.status ?? "inactive",
|
|
1264
|
+
\t\t\tcustomerId: session.customerId ?? account?.customerId,
|
|
1265
|
+
\t\t\tsubscriptionId: account?.subscriptionId,
|
|
1266
|
+
\t\t\tcurrentPeriodEnd: account?.currentPeriodEnd,
|
|
1267
|
+
\t\t\tcancelAtPeriodEnd: account?.cancelAtPeriodEnd,
|
|
1268
|
+
\t\t\tcheckoutSessionId: session.id,
|
|
1269
|
+
\t\t\tlastEventId: account?.lastEventId,
|
|
1270
|
+
\t\t\tupdatedAt: nowIso(),
|
|
1271
|
+
\t\t});
|
|
1272
|
+
|
|
1273
|
+
\t\treturn {
|
|
1274
|
+
\t\t\tsessionId: session.id,
|
|
1275
|
+
\t\t\turl: session.url,
|
|
1276
|
+
\t\t};
|
|
1277
|
+
\t});
|
|
1278
|
+
|
|
1279
|
+
export const createBillingPortalSessionUseCase = useCase
|
|
1280
|
+
\t.command("billing.createPortalSession")
|
|
1281
|
+
\t.input(z.object({}))
|
|
1282
|
+
\t.output(createBillingPortalSessionOutputSchema)
|
|
1283
|
+
\t.run(async ({ ctx }) => {
|
|
1284
|
+
\t\tconst tenantId = billingTenantId(ctx);
|
|
1285
|
+
\t\tconst account = await ctx.ports.billing.findByTenantId(tenantId);
|
|
1286
|
+
|
|
1287
|
+
\t\tif (!account?.customerId) {
|
|
1288
|
+
\t\t\tthrow appError("BillingAccountNotFound", {
|
|
1289
|
+
\t\t\t\tdetails: { tenantId },
|
|
1290
|
+
\t\t\t});
|
|
1291
|
+
\t\t}
|
|
1292
|
+
|
|
1293
|
+
\t\tconst session = await ctx.ports.payments.createBillingPortalSession({
|
|
1294
|
+
\t\t\tcustomerId: account.customerId,
|
|
1295
|
+
\t\t\treturnUrl: \`\${env.APP_URL}/settings/plan\`,
|
|
1296
|
+
\t\t});
|
|
1297
|
+
|
|
1298
|
+
\t\treturn { url: session.url };
|
|
1299
|
+
\t});
|
|
1300
|
+
|
|
1301
|
+
export const handlePaymentWebhookUseCase = useCase
|
|
1302
|
+
\t.command("billing.handlePaymentWebhook")
|
|
1303
|
+
\t.input(paymentWebhookEventSchema)
|
|
1304
|
+
\t.output(paymentWebhookOutputSchema)
|
|
1305
|
+
\t.run(async ({ ctx, input }) => {
|
|
1306
|
+
\t\tconst fingerprint = await createIdempotencyFingerprint({
|
|
1307
|
+
\t\t\ttype: input.type,
|
|
1308
|
+
\t\t\tdata: input.data,
|
|
1309
|
+
\t\t});
|
|
1310
|
+
|
|
1311
|
+
\t\treturn runIdempotently(ctx.ports.idempotency, {
|
|
1312
|
+
\t\t\tnamespace: "webhooks.payments",
|
|
1313
|
+
\t\t\tkey: input.id,
|
|
1314
|
+
\t\t\tscope: { provider: input.provider },
|
|
1315
|
+
\t\t\tfingerprint,
|
|
1316
|
+
\t\t\tttlSec: 60 * 60 * 24 * 30,
|
|
1317
|
+
\t\t\trun: () =>
|
|
1318
|
+
\t\t\t\tctx.ports.uow.transaction(async (tx) => {
|
|
1319
|
+
\t\t\t\t\tlet account: BillingAccount | null = null;
|
|
1320
|
+
|
|
1321
|
+
\t\t\t\t\tif (input.type === "checkout.session.completed") {
|
|
1322
|
+
\t\t\t\t\t\taccount = await applyCheckoutCompleted(
|
|
1323
|
+
\t\t\t\t\t\t\tinput,
|
|
1324
|
+
\t\t\t\t\t\t\ttx.billing.save,
|
|
1325
|
+
\t\t\t\t\t\t\ttx.billing.findByTenantId,
|
|
1326
|
+
\t\t\t\t\t\t);
|
|
1327
|
+
\t\t\t\t\t} else if (input.type.startsWith("customer.subscription.")) {
|
|
1328
|
+
\t\t\t\t\t\taccount = await applySubscriptionEvent(
|
|
1329
|
+
\t\t\t\t\t\t\tinput,
|
|
1330
|
+
\t\t\t\t\t\t\ttx.billing.save,
|
|
1331
|
+
\t\t\t\t\t\t\ttx.billing.findByCustomerId,
|
|
1332
|
+
\t\t\t\t\t\t\ttx.billing.findByTenantId,
|
|
1333
|
+
\t\t\t\t\t\t);
|
|
1334
|
+
\t\t\t\t\t} else if (input.type === "invoice.payment_succeeded") {
|
|
1335
|
+
\t\t\t\t\t\taccount = await applyInvoiceSucceeded(
|
|
1336
|
+
\t\t\t\t\t\t\tinput,
|
|
1337
|
+
\t\t\t\t\t\t\ttx.billing.save,
|
|
1338
|
+
\t\t\t\t\t\t\ttx.billing.findByCustomerId,
|
|
1339
|
+
\t\t\t\t\t\t);
|
|
1340
|
+
\t\t\t\t\t} else if (input.type === "invoice.payment_failed") {
|
|
1341
|
+
\t\t\t\t\t\taccount = await applyInvoiceFailed(
|
|
1342
|
+
\t\t\t\t\t\t\tinput,
|
|
1343
|
+
\t\t\t\t\t\t\ttx.billing.save,
|
|
1344
|
+
\t\t\t\t\t\t\ttx.billing.findByCustomerId,
|
|
1345
|
+
\t\t\t\t\t\t);
|
|
1346
|
+
\t\t\t\t\t}
|
|
1347
|
+
|
|
1348
|
+
\t\t\t\t\treturn {
|
|
1349
|
+
\t\t\t\t\t\treceived: true,
|
|
1350
|
+
\t\t\t\t\t\taccountUpdated: Boolean(account),
|
|
1351
|
+
\t\t\t\t\t};
|
|
1352
|
+
\t\t\t\t}),
|
|
1353
|
+
\t\t});
|
|
1354
|
+
\t});
|
|
1355
|
+
`;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
export function billingRoutesFile(config: ResolvedBeignetConfig): string {
|
|
1359
|
+
const routeFilePath = path.join(config.paths.features, "billing/routes.ts");
|
|
1360
|
+
|
|
1361
|
+
return `import { defineRouteGroup } from "@beignet/next";
|
|
1362
|
+
import type { AppContext } from "${relativeModule(routeFilePath, config.paths.appContext)}";
|
|
1363
|
+
import * as billingContracts from "@/features/billing/contracts";
|
|
1364
|
+
import * as billingUseCases from "@/features/billing/use-cases";
|
|
1365
|
+
|
|
1366
|
+
export const billingRoutes = defineRouteGroup<AppContext>()({
|
|
1367
|
+
\tname: "billing",
|
|
1368
|
+
\troutes: [
|
|
1369
|
+
\t\t{
|
|
1370
|
+
\t\t\tcontract: billingContracts.getBillingStatus,
|
|
1371
|
+
\t\t\tuseCase: billingUseCases.getBillingStatusUseCase,
|
|
1372
|
+
\t\t},
|
|
1373
|
+
\t\t{
|
|
1374
|
+
\t\t\tcontract: billingContracts.createCheckoutSession,
|
|
1375
|
+
\t\t\tuseCase: billingUseCases.createCheckoutSessionUseCase,
|
|
1376
|
+
\t\t},
|
|
1377
|
+
\t\t{
|
|
1378
|
+
\t\t\tcontract: billingContracts.createBillingPortalSession,
|
|
1379
|
+
\t\t\tuseCase: billingUseCases.createBillingPortalSessionUseCase,
|
|
1380
|
+
\t\t},
|
|
1381
|
+
\t],
|
|
1382
|
+
});
|
|
1383
|
+
`;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
export function billingTestFile(config: ResolvedBeignetConfig): string {
|
|
1387
|
+
return `import { describe, expect, it } from "bun:test";
|
|
1388
|
+
import { createUseCaseTester } from "@beignet/core/application";
|
|
1389
|
+
import { requireEntitlement } from "@beignet/core/entitlements";
|
|
1390
|
+
import { createAnonymousActor } from "@beignet/core/ports";
|
|
1391
|
+
import { createTestUserActor } from "@beignet/core/ports/testing";
|
|
1392
|
+
import {
|
|
1393
|
+
\tcreateTestContextFactory,
|
|
1394
|
+
\tcreateTestPorts,
|
|
1395
|
+
} from "@beignet/core/testing";
|
|
1396
|
+
import type { AppContext } from "${aliasModule(config.paths.appContext)}";
|
|
1397
|
+
import { appPorts } from "@/infra/app-ports";
|
|
1398
|
+
import type { AppTransactionPorts } from "@/ports";
|
|
1399
|
+
import { createBillingEntitlements } from "../entitlements";
|
|
1400
|
+
import type { BillingAccount, BillingRepository } from "../ports";
|
|
1401
|
+
import {
|
|
1402
|
+
\tcreateBillingPortalSessionUseCase,
|
|
1403
|
+
\tcreateCheckoutSessionUseCase,
|
|
1404
|
+
\tgetBillingStatusUseCase,
|
|
1405
|
+
\thandlePaymentWebhookUseCase,
|
|
1406
|
+
} from "../use-cases";
|
|
1407
|
+
|
|
1408
|
+
function createMemoryBillingRepository(): BillingRepository {
|
|
1409
|
+
\tconst accounts = new Map<string, BillingAccount>();
|
|
1410
|
+
|
|
1411
|
+
\treturn {
|
|
1412
|
+
\t\tasync findByTenantId(tenantId) {
|
|
1413
|
+
\t\t\treturn accounts.get(tenantId) ?? null;
|
|
1414
|
+
\t\t},
|
|
1415
|
+
|
|
1416
|
+
\t\tasync findByCustomerId(customerId, provider) {
|
|
1417
|
+
\t\t\treturn (
|
|
1418
|
+
\t\t\t\t[...accounts.values()].find(
|
|
1419
|
+
\t\t\t\t\t(account) =>
|
|
1420
|
+
\t\t\t\t\t\taccount.customerId === customerId &&
|
|
1421
|
+
\t\t\t\t\t\taccount.provider === provider,
|
|
1422
|
+
\t\t\t\t) ?? null
|
|
1423
|
+
\t\t\t);
|
|
1424
|
+
\t\t},
|
|
1425
|
+
|
|
1426
|
+
\t\tasync save(input) {
|
|
1427
|
+
\t\t\taccounts.set(input.tenantId, { ...input });
|
|
1428
|
+
\t\t\treturn input;
|
|
1429
|
+
\t\t},
|
|
1430
|
+
\t};
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function createHarness(options: { actor?: AppContext["actor"] } = {}) {
|
|
1434
|
+
\tconst billing = createMemoryBillingRepository();
|
|
1435
|
+
\tconst actor = options.actor ?? createTestUserActor("user_test");
|
|
1436
|
+
\tconst fixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
|
|
1437
|
+
\t\tbase: appPorts,
|
|
1438
|
+
\t\toverrides: {
|
|
1439
|
+
\t\t\tbilling,
|
|
1440
|
+
\t\t\tentitlements: createBillingEntitlements(billing),
|
|
1441
|
+
\t\t\tgate: appPorts.gate,
|
|
1442
|
+
\t\t},
|
|
1443
|
+
\t});
|
|
1444
|
+
\tconst createContext = createTestContextFactory<
|
|
1445
|
+
\t\tAppContext,
|
|
1446
|
+
\t\tAppContext["ports"]
|
|
1447
|
+
\t>({
|
|
1448
|
+
\t\tports: fixture.ports,
|
|
1449
|
+
\t\tactor,
|
|
1450
|
+
\t\tauth:
|
|
1451
|
+
\t\t\tactor.type === "user" && actor.id
|
|
1452
|
+
\t\t\t\t? { user: { id: actor.id }, session: { id: "session_test" } }
|
|
1453
|
+
\t\t\t\t: null,
|
|
1454
|
+
\t\trequestId: "test-request",
|
|
1455
|
+
\t\ttraceId: "test-trace",
|
|
1456
|
+
\t\ttenant: { id: "tenant_example" },
|
|
1457
|
+
\t});
|
|
1458
|
+
|
|
1459
|
+
\treturn {
|
|
1460
|
+
\t\tbilling,
|
|
1461
|
+
\t\tentitlements: fixture.ports.entitlements,
|
|
1462
|
+
\t\tpayments: fixture.payments,
|
|
1463
|
+
\t\ttester: createUseCaseTester<AppContext>(createContext),
|
|
1464
|
+
\t};
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
describe("billing use cases", () => {
|
|
1468
|
+
\tit("requires an authenticated user to read billing status", async () => {
|
|
1469
|
+
\t\tconst harness = createHarness({ actor: createAnonymousActor() });
|
|
1470
|
+
|
|
1471
|
+
\t\tawait expect(
|
|
1472
|
+
\t\t\tharness.tester.run(getBillingStatusUseCase, {}),
|
|
1473
|
+
\t\t).rejects.toMatchObject({
|
|
1474
|
+
\t\t\tcode: "UNAUTHORIZED",
|
|
1475
|
+
\t\t});
|
|
1476
|
+
\t});
|
|
1477
|
+
|
|
1478
|
+
\tit("creates checkout sessions through the payments port and records billing state", async () => {
|
|
1479
|
+
\t\tconst harness = createHarness();
|
|
1480
|
+
|
|
1481
|
+
\t\tconst checkout = await harness.tester.run(createCheckoutSessionUseCase, {
|
|
1482
|
+
\t\t\tplan: "pro",
|
|
1483
|
+
\t\t});
|
|
1484
|
+
|
|
1485
|
+
\t\texpect(checkout.sessionId).toMatch(/^checkout_/);
|
|
1486
|
+
\t\texpect(checkout.url).toContain("/checkout/");
|
|
1487
|
+
\t\texpect(harness.payments.checkoutSessions).toHaveLength(1);
|
|
1488
|
+
\t\texpect(harness.payments.checkoutSessions[0]?.input).toMatchObject({
|
|
1489
|
+
\t\t\tmode: "subscription",
|
|
1490
|
+
\t\t\tlineItems: [{ priceId: "price_pro_example", quantity: 1 }],
|
|
1491
|
+
\t\t\tclientReferenceId: "tenant_example",
|
|
1492
|
+
\t\t\tmetadata: {
|
|
1493
|
+
\t\t\t\ttenantId: "tenant_example",
|
|
1494
|
+
\t\t\t\tplan: "pro",
|
|
1495
|
+
\t\t\t},
|
|
1496
|
+
\t\t});
|
|
1497
|
+
\t\texpect(
|
|
1498
|
+
\t\t\tharness.payments.checkoutSessions[0]?.input.idempotencyKey,
|
|
1499
|
+
\t\t).toBe(undefined);
|
|
1500
|
+
\t\tawait expect(
|
|
1501
|
+
\t\t\tharness.billing.findByTenantId("tenant_example"),
|
|
1502
|
+
\t\t).resolves.toMatchObject({
|
|
1503
|
+
\t\t\tcheckoutSessionId: checkout.sessionId,
|
|
1504
|
+
\t\t\tplan: "pro",
|
|
1505
|
+
\t\t\tstatus: "inactive",
|
|
1506
|
+
\t\t});
|
|
1507
|
+
\t});
|
|
1508
|
+
|
|
1509
|
+
\tit("creates billing portal sessions for tenants with provider customers", async () => {
|
|
1510
|
+
\t\tconst harness = createHarness();
|
|
1511
|
+
\t\tawait harness.billing.save({
|
|
1512
|
+
\t\t\ttenantId: "tenant_example",
|
|
1513
|
+
\t\t\tprovider: "memory",
|
|
1514
|
+
\t\t\tplan: "pro",
|
|
1515
|
+
\t\t\tstatus: "active",
|
|
1516
|
+
\t\t\tcustomerId: "cus_123",
|
|
1517
|
+
\t\t\tupdatedAt: new Date(0).toISOString(),
|
|
1518
|
+
\t\t});
|
|
1519
|
+
|
|
1520
|
+
\t\tconst portal = await harness.tester.run(
|
|
1521
|
+
\t\t\tcreateBillingPortalSessionUseCase,
|
|
1522
|
+
\t\t\t{},
|
|
1523
|
+
\t\t);
|
|
1524
|
+
|
|
1525
|
+
\t\texpect(portal.url).toContain("/portal/");
|
|
1526
|
+
\t\texpect(harness.payments.billingPortalSessions).toHaveLength(1);
|
|
1527
|
+
\t\texpect(harness.payments.billingPortalSessions[0]?.input.customerId).toBe(
|
|
1528
|
+
\t\t\t"cus_123",
|
|
1529
|
+
\t\t);
|
|
1530
|
+
\t});
|
|
1531
|
+
|
|
1532
|
+
\tit.each(["active", "trialing"] as const)(
|
|
1533
|
+
\t\t"grants pro entitlements for %s accounts",
|
|
1534
|
+
\t\tasync (status) => {
|
|
1535
|
+
\t\t\tconst harness = createHarness();
|
|
1536
|
+
\t\t\tawait harness.billing.save({
|
|
1537
|
+
\t\t\t\ttenantId: "tenant_example",
|
|
1538
|
+
\t\t\t\tprovider: "stripe",
|
|
1539
|
+
\t\t\t\tplan: "pro",
|
|
1540
|
+
\t\t\t\tstatus,
|
|
1541
|
+
\t\t\t\tupdatedAt: new Date(0).toISOString(),
|
|
1542
|
+
\t\t\t});
|
|
1543
|
+
|
|
1544
|
+
\t\t\tawait expect(
|
|
1545
|
+
\t\t\t\tharness.entitlements.can({
|
|
1546
|
+
\t\t\t\t\tentitlement: "todos.create",
|
|
1547
|
+
\t\t\t\t\tsubject: { type: "tenant", id: "tenant_example" },
|
|
1548
|
+
\t\t\t\t}),
|
|
1549
|
+
\t\t\t).resolves.toBe(true);
|
|
1550
|
+
\t\t},
|
|
1551
|
+
\t);
|
|
1552
|
+
|
|
1553
|
+
\tit.each(["inactive", "past_due", "canceled"] as const)(
|
|
1554
|
+
\t\t"resolves %s accounts to the free plan and denies pro entitlements",
|
|
1555
|
+
\t\tasync (status) => {
|
|
1556
|
+
\t\t\tconst harness = createHarness();
|
|
1557
|
+
\t\t\tawait harness.billing.save({
|
|
1558
|
+
\t\t\t\ttenantId: "tenant_example",
|
|
1559
|
+
\t\t\t\tprovider: "stripe",
|
|
1560
|
+
\t\t\t\tplan: "pro",
|
|
1561
|
+
\t\t\t\tstatus,
|
|
1562
|
+
\t\t\t\tupdatedAt: new Date(0).toISOString(),
|
|
1563
|
+
\t\t\t});
|
|
1564
|
+
|
|
1565
|
+
\t\t\tawait expect(
|
|
1566
|
+
\t\t\t\tharness.entitlements.inspect({
|
|
1567
|
+
\t\t\t\t\tentitlement: "todos.create",
|
|
1568
|
+
\t\t\t\t\tsubject: { type: "tenant", id: "tenant_example" },
|
|
1569
|
+
\t\t\t\t}),
|
|
1570
|
+
\t\t\t).resolves.toMatchObject({
|
|
1571
|
+
\t\t\t\tallowed: false,
|
|
1572
|
+
\t\t\t\tcode: "ENTITLEMENT_NOT_INCLUDED",
|
|
1573
|
+
\t\t\t\tdetails: { plan: "free" },
|
|
1574
|
+
\t\t\t});
|
|
1575
|
+
\t\t},
|
|
1576
|
+
\t);
|
|
1577
|
+
|
|
1578
|
+
\tit("gates paid actions with requireEntitlement once the free limit is reached", async () => {
|
|
1579
|
+
\t\tconst harness = createHarness();
|
|
1580
|
+
\t\tconst ctx = { ports: { entitlements: harness.entitlements } };
|
|
1581
|
+
\t\tconst subject = { type: "tenant", id: "tenant_example" } as const;
|
|
1582
|
+
|
|
1583
|
+
\t\t// In a use case, count usage first and only gate past the free limit:
|
|
1584
|
+
\t\t//
|
|
1585
|
+
\t\t// if (todoCount >= FREE_PLAN_LIMITS.todos) {
|
|
1586
|
+
\t\t// await requireEntitlement(ctx, {
|
|
1587
|
+
\t\t// entitlement: "todos.create",
|
|
1588
|
+
\t\t// subject: { type: "tenant", id: tenantId },
|
|
1589
|
+
\t\t// });
|
|
1590
|
+
\t\t// }
|
|
1591
|
+
\t\t//
|
|
1592
|
+
\t\t// Without an active subscription the account resolves to the free plan,
|
|
1593
|
+
\t\t// so the pro entitlement is denied with a 403.
|
|
1594
|
+
\t\tawait expect(
|
|
1595
|
+
\t\t\trequireEntitlement(ctx, { entitlement: "todos.create", subject }),
|
|
1596
|
+
\t\t).rejects.toMatchObject({
|
|
1597
|
+
\t\t\tcode: "ENTITLEMENT_NOT_INCLUDED",
|
|
1598
|
+
\t\t\tstatus: 403,
|
|
1599
|
+
\t\t});
|
|
1600
|
+
|
|
1601
|
+
\t\tawait harness.billing.save({
|
|
1602
|
+
\t\t\ttenantId: "tenant_example",
|
|
1603
|
+
\t\t\tprovider: "stripe",
|
|
1604
|
+
\t\t\tplan: "pro",
|
|
1605
|
+
\t\t\tstatus: "active",
|
|
1606
|
+
\t\t\tupdatedAt: new Date(0).toISOString(),
|
|
1607
|
+
\t\t});
|
|
1608
|
+
|
|
1609
|
+
\t\tawait expect(
|
|
1610
|
+
\t\t\trequireEntitlement(ctx, { entitlement: "todos.create", subject }),
|
|
1611
|
+
\t\t).resolves.toMatchObject({ allowed: true });
|
|
1612
|
+
\t});
|
|
1613
|
+
|
|
1614
|
+
\tit("handles checkout and subscription webhooks idempotently", async () => {
|
|
1615
|
+
\t\tconst harness = createHarness();
|
|
1616
|
+
\t\tconst checkoutEvent = {
|
|
1617
|
+
\t\t\tid: "evt_checkout_completed",
|
|
1618
|
+
\t\t\ttype: "checkout.session.completed",
|
|
1619
|
+
\t\t\tprovider: "stripe",
|
|
1620
|
+
\t\t\tdata: {
|
|
1621
|
+
\t\t\t\tid: "cs_123",
|
|
1622
|
+
\t\t\t\tclient_reference_id: "tenant_example",
|
|
1623
|
+
\t\t\t\tcustomer: "cus_123",
|
|
1624
|
+
\t\t\t\tsubscription: "sub_123",
|
|
1625
|
+
\t\t\t\tmetadata: {
|
|
1626
|
+
\t\t\t\t\ttenantId: "tenant_example",
|
|
1627
|
+
\t\t\t\t\tplan: "pro",
|
|
1628
|
+
\t\t\t\t},
|
|
1629
|
+
\t\t\t},
|
|
1630
|
+
\t\t};
|
|
1631
|
+
\t\tconst subscriptionEvent = {
|
|
1632
|
+
\t\t\tid: "evt_subscription_updated",
|
|
1633
|
+
\t\t\ttype: "customer.subscription.updated",
|
|
1634
|
+
\t\t\tprovider: "stripe",
|
|
1635
|
+
\t\t\tdata: {
|
|
1636
|
+
\t\t\t\tid: "sub_123",
|
|
1637
|
+
\t\t\t\tcustomer: "cus_123",
|
|
1638
|
+
\t\t\t\tstatus: "active",
|
|
1639
|
+
\t\t\t\tcurrent_period_end: 1_799_798_400,
|
|
1640
|
+
\t\t\t\tcancel_at_period_end: true,
|
|
1641
|
+
\t\t\t},
|
|
1642
|
+
\t\t};
|
|
1643
|
+
|
|
1644
|
+
\t\tawait expect(
|
|
1645
|
+
\t\t\tharness.tester.run(handlePaymentWebhookUseCase, checkoutEvent),
|
|
1646
|
+
\t\t).resolves.toEqual({ received: true, accountUpdated: true });
|
|
1647
|
+
\t\tawait expect(
|
|
1648
|
+
\t\t\tharness.tester.run(handlePaymentWebhookUseCase, checkoutEvent),
|
|
1649
|
+
\t\t).resolves.toEqual({ received: true, accountUpdated: true });
|
|
1650
|
+
\t\tawait expect(
|
|
1651
|
+
\t\t\tharness.tester.run(handlePaymentWebhookUseCase, subscriptionEvent),
|
|
1652
|
+
\t\t).resolves.toEqual({ received: true, accountUpdated: true });
|
|
1653
|
+
\t\tawait expect(
|
|
1654
|
+
\t\t\tharness.tester.run(getBillingStatusUseCase, {}),
|
|
1655
|
+
\t\t).resolves.toMatchObject({
|
|
1656
|
+
\t\t\ttenantId: "tenant_example",
|
|
1657
|
+
\t\t\tplan: "pro",
|
|
1658
|
+
\t\t\tactive: true,
|
|
1659
|
+
\t\t\tstatus: "active",
|
|
1660
|
+
\t\t\tcustomerId: "cus_123",
|
|
1661
|
+
\t\t\tsubscriptionId: "sub_123",
|
|
1662
|
+
\t\t\tcurrentPeriodEnd: "2027-01-13T00:00:00.000Z",
|
|
1663
|
+
\t\t\tcancelAtPeriodEnd: true,
|
|
1664
|
+
\t\t\tcheckoutSessionId: "cs_123",
|
|
1665
|
+
\t\t\tlastEventId: "evt_subscription_updated",
|
|
1666
|
+
\t\t});
|
|
1667
|
+
\t});
|
|
1668
|
+
|
|
1669
|
+
\tit("marks active subscriptions as past due when invoice payment fails", async () => {
|
|
1670
|
+
\t\tconst harness = createHarness();
|
|
1671
|
+
\t\tawait harness.billing.save({
|
|
1672
|
+
\t\t\ttenantId: "tenant_example",
|
|
1673
|
+
\t\t\tprovider: "stripe",
|
|
1674
|
+
\t\t\tplan: "pro",
|
|
1675
|
+
\t\t\tstatus: "active",
|
|
1676
|
+
\t\t\tcustomerId: "cus_123",
|
|
1677
|
+
\t\t\tsubscriptionId: "sub_123",
|
|
1678
|
+
\t\t\tupdatedAt: new Date(0).toISOString(),
|
|
1679
|
+
\t\t});
|
|
1680
|
+
|
|
1681
|
+
\t\tawait expect(
|
|
1682
|
+
\t\t\tharness.tester.run(handlePaymentWebhookUseCase, {
|
|
1683
|
+
\t\t\t\tid: "evt_invoice_failed",
|
|
1684
|
+
\t\t\t\ttype: "invoice.payment_failed",
|
|
1685
|
+
\t\t\t\tprovider: "stripe",
|
|
1686
|
+
\t\t\t\tdata: {
|
|
1687
|
+
\t\t\t\t\tcustomer: "cus_123",
|
|
1688
|
+
\t\t\t\t\tsubscription: "sub_123",
|
|
1689
|
+
\t\t\t\t},
|
|
1690
|
+
\t\t\t}),
|
|
1691
|
+
\t\t).resolves.toEqual({ received: true, accountUpdated: true });
|
|
1692
|
+
\t\tawait expect(
|
|
1693
|
+
\t\t\tharness.billing.findByTenantId("tenant_example"),
|
|
1694
|
+
\t\t).resolves.toMatchObject({
|
|
1695
|
+
\t\t\tstatus: "past_due",
|
|
1696
|
+
\t\t\tlastEventId: "evt_invoice_failed",
|
|
1697
|
+
\t\t});
|
|
1698
|
+
\t});
|
|
1699
|
+
|
|
1700
|
+
\tit("preserves pending cancellation when an invoice succeeds", async () => {
|
|
1701
|
+
\t\tconst harness = createHarness();
|
|
1702
|
+
\t\tawait harness.billing.save({
|
|
1703
|
+
\t\t\ttenantId: "tenant_example",
|
|
1704
|
+
\t\t\tprovider: "stripe",
|
|
1705
|
+
\t\t\tplan: "pro",
|
|
1706
|
+
\t\t\tstatus: "active",
|
|
1707
|
+
\t\t\tcustomerId: "cus_123",
|
|
1708
|
+
\t\t\tsubscriptionId: "sub_123",
|
|
1709
|
+
\t\t\tcancelAtPeriodEnd: true,
|
|
1710
|
+
\t\t\tupdatedAt: new Date(0).toISOString(),
|
|
1711
|
+
\t\t});
|
|
1712
|
+
|
|
1713
|
+
\t\tawait expect(
|
|
1714
|
+
\t\t\tharness.tester.run(handlePaymentWebhookUseCase, {
|
|
1715
|
+
\t\t\t\tid: "evt_invoice_succeeded",
|
|
1716
|
+
\t\t\t\ttype: "invoice.payment_succeeded",
|
|
1717
|
+
\t\t\t\tprovider: "stripe",
|
|
1718
|
+
\t\t\t\tdata: {
|
|
1719
|
+
\t\t\t\t\tcustomer: "cus_123",
|
|
1720
|
+
\t\t\t\t\tsubscription: "sub_123",
|
|
1721
|
+
\t\t\t\t},
|
|
1722
|
+
\t\t\t}),
|
|
1723
|
+
\t\t).resolves.toEqual({ received: true, accountUpdated: true });
|
|
1724
|
+
\t\tawait expect(
|
|
1725
|
+
\t\t\tharness.billing.findByTenantId("tenant_example"),
|
|
1726
|
+
\t\t).resolves.toMatchObject({
|
|
1727
|
+
\t\t\tstatus: "active",
|
|
1728
|
+
\t\t\tcancelAtPeriodEnd: true,
|
|
1729
|
+
\t\t\tlastEventId: "evt_invoice_succeeded",
|
|
1730
|
+
\t\t});
|
|
1731
|
+
\t});
|
|
1732
|
+
});
|
|
1733
|
+
`;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
export function billingDrizzleSchemaFile(database: DatabaseName): string {
|
|
1737
|
+
const dialect = drizzleDialects[database];
|
|
1738
|
+
const idColumn =
|
|
1739
|
+
database === "mysql"
|
|
1740
|
+
? (columnName: string) => `varchar("${columnName}", { length: 255 })`
|
|
1741
|
+
: (columnName: string) => `text("${columnName}")`;
|
|
1742
|
+
const imports =
|
|
1743
|
+
database === "mysql"
|
|
1744
|
+
? "mysqlTable, uniqueIndex, varchar"
|
|
1745
|
+
: `${dialect.tableFunction}, text, uniqueIndex`;
|
|
1746
|
+
|
|
1747
|
+
return `import type { BillingPlan, BillingStatus } from "@/features/billing/schemas";
|
|
1748
|
+
import { ${imports} } from "${dialect.columnModule}";
|
|
1749
|
+
|
|
1750
|
+
export const billingAccounts = ${dialect.tableFunction}(
|
|
1751
|
+
\t"billing_accounts",
|
|
1752
|
+
\t{
|
|
1753
|
+
\t\ttenantId: ${idColumn("tenant_id")}.primaryKey(),
|
|
1754
|
+
\t\tplan: ${idColumn("plan")}.$type<BillingPlan>().notNull(),
|
|
1755
|
+
\t\tstatus: ${idColumn("status")}.$type<BillingStatus>().notNull(),
|
|
1756
|
+
\t\tprovider: ${idColumn("provider")}.notNull(),
|
|
1757
|
+
\t\tcustomerId: ${idColumn("customer_id")},
|
|
1758
|
+
\t\tsubscriptionId: ${idColumn("subscription_id")},
|
|
1759
|
+
\t\tcurrentPeriodEnd: ${idColumn("current_period_end")},
|
|
1760
|
+
\t\tcancelAtPeriodEnd: ${idColumn("cancel_at_period_end")}.$type<"true" | "false">(),
|
|
1761
|
+
\t\tcheckoutSessionId: ${idColumn("checkout_session_id")},
|
|
1762
|
+
\t\tlastEventId: ${idColumn("last_event_id")},
|
|
1763
|
+
\t\tupdatedAt: ${idColumn("updated_at")}.notNull(),
|
|
1764
|
+
\t},
|
|
1765
|
+
\t(table) => ({
|
|
1766
|
+
\t\tproviderCustomerUnique: uniqueIndex(
|
|
1767
|
+
\t\t\t"billing_accounts_provider_customer_unique",
|
|
1768
|
+
\t\t).on(table.provider, table.customerId),
|
|
1769
|
+
\t\tproviderSubscriptionUnique: uniqueIndex(
|
|
1770
|
+
\t\t\t"billing_accounts_provider_subscription_unique",
|
|
1771
|
+
\t\t).on(table.provider, table.subscriptionId),
|
|
1772
|
+
\t}),
|
|
1773
|
+
);
|
|
1774
|
+
`;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
export function billingDrizzleRepositoryFile(
|
|
1778
|
+
config: ResolvedBeignetConfig,
|
|
1779
|
+
database: DatabaseName,
|
|
1780
|
+
): string {
|
|
1781
|
+
const dialect = drizzleDialects[database];
|
|
1782
|
+
const billingPortPath = path.join(config.paths.features, "billing/ports.ts");
|
|
1783
|
+
const upsert =
|
|
1784
|
+
database === "mysql"
|
|
1785
|
+
? `\t\t\tconst row = toRow(input);
|
|
1786
|
+
\t\t\tawait db
|
|
1787
|
+
\t\t\t\t.insert(schema.billingAccounts)
|
|
1788
|
+
\t\t\t\t.values(row)
|
|
1789
|
+
\t\t\t\t.onDuplicateKeyUpdate({ set: row });
|
|
1790
|
+
|
|
1791
|
+
\t\t\tconst [saved] = await db
|
|
1792
|
+
\t\t\t\t.select()
|
|
1793
|
+
\t\t\t\t.from(schema.billingAccounts)
|
|
1794
|
+
\t\t\t\t.where(eq(schema.billingAccounts.tenantId, input.tenantId))
|
|
1795
|
+
\t\t\t\t.limit(1);
|
|
1796
|
+
|
|
1797
|
+
\t\t\tif (!saved) {
|
|
1798
|
+
\t\t\t\tthrow new Error("Billing account save did not return a row.");
|
|
1799
|
+
\t\t\t}
|
|
1800
|
+
|
|
1801
|
+
\t\t\treturn toBillingAccount(saved);`
|
|
1802
|
+
: `\t\t\tconst [row] = await db
|
|
1803
|
+
\t\t\t\t.insert(schema.billingAccounts)
|
|
1804
|
+
\t\t\t\t.values(toRow(input))
|
|
1805
|
+
\t\t\t\t.onConflictDoUpdate({
|
|
1806
|
+
\t\t\t\t\ttarget: schema.billingAccounts.tenantId,
|
|
1807
|
+
\t\t\t\t\tset: toRow(input),
|
|
1808
|
+
\t\t\t\t})
|
|
1809
|
+
\t\t\t\t.returning();
|
|
1810
|
+
|
|
1811
|
+
\t\t\tif (!row) {
|
|
1812
|
+
\t\t\t\tthrow new Error("Billing account save did not return a row.");
|
|
1813
|
+
\t\t\t}
|
|
1814
|
+
|
|
1815
|
+
\t\t\treturn toBillingAccount(row);`;
|
|
1816
|
+
|
|
1817
|
+
return `import type { ${dialect.dbTypeName} } from "@beignet/provider-db-drizzle/${dialect.subpath}";
|
|
1818
|
+
import { and, eq } from "drizzle-orm";
|
|
1819
|
+
import type {
|
|
1820
|
+
\tBillingAccount,
|
|
1821
|
+
\tBillingRepository,
|
|
1822
|
+
\tSaveBillingAccountInput,
|
|
1823
|
+
} from "${aliasModule(billingPortPath)}";
|
|
1824
|
+
import * as schema from "@/infra/db/schema";
|
|
1825
|
+
|
|
1826
|
+
type BillingAccountRow = typeof schema.billingAccounts.$inferSelect;
|
|
1827
|
+
|
|
1828
|
+
function toBillingAccount(row: BillingAccountRow): BillingAccount {
|
|
1829
|
+
\treturn {
|
|
1830
|
+
\t\ttenantId: row.tenantId,
|
|
1831
|
+
\t\tplan: row.plan,
|
|
1832
|
+
\t\tstatus: row.status,
|
|
1833
|
+
\t\tprovider: row.provider,
|
|
1834
|
+
\t\t...(row.customerId ? { customerId: row.customerId } : {}),
|
|
1835
|
+
\t\t...(row.subscriptionId ? { subscriptionId: row.subscriptionId } : {}),
|
|
1836
|
+
\t\t...(row.currentPeriodEnd ? { currentPeriodEnd: row.currentPeriodEnd } : {}),
|
|
1837
|
+
\t\t...(row.cancelAtPeriodEnd
|
|
1838
|
+
\t\t\t? { cancelAtPeriodEnd: row.cancelAtPeriodEnd === "true" }
|
|
1839
|
+
\t\t\t: {}),
|
|
1840
|
+
\t\t...(row.checkoutSessionId
|
|
1841
|
+
\t\t\t? { checkoutSessionId: row.checkoutSessionId }
|
|
1842
|
+
\t\t\t: {}),
|
|
1843
|
+
\t\t...(row.lastEventId ? { lastEventId: row.lastEventId } : {}),
|
|
1844
|
+
\t\tupdatedAt: row.updatedAt,
|
|
1845
|
+
\t};
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
function toRow(input: SaveBillingAccountInput) {
|
|
1849
|
+
\treturn {
|
|
1850
|
+
\t\ttenantId: input.tenantId,
|
|
1851
|
+
\t\tplan: input.plan,
|
|
1852
|
+
\t\tstatus: input.status,
|
|
1853
|
+
\t\tprovider: input.provider,
|
|
1854
|
+
\t\tcustomerId: input.customerId ?? null,
|
|
1855
|
+
\t\tsubscriptionId: input.subscriptionId ?? null,
|
|
1856
|
+
\t\tcurrentPeriodEnd: input.currentPeriodEnd ?? null,
|
|
1857
|
+
\t\tcancelAtPeriodEnd:
|
|
1858
|
+
\t\t\tinput.cancelAtPeriodEnd === undefined
|
|
1859
|
+
\t\t\t\t? null
|
|
1860
|
+
\t\t\t\t: input.cancelAtPeriodEnd
|
|
1861
|
+
\t\t\t\t\t? ("true" as const)
|
|
1862
|
+
\t\t\t\t\t: ("false" as const),
|
|
1863
|
+
\t\tcheckoutSessionId: input.checkoutSessionId ?? null,
|
|
1864
|
+
\t\tlastEventId: input.lastEventId ?? null,
|
|
1865
|
+
\t\tupdatedAt: input.updatedAt,
|
|
1866
|
+
\t};
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
export function createDrizzleBillingRepository(
|
|
1870
|
+
\tdb: ${dialect.dbTypeName}<typeof schema>,
|
|
1871
|
+
): BillingRepository {
|
|
1872
|
+
\treturn {
|
|
1873
|
+
\t\tasync findByTenantId(tenantId) {
|
|
1874
|
+
\t\t\tconst [row] = await db
|
|
1875
|
+
\t\t\t\t.select()
|
|
1876
|
+
\t\t\t\t.from(schema.billingAccounts)
|
|
1877
|
+
\t\t\t\t.where(eq(schema.billingAccounts.tenantId, tenantId))
|
|
1878
|
+
\t\t\t\t.limit(1);
|
|
1879
|
+
|
|
1880
|
+
\t\t\treturn row ? toBillingAccount(row) : null;
|
|
1881
|
+
\t\t},
|
|
1882
|
+
|
|
1883
|
+
\t\tasync findByCustomerId(customerId, provider) {
|
|
1884
|
+
\t\t\tconst rows = await db
|
|
1885
|
+
\t\t\t\t.select()
|
|
1886
|
+
\t\t\t\t.from(schema.billingAccounts)
|
|
1887
|
+
\t\t\t\t.where(
|
|
1888
|
+
\t\t\t\t\tand(
|
|
1889
|
+
\t\t\t\t\t\teq(schema.billingAccounts.customerId, customerId),
|
|
1890
|
+
\t\t\t\t\t\teq(schema.billingAccounts.provider, provider),
|
|
1891
|
+
\t\t\t\t\t),
|
|
1892
|
+
\t\t\t\t)
|
|
1893
|
+
\t\t\t\t.limit(2);
|
|
1894
|
+
|
|
1895
|
+
\t\t\tif (rows.length > 1) {
|
|
1896
|
+
\t\t\t\tthrow new Error(
|
|
1897
|
+
\t\t\t\t\t\`Multiple billing accounts found for provider "\${provider}" customer "\${customerId}".\`,
|
|
1898
|
+
\t\t\t\t);
|
|
1899
|
+
\t\t\t}
|
|
1900
|
+
|
|
1901
|
+
\t\t\treturn rows[0] ? toBillingAccount(rows[0]) : null;
|
|
1902
|
+
\t\t},
|
|
1903
|
+
|
|
1904
|
+
\t\tasync save(input) {
|
|
1905
|
+
${upsert}
|
|
1906
|
+
\t\t},
|
|
1907
|
+
\t};
|
|
1908
|
+
}
|
|
1909
|
+
`;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
export function billingSeedsFile(config: ResolvedBeignetConfig): string {
|
|
1913
|
+
return `import { defineSeed } from "@beignet/core/testing";
|
|
1914
|
+
import type { AppContext } from "${aliasModule(config.paths.appContext)}";
|
|
1915
|
+
|
|
1916
|
+
/**
|
|
1917
|
+
* Demo billing account on the free plan. Billing accounts are keyed by
|
|
1918
|
+
* tenant id, which falls back to the user id until tenancy is added, so the
|
|
1919
|
+
* demo account uses a stable placeholder id.
|
|
1920
|
+
*/
|
|
1921
|
+
export const demoBillingAccountSeed = defineSeed(
|
|
1922
|
+
\t"billing.demo-billing-account",
|
|
1923
|
+
\t{
|
|
1924
|
+
\t\tdescription: "Creates a demo billing account on the free plan.",
|
|
1925
|
+
\t\trun: async (ctx: AppContext) => {
|
|
1926
|
+
\t\t\tawait ctx.ports.billing.save({
|
|
1927
|
+
\t\t\t\ttenantId: "demo-user",
|
|
1928
|
+
\t\t\t\tplan: "free",
|
|
1929
|
+
\t\t\t\tstatus: "inactive",
|
|
1930
|
+
\t\t\t\tprovider: "memory",
|
|
1931
|
+
\t\t\t\tupdatedAt: new Date().toISOString(),
|
|
1932
|
+
\t\t\t});
|
|
1933
|
+
\t\t},
|
|
1934
|
+
\t},
|
|
1935
|
+
);
|
|
1936
|
+
|
|
1937
|
+
export const billingSeeds = [demoBillingAccountSeed] as const;
|
|
1938
|
+
`;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
export function billingClientQueriesFile(): string {
|
|
1942
|
+
return `import type { QueryClient } from "@tanstack/react-query";
|
|
1943
|
+
import { rq } from "@/client";
|
|
1944
|
+
import {
|
|
1945
|
+
\tcreateBillingPortalSession,
|
|
1946
|
+
\tcreateCheckoutSession,
|
|
1947
|
+
\tgetBillingStatus,
|
|
1948
|
+
} from "@/features/billing/contracts";
|
|
1949
|
+
|
|
1950
|
+
export function getBillingStatusQueryOptions() {
|
|
1951
|
+
\treturn rq(getBillingStatus).queryOptions({ query: {} });
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
export function createCheckoutSessionMutationOptions() {
|
|
1955
|
+
\treturn rq(createCheckoutSession).mutationOptions();
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
export function createBillingPortalSessionMutationOptions() {
|
|
1959
|
+
\treturn rq(createBillingPortalSession).mutationOptions();
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
export function invalidateBillingStatus(queryClient: QueryClient) {
|
|
1963
|
+
\treturn rq(getBillingStatus).invalidate(queryClient);
|
|
1964
|
+
}
|
|
1965
|
+
`;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
export function billingPlanCardFile(): string {
|
|
1969
|
+
return `import { SparklesIcon } from "lucide-react";
|
|
1970
|
+
import type { ReactNode } from "react";
|
|
1971
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
1972
|
+
import type { BillingStatusOutput } from "@/features/billing/schemas";
|
|
1973
|
+
import { cn } from "@/lib/utils";
|
|
1974
|
+
|
|
1975
|
+
/**
|
|
1976
|
+
* Current plan overview. Actions (upgrade / portal) render through
|
|
1977
|
+
* \`actions\`.
|
|
1978
|
+
*/
|
|
1979
|
+
export function PlanCard({
|
|
1980
|
+
\tbilling,
|
|
1981
|
+
\tactions,
|
|
1982
|
+
}: {
|
|
1983
|
+
\tbilling: BillingStatusOutput;
|
|
1984
|
+
\tactions?: ReactNode;
|
|
1985
|
+
}) {
|
|
1986
|
+
\tconst isPro = billing.plan === "pro";
|
|
1987
|
+
|
|
1988
|
+
\treturn (
|
|
1989
|
+
\t\t<Card>
|
|
1990
|
+
\t\t\t<CardHeader>
|
|
1991
|
+
\t\t\t\t<CardTitle className="flex items-center gap-2">
|
|
1992
|
+
\t\t\t\t\t{isPro ? <SparklesIcon className="size-4 text-primary" /> : null}
|
|
1993
|
+
\t\t\t\t\t{isPro ? "Pro" : "Free"} plan
|
|
1994
|
+
\t\t\t\t\t<span
|
|
1995
|
+
\t\t\t\t\t\tclassName={cn(
|
|
1996
|
+
\t\t\t\t\t\t\t"rounded-full border px-2 py-0.5 text-xs font-medium",
|
|
1997
|
+
\t\t\t\t\t\t\tbilling.active
|
|
1998
|
+
\t\t\t\t\t\t\t\t? "border-primary/40 text-primary"
|
|
1999
|
+
\t\t\t\t\t\t\t\t: "text-muted-foreground",
|
|
2000
|
+
\t\t\t\t\t\t)}
|
|
2001
|
+
\t\t\t\t\t>
|
|
2002
|
+
\t\t\t\t\t\t{billing.status}
|
|
2003
|
+
\t\t\t\t\t</span>
|
|
2004
|
+
\t\t\t\t</CardTitle>
|
|
2005
|
+
\t\t\t</CardHeader>
|
|
2006
|
+
\t\t\t<CardContent className="flex flex-col gap-4">
|
|
2007
|
+
\t\t\t\t<p className="text-sm text-muted-foreground">
|
|
2008
|
+
\t\t\t\t\t{isPro
|
|
2009
|
+
\t\t\t\t\t\t? "Pro removes the free-plan limits."
|
|
2010
|
+
\t\t\t\t\t\t: "The free plan is fully functional up to the free-plan limits. Upgrade to Pro to remove them."}
|
|
2011
|
+
\t\t\t\t</p>
|
|
2012
|
+
\t\t\t\t{actions}
|
|
2013
|
+
\t\t\t</CardContent>
|
|
2014
|
+
\t\t</Card>
|
|
2015
|
+
\t);
|
|
2016
|
+
}
|
|
2017
|
+
`;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
export function billingActionsFile(): string {
|
|
2021
|
+
return `"use client";
|
|
2022
|
+
|
|
2023
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
2024
|
+
import { CreditCardIcon, ExternalLinkIcon, Loader2Icon } from "lucide-react";
|
|
2025
|
+
import { useState } from "react";
|
|
2026
|
+
import { Button } from "@/components/ui/button";
|
|
2027
|
+
import {
|
|
2028
|
+
\tcreateBillingPortalSessionMutationOptions,
|
|
2029
|
+
\tcreateCheckoutSessionMutationOptions,
|
|
2030
|
+
\tinvalidateBillingStatus,
|
|
2031
|
+
} from "@/features/billing/client/queries";
|
|
2032
|
+
import type { BillingPlan } from "@/features/billing/schemas";
|
|
2033
|
+
|
|
2034
|
+
/**
|
|
2035
|
+
* Upgrade and billing-portal actions. Checkout is idempotent: each attempt
|
|
2036
|
+
* sends a fresh \`idempotency-key\`, and retries of the same attempt replay
|
|
2037
|
+
* it.
|
|
2038
|
+
*/
|
|
2039
|
+
export function BillingActions({
|
|
2040
|
+
\tplan,
|
|
2041
|
+
\thasCustomer,
|
|
2042
|
+
}: {
|
|
2043
|
+
\tplan: BillingPlan;
|
|
2044
|
+
\thasCustomer: boolean;
|
|
2045
|
+
}) {
|
|
2046
|
+
\tconst queryClient = useQueryClient();
|
|
2047
|
+
\tconst [error, setError] = useState<string | null>(null);
|
|
2048
|
+
\tconst [idempotencyKey, setIdempotencyKey] = useState(() =>
|
|
2049
|
+
\t\tcrypto.randomUUID(),
|
|
2050
|
+
\t);
|
|
2051
|
+
|
|
2052
|
+
\tconst checkout = useMutation({
|
|
2053
|
+
\t\t...createCheckoutSessionMutationOptions(),
|
|
2054
|
+
\t\tonSuccess(data) {
|
|
2055
|
+
\t\t\tvoid invalidateBillingStatus(queryClient);
|
|
2056
|
+
\t\t\twindow.location.assign(data.url);
|
|
2057
|
+
\t\t},
|
|
2058
|
+
\t\tonError() {
|
|
2059
|
+
\t\t\tsetError("Could not start checkout. Try again.");
|
|
2060
|
+
\t\t\tsetIdempotencyKey(crypto.randomUUID());
|
|
2061
|
+
\t\t},
|
|
2062
|
+
\t});
|
|
2063
|
+
\tconst portal = useMutation({
|
|
2064
|
+
\t\t...createBillingPortalSessionMutationOptions(),
|
|
2065
|
+
\t\tonSuccess(data) {
|
|
2066
|
+
\t\t\twindow.location.assign(data.url);
|
|
2067
|
+
\t\t},
|
|
2068
|
+
\t\tonError() {
|
|
2069
|
+
\t\t\tsetError("Could not open the billing portal.");
|
|
2070
|
+
\t\t},
|
|
2071
|
+
\t});
|
|
2072
|
+
\tconst busy = checkout.isPending || portal.isPending;
|
|
2073
|
+
|
|
2074
|
+
\treturn (
|
|
2075
|
+
\t\t<div className="flex flex-col gap-3">
|
|
2076
|
+
\t\t\t<div className="flex flex-wrap gap-2">
|
|
2077
|
+
\t\t\t\t{plan === "free" ? (
|
|
2078
|
+
\t\t\t\t\t<Button
|
|
2079
|
+
\t\t\t\t\t\ttype="button"
|
|
2080
|
+
\t\t\t\t\t\tdisabled={busy}
|
|
2081
|
+
\t\t\t\t\t\tonClick={() => {
|
|
2082
|
+
\t\t\t\t\t\t\tsetError(null);
|
|
2083
|
+
\t\t\t\t\t\t\tcheckout.mutate({
|
|
2084
|
+
\t\t\t\t\t\t\t\tbody: { plan: "pro" },
|
|
2085
|
+
\t\t\t\t\t\t\t\theaders: { "idempotency-key": idempotencyKey },
|
|
2086
|
+
\t\t\t\t\t\t\t});
|
|
2087
|
+
\t\t\t\t\t\t}}
|
|
2088
|
+
\t\t\t\t\t>
|
|
2089
|
+
\t\t\t\t\t\t{checkout.isPending ? (
|
|
2090
|
+
\t\t\t\t\t\t\t<Loader2Icon className="size-4 animate-spin" />
|
|
2091
|
+
\t\t\t\t\t\t) : (
|
|
2092
|
+
\t\t\t\t\t\t\t<CreditCardIcon className="size-4" />
|
|
2093
|
+
\t\t\t\t\t\t)}
|
|
2094
|
+
\t\t\t\t\t\tUpgrade to Pro
|
|
2095
|
+
\t\t\t\t\t</Button>
|
|
2096
|
+
\t\t\t\t) : null}
|
|
2097
|
+
\t\t\t\t<Button
|
|
2098
|
+
\t\t\t\t\ttype="button"
|
|
2099
|
+
\t\t\t\t\tvariant="secondary"
|
|
2100
|
+
\t\t\t\t\tdisabled={busy || !hasCustomer}
|
|
2101
|
+
\t\t\t\t\tonClick={() => {
|
|
2102
|
+
\t\t\t\t\t\tsetError(null);
|
|
2103
|
+
\t\t\t\t\t\tportal.mutate({ body: {} });
|
|
2104
|
+
\t\t\t\t\t}}
|
|
2105
|
+
\t\t\t\t>
|
|
2106
|
+
\t\t\t\t\t{portal.isPending ? (
|
|
2107
|
+
\t\t\t\t\t\t<Loader2Icon className="size-4 animate-spin" />
|
|
2108
|
+
\t\t\t\t\t) : (
|
|
2109
|
+
\t\t\t\t\t\t<ExternalLinkIcon className="size-4" />
|
|
2110
|
+
\t\t\t\t\t)}
|
|
2111
|
+
\t\t\t\t\tBilling portal
|
|
2112
|
+
\t\t\t\t</Button>
|
|
2113
|
+
\t\t\t</div>
|
|
2114
|
+
\t\t\t{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
|
2115
|
+
\t\t</div>
|
|
2116
|
+
\t);
|
|
2117
|
+
}
|
|
2118
|
+
`;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
export function billingComponentsIndexFile(): string {
|
|
2122
|
+
return `export { BillingActions } from "./billing-actions";
|
|
2123
|
+
export { PlanCard } from "./plan-card";
|
|
2124
|
+
`;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
export function billingPlanPageFile(): string {
|
|
2128
|
+
return `"use client";
|
|
2129
|
+
|
|
2130
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2131
|
+
import { getBillingStatusQueryOptions } from "@/features/billing/client/queries";
|
|
2132
|
+
import { BillingActions, PlanCard } from "@/features/billing/components";
|
|
2133
|
+
|
|
2134
|
+
export default function SettingsPlanPage() {
|
|
2135
|
+
\tconst billingQuery = useQuery(getBillingStatusQueryOptions());
|
|
2136
|
+
|
|
2137
|
+
\tif (billingQuery.isPending) {
|
|
2138
|
+
\t\treturn <p className="text-sm text-muted-foreground">Loading plan...</p>;
|
|
2139
|
+
\t}
|
|
2140
|
+
|
|
2141
|
+
\tif (billingQuery.isError) {
|
|
2142
|
+
\t\treturn (
|
|
2143
|
+
\t\t\t<p className="text-sm text-destructive">Could not load your plan.</p>
|
|
2144
|
+
\t\t);
|
|
2145
|
+
\t}
|
|
2146
|
+
|
|
2147
|
+
\treturn (
|
|
2148
|
+
\t\t<PlanCard
|
|
2149
|
+
\t\t\tbilling={billingQuery.data}
|
|
2150
|
+
\t\t\tactions={
|
|
2151
|
+
\t\t\t\t<BillingActions
|
|
2152
|
+
\t\t\t\t\tplan={billingQuery.data.plan}
|
|
2153
|
+
\t\t\t\t\thasCustomer={Boolean(billingQuery.data.customerId)}
|
|
2154
|
+
\t\t\t\t/>
|
|
2155
|
+
\t\t\t}
|
|
2156
|
+
\t\t/>
|
|
2157
|
+
\t);
|
|
2158
|
+
}
|
|
2159
|
+
`;
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
export function billingWebhookRouteFile(): string {
|
|
2163
|
+
return `import { createPaymentWebhookRoute } from "@beignet/next";
|
|
2164
|
+
import { handlePaymentWebhookUseCase } from "@/features/billing/use-cases";
|
|
2165
|
+
import { getServer } from "@/server";
|
|
2166
|
+
|
|
2167
|
+
export const runtime = "nodejs";
|
|
2168
|
+
|
|
2169
|
+
export const { POST } = createPaymentWebhookRoute({
|
|
2170
|
+
\tserver: getServer,
|
|
2171
|
+
\thandle: async ({ ctx, event }) => {
|
|
2172
|
+
\t\tawait handlePaymentWebhookUseCase.run({ ctx, input: event });
|
|
2173
|
+
\t\treturn {
|
|
2174
|
+
\t\t\tstatus: 200,
|
|
2175
|
+
\t\t\tbody: {
|
|
2176
|
+
\t\t\t\treceived: true,
|
|
2177
|
+
\t\t\t},
|
|
2178
|
+
\t\t};
|
|
2179
|
+
\t},
|
|
2180
|
+
});
|
|
2181
|
+
`;
|
|
2182
|
+
}
|