@beignet/cli 0.0.12 → 0.0.14
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 +24 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -1
- package/dist/inspect.js +174 -0
- package/dist/inspect.js.map +1 -1
- package/dist/lint.d.ts.map +1 -1
- package/dist/lint.js +22 -3
- package/dist/lint.js.map +1 -1
- package/dist/make.d.ts +11 -0
- package/dist/make.d.ts.map +1 -1
- package/dist/make.js +1608 -0
- package/dist/make.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +14 -2
- package/dist/mcp.js.map +1 -1
- package/dist/templates/agents.d.ts +4 -3
- package/dist/templates/agents.d.ts.map +1 -1
- package/dist/templates/agents.js +4 -3
- package/dist/templates/agents.js.map +1 -1
- package/dist/templates/index.d.ts.map +1 -1
- package/dist/templates/index.js +1 -0
- package/dist/templates/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +35 -0
- package/src/inspect.ts +361 -0
- package/src/lint.ts +28 -3
- package/src/make.ts +1823 -0
- package/src/mcp.ts +16 -2
- package/src/templates/agents.ts +4 -3
- package/src/templates/index.ts +1 -0
package/src/make.ts
CHANGED
|
@@ -172,6 +172,13 @@ type MakeUploadOptions = {
|
|
|
172
172
|
config?: BeignetConfig;
|
|
173
173
|
};
|
|
174
174
|
|
|
175
|
+
type MakePaymentsOptions = {
|
|
176
|
+
cwd?: string;
|
|
177
|
+
force?: boolean;
|
|
178
|
+
dryRun?: boolean;
|
|
179
|
+
config?: BeignetConfig;
|
|
180
|
+
};
|
|
181
|
+
|
|
175
182
|
type MakeResult = {
|
|
176
183
|
schemaVersion: 1;
|
|
177
184
|
name: string;
|
|
@@ -251,6 +258,10 @@ export type MakeScheduleResult = MakeResult;
|
|
|
251
258
|
* Result returned by `beignet make upload`.
|
|
252
259
|
*/
|
|
253
260
|
export type MakeUploadResult = MakeResult;
|
|
261
|
+
/**
|
|
262
|
+
* Result returned by `beignet make payments`.
|
|
263
|
+
*/
|
|
264
|
+
export type MakePaymentsResult = MakeResult;
|
|
254
265
|
|
|
255
266
|
type ResourceNames = {
|
|
256
267
|
input: string;
|
|
@@ -473,6 +484,90 @@ export async function makeFeature(
|
|
|
473
484
|
return makeFeatureSlice(options, addons);
|
|
474
485
|
}
|
|
475
486
|
|
|
487
|
+
export async function makePayments(
|
|
488
|
+
options: MakePaymentsOptions,
|
|
489
|
+
): Promise<MakePaymentsResult> {
|
|
490
|
+
const targetDir = path.resolve(options.cwd ?? process.cwd());
|
|
491
|
+
const config = options.config
|
|
492
|
+
? resolveConfig(options.config)
|
|
493
|
+
: await loadBeignetConfig(targetDir);
|
|
494
|
+
|
|
495
|
+
await assertStandardApp(targetDir, config);
|
|
496
|
+
|
|
497
|
+
const persistence = await detectResourcePersistence(targetDir, config);
|
|
498
|
+
if (persistence !== "drizzle") {
|
|
499
|
+
throw new Error(
|
|
500
|
+
"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.",
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const database = await detectResourceDatabase(targetDir, config);
|
|
505
|
+
const generatedFiles = paymentsFiles(config, database);
|
|
506
|
+
const plannedFiles = await planGeneratedFiles(targetDir, generatedFiles, {
|
|
507
|
+
force: Boolean(options.force),
|
|
508
|
+
});
|
|
509
|
+
const billingNames = resourceNames("billing");
|
|
510
|
+
const plannedBillingUpdates = await updatePaymentsWiring(
|
|
511
|
+
targetDir,
|
|
512
|
+
billingNames,
|
|
513
|
+
config,
|
|
514
|
+
{ dryRun: true },
|
|
515
|
+
);
|
|
516
|
+
const plannedPaymentsUpdates = (
|
|
517
|
+
await wirePortProviders(
|
|
518
|
+
targetDir,
|
|
519
|
+
config,
|
|
520
|
+
[paymentsPortWiring("make payments")],
|
|
521
|
+
{ dryRun: true },
|
|
522
|
+
)
|
|
523
|
+
).updatedFiles;
|
|
524
|
+
|
|
525
|
+
const createdFiles = plannedFiles
|
|
526
|
+
.filter((file) => file.result === "created")
|
|
527
|
+
.map((file) => file.path);
|
|
528
|
+
const updatedFiles = plannedFiles
|
|
529
|
+
.filter((file) => file.result === "updated")
|
|
530
|
+
.map((file) => file.path);
|
|
531
|
+
const skippedFiles = plannedFiles
|
|
532
|
+
.filter((file) => file.result === "skipped")
|
|
533
|
+
.map((file) => file.path);
|
|
534
|
+
|
|
535
|
+
if (options.dryRun) {
|
|
536
|
+
updatedFiles.push(...plannedBillingUpdates, ...plannedPaymentsUpdates);
|
|
537
|
+
} else {
|
|
538
|
+
for (const file of plannedFiles) {
|
|
539
|
+
await writePlannedGeneratedFile(targetDir, file);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
updatedFiles.push(
|
|
543
|
+
...(await updatePaymentsWiring(targetDir, billingNames, config, {
|
|
544
|
+
dryRun: false,
|
|
545
|
+
})),
|
|
546
|
+
);
|
|
547
|
+
updatedFiles.push(
|
|
548
|
+
...(
|
|
549
|
+
await wirePortProviders(
|
|
550
|
+
targetDir,
|
|
551
|
+
config,
|
|
552
|
+
[paymentsPortWiring("make payments")],
|
|
553
|
+
{ dryRun: false },
|
|
554
|
+
)
|
|
555
|
+
).updatedFiles,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
schemaVersion: 1,
|
|
561
|
+
name: "payments",
|
|
562
|
+
targetDir,
|
|
563
|
+
dryRun: Boolean(options.dryRun),
|
|
564
|
+
files: generatedFiles.map((file) => file.path),
|
|
565
|
+
createdFiles,
|
|
566
|
+
updatedFiles: uniqueStrings(updatedFiles),
|
|
567
|
+
skippedFiles,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
476
571
|
async function makeFeatureResource(
|
|
477
572
|
options: MakeResourceOptions,
|
|
478
573
|
): Promise<MakeResourceResult> {
|
|
@@ -1482,6 +1577,17 @@ function notificationPortWirings(command: string): PortProviderWiring[] {
|
|
|
1482
1577
|
];
|
|
1483
1578
|
}
|
|
1484
1579
|
|
|
1580
|
+
function paymentsPortWiring(command: string): PortProviderWiring {
|
|
1581
|
+
return {
|
|
1582
|
+
command,
|
|
1583
|
+
portKey: "payments",
|
|
1584
|
+
portType: "PaymentsPort",
|
|
1585
|
+
portTypeModule: "@beignet/core/payments",
|
|
1586
|
+
providerFactory: "createMemoryPaymentsProvider",
|
|
1587
|
+
providerModule: "@beignet/core/payments",
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1485
1591
|
function providersFilePath(config: ResolvedBeignetConfig): string {
|
|
1486
1592
|
return `${directoryPath(path.dirname(config.paths.server))}/providers.ts`;
|
|
1487
1593
|
}
|
|
@@ -2310,6 +2416,87 @@ async function updateResourceWiring(
|
|
|
2310
2416
|
return [...updated];
|
|
2311
2417
|
}
|
|
2312
2418
|
|
|
2419
|
+
async function updatePaymentsWiring(
|
|
2420
|
+
targetDir: string,
|
|
2421
|
+
billingNames: ResourceNames,
|
|
2422
|
+
config: ResolvedBeignetConfig,
|
|
2423
|
+
options: { dryRun: boolean },
|
|
2424
|
+
): Promise<string[]> {
|
|
2425
|
+
const updated = new Set<string>();
|
|
2426
|
+
|
|
2427
|
+
if (await updatePackageJson(targetDir, options)) {
|
|
2428
|
+
updated.add("package.json");
|
|
2429
|
+
}
|
|
2430
|
+
if (
|
|
2431
|
+
await updatePortsIndex(targetDir, billingNames, config, {
|
|
2432
|
+
...options,
|
|
2433
|
+
generationOptions: {
|
|
2434
|
+
auth: false,
|
|
2435
|
+
tenant: false,
|
|
2436
|
+
events: false,
|
|
2437
|
+
softDelete: false,
|
|
2438
|
+
},
|
|
2439
|
+
})
|
|
2440
|
+
) {
|
|
2441
|
+
updated.add(config.paths.ports);
|
|
2442
|
+
}
|
|
2443
|
+
if (await updatePaymentsEntitlementsPort(targetDir, config, options)) {
|
|
2444
|
+
updated.add(config.paths.ports);
|
|
2445
|
+
updated.add(config.paths.infrastructurePorts);
|
|
2446
|
+
}
|
|
2447
|
+
if (
|
|
2448
|
+
await updateInfrastructureDeferredPorts(
|
|
2449
|
+
targetDir,
|
|
2450
|
+
billingNames,
|
|
2451
|
+
config,
|
|
2452
|
+
options,
|
|
2453
|
+
)
|
|
2454
|
+
) {
|
|
2455
|
+
updated.add(config.paths.infrastructurePorts);
|
|
2456
|
+
}
|
|
2457
|
+
if (await updateBillingDrizzleSchemaIndex(targetDir, config, options)) {
|
|
2458
|
+
updated.add(drizzleSchemaIndexPath(config));
|
|
2459
|
+
}
|
|
2460
|
+
if (
|
|
2461
|
+
await updateDrizzleRepositories(targetDir, billingNames, config, options)
|
|
2462
|
+
) {
|
|
2463
|
+
updated.add(drizzleRepositoriesPath(config));
|
|
2464
|
+
}
|
|
2465
|
+
if (await updateDatabaseProviderPick(targetDir, config, options)) {
|
|
2466
|
+
updated.add(path.join(infrastructureDir(config), "db/provider.ts"));
|
|
2467
|
+
}
|
|
2468
|
+
if (await updateDatabaseProviderEntitlements(targetDir, config, options)) {
|
|
2469
|
+
updated.add(path.join(infrastructureDir(config), "db/provider.ts"));
|
|
2470
|
+
}
|
|
2471
|
+
if (await updateBillingTransactionPorts(targetDir, config, options)) {
|
|
2472
|
+
updated.add(
|
|
2473
|
+
path.join(infrastructureDir(config), "db/transaction-ports.ts"),
|
|
2474
|
+
);
|
|
2475
|
+
}
|
|
2476
|
+
if (await updateDatabaseResetTables(targetDir, config, options)) {
|
|
2477
|
+
updated.add(path.join(infrastructureDir(config), "db/reset.ts"));
|
|
2478
|
+
}
|
|
2479
|
+
if (await updateBillingSharedErrors(targetDir, config, options)) {
|
|
2480
|
+
updated.add(resourceSharedErrorsPath(config));
|
|
2481
|
+
}
|
|
2482
|
+
if (await updateBillingEnv(targetDir, options)) {
|
|
2483
|
+
updated.add("lib/env.ts");
|
|
2484
|
+
}
|
|
2485
|
+
if (await updateBillingEnvExample(targetDir, options)) {
|
|
2486
|
+
updated.add(".env.example");
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
const routesFile = await updateServerRoutes(
|
|
2490
|
+
targetDir,
|
|
2491
|
+
billingNames,
|
|
2492
|
+
config,
|
|
2493
|
+
options,
|
|
2494
|
+
);
|
|
2495
|
+
if (routesFile) updated.add(routesFile);
|
|
2496
|
+
|
|
2497
|
+
return [...updated];
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2313
2500
|
async function updatePackageJson(
|
|
2314
2501
|
targetDir: string,
|
|
2315
2502
|
options: { dryRun: boolean; webTestingDependency?: boolean },
|
|
@@ -2439,6 +2626,59 @@ async function updatePortIndex(
|
|
|
2439
2626
|
return true;
|
|
2440
2627
|
}
|
|
2441
2628
|
|
|
2629
|
+
async function updatePaymentsEntitlementsPort(
|
|
2630
|
+
targetDir: string,
|
|
2631
|
+
config: ResolvedBeignetConfig,
|
|
2632
|
+
options: { dryRun: boolean },
|
|
2633
|
+
): Promise<boolean> {
|
|
2634
|
+
let changed = false;
|
|
2635
|
+
const portsFilePath = path.join(targetDir, config.paths.ports);
|
|
2636
|
+
const portsOriginal = await readFile(portsFilePath, "utf8");
|
|
2637
|
+
let portsNext = addNamedTypeImport(
|
|
2638
|
+
portsOriginal,
|
|
2639
|
+
"EntitlementsPort",
|
|
2640
|
+
"@beignet/core/entitlements",
|
|
2641
|
+
);
|
|
2642
|
+
if (!/\bentitlements\s*:/.test(portsNext)) {
|
|
2643
|
+
portsNext = insertTypeProperty(
|
|
2644
|
+
portsNext,
|
|
2645
|
+
"AppPorts",
|
|
2646
|
+
"entitlements: EntitlementsPort;",
|
|
2647
|
+
`Could not find AppPorts in ${config.paths.ports}. Add entitlements: EntitlementsPort; manually, or restore the generated ports file before running make payments.`,
|
|
2648
|
+
);
|
|
2649
|
+
}
|
|
2650
|
+
if (portsNext !== portsOriginal) {
|
|
2651
|
+
changed = true;
|
|
2652
|
+
if (!options.dryRun) await writeFile(portsFilePath, portsNext);
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
const infrastructureFilePath = path.join(
|
|
2656
|
+
targetDir,
|
|
2657
|
+
config.paths.infrastructurePorts,
|
|
2658
|
+
);
|
|
2659
|
+
const infrastructureOriginal = await readFile(infrastructureFilePath, "utf8");
|
|
2660
|
+
const infrastructureNext = appendDeferredPortKey(
|
|
2661
|
+
infrastructureOriginal,
|
|
2662
|
+
"entitlements",
|
|
2663
|
+
);
|
|
2664
|
+
if (
|
|
2665
|
+
infrastructureNext === infrastructureOriginal &&
|
|
2666
|
+
!/["']entitlements["']/.test(infrastructureOriginal)
|
|
2667
|
+
) {
|
|
2668
|
+
throw new Error(
|
|
2669
|
+
`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.`,
|
|
2670
|
+
);
|
|
2671
|
+
}
|
|
2672
|
+
if (infrastructureNext !== infrastructureOriginal) {
|
|
2673
|
+
changed = true;
|
|
2674
|
+
if (!options.dryRun) {
|
|
2675
|
+
await writeFile(infrastructureFilePath, infrastructureNext);
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
return changed;
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2442
2682
|
async function updateInfrastructurePortStub(
|
|
2443
2683
|
targetDir: string,
|
|
2444
2684
|
names: PortNames,
|
|
@@ -2620,6 +2860,23 @@ async function updateDrizzleSchemaIndex(
|
|
|
2620
2860
|
return true;
|
|
2621
2861
|
}
|
|
2622
2862
|
|
|
2863
|
+
async function updateBillingDrizzleSchemaIndex(
|
|
2864
|
+
targetDir: string,
|
|
2865
|
+
config: ResolvedBeignetConfig,
|
|
2866
|
+
options: { dryRun: boolean },
|
|
2867
|
+
): Promise<boolean> {
|
|
2868
|
+
const filePath = path.join(targetDir, drizzleSchemaIndexPath(config));
|
|
2869
|
+
if (!(await fileExists(filePath))) return false;
|
|
2870
|
+
|
|
2871
|
+
const original = await readFile(filePath, "utf8");
|
|
2872
|
+
const exportLine = 'export { billingAccounts } from "./billing";';
|
|
2873
|
+
if (original.includes(exportLine)) return false;
|
|
2874
|
+
|
|
2875
|
+
const next = `${original.trimEnd()}\n${exportLine}\n`;
|
|
2876
|
+
if (!options.dryRun) await writeFile(filePath, next);
|
|
2877
|
+
return true;
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2623
2880
|
async function updateDrizzleRepositories(
|
|
2624
2881
|
targetDir: string,
|
|
2625
2882
|
names: ResourceNames,
|
|
@@ -2659,6 +2916,138 @@ async function updateDrizzleRepositories(
|
|
|
2659
2916
|
return true;
|
|
2660
2917
|
}
|
|
2661
2918
|
|
|
2919
|
+
async function updateDatabaseProviderPick(
|
|
2920
|
+
targetDir: string,
|
|
2921
|
+
config: ResolvedBeignetConfig,
|
|
2922
|
+
options: { dryRun: boolean },
|
|
2923
|
+
): Promise<boolean> {
|
|
2924
|
+
const file = path.join(infrastructureDir(config), "db/provider.ts");
|
|
2925
|
+
const filePath = path.join(targetDir, file);
|
|
2926
|
+
if (!(await fileExists(filePath))) return false;
|
|
2927
|
+
|
|
2928
|
+
const original = await readFile(filePath, "utf8");
|
|
2929
|
+
const next = appendPickStringLiteralMember(original, "AppPorts", "billing");
|
|
2930
|
+
|
|
2931
|
+
if (next === original) return false;
|
|
2932
|
+
if (!options.dryRun) await writeFile(filePath, next);
|
|
2933
|
+
return true;
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
async function updateDatabaseProviderEntitlements(
|
|
2937
|
+
targetDir: string,
|
|
2938
|
+
config: ResolvedBeignetConfig,
|
|
2939
|
+
options: { dryRun: boolean },
|
|
2940
|
+
): Promise<boolean> {
|
|
2941
|
+
const file = path.join(infrastructureDir(config), "db/provider.ts");
|
|
2942
|
+
const filePath = path.join(targetDir, file);
|
|
2943
|
+
if (!(await fileExists(filePath))) return false;
|
|
2944
|
+
|
|
2945
|
+
const original = await readFile(filePath, "utf8");
|
|
2946
|
+
let next = original;
|
|
2947
|
+
const importLine =
|
|
2948
|
+
'import { createBillingEntitlements } from "@/features/billing/entitlements";';
|
|
2949
|
+
if (!next.includes(importLine)) {
|
|
2950
|
+
next = insertAfterImports(next, importLine);
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
next = appendPickStringLiteralMember(next, "AppPorts", "entitlements");
|
|
2954
|
+
|
|
2955
|
+
if (!/\bconst\s+entitlements\s*=/.test(next)) {
|
|
2956
|
+
const withEntitlementsConst = next.replace(
|
|
2957
|
+
/(\n\s*const repositories = createRepositories\(dbPort\.db\);)/,
|
|
2958
|
+
`$1\n\t\tconst entitlements = createBillingEntitlements(repositories.billing);`,
|
|
2959
|
+
);
|
|
2960
|
+
if (withEntitlementsConst === next) {
|
|
2961
|
+
throw new Error(
|
|
2962
|
+
`Could not find createRepositories(dbPort.db) in ${file}. Add const entitlements = createBillingEntitlements(repositories.billing) manually, or restore the generated database provider before running make payments.`,
|
|
2963
|
+
);
|
|
2964
|
+
}
|
|
2965
|
+
next = withEntitlementsConst;
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
if (!/\bentitlements\s*,/.test(next)) {
|
|
2969
|
+
const withEntitlementsPort = next.replace(
|
|
2970
|
+
/(\n\s*\.\.\.repositories,\n)/,
|
|
2971
|
+
`$1\t\t\tentitlements,\n`,
|
|
2972
|
+
);
|
|
2973
|
+
if (withEntitlementsPort === next) {
|
|
2974
|
+
throw new Error(
|
|
2975
|
+
`Could not find the repository spread in ${file}. Add entitlements to providedPorts manually, or restore the generated database provider before running make payments.`,
|
|
2976
|
+
);
|
|
2977
|
+
}
|
|
2978
|
+
next = withEntitlementsPort;
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
if (next === original) return false;
|
|
2982
|
+
if (!options.dryRun) await writeFile(filePath, next);
|
|
2983
|
+
return true;
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
async function updateBillingTransactionPorts(
|
|
2987
|
+
targetDir: string,
|
|
2988
|
+
config: ResolvedBeignetConfig,
|
|
2989
|
+
options: { dryRun: boolean },
|
|
2990
|
+
): Promise<boolean> {
|
|
2991
|
+
const file = path.join(infrastructureDir(config), "db/transaction-ports.ts");
|
|
2992
|
+
const filePath = path.join(targetDir, file);
|
|
2993
|
+
if (!(await fileExists(filePath))) return false;
|
|
2994
|
+
|
|
2995
|
+
const original = await readFile(filePath, "utf8");
|
|
2996
|
+
if (/Pick<AppTransactionPorts,\s*["']billing["']>/.test(original)) {
|
|
2997
|
+
return false;
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
const repositoryProperty = /repositories:\s*([^;\n]+);/.exec(original);
|
|
3001
|
+
if (!repositoryProperty) {
|
|
3002
|
+
throw new Error(
|
|
3003
|
+
`Could not find repositories: ... in ${file}. Add billing to the transaction repository dependency manually, or restore the generated transaction ports file before running make payments.`,
|
|
3004
|
+
);
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
const repositoryType = repositoryProperty[1]?.trim();
|
|
3008
|
+
if (!repositoryType) {
|
|
3009
|
+
throw new Error(
|
|
3010
|
+
`Could not update repositories in ${file}. Add billing to the transaction repository dependency manually, or restore the generated transaction ports file before running make payments.`,
|
|
3011
|
+
);
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
const next = original.replace(
|
|
3015
|
+
repositoryProperty[0],
|
|
3016
|
+
`repositories: ${repositoryType} & Pick<AppTransactionPorts, "billing">;`,
|
|
3017
|
+
);
|
|
3018
|
+
|
|
3019
|
+
if (next === original) return false;
|
|
3020
|
+
if (!options.dryRun) await writeFile(filePath, next);
|
|
3021
|
+
return true;
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
async function updateDatabaseResetTables(
|
|
3025
|
+
targetDir: string,
|
|
3026
|
+
config: ResolvedBeignetConfig,
|
|
3027
|
+
options: { dryRun: boolean },
|
|
3028
|
+
): Promise<boolean> {
|
|
3029
|
+
const file = path.join(infrastructureDir(config), "db/reset.ts");
|
|
3030
|
+
const filePath = path.join(targetDir, file);
|
|
3031
|
+
if (!(await fileExists(filePath))) return false;
|
|
3032
|
+
|
|
3033
|
+
const original = await readFile(filePath, "utf8");
|
|
3034
|
+
if (/["']billing_accounts["']/.test(original)) return false;
|
|
3035
|
+
|
|
3036
|
+
const tables = arrayInitializerInfo(original, "tables");
|
|
3037
|
+
if (!tables) return false;
|
|
3038
|
+
|
|
3039
|
+
const nextArray = appendToArrayExpression(tables.text, [
|
|
3040
|
+
'"billing_accounts"',
|
|
3041
|
+
]);
|
|
3042
|
+
const next = `${original.slice(0, tables.start)}${nextArray}${original.slice(
|
|
3043
|
+
tables.end,
|
|
3044
|
+
)}`;
|
|
3045
|
+
|
|
3046
|
+
if (next === original) return false;
|
|
3047
|
+
if (!options.dryRun) await writeFile(filePath, next);
|
|
3048
|
+
return true;
|
|
3049
|
+
}
|
|
3050
|
+
|
|
2662
3051
|
async function updateInfrastructureDeferredPorts(
|
|
2663
3052
|
targetDir: string,
|
|
2664
3053
|
names: ResourceNames,
|
|
@@ -2816,6 +3205,137 @@ async function updateSharedErrors(
|
|
|
2816
3205
|
return true;
|
|
2817
3206
|
}
|
|
2818
3207
|
|
|
3208
|
+
async function updateBillingSharedErrors(
|
|
3209
|
+
targetDir: string,
|
|
3210
|
+
config: ResolvedBeignetConfig,
|
|
3211
|
+
options: { dryRun: boolean },
|
|
3212
|
+
): Promise<boolean> {
|
|
3213
|
+
const file = resourceSharedErrorsPath(config);
|
|
3214
|
+
const filePath = path.join(targetDir, file);
|
|
3215
|
+
if (!(await fileExists(filePath))) return false;
|
|
3216
|
+
|
|
3217
|
+
const original = await readFile(filePath, "utf8");
|
|
3218
|
+
const missingEntries = [
|
|
3219
|
+
{
|
|
3220
|
+
name: "BillingAccountNotFound",
|
|
3221
|
+
entry: `BillingAccountNotFound: {
|
|
3222
|
+
\t\tcode: "BILLING_ACCOUNT_NOT_FOUND",
|
|
3223
|
+
\t\tstatus: 404,
|
|
3224
|
+
\t\tmessage: "Billing account not found",
|
|
3225
|
+
\t},`,
|
|
3226
|
+
},
|
|
3227
|
+
{
|
|
3228
|
+
name: "IdempotencyConflict",
|
|
3229
|
+
entry: `IdempotencyConflict: {
|
|
3230
|
+
\t\tcode: "IDEMPOTENCY_CONFLICT",
|
|
3231
|
+
\t\tstatus: 409,
|
|
3232
|
+
\t\tmessage: "Idempotency key was already used with a different request",
|
|
3233
|
+
\t},`,
|
|
3234
|
+
},
|
|
3235
|
+
{
|
|
3236
|
+
name: "IdempotencyInProgress",
|
|
3237
|
+
entry: `IdempotencyInProgress: {
|
|
3238
|
+
\t\tcode: "IDEMPOTENCY_IN_PROGRESS",
|
|
3239
|
+
\t\tstatus: 409,
|
|
3240
|
+
\t\tmessage: "Idempotency key is already being processed",
|
|
3241
|
+
\t},`,
|
|
3242
|
+
},
|
|
3243
|
+
{
|
|
3244
|
+
name: "BillingCheckoutUnavailable",
|
|
3245
|
+
entry: `BillingCheckoutUnavailable: {
|
|
3246
|
+
\t\tcode: "BILLING_CHECKOUT_UNAVAILABLE",
|
|
3247
|
+
\t\tstatus: 502,
|
|
3248
|
+
\t\tmessage: "Billing checkout is unavailable",
|
|
3249
|
+
\t},`,
|
|
3250
|
+
},
|
|
3251
|
+
].filter(({ name }) => !new RegExp(`\\b${name}\\b`).test(original));
|
|
3252
|
+
|
|
3253
|
+
if (missingEntries.length === 0) return false;
|
|
3254
|
+
|
|
3255
|
+
const defineErrorsStart = original.indexOf("defineErrors(");
|
|
3256
|
+
if (defineErrorsStart === -1) {
|
|
3257
|
+
throw new Error(
|
|
3258
|
+
`Could not find defineErrors({ in ${file}. Add BillingAccountNotFound and BillingCheckoutUnavailable manually, or restore the generated shared error catalog before running make payments.`,
|
|
3259
|
+
);
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
const openBrace = original.indexOf("{", defineErrorsStart);
|
|
3263
|
+
if (openBrace === -1) {
|
|
3264
|
+
throw new Error(
|
|
3265
|
+
`Could not find defineErrors({ in ${file}. Add BillingAccountNotFound and BillingCheckoutUnavailable manually, or restore the generated shared error catalog before running make payments.`,
|
|
3266
|
+
);
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
const closeBrace = matchingDelimiterIndex(original, openBrace, "{", "}");
|
|
3270
|
+
if (closeBrace === -1) {
|
|
3271
|
+
throw new Error(
|
|
3272
|
+
`Could not update ${file} because the error catalog object is malformed. Add BillingAccountNotFound and BillingCheckoutUnavailable manually, or restore the generated shared error catalog before running make payments.`,
|
|
3273
|
+
);
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
const next = insertBeforeClosingBrace(
|
|
3277
|
+
original,
|
|
3278
|
+
openBrace,
|
|
3279
|
+
closeBrace,
|
|
3280
|
+
missingEntries.map(({ entry }) => entry).join("\n"),
|
|
3281
|
+
);
|
|
3282
|
+
|
|
3283
|
+
if (next === original) return false;
|
|
3284
|
+
if (!options.dryRun) await writeFile(filePath, next);
|
|
3285
|
+
return true;
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
async function updateBillingEnv(
|
|
3289
|
+
targetDir: string,
|
|
3290
|
+
options: { dryRun: boolean },
|
|
3291
|
+
): Promise<boolean> {
|
|
3292
|
+
const file = "lib/env.ts";
|
|
3293
|
+
const filePath = path.join(targetDir, file);
|
|
3294
|
+
const original = await readOptionalFile(filePath);
|
|
3295
|
+
if (original === undefined) {
|
|
3296
|
+
throw new Error(
|
|
3297
|
+
`Could not find ${file}. Add BILLING_TEAM_PRICE_ID to your env schema manually, or restore the generated env file before running make payments.`,
|
|
3298
|
+
);
|
|
3299
|
+
}
|
|
3300
|
+
if (/\bBILLING_TEAM_PRICE_ID\b/.test(original)) return false;
|
|
3301
|
+
|
|
3302
|
+
const match = /\n([\t ]*)LOG_LEVEL:/.exec(original);
|
|
3303
|
+
if (!match) {
|
|
3304
|
+
throw new Error(
|
|
3305
|
+
`Could not find LOG_LEVEL in ${file}. Add BILLING_TEAM_PRICE_ID: z.string().min(1).default("price_team_example") to the createEnv server block manually, or restore the generated env file before running make payments.`,
|
|
3306
|
+
);
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
const entry = `\n${match[1]}BILLING_TEAM_PRICE_ID: z.string().min(1).default("price_team_example"),`;
|
|
3310
|
+
const next = `${original.slice(0, match.index)}${entry}${original.slice(
|
|
3311
|
+
match.index,
|
|
3312
|
+
)}`;
|
|
3313
|
+
|
|
3314
|
+
if (!options.dryRun) await writeFile(filePath, next);
|
|
3315
|
+
return true;
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
async function updateBillingEnvExample(
|
|
3319
|
+
targetDir: string,
|
|
3320
|
+
options: { dryRun: boolean },
|
|
3321
|
+
): Promise<boolean> {
|
|
3322
|
+
const file = ".env.example";
|
|
3323
|
+
const filePath = path.join(targetDir, file);
|
|
3324
|
+
const original = await readOptionalFile(filePath);
|
|
3325
|
+
if (original === undefined) return false;
|
|
3326
|
+
if (/^BILLING_TEAM_PRICE_ID=/m.test(original)) return false;
|
|
3327
|
+
|
|
3328
|
+
const match = /\nLOG_LEVEL=/.exec(original);
|
|
3329
|
+
const next = match
|
|
3330
|
+
? `${original.slice(0, match.index)}\nBILLING_TEAM_PRICE_ID=price_team_example${original.slice(
|
|
3331
|
+
match.index,
|
|
3332
|
+
)}`
|
|
3333
|
+
: `${original.trimEnd()}\nBILLING_TEAM_PRICE_ID=price_team_example\n`;
|
|
3334
|
+
|
|
3335
|
+
if (!options.dryRun) await writeFile(filePath, next);
|
|
3336
|
+
return true;
|
|
3337
|
+
}
|
|
3338
|
+
|
|
2819
3339
|
async function updateServerRoutes(
|
|
2820
3340
|
targetDir: string,
|
|
2821
3341
|
names: ResourceNames,
|
|
@@ -3169,6 +3689,30 @@ function appendUnionTypeMember(
|
|
|
3169
3689
|
)}`;
|
|
3170
3690
|
}
|
|
3171
3691
|
|
|
3692
|
+
function appendPickStringLiteralMember(
|
|
3693
|
+
source: string,
|
|
3694
|
+
typeName: string,
|
|
3695
|
+
member: string,
|
|
3696
|
+
): string {
|
|
3697
|
+
const match = new RegExp(`Pick<\\s*${typeName}\\s*,([\\s\\S]*?)>`).exec(
|
|
3698
|
+
source,
|
|
3699
|
+
);
|
|
3700
|
+
if (!match) return source;
|
|
3701
|
+
|
|
3702
|
+
const literal = `"${member}"`;
|
|
3703
|
+
if (match[1].includes(literal)) return source;
|
|
3704
|
+
|
|
3705
|
+
const union = match[1];
|
|
3706
|
+
const indent = union.match(/\n([\t ]*)\|/)?.[1] ?? "\t";
|
|
3707
|
+
const nextUnion = union.includes("\n")
|
|
3708
|
+
? `${union.replace(/\s*$/, "")}\n${indent}| ${literal}\n`
|
|
3709
|
+
: `${union.trimEnd()} | ${literal}`;
|
|
3710
|
+
|
|
3711
|
+
return `${source.slice(0, match.index)}Pick<${typeName},${nextUnion}>${source.slice(
|
|
3712
|
+
match.index + match[0].length,
|
|
3713
|
+
)}`;
|
|
3714
|
+
}
|
|
3715
|
+
|
|
3172
3716
|
function appendTupleTypeMember(
|
|
3173
3717
|
source: string,
|
|
3174
3718
|
typeName: string,
|
|
@@ -4249,6 +4793,61 @@ function resourceFiles(
|
|
|
4249
4793
|
return files;
|
|
4250
4794
|
}
|
|
4251
4795
|
|
|
4796
|
+
function paymentsFiles(
|
|
4797
|
+
config: ResolvedBeignetConfig,
|
|
4798
|
+
database: DatabaseName,
|
|
4799
|
+
): GeneratedFile[] {
|
|
4800
|
+
return [
|
|
4801
|
+
{
|
|
4802
|
+
path: path.join(config.paths.features, "billing/schemas.ts"),
|
|
4803
|
+
content: billingSchemasFile(),
|
|
4804
|
+
},
|
|
4805
|
+
{
|
|
4806
|
+
path: path.join(config.paths.features, "billing/pricing.ts"),
|
|
4807
|
+
content: billingPricingFile(),
|
|
4808
|
+
},
|
|
4809
|
+
{
|
|
4810
|
+
path: path.join(config.paths.features, "billing/entitlements.ts"),
|
|
4811
|
+
content: billingEntitlementsFile(),
|
|
4812
|
+
},
|
|
4813
|
+
{
|
|
4814
|
+
path: path.join(config.paths.features, "billing/ports.ts"),
|
|
4815
|
+
content: billingPortsFile(),
|
|
4816
|
+
},
|
|
4817
|
+
{
|
|
4818
|
+
path: path.join(config.paths.features, "billing/contracts.ts"),
|
|
4819
|
+
content: billingContractsFile(),
|
|
4820
|
+
},
|
|
4821
|
+
{
|
|
4822
|
+
path: path.join(config.paths.features, "billing/use-cases/index.ts"),
|
|
4823
|
+
content: billingUseCasesFile(config),
|
|
4824
|
+
},
|
|
4825
|
+
{
|
|
4826
|
+
path: path.join(config.paths.features, "billing/routes.ts"),
|
|
4827
|
+
content: billingRoutesFile(config),
|
|
4828
|
+
},
|
|
4829
|
+
{
|
|
4830
|
+
path: path.join(config.paths.features, "billing/tests/billing.test.ts"),
|
|
4831
|
+
content: billingTestFile(config),
|
|
4832
|
+
},
|
|
4833
|
+
{
|
|
4834
|
+
path: path.join(
|
|
4835
|
+
infrastructureDir(config),
|
|
4836
|
+
"billing/drizzle-billing-repository.ts",
|
|
4837
|
+
),
|
|
4838
|
+
content: billingDrizzleRepositoryFile(config, database),
|
|
4839
|
+
},
|
|
4840
|
+
{
|
|
4841
|
+
path: path.join(infrastructureDir(config), "db/schema/billing.ts"),
|
|
4842
|
+
content: billingDrizzleSchemaFile(database),
|
|
4843
|
+
},
|
|
4844
|
+
{
|
|
4845
|
+
path: "app/api/webhooks/payments/route.ts",
|
|
4846
|
+
content: billingWebhookRouteFile(),
|
|
4847
|
+
},
|
|
4848
|
+
];
|
|
4849
|
+
}
|
|
4850
|
+
|
|
4252
4851
|
function featureUiComponentFiles(
|
|
4253
4852
|
names: FeatureUiNames,
|
|
4254
4853
|
config: ResolvedBeignetConfig,
|
|
@@ -6889,6 +7488,1230 @@ export const ${names.uploadExportName} = defineUpload<
|
|
|
6889
7488
|
`;
|
|
6890
7489
|
}
|
|
6891
7490
|
|
|
7491
|
+
function billingSchemasFile(): string {
|
|
7492
|
+
return `import { z } from "zod";
|
|
7493
|
+
|
|
7494
|
+
export const billingPlanSchema = z.enum(["team"]);
|
|
7495
|
+
|
|
7496
|
+
export const billingStatusSchema = z.enum([
|
|
7497
|
+
\t"inactive",
|
|
7498
|
+
\t"trialing",
|
|
7499
|
+
\t"active",
|
|
7500
|
+
\t"past_due",
|
|
7501
|
+
\t"canceled",
|
|
7502
|
+
]);
|
|
7503
|
+
|
|
7504
|
+
export const createCheckoutSessionInputSchema = z.object({
|
|
7505
|
+
\tplan: billingPlanSchema,
|
|
7506
|
+
});
|
|
7507
|
+
|
|
7508
|
+
export const createCheckoutSessionOutputSchema = z.object({
|
|
7509
|
+
\tsessionId: z.string(),
|
|
7510
|
+
\turl: z.string().url(),
|
|
7511
|
+
});
|
|
7512
|
+
|
|
7513
|
+
export const createBillingPortalSessionOutputSchema = z.object({
|
|
7514
|
+
\turl: z.string().url(),
|
|
7515
|
+
});
|
|
7516
|
+
|
|
7517
|
+
export const billingStatusOutputSchema = z.object({
|
|
7518
|
+
\ttenantId: z.string(),
|
|
7519
|
+
\tplan: billingPlanSchema,
|
|
7520
|
+
\tstatus: billingStatusSchema,
|
|
7521
|
+
\tactive: z.boolean(),
|
|
7522
|
+
\tprovider: z.string().optional(),
|
|
7523
|
+
\tcustomerId: z.string().optional(),
|
|
7524
|
+
\tsubscriptionId: z.string().optional(),
|
|
7525
|
+
\tcurrentPeriodEnd: z.string().optional(),
|
|
7526
|
+
\tcancelAtPeriodEnd: z.boolean().optional(),
|
|
7527
|
+
\tcheckoutSessionId: z.string().optional(),
|
|
7528
|
+
\tlastEventId: z.string().optional(),
|
|
7529
|
+
\tupdatedAt: z.string().optional(),
|
|
7530
|
+
});
|
|
7531
|
+
|
|
7532
|
+
export type BillingPlan = z.infer<typeof billingPlanSchema>;
|
|
7533
|
+
export type BillingStatus = z.infer<typeof billingStatusSchema>;
|
|
7534
|
+
export type BillingStatusOutput = z.infer<typeof billingStatusOutputSchema>;
|
|
7535
|
+
`;
|
|
7536
|
+
}
|
|
7537
|
+
|
|
7538
|
+
function billingPricingFile(): string {
|
|
7539
|
+
return `import type {
|
|
7540
|
+
\tPaymentCheckoutLineItem,
|
|
7541
|
+
\tPaymentCheckoutMode,
|
|
7542
|
+
} from "@beignet/core/payments";
|
|
7543
|
+
import { env } from "@/lib/env";
|
|
7544
|
+
import type { BillingPlan } from "./schemas";
|
|
7545
|
+
|
|
7546
|
+
export type BillingEntitlement = "todos.create";
|
|
7547
|
+
|
|
7548
|
+
export type BillingPlanDefinition = {
|
|
7549
|
+
\tid: BillingPlan;
|
|
7550
|
+
\tlabel: string;
|
|
7551
|
+
\tmode: PaymentCheckoutMode;
|
|
7552
|
+
\tlineItems: readonly PaymentCheckoutLineItem[];
|
|
7553
|
+
\tentitlements: readonly BillingEntitlement[];
|
|
7554
|
+
};
|
|
7555
|
+
|
|
7556
|
+
export const billingPlans = {
|
|
7557
|
+
\tteam: {
|
|
7558
|
+
\t\tid: "team",
|
|
7559
|
+
\t\tlabel: "Team",
|
|
7560
|
+
\t\tmode: "subscription",
|
|
7561
|
+
\t\tlineItems: [{ priceId: env.BILLING_TEAM_PRICE_ID, quantity: 1 }],
|
|
7562
|
+
\t\tentitlements: ["todos.create"],
|
|
7563
|
+
\t},
|
|
7564
|
+
} as const satisfies Record<BillingPlan, BillingPlanDefinition>;
|
|
7565
|
+
|
|
7566
|
+
export function getBillingPlan(plan: BillingPlan): BillingPlanDefinition {
|
|
7567
|
+
\treturn billingPlans[plan];
|
|
7568
|
+
}
|
|
7569
|
+
|
|
7570
|
+
export function billingPlanIncludesEntitlement(
|
|
7571
|
+
\tplan: BillingPlan,
|
|
7572
|
+
\tentitlement: string,
|
|
7573
|
+
): entitlement is BillingEntitlement {
|
|
7574
|
+
\treturn getBillingPlan(plan).entitlements.includes(
|
|
7575
|
+
\t\tentitlement as BillingEntitlement,
|
|
7576
|
+
\t);
|
|
7577
|
+
}
|
|
7578
|
+
`;
|
|
7579
|
+
}
|
|
7580
|
+
|
|
7581
|
+
function billingEntitlementsFile(): string {
|
|
7582
|
+
return `import {
|
|
7583
|
+
\tcreateEntitlements,
|
|
7584
|
+
\tdenyEntitlement,
|
|
7585
|
+
} from "@beignet/core/entitlements";
|
|
7586
|
+
import { billingPlanIncludesEntitlement } from "./pricing";
|
|
7587
|
+
import { isBillingAccountActive, type BillingRepository } from "./ports";
|
|
7588
|
+
|
|
7589
|
+
export function createBillingEntitlements(billing: BillingRepository) {
|
|
7590
|
+
\treturn createEntitlements({
|
|
7591
|
+
\t\tasync inspect(input) {
|
|
7592
|
+
\t\t\tif (input.subject.type !== "tenant") {
|
|
7593
|
+
\t\t\t\treturn denyEntitlement({
|
|
7594
|
+
\t\t\t\t\treason: "Billing entitlements require a tenant subject.",
|
|
7595
|
+
\t\t\t\t\tcode: "ENTITLEMENT_SUBJECT_UNSUPPORTED",
|
|
7596
|
+
\t\t\t\t\tdetails: { subject: input.subject },
|
|
7597
|
+
\t\t\t\t});
|
|
7598
|
+
\t\t\t}
|
|
7599
|
+
|
|
7600
|
+
\t\t\tconst account = await billing.findByTenantId(input.subject.id);
|
|
7601
|
+
\t\t\tif (!isBillingAccountActive(account)) {
|
|
7602
|
+
\t\t\t\treturn denyEntitlement({
|
|
7603
|
+
\t\t\t\t\treason: "An active billing plan is required for this action.",
|
|
7604
|
+
\t\t\t\t\tcode: "BILLING_INACTIVE",
|
|
7605
|
+
\t\t\t\t\tdetails: {
|
|
7606
|
+
\t\t\t\t\t\tentitlement: input.entitlement,
|
|
7607
|
+
\t\t\t\t\t\tstatus: account?.status ?? "inactive",
|
|
7608
|
+
\t\t\t\t\t\tsubject: input.subject,
|
|
7609
|
+
\t\t\t\t\t},
|
|
7610
|
+
\t\t\t\t});
|
|
7611
|
+
\t\t\t}
|
|
7612
|
+
|
|
7613
|
+
\t\t\tif (!billingPlanIncludesEntitlement(account.plan, input.entitlement)) {
|
|
7614
|
+
\t\t\t\treturn denyEntitlement({
|
|
7615
|
+
\t\t\t\t\treason:
|
|
7616
|
+
\t\t\t\t\t\t"The current billing plan does not include this entitlement.",
|
|
7617
|
+
\t\t\t\t\tcode: "ENTITLEMENT_NOT_INCLUDED",
|
|
7618
|
+
\t\t\t\t\tdetails: {
|
|
7619
|
+
\t\t\t\t\t\tentitlement: input.entitlement,
|
|
7620
|
+
\t\t\t\t\t\tplan: account.plan,
|
|
7621
|
+
\t\t\t\t\t\tsubject: input.subject,
|
|
7622
|
+
\t\t\t\t\t},
|
|
7623
|
+
\t\t\t\t});
|
|
7624
|
+
\t\t\t}
|
|
7625
|
+
|
|
7626
|
+
\t\t\treturn true;
|
|
7627
|
+
\t\t},
|
|
7628
|
+
\t});
|
|
7629
|
+
}
|
|
7630
|
+
`;
|
|
7631
|
+
}
|
|
7632
|
+
|
|
7633
|
+
function billingPortsFile(): string {
|
|
7634
|
+
return `import type { BillingPlan, BillingStatus } from "./schemas";
|
|
7635
|
+
|
|
7636
|
+
export type BillingAccount = {
|
|
7637
|
+
\ttenantId: string;
|
|
7638
|
+
\tplan: BillingPlan;
|
|
7639
|
+
\tstatus: BillingStatus;
|
|
7640
|
+
\tprovider: string;
|
|
7641
|
+
\tcustomerId?: string;
|
|
7642
|
+
\tsubscriptionId?: string;
|
|
7643
|
+
\tcurrentPeriodEnd?: string;
|
|
7644
|
+
\tcancelAtPeriodEnd?: boolean;
|
|
7645
|
+
\tcheckoutSessionId?: string;
|
|
7646
|
+
\tlastEventId?: string;
|
|
7647
|
+
\tupdatedAt: string;
|
|
7648
|
+
};
|
|
7649
|
+
|
|
7650
|
+
export type SaveBillingAccountInput = BillingAccount;
|
|
7651
|
+
|
|
7652
|
+
export interface BillingRepository {
|
|
7653
|
+
\tfindByTenantId(tenantId: string): Promise<BillingAccount | null>;
|
|
7654
|
+
\tfindByCustomerId(
|
|
7655
|
+
\t\tcustomerId: string,
|
|
7656
|
+
\t\tprovider: string,
|
|
7657
|
+
\t): Promise<BillingAccount | null>;
|
|
7658
|
+
\tsave(input: SaveBillingAccountInput): Promise<BillingAccount>;
|
|
7659
|
+
}
|
|
7660
|
+
|
|
7661
|
+
export function isBillingAccountActive(
|
|
7662
|
+
\taccount: BillingAccount | null,
|
|
7663
|
+
): account is BillingAccount & { status: "active" | "trialing" } {
|
|
7664
|
+
\treturn account?.status === "active" || account?.status === "trialing";
|
|
7665
|
+
}
|
|
7666
|
+
`;
|
|
7667
|
+
}
|
|
7668
|
+
|
|
7669
|
+
function billingContractsFile(): string {
|
|
7670
|
+
return `import { defineContractGroup } from "@beignet/core/contracts";
|
|
7671
|
+
import { z } from "zod";
|
|
7672
|
+
import {
|
|
7673
|
+
\tbillingStatusOutputSchema,
|
|
7674
|
+
\tcreateBillingPortalSessionOutputSchema,
|
|
7675
|
+
\tcreateCheckoutSessionInputSchema,
|
|
7676
|
+
\tcreateCheckoutSessionOutputSchema,
|
|
7677
|
+
} from "@/features/billing/schemas";
|
|
7678
|
+
import { errors } from "@/features/shared/errors";
|
|
7679
|
+
|
|
7680
|
+
const billing = defineContractGroup()
|
|
7681
|
+
\t.namespace("billing")
|
|
7682
|
+
\t.prefix("/api/billing");
|
|
7683
|
+
const checkoutHeadersSchema = z.object({
|
|
7684
|
+
\t"idempotency-key": z.string().min(1),
|
|
7685
|
+
});
|
|
7686
|
+
const checkoutBilling = billing.headers(checkoutHeadersSchema);
|
|
7687
|
+
|
|
7688
|
+
export const getBillingStatus = billing
|
|
7689
|
+
\t.get("/")
|
|
7690
|
+
\t.query(z.object({}))
|
|
7691
|
+
\t.meta({ auth: "required" })
|
|
7692
|
+
\t.errors({ Unauthorized: errors.Unauthorized })
|
|
7693
|
+
\t.responses({
|
|
7694
|
+
\t\t200: billingStatusOutputSchema,
|
|
7695
|
+
\t})
|
|
7696
|
+
\t.openapi({ summary: "Get billing status", tags: ["billing"] });
|
|
7697
|
+
|
|
7698
|
+
export const createCheckoutSession = checkoutBilling
|
|
7699
|
+
\t.post("/checkout")
|
|
7700
|
+
\t.body(createCheckoutSessionInputSchema)
|
|
7701
|
+
\t.meta({
|
|
7702
|
+
\t\tauth: "required",
|
|
7703
|
+
\t\tidempotency: {
|
|
7704
|
+
\t\t\trequired: true,
|
|
7705
|
+
\t\t\theader: "idempotency-key",
|
|
7706
|
+
\t\t\tscope: "actor-tenant",
|
|
7707
|
+
\t\t\tttlSec: 86_400,
|
|
7708
|
+
\t\t},
|
|
7709
|
+
\t})
|
|
7710
|
+
\t.errors({
|
|
7711
|
+
\t\tUnauthorized: errors.Unauthorized,
|
|
7712
|
+
\t\tIdempotencyConflict: errors.IdempotencyConflict,
|
|
7713
|
+
\t\tIdempotencyInProgress: errors.IdempotencyInProgress,
|
|
7714
|
+
\t\tBillingCheckoutUnavailable: errors.BillingCheckoutUnavailable,
|
|
7715
|
+
\t})
|
|
7716
|
+
\t.responses({
|
|
7717
|
+
\t\t201: createCheckoutSessionOutputSchema,
|
|
7718
|
+
\t})
|
|
7719
|
+
\t.openapi({ summary: "Create a checkout session", tags: ["billing"] });
|
|
7720
|
+
|
|
7721
|
+
export const createBillingPortalSession = billing
|
|
7722
|
+
\t.post("/portal")
|
|
7723
|
+
\t.body(z.object({}))
|
|
7724
|
+
\t.meta({ auth: "required" })
|
|
7725
|
+
\t.errors({
|
|
7726
|
+
\t\tUnauthorized: errors.Unauthorized,
|
|
7727
|
+
\t\tBillingAccountNotFound: errors.BillingAccountNotFound,
|
|
7728
|
+
\t})
|
|
7729
|
+
\t.responses({
|
|
7730
|
+
\t\t200: createBillingPortalSessionOutputSchema,
|
|
7731
|
+
\t})
|
|
7732
|
+
\t.openapi({ summary: "Create a billing portal session", tags: ["billing"] });
|
|
7733
|
+
|
|
7734
|
+
export const billingContracts = [
|
|
7735
|
+
\tgetBillingStatus,
|
|
7736
|
+
\tcreateCheckoutSession,
|
|
7737
|
+
\tcreateBillingPortalSession,
|
|
7738
|
+
];
|
|
7739
|
+
`;
|
|
7740
|
+
}
|
|
7741
|
+
|
|
7742
|
+
function billingUseCasesFile(config: ResolvedBeignetConfig): string {
|
|
7743
|
+
return `import {
|
|
7744
|
+
\tcreateIdempotencyFingerprint,
|
|
7745
|
+
\trunIdempotently,
|
|
7746
|
+
} from "@beignet/core/idempotency";
|
|
7747
|
+
import type { PaymentWebhookEvent } from "@beignet/core/payments";
|
|
7748
|
+
import { requireUser } from "@beignet/core/ports";
|
|
7749
|
+
import { z } from "zod";
|
|
7750
|
+
import type { AppContext } from "${aliasModule(config.paths.appContext)}";
|
|
7751
|
+
import { appError } from "@/features/shared/errors";
|
|
7752
|
+
import { env } from "@/lib/env";
|
|
7753
|
+
import { useCase } from "@/lib/use-case";
|
|
7754
|
+
import { getBillingPlan } from "../pricing";
|
|
7755
|
+
import type { BillingAccount } from "../ports";
|
|
7756
|
+
import { isBillingAccountActive } from "../ports";
|
|
7757
|
+
import {
|
|
7758
|
+
\ttype BillingPlan,
|
|
7759
|
+
\ttype BillingStatus,
|
|
7760
|
+
\tbillingStatusOutputSchema,
|
|
7761
|
+
\tcreateBillingPortalSessionOutputSchema,
|
|
7762
|
+
\tcreateCheckoutSessionInputSchema,
|
|
7763
|
+
\tcreateCheckoutSessionOutputSchema,
|
|
7764
|
+
} from "../schemas";
|
|
7765
|
+
|
|
7766
|
+
type ProviderPayload = Record<string, unknown>;
|
|
7767
|
+
|
|
7768
|
+
const paymentWebhookEventSchema = z.custom<PaymentWebhookEvent>(
|
|
7769
|
+
\t(value): value is PaymentWebhookEvent =>
|
|
7770
|
+
\t\ttypeof value === "object" &&
|
|
7771
|
+
\t\tvalue !== null &&
|
|
7772
|
+
\t\ttypeof (value as PaymentWebhookEvent).id === "string" &&
|
|
7773
|
+
\t\ttypeof (value as PaymentWebhookEvent).type === "string" &&
|
|
7774
|
+
\t\ttypeof (value as PaymentWebhookEvent).provider === "string",
|
|
7775
|
+
);
|
|
7776
|
+
const paymentWebhookOutputSchema = z.object({
|
|
7777
|
+
\treceived: z.boolean(),
|
|
7778
|
+
\taccountUpdated: z.boolean(),
|
|
7779
|
+
});
|
|
7780
|
+
|
|
7781
|
+
function nowIso(): string {
|
|
7782
|
+
\treturn new Date().toISOString();
|
|
7783
|
+
}
|
|
7784
|
+
|
|
7785
|
+
function record(value: unknown): ProviderPayload {
|
|
7786
|
+
\treturn value && typeof value === "object" && !Array.isArray(value)
|
|
7787
|
+
\t\t? (value as ProviderPayload)
|
|
7788
|
+
\t\t: {};
|
|
7789
|
+
}
|
|
7790
|
+
|
|
7791
|
+
function stringValue(value: unknown): string | undefined {
|
|
7792
|
+
\treturn typeof value === "string" && value.length > 0 ? value : undefined;
|
|
7793
|
+
}
|
|
7794
|
+
|
|
7795
|
+
function providerId(value: unknown): string | undefined {
|
|
7796
|
+
\tif (typeof value === "string") return stringValue(value);
|
|
7797
|
+
\tconst payload = record(value);
|
|
7798
|
+
\treturn stringValue(payload.id);
|
|
7799
|
+
}
|
|
7800
|
+
|
|
7801
|
+
function metadataValue(data: ProviderPayload, key: string): string | undefined {
|
|
7802
|
+
\treturn stringValue(record(data.metadata)[key]);
|
|
7803
|
+
}
|
|
7804
|
+
|
|
7805
|
+
function tenantIdFromPayload(data: ProviderPayload): string | undefined {
|
|
7806
|
+
\treturn (
|
|
7807
|
+
\t\tstringValue(data.client_reference_id) ??
|
|
7808
|
+
\t\tmetadataValue(data, "tenantId") ??
|
|
7809
|
+
\t\tmetadataValue(data, "tenant_id")
|
|
7810
|
+
\t);
|
|
7811
|
+
}
|
|
7812
|
+
|
|
7813
|
+
function planFromPayload(data: ProviderPayload): BillingPlan {
|
|
7814
|
+
\tconst plan = metadataValue(data, "plan");
|
|
7815
|
+
\treturn plan === "team" ? plan : "team";
|
|
7816
|
+
}
|
|
7817
|
+
|
|
7818
|
+
function statusFromPayload(
|
|
7819
|
+
\tdata: ProviderPayload,
|
|
7820
|
+
\tfallback: BillingStatus,
|
|
7821
|
+
): BillingStatus {
|
|
7822
|
+
\tconst status = stringValue(data.status);
|
|
7823
|
+
\tswitch (status) {
|
|
7824
|
+
\t\tcase "active":
|
|
7825
|
+
\t\tcase "trialing":
|
|
7826
|
+
\t\tcase "past_due":
|
|
7827
|
+
\t\tcase "canceled":
|
|
7828
|
+
\t\tcase "inactive":
|
|
7829
|
+
\t\t\treturn status;
|
|
7830
|
+
\t\tcase "unpaid":
|
|
7831
|
+
\t\t\treturn "past_due";
|
|
7832
|
+
\t\tcase "incomplete":
|
|
7833
|
+
\t\t\treturn "inactive";
|
|
7834
|
+
\t\tcase "incomplete_expired":
|
|
7835
|
+
\t\t\treturn "canceled";
|
|
7836
|
+
\t\tcase "paused":
|
|
7837
|
+
\t\t\treturn "inactive";
|
|
7838
|
+
\t\tdefault:
|
|
7839
|
+
\t\t\treturn fallback;
|
|
7840
|
+
\t}
|
|
7841
|
+
}
|
|
7842
|
+
|
|
7843
|
+
function periodEndFromPayload(data: ProviderPayload): string | undefined {
|
|
7844
|
+
\tconst value = data.current_period_end;
|
|
7845
|
+
\treturn typeof value === "number"
|
|
7846
|
+
\t\t? new Date(value * 1000).toISOString()
|
|
7847
|
+
\t\t: stringValue(value);
|
|
7848
|
+
}
|
|
7849
|
+
|
|
7850
|
+
function cancelAtPeriodEndFromPayload(
|
|
7851
|
+
\tdata: ProviderPayload,
|
|
7852
|
+
): boolean | undefined {
|
|
7853
|
+
\treturn typeof data.cancel_at_period_end === "boolean"
|
|
7854
|
+
\t\t? data.cancel_at_period_end
|
|
7855
|
+
\t\t: undefined;
|
|
7856
|
+
}
|
|
7857
|
+
|
|
7858
|
+
function billingTenantId(ctx: AppContext): string {
|
|
7859
|
+
\tconst user = requireUser(ctx);
|
|
7860
|
+
\treturn ctx.tenant?.id ?? user.id;
|
|
7861
|
+
}
|
|
7862
|
+
|
|
7863
|
+
function billingStatusOutput(tenantId: string, account: BillingAccount | null) {
|
|
7864
|
+
\treturn {
|
|
7865
|
+
\t\ttenantId,
|
|
7866
|
+
\t\tplan: account?.plan ?? "team",
|
|
7867
|
+
\t\tstatus: account?.status ?? "inactive",
|
|
7868
|
+
\t\tactive: isBillingAccountActive(account),
|
|
7869
|
+
\t\t...(account?.provider ? { provider: account.provider } : {}),
|
|
7870
|
+
\t\t...(account?.customerId ? { customerId: account.customerId } : {}),
|
|
7871
|
+
\t\t...(account?.subscriptionId
|
|
7872
|
+
\t\t\t? { subscriptionId: account.subscriptionId }
|
|
7873
|
+
\t\t\t: {}),
|
|
7874
|
+
\t\t...(account?.currentPeriodEnd
|
|
7875
|
+
\t\t\t? { currentPeriodEnd: account.currentPeriodEnd }
|
|
7876
|
+
\t\t\t: {}),
|
|
7877
|
+
\t\t...(account?.cancelAtPeriodEnd !== undefined
|
|
7878
|
+
\t\t\t? { cancelAtPeriodEnd: account.cancelAtPeriodEnd }
|
|
7879
|
+
\t\t\t: {}),
|
|
7880
|
+
\t\t...(account?.checkoutSessionId
|
|
7881
|
+
\t\t\t? { checkoutSessionId: account.checkoutSessionId }
|
|
7882
|
+
\t\t\t: {}),
|
|
7883
|
+
\t\t...(account?.lastEventId ? { lastEventId: account.lastEventId } : {}),
|
|
7884
|
+
\t\t...(account?.updatedAt ? { updatedAt: account.updatedAt } : {}),
|
|
7885
|
+
\t};
|
|
7886
|
+
}
|
|
7887
|
+
|
|
7888
|
+
async function saveBillingAccount(args: {
|
|
7889
|
+
\texisting: BillingAccount | null;
|
|
7890
|
+
\ttenantId: string;
|
|
7891
|
+
\tprovider: string;
|
|
7892
|
+
\tplan?: BillingPlan;
|
|
7893
|
+
\tstatus?: BillingStatus;
|
|
7894
|
+
\tcustomerId?: string;
|
|
7895
|
+
\tsubscriptionId?: string;
|
|
7896
|
+
\tcurrentPeriodEnd?: string;
|
|
7897
|
+
\tcancelAtPeriodEnd?: boolean;
|
|
7898
|
+
\tcheckoutSessionId?: string;
|
|
7899
|
+
\tlastEventId?: string;
|
|
7900
|
+
\tsave: (account: BillingAccount) => Promise<BillingAccount>;
|
|
7901
|
+
}) {
|
|
7902
|
+
\treturn args.save({
|
|
7903
|
+
\t\ttenantId: args.tenantId,
|
|
7904
|
+
\t\tprovider: args.provider,
|
|
7905
|
+
\t\tplan: args.plan ?? args.existing?.plan ?? "team",
|
|
7906
|
+
\t\tstatus: args.status ?? args.existing?.status ?? "inactive",
|
|
7907
|
+
\t\tcustomerId: args.customerId ?? args.existing?.customerId,
|
|
7908
|
+
\t\tsubscriptionId: args.subscriptionId ?? args.existing?.subscriptionId,
|
|
7909
|
+
\t\tcurrentPeriodEnd:
|
|
7910
|
+
\t\t\targs.currentPeriodEnd ?? args.existing?.currentPeriodEnd,
|
|
7911
|
+
\t\tcancelAtPeriodEnd:
|
|
7912
|
+
\t\t\targs.cancelAtPeriodEnd ?? args.existing?.cancelAtPeriodEnd,
|
|
7913
|
+
\t\tcheckoutSessionId:
|
|
7914
|
+
\t\t\targs.checkoutSessionId ?? args.existing?.checkoutSessionId,
|
|
7915
|
+
\t\tlastEventId: args.lastEventId ?? args.existing?.lastEventId,
|
|
7916
|
+
\t\tupdatedAt: nowIso(),
|
|
7917
|
+
\t});
|
|
7918
|
+
}
|
|
7919
|
+
|
|
7920
|
+
async function applyCheckoutCompleted(
|
|
7921
|
+
\tevent: PaymentWebhookEvent,
|
|
7922
|
+
\tsave: (account: BillingAccount) => Promise<BillingAccount>,
|
|
7923
|
+
\tfindByTenantId: (tenantId: string) => Promise<BillingAccount | null>,
|
|
7924
|
+
) {
|
|
7925
|
+
\tconst data = record(event.data);
|
|
7926
|
+
\tconst tenantId = tenantIdFromPayload(data);
|
|
7927
|
+
\tif (!tenantId) return null;
|
|
7928
|
+
|
|
7929
|
+
\tconst existing = await findByTenantId(tenantId);
|
|
7930
|
+
\treturn saveBillingAccount({
|
|
7931
|
+
\t\texisting,
|
|
7932
|
+
\t\ttenantId,
|
|
7933
|
+
\t\tprovider: event.provider,
|
|
7934
|
+
\t\tplan: planFromPayload(data),
|
|
7935
|
+
\t\tstatus: "active",
|
|
7936
|
+
\t\tcustomerId: providerId(data.customer),
|
|
7937
|
+
\t\tsubscriptionId: providerId(data.subscription),
|
|
7938
|
+
\t\tcheckoutSessionId: stringValue(data.id),
|
|
7939
|
+
\t\tlastEventId: event.id,
|
|
7940
|
+
\t\tsave,
|
|
7941
|
+
\t});
|
|
7942
|
+
}
|
|
7943
|
+
|
|
7944
|
+
async function applySubscriptionEvent(
|
|
7945
|
+
\tevent: PaymentWebhookEvent,
|
|
7946
|
+
\tsave: (account: BillingAccount) => Promise<BillingAccount>,
|
|
7947
|
+
\tfindByCustomerId: (
|
|
7948
|
+
\t\tcustomerId: string,
|
|
7949
|
+
\t\tprovider: string,
|
|
7950
|
+
\t) => Promise<BillingAccount | null>,
|
|
7951
|
+
\tfindByTenantId: (tenantId: string) => Promise<BillingAccount | null>,
|
|
7952
|
+
) {
|
|
7953
|
+
\tconst data = record(event.data);
|
|
7954
|
+
\tconst customerId = providerId(data.customer);
|
|
7955
|
+
\tconst explicitTenantId = tenantIdFromPayload(data);
|
|
7956
|
+
\tconst existing = explicitTenantId
|
|
7957
|
+
\t\t? await findByTenantId(explicitTenantId)
|
|
7958
|
+
\t\t: customerId
|
|
7959
|
+
\t\t\t? await findByCustomerId(customerId, event.provider)
|
|
7960
|
+
\t\t\t: null;
|
|
7961
|
+
\tconst tenantId = explicitTenantId ?? existing?.tenantId;
|
|
7962
|
+
\tif (!tenantId) return null;
|
|
7963
|
+
|
|
7964
|
+
\treturn saveBillingAccount({
|
|
7965
|
+
\t\texisting,
|
|
7966
|
+
\t\ttenantId,
|
|
7967
|
+
\t\tprovider: event.provider,
|
|
7968
|
+
\t\tplan: planFromPayload(data),
|
|
7969
|
+
\t\tstatus:
|
|
7970
|
+
\t\t\tevent.type === "customer.subscription.deleted"
|
|
7971
|
+
\t\t\t\t? "canceled"
|
|
7972
|
+
\t\t\t\t: statusFromPayload(data, existing?.status ?? "active"),
|
|
7973
|
+
\t\tcustomerId,
|
|
7974
|
+
\t\tsubscriptionId: providerId(data.id) ?? existing?.subscriptionId,
|
|
7975
|
+
\t\tcurrentPeriodEnd: periodEndFromPayload(data),
|
|
7976
|
+
\t\tcancelAtPeriodEnd:
|
|
7977
|
+
\t\t\tevent.type === "customer.subscription.deleted"
|
|
7978
|
+
\t\t\t\t? false
|
|
7979
|
+
\t\t\t\t: cancelAtPeriodEndFromPayload(data),
|
|
7980
|
+
\t\tlastEventId: event.id,
|
|
7981
|
+
\t\tsave,
|
|
7982
|
+
\t});
|
|
7983
|
+
}
|
|
7984
|
+
|
|
7985
|
+
async function applyInvoiceSucceeded(
|
|
7986
|
+
\tevent: PaymentWebhookEvent,
|
|
7987
|
+
\tsave: (account: BillingAccount) => Promise<BillingAccount>,
|
|
7988
|
+
\tfindByCustomerId: (
|
|
7989
|
+
\t\tcustomerId: string,
|
|
7990
|
+
\t\tprovider: string,
|
|
7991
|
+
\t) => Promise<BillingAccount | null>,
|
|
7992
|
+
) {
|
|
7993
|
+
\tconst data = record(event.data);
|
|
7994
|
+
\tconst customerId = providerId(data.customer);
|
|
7995
|
+
\tif (!customerId) return null;
|
|
7996
|
+
|
|
7997
|
+
\tconst existing = await findByCustomerId(customerId, event.provider);
|
|
7998
|
+
\tif (!existing) return null;
|
|
7999
|
+
\tif (existing.status === "canceled") return null;
|
|
8000
|
+
|
|
8001
|
+
\treturn saveBillingAccount({
|
|
8002
|
+
\t\texisting,
|
|
8003
|
+
\t\ttenantId: existing.tenantId,
|
|
8004
|
+
\t\tprovider: event.provider,
|
|
8005
|
+
\t\tstatus: "active",
|
|
8006
|
+
\t\tcustomerId,
|
|
8007
|
+
\t\tsubscriptionId: providerId(data.subscription),
|
|
8008
|
+
\t\tcancelAtPeriodEnd: cancelAtPeriodEndFromPayload(data),
|
|
8009
|
+
\t\tlastEventId: event.id,
|
|
8010
|
+
\t\tsave,
|
|
8011
|
+
\t});
|
|
8012
|
+
}
|
|
8013
|
+
|
|
8014
|
+
async function applyInvoiceFailed(
|
|
8015
|
+
\tevent: PaymentWebhookEvent,
|
|
8016
|
+
\tsave: (account: BillingAccount) => Promise<BillingAccount>,
|
|
8017
|
+
\tfindByCustomerId: (
|
|
8018
|
+
\t\tcustomerId: string,
|
|
8019
|
+
\t\tprovider: string,
|
|
8020
|
+
\t) => Promise<BillingAccount | null>,
|
|
8021
|
+
) {
|
|
8022
|
+
\tconst data = record(event.data);
|
|
8023
|
+
\tconst customerId = providerId(data.customer);
|
|
8024
|
+
\tif (!customerId) return null;
|
|
8025
|
+
|
|
8026
|
+
\tconst existing = await findByCustomerId(customerId, event.provider);
|
|
8027
|
+
\tif (!existing) return null;
|
|
8028
|
+
\tif (existing.status === "canceled") return null;
|
|
8029
|
+
|
|
8030
|
+
\treturn saveBillingAccount({
|
|
8031
|
+
\t\texisting,
|
|
8032
|
+
\t\ttenantId: existing.tenantId,
|
|
8033
|
+
\t\tprovider: event.provider,
|
|
8034
|
+
\t\tstatus: "past_due",
|
|
8035
|
+
\t\tcustomerId,
|
|
8036
|
+
\t\tsubscriptionId: providerId(data.subscription),
|
|
8037
|
+
\t\tlastEventId: event.id,
|
|
8038
|
+
\t\tsave,
|
|
8039
|
+
\t});
|
|
8040
|
+
}
|
|
8041
|
+
|
|
8042
|
+
export const getBillingStatusUseCase = useCase
|
|
8043
|
+
\t.query("billing.getStatus")
|
|
8044
|
+
\t.input(z.object({}))
|
|
8045
|
+
\t.output(billingStatusOutputSchema)
|
|
8046
|
+
\t.run(async ({ ctx }) => {
|
|
8047
|
+
\t\tconst tenantId = billingTenantId(ctx);
|
|
8048
|
+
\t\tconst account = await ctx.ports.billing.findByTenantId(tenantId);
|
|
8049
|
+
\t\treturn billingStatusOutput(tenantId, account);
|
|
8050
|
+
\t});
|
|
8051
|
+
|
|
8052
|
+
export const createCheckoutSessionUseCase = useCase
|
|
8053
|
+
\t.command("billing.createCheckoutSession")
|
|
8054
|
+
\t.input(createCheckoutSessionInputSchema)
|
|
8055
|
+
\t.output(createCheckoutSessionOutputSchema)
|
|
8056
|
+
\t.run(async ({ ctx, input }) => {
|
|
8057
|
+
\t\tconst tenantId = billingTenantId(ctx);
|
|
8058
|
+
\t\tconst account = await ctx.ports.billing.findByTenantId(tenantId);
|
|
8059
|
+
\t\tconst plan = getBillingPlan(input.plan);
|
|
8060
|
+
\t\tconst session = await ctx.ports.payments.createCheckoutSession({
|
|
8061
|
+
\t\t\tmode: plan.mode,
|
|
8062
|
+
\t\t\tlineItems: plan.lineItems,
|
|
8063
|
+
\t\t\tsuccessUrl: \`\${env.APP_URL}/billing?checkout=success\`,
|
|
8064
|
+
\t\t\tcancelUrl: \`\${env.APP_URL}/billing?checkout=cancelled\`,
|
|
8065
|
+
\t\t\tcustomerId: account?.customerId,
|
|
8066
|
+
\t\t\tclientReferenceId: tenantId,
|
|
8067
|
+
\t\t\tmetadata: {
|
|
8068
|
+
\t\t\t\ttenantId,
|
|
8069
|
+
\t\t\t\tplan: input.plan,
|
|
8070
|
+
\t\t\t},
|
|
8071
|
+
\t\t});
|
|
8072
|
+
|
|
8073
|
+
\t\tif (!session.url) {
|
|
8074
|
+
\t\t\tthrow appError("BillingCheckoutUnavailable", {
|
|
8075
|
+
\t\t\t\tdetails: { provider: session.provider, sessionId: session.id },
|
|
8076
|
+
\t\t\t});
|
|
8077
|
+
\t\t}
|
|
8078
|
+
|
|
8079
|
+
\t\tawait ctx.ports.billing.save({
|
|
8080
|
+
\t\t\ttenantId,
|
|
8081
|
+
\t\t\tprovider: session.provider,
|
|
8082
|
+
\t\t\tplan: input.plan,
|
|
8083
|
+
\t\t\tstatus: account?.status ?? "inactive",
|
|
8084
|
+
\t\t\tcustomerId: session.customerId ?? account?.customerId,
|
|
8085
|
+
\t\t\tsubscriptionId: account?.subscriptionId,
|
|
8086
|
+
\t\t\tcurrentPeriodEnd: account?.currentPeriodEnd,
|
|
8087
|
+
\t\t\tcancelAtPeriodEnd: account?.cancelAtPeriodEnd,
|
|
8088
|
+
\t\t\tcheckoutSessionId: session.id,
|
|
8089
|
+
\t\t\tlastEventId: account?.lastEventId,
|
|
8090
|
+
\t\t\tupdatedAt: nowIso(),
|
|
8091
|
+
\t\t});
|
|
8092
|
+
|
|
8093
|
+
\t\treturn {
|
|
8094
|
+
\t\t\tsessionId: session.id,
|
|
8095
|
+
\t\t\turl: session.url,
|
|
8096
|
+
\t\t};
|
|
8097
|
+
\t});
|
|
8098
|
+
|
|
8099
|
+
export const createBillingPortalSessionUseCase = useCase
|
|
8100
|
+
\t.command("billing.createPortalSession")
|
|
8101
|
+
\t.input(z.object({}))
|
|
8102
|
+
\t.output(createBillingPortalSessionOutputSchema)
|
|
8103
|
+
\t.run(async ({ ctx }) => {
|
|
8104
|
+
\t\tconst tenantId = billingTenantId(ctx);
|
|
8105
|
+
\t\tconst account = await ctx.ports.billing.findByTenantId(tenantId);
|
|
8106
|
+
|
|
8107
|
+
\t\tif (!account?.customerId) {
|
|
8108
|
+
\t\t\tthrow appError("BillingAccountNotFound", {
|
|
8109
|
+
\t\t\t\tdetails: { tenantId },
|
|
8110
|
+
\t\t\t});
|
|
8111
|
+
\t\t}
|
|
8112
|
+
|
|
8113
|
+
\t\tconst session = await ctx.ports.payments.createBillingPortalSession({
|
|
8114
|
+
\t\t\tcustomerId: account.customerId,
|
|
8115
|
+
\t\t\treturnUrl: \`\${env.APP_URL}/billing\`,
|
|
8116
|
+
\t\t});
|
|
8117
|
+
|
|
8118
|
+
\t\treturn { url: session.url };
|
|
8119
|
+
\t});
|
|
8120
|
+
|
|
8121
|
+
export const handlePaymentWebhookUseCase = useCase
|
|
8122
|
+
\t.command("billing.handlePaymentWebhook")
|
|
8123
|
+
\t.input(paymentWebhookEventSchema)
|
|
8124
|
+
\t.output(paymentWebhookOutputSchema)
|
|
8125
|
+
\t.run(async ({ ctx, input }) => {
|
|
8126
|
+
\t\tconst fingerprint = await createIdempotencyFingerprint({
|
|
8127
|
+
\t\t\ttype: input.type,
|
|
8128
|
+
\t\t\tdata: input.data,
|
|
8129
|
+
\t\t});
|
|
8130
|
+
|
|
8131
|
+
\t\treturn runIdempotently(ctx.ports.idempotency, {
|
|
8132
|
+
\t\t\tnamespace: "webhooks.payments",
|
|
8133
|
+
\t\t\tkey: input.id,
|
|
8134
|
+
\t\t\tscope: { provider: input.provider },
|
|
8135
|
+
\t\t\tfingerprint,
|
|
8136
|
+
\t\t\tttlSec: 60 * 60 * 24 * 30,
|
|
8137
|
+
\t\t\trun: () =>
|
|
8138
|
+
\t\t\t\tctx.ports.uow.transaction(async (tx) => {
|
|
8139
|
+
\t\t\t\t\tlet account: BillingAccount | null = null;
|
|
8140
|
+
|
|
8141
|
+
\t\t\t\t\tif (input.type === "checkout.session.completed") {
|
|
8142
|
+
\t\t\t\t\t\taccount = await applyCheckoutCompleted(
|
|
8143
|
+
\t\t\t\t\t\t\tinput,
|
|
8144
|
+
\t\t\t\t\t\t\ttx.billing.save,
|
|
8145
|
+
\t\t\t\t\t\t\ttx.billing.findByTenantId,
|
|
8146
|
+
\t\t\t\t\t\t);
|
|
8147
|
+
\t\t\t\t\t} else if (input.type.startsWith("customer.subscription.")) {
|
|
8148
|
+
\t\t\t\t\t\taccount = await applySubscriptionEvent(
|
|
8149
|
+
\t\t\t\t\t\t\tinput,
|
|
8150
|
+
\t\t\t\t\t\t\ttx.billing.save,
|
|
8151
|
+
\t\t\t\t\t\t\ttx.billing.findByCustomerId,
|
|
8152
|
+
\t\t\t\t\t\t\ttx.billing.findByTenantId,
|
|
8153
|
+
\t\t\t\t\t\t);
|
|
8154
|
+
\t\t\t\t\t} else if (input.type === "invoice.payment_succeeded") {
|
|
8155
|
+
\t\t\t\t\t\taccount = await applyInvoiceSucceeded(
|
|
8156
|
+
\t\t\t\t\t\t\tinput,
|
|
8157
|
+
\t\t\t\t\t\t\ttx.billing.save,
|
|
8158
|
+
\t\t\t\t\t\t\ttx.billing.findByCustomerId,
|
|
8159
|
+
\t\t\t\t\t\t);
|
|
8160
|
+
\t\t\t\t\t} else if (input.type === "invoice.payment_failed") {
|
|
8161
|
+
\t\t\t\t\t\taccount = await applyInvoiceFailed(
|
|
8162
|
+
\t\t\t\t\t\t\tinput,
|
|
8163
|
+
\t\t\t\t\t\t\ttx.billing.save,
|
|
8164
|
+
\t\t\t\t\t\t\ttx.billing.findByCustomerId,
|
|
8165
|
+
\t\t\t\t\t\t);
|
|
8166
|
+
\t\t\t\t\t}
|
|
8167
|
+
|
|
8168
|
+
\t\t\t\t\treturn {
|
|
8169
|
+
\t\t\t\t\t\treceived: true,
|
|
8170
|
+
\t\t\t\t\t\taccountUpdated: Boolean(account),
|
|
8171
|
+
\t\t\t\t\t};
|
|
8172
|
+
\t\t\t\t}),
|
|
8173
|
+
\t\t});
|
|
8174
|
+
\t});
|
|
8175
|
+
`;
|
|
8176
|
+
}
|
|
8177
|
+
|
|
8178
|
+
function billingRoutesFile(config: ResolvedBeignetConfig): string {
|
|
8179
|
+
const routeFilePath = path.join(config.paths.features, "billing/routes.ts");
|
|
8180
|
+
|
|
8181
|
+
return `import { defineRouteGroup } from "@beignet/next";
|
|
8182
|
+
import type { AppContext } from "${relativeModule(routeFilePath, config.paths.appContext)}";
|
|
8183
|
+
import * as billingContracts from "@/features/billing/contracts";
|
|
8184
|
+
import * as billingUseCases from "@/features/billing/use-cases";
|
|
8185
|
+
|
|
8186
|
+
export const billingRoutes = defineRouteGroup<AppContext>({
|
|
8187
|
+
\tname: "billing",
|
|
8188
|
+
\troutes: [
|
|
8189
|
+
\t\t{
|
|
8190
|
+
\t\t\tcontract: billingContracts.getBillingStatus,
|
|
8191
|
+
\t\t\tuseCase: billingUseCases.getBillingStatusUseCase,
|
|
8192
|
+
\t\t},
|
|
8193
|
+
\t\t{
|
|
8194
|
+
\t\t\tcontract: billingContracts.createCheckoutSession,
|
|
8195
|
+
\t\t\tuseCase: billingUseCases.createCheckoutSessionUseCase,
|
|
8196
|
+
\t\t},
|
|
8197
|
+
\t\t{
|
|
8198
|
+
\t\t\tcontract: billingContracts.createBillingPortalSession,
|
|
8199
|
+
\t\t\tuseCase: billingUseCases.createBillingPortalSessionUseCase,
|
|
8200
|
+
\t\t},
|
|
8201
|
+
\t],
|
|
8202
|
+
});
|
|
8203
|
+
`;
|
|
8204
|
+
}
|
|
8205
|
+
|
|
8206
|
+
function billingTestFile(config: ResolvedBeignetConfig): string {
|
|
8207
|
+
return `import { describe, expect, it } from "bun:test";
|
|
8208
|
+
import { createUseCaseTester } from "@beignet/core/application";
|
|
8209
|
+
import { createAnonymousActor } from "@beignet/core/ports";
|
|
8210
|
+
import { createTestUserActor } from "@beignet/core/ports/testing";
|
|
8211
|
+
import {
|
|
8212
|
+
\tcreateTestContextFactory,
|
|
8213
|
+
\tcreateTestPorts,
|
|
8214
|
+
} from "@beignet/core/testing";
|
|
8215
|
+
import type { AppContext } from "${aliasModule(config.paths.appContext)}";
|
|
8216
|
+
import { appPorts } from "@/infra/app-ports";
|
|
8217
|
+
import type { AppTransactionPorts } from "@/ports";
|
|
8218
|
+
import { createBillingEntitlements } from "../entitlements";
|
|
8219
|
+
import type { BillingAccount, BillingRepository } from "../ports";
|
|
8220
|
+
import {
|
|
8221
|
+
\tcreateBillingPortalSessionUseCase,
|
|
8222
|
+
\tcreateCheckoutSessionUseCase,
|
|
8223
|
+
\tgetBillingStatusUseCase,
|
|
8224
|
+
\thandlePaymentWebhookUseCase,
|
|
8225
|
+
} from "../use-cases";
|
|
8226
|
+
|
|
8227
|
+
function createMemoryBillingRepository(): BillingRepository {
|
|
8228
|
+
\tconst accounts = new Map<string, BillingAccount>();
|
|
8229
|
+
|
|
8230
|
+
\treturn {
|
|
8231
|
+
\t\tasync findByTenantId(tenantId) {
|
|
8232
|
+
\t\t\treturn accounts.get(tenantId) ?? null;
|
|
8233
|
+
\t\t},
|
|
8234
|
+
|
|
8235
|
+
\t\tasync findByCustomerId(customerId, provider) {
|
|
8236
|
+
\t\t\treturn (
|
|
8237
|
+
\t\t\t\t[...accounts.values()].find(
|
|
8238
|
+
\t\t\t\t\t(account) =>
|
|
8239
|
+
\t\t\t\t\t\taccount.customerId === customerId &&
|
|
8240
|
+
\t\t\t\t\t\taccount.provider === provider,
|
|
8241
|
+
\t\t\t\t) ?? null
|
|
8242
|
+
\t\t\t);
|
|
8243
|
+
\t\t},
|
|
8244
|
+
|
|
8245
|
+
\t\tasync save(input) {
|
|
8246
|
+
\t\t\taccounts.set(input.tenantId, { ...input });
|
|
8247
|
+
\t\t\treturn input;
|
|
8248
|
+
\t\t},
|
|
8249
|
+
\t};
|
|
8250
|
+
}
|
|
8251
|
+
|
|
8252
|
+
function createHarness(options: { actor?: AppContext["actor"] } = {}) {
|
|
8253
|
+
\tconst billing = createMemoryBillingRepository();
|
|
8254
|
+
\tconst actor = options.actor ?? createTestUserActor("user_test");
|
|
8255
|
+
\tconst fixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
|
|
8256
|
+
\t\tbase: appPorts,
|
|
8257
|
+
\t\toverrides: {
|
|
8258
|
+
\t\t\tbilling,
|
|
8259
|
+
\t\t\tentitlements: createBillingEntitlements(billing),
|
|
8260
|
+
\t\t\tgate: appPorts.gate,
|
|
8261
|
+
\t\t},
|
|
8262
|
+
\t});
|
|
8263
|
+
\tconst createContext = createTestContextFactory<
|
|
8264
|
+
\t\tAppContext,
|
|
8265
|
+
\t\tAppContext["ports"]
|
|
8266
|
+
\t>({
|
|
8267
|
+
\t\tports: fixture.ports,
|
|
8268
|
+
\t\tactor,
|
|
8269
|
+
\t\tauth:
|
|
8270
|
+
\t\t\tactor.type === "user" && actor.id
|
|
8271
|
+
\t\t\t\t? { user: { id: actor.id }, session: { id: "session_test" } }
|
|
8272
|
+
\t\t\t\t: null,
|
|
8273
|
+
\t\trequestId: "test-request",
|
|
8274
|
+
\t\ttraceId: "test-trace",
|
|
8275
|
+
\t\ttenant: { id: "tenant_example" },
|
|
8276
|
+
\t});
|
|
8277
|
+
|
|
8278
|
+
\treturn {
|
|
8279
|
+
\t\tbilling,
|
|
8280
|
+
\t\tentitlements: fixture.ports.entitlements,
|
|
8281
|
+
\t\tpayments: fixture.payments,
|
|
8282
|
+
\t\ttester: createUseCaseTester<AppContext>(createContext),
|
|
8283
|
+
\t};
|
|
8284
|
+
}
|
|
8285
|
+
|
|
8286
|
+
describe("billing use cases", () => {
|
|
8287
|
+
\tit("requires an authenticated user to read billing status", async () => {
|
|
8288
|
+
\t\tconst harness = createHarness({ actor: createAnonymousActor() });
|
|
8289
|
+
|
|
8290
|
+
\t\tawait expect(
|
|
8291
|
+
\t\t\tharness.tester.run(getBillingStatusUseCase, {}),
|
|
8292
|
+
\t\t).rejects.toMatchObject({
|
|
8293
|
+
\t\t\tcode: "UNAUTHORIZED",
|
|
8294
|
+
\t\t});
|
|
8295
|
+
\t});
|
|
8296
|
+
|
|
8297
|
+
\tit("creates checkout sessions through the payments port and records billing state", async () => {
|
|
8298
|
+
\t\tconst harness = createHarness();
|
|
8299
|
+
|
|
8300
|
+
\t\tconst checkout = await harness.tester.run(createCheckoutSessionUseCase, {
|
|
8301
|
+
\t\t\tplan: "team",
|
|
8302
|
+
\t\t});
|
|
8303
|
+
|
|
8304
|
+
\t\texpect(checkout.sessionId).toMatch(/^checkout_/);
|
|
8305
|
+
\t\texpect(checkout.url).toContain("/checkout/");
|
|
8306
|
+
\t\texpect(harness.payments.checkoutSessions).toHaveLength(1);
|
|
8307
|
+
\t\texpect(harness.payments.checkoutSessions[0]?.input).toMatchObject({
|
|
8308
|
+
\t\t\tmode: "subscription",
|
|
8309
|
+
\t\t\tlineItems: [{ priceId: "price_team_example", quantity: 1 }],
|
|
8310
|
+
\t\t\tclientReferenceId: "tenant_example",
|
|
8311
|
+
\t\t\tmetadata: {
|
|
8312
|
+
\t\t\t\ttenantId: "tenant_example",
|
|
8313
|
+
\t\t\t\tplan: "team",
|
|
8314
|
+
\t\t\t},
|
|
8315
|
+
\t\t});
|
|
8316
|
+
\t\texpect(
|
|
8317
|
+
\t\t\tharness.payments.checkoutSessions[0]?.input.idempotencyKey,
|
|
8318
|
+
\t\t).toBe(undefined);
|
|
8319
|
+
\t\tawait expect(
|
|
8320
|
+
\t\t\tharness.billing.findByTenantId("tenant_example"),
|
|
8321
|
+
\t\t).resolves.toMatchObject({
|
|
8322
|
+
\t\t\tcheckoutSessionId: checkout.sessionId,
|
|
8323
|
+
\t\t\tplan: "team",
|
|
8324
|
+
\t\t\tstatus: "inactive",
|
|
8325
|
+
\t\t});
|
|
8326
|
+
\t});
|
|
8327
|
+
|
|
8328
|
+
\tit("creates billing portal sessions for tenants with provider customers", async () => {
|
|
8329
|
+
\t\tconst harness = createHarness();
|
|
8330
|
+
\t\tawait harness.billing.save({
|
|
8331
|
+
\t\t\ttenantId: "tenant_example",
|
|
8332
|
+
\t\t\tprovider: "memory",
|
|
8333
|
+
\t\t\tplan: "team",
|
|
8334
|
+
\t\t\tstatus: "active",
|
|
8335
|
+
\t\t\tcustomerId: "cus_123",
|
|
8336
|
+
\t\t\tupdatedAt: new Date(0).toISOString(),
|
|
8337
|
+
\t\t});
|
|
8338
|
+
|
|
8339
|
+
\t\tconst portal = await harness.tester.run(
|
|
8340
|
+
\t\t\tcreateBillingPortalSessionUseCase,
|
|
8341
|
+
\t\t\t{},
|
|
8342
|
+
\t\t);
|
|
8343
|
+
|
|
8344
|
+
\t\texpect(portal.url).toContain("/portal/");
|
|
8345
|
+
\t\texpect(harness.payments.billingPortalSessions).toHaveLength(1);
|
|
8346
|
+
\t\texpect(harness.payments.billingPortalSessions[0]?.input.customerId).toBe(
|
|
8347
|
+
\t\t\t"cus_123",
|
|
8348
|
+
\t\t);
|
|
8349
|
+
\t});
|
|
8350
|
+
|
|
8351
|
+
\tit.each(["active", "trialing"] as const)(
|
|
8352
|
+
\t\t"grants billing entitlements for %s accounts",
|
|
8353
|
+
\t\tasync (status) => {
|
|
8354
|
+
\t\t\tconst harness = createHarness();
|
|
8355
|
+
\t\t\tawait harness.billing.save({
|
|
8356
|
+
\t\t\t\ttenantId: "tenant_example",
|
|
8357
|
+
\t\t\t\tprovider: "stripe",
|
|
8358
|
+
\t\t\t\tplan: "team",
|
|
8359
|
+
\t\t\t\tstatus,
|
|
8360
|
+
\t\t\t\tupdatedAt: new Date(0).toISOString(),
|
|
8361
|
+
\t\t\t});
|
|
8362
|
+
|
|
8363
|
+
\t\t\tawait expect(
|
|
8364
|
+
\t\t\t\tharness.entitlements.can({
|
|
8365
|
+
\t\t\t\t\tentitlement: "todos.create",
|
|
8366
|
+
\t\t\t\t\tsubject: { type: "tenant", id: "tenant_example" },
|
|
8367
|
+
\t\t\t\t}),
|
|
8368
|
+
\t\t\t).resolves.toBe(true);
|
|
8369
|
+
\t\t},
|
|
8370
|
+
\t);
|
|
8371
|
+
|
|
8372
|
+
\tit.each(["inactive", "past_due", "canceled"] as const)(
|
|
8373
|
+
\t\t"denies billing entitlements for %s accounts",
|
|
8374
|
+
\t\tasync (status) => {
|
|
8375
|
+
\t\t\tconst harness = createHarness();
|
|
8376
|
+
\t\t\tawait harness.billing.save({
|
|
8377
|
+
\t\t\t\ttenantId: "tenant_example",
|
|
8378
|
+
\t\t\t\tprovider: "stripe",
|
|
8379
|
+
\t\t\t\tplan: "team",
|
|
8380
|
+
\t\t\t\tstatus,
|
|
8381
|
+
\t\t\t\tupdatedAt: new Date(0).toISOString(),
|
|
8382
|
+
\t\t\t});
|
|
8383
|
+
|
|
8384
|
+
\t\t\tawait expect(
|
|
8385
|
+
\t\t\t\tharness.entitlements.inspect({
|
|
8386
|
+
\t\t\t\t\tentitlement: "todos.create",
|
|
8387
|
+
\t\t\t\t\tsubject: { type: "tenant", id: "tenant_example" },
|
|
8388
|
+
\t\t\t\t}),
|
|
8389
|
+
\t\t\t).resolves.toMatchObject({
|
|
8390
|
+
\t\t\t\tallowed: false,
|
|
8391
|
+
\t\t\t\tcode: "BILLING_INACTIVE",
|
|
8392
|
+
\t\t\t});
|
|
8393
|
+
\t\t},
|
|
8394
|
+
\t);
|
|
8395
|
+
|
|
8396
|
+
\tit("handles checkout and subscription webhooks idempotently", async () => {
|
|
8397
|
+
\t\tconst harness = createHarness();
|
|
8398
|
+
\t\tconst checkoutEvent = {
|
|
8399
|
+
\t\t\tid: "evt_checkout_completed",
|
|
8400
|
+
\t\t\ttype: "checkout.session.completed",
|
|
8401
|
+
\t\t\tprovider: "stripe",
|
|
8402
|
+
\t\t\tdata: {
|
|
8403
|
+
\t\t\t\tid: "cs_123",
|
|
8404
|
+
\t\t\t\tclient_reference_id: "tenant_example",
|
|
8405
|
+
\t\t\t\tcustomer: "cus_123",
|
|
8406
|
+
\t\t\t\tsubscription: "sub_123",
|
|
8407
|
+
\t\t\t\tmetadata: {
|
|
8408
|
+
\t\t\t\t\ttenantId: "tenant_example",
|
|
8409
|
+
\t\t\t\t\tplan: "team",
|
|
8410
|
+
\t\t\t\t},
|
|
8411
|
+
\t\t\t},
|
|
8412
|
+
\t\t};
|
|
8413
|
+
\t\tconst subscriptionEvent = {
|
|
8414
|
+
\t\t\tid: "evt_subscription_updated",
|
|
8415
|
+
\t\t\ttype: "customer.subscription.updated",
|
|
8416
|
+
\t\t\tprovider: "stripe",
|
|
8417
|
+
\t\t\tdata: {
|
|
8418
|
+
\t\t\t\tid: "sub_123",
|
|
8419
|
+
\t\t\t\tcustomer: "cus_123",
|
|
8420
|
+
\t\t\t\tstatus: "active",
|
|
8421
|
+
\t\t\t\tcurrent_period_end: 1_799_798_400,
|
|
8422
|
+
\t\t\t\tcancel_at_period_end: true,
|
|
8423
|
+
\t\t\t},
|
|
8424
|
+
\t\t};
|
|
8425
|
+
|
|
8426
|
+
\t\tawait expect(
|
|
8427
|
+
\t\t\tharness.tester.run(handlePaymentWebhookUseCase, checkoutEvent),
|
|
8428
|
+
\t\t).resolves.toEqual({ received: true, accountUpdated: true });
|
|
8429
|
+
\t\tawait expect(
|
|
8430
|
+
\t\t\tharness.tester.run(handlePaymentWebhookUseCase, checkoutEvent),
|
|
8431
|
+
\t\t).resolves.toEqual({ received: true, accountUpdated: true });
|
|
8432
|
+
\t\tawait expect(
|
|
8433
|
+
\t\t\tharness.tester.run(handlePaymentWebhookUseCase, subscriptionEvent),
|
|
8434
|
+
\t\t).resolves.toEqual({ received: true, accountUpdated: true });
|
|
8435
|
+
\t\tawait expect(
|
|
8436
|
+
\t\t\tharness.tester.run(getBillingStatusUseCase, {}),
|
|
8437
|
+
\t\t).resolves.toMatchObject({
|
|
8438
|
+
\t\t\ttenantId: "tenant_example",
|
|
8439
|
+
\t\t\tactive: true,
|
|
8440
|
+
\t\t\tstatus: "active",
|
|
8441
|
+
\t\t\tcustomerId: "cus_123",
|
|
8442
|
+
\t\t\tsubscriptionId: "sub_123",
|
|
8443
|
+
\t\t\tcurrentPeriodEnd: "2027-01-13T00:00:00.000Z",
|
|
8444
|
+
\t\t\tcancelAtPeriodEnd: true,
|
|
8445
|
+
\t\t\tcheckoutSessionId: "cs_123",
|
|
8446
|
+
\t\t\tlastEventId: "evt_subscription_updated",
|
|
8447
|
+
\t\t});
|
|
8448
|
+
\t});
|
|
8449
|
+
|
|
8450
|
+
\tit("marks active subscriptions as past due when invoice payment fails", async () => {
|
|
8451
|
+
\t\tconst harness = createHarness();
|
|
8452
|
+
\t\tawait harness.billing.save({
|
|
8453
|
+
\t\t\ttenantId: "tenant_example",
|
|
8454
|
+
\t\t\tprovider: "stripe",
|
|
8455
|
+
\t\t\tplan: "team",
|
|
8456
|
+
\t\t\tstatus: "active",
|
|
8457
|
+
\t\t\tcustomerId: "cus_123",
|
|
8458
|
+
\t\t\tsubscriptionId: "sub_123",
|
|
8459
|
+
\t\t\tupdatedAt: new Date(0).toISOString(),
|
|
8460
|
+
\t\t});
|
|
8461
|
+
|
|
8462
|
+
\t\tawait expect(
|
|
8463
|
+
\t\t\tharness.tester.run(handlePaymentWebhookUseCase, {
|
|
8464
|
+
\t\t\t\tid: "evt_invoice_failed",
|
|
8465
|
+
\t\t\t\ttype: "invoice.payment_failed",
|
|
8466
|
+
\t\t\t\tprovider: "stripe",
|
|
8467
|
+
\t\t\t\tdata: {
|
|
8468
|
+
\t\t\t\t\tcustomer: "cus_123",
|
|
8469
|
+
\t\t\t\t\tsubscription: "sub_123",
|
|
8470
|
+
\t\t\t\t},
|
|
8471
|
+
\t\t\t}),
|
|
8472
|
+
\t\t).resolves.toEqual({ received: true, accountUpdated: true });
|
|
8473
|
+
\t\tawait expect(
|
|
8474
|
+
\t\t\tharness.billing.findByTenantId("tenant_example"),
|
|
8475
|
+
\t\t).resolves.toMatchObject({
|
|
8476
|
+
\t\t\tstatus: "past_due",
|
|
8477
|
+
\t\t\tlastEventId: "evt_invoice_failed",
|
|
8478
|
+
\t\t});
|
|
8479
|
+
\t});
|
|
8480
|
+
|
|
8481
|
+
\tit("preserves pending cancellation when an invoice succeeds", async () => {
|
|
8482
|
+
\t\tconst harness = createHarness();
|
|
8483
|
+
\t\tawait harness.billing.save({
|
|
8484
|
+
\t\t\ttenantId: "tenant_example",
|
|
8485
|
+
\t\t\tprovider: "stripe",
|
|
8486
|
+
\t\t\tplan: "team",
|
|
8487
|
+
\t\t\tstatus: "active",
|
|
8488
|
+
\t\t\tcustomerId: "cus_123",
|
|
8489
|
+
\t\t\tsubscriptionId: "sub_123",
|
|
8490
|
+
\t\t\tcancelAtPeriodEnd: true,
|
|
8491
|
+
\t\t\tupdatedAt: new Date(0).toISOString(),
|
|
8492
|
+
\t\t});
|
|
8493
|
+
|
|
8494
|
+
\t\tawait expect(
|
|
8495
|
+
\t\t\tharness.tester.run(handlePaymentWebhookUseCase, {
|
|
8496
|
+
\t\t\t\tid: "evt_invoice_succeeded",
|
|
8497
|
+
\t\t\t\ttype: "invoice.payment_succeeded",
|
|
8498
|
+
\t\t\t\tprovider: "stripe",
|
|
8499
|
+
\t\t\t\tdata: {
|
|
8500
|
+
\t\t\t\t\tcustomer: "cus_123",
|
|
8501
|
+
\t\t\t\t\tsubscription: "sub_123",
|
|
8502
|
+
\t\t\t\t},
|
|
8503
|
+
\t\t\t}),
|
|
8504
|
+
\t\t).resolves.toEqual({ received: true, accountUpdated: true });
|
|
8505
|
+
\t\tawait expect(
|
|
8506
|
+
\t\t\tharness.billing.findByTenantId("tenant_example"),
|
|
8507
|
+
\t\t).resolves.toMatchObject({
|
|
8508
|
+
\t\t\tstatus: "active",
|
|
8509
|
+
\t\t\tcancelAtPeriodEnd: true,
|
|
8510
|
+
\t\t\tlastEventId: "evt_invoice_succeeded",
|
|
8511
|
+
\t\t});
|
|
8512
|
+
\t});
|
|
8513
|
+
});
|
|
8514
|
+
`;
|
|
8515
|
+
}
|
|
8516
|
+
|
|
8517
|
+
function billingDrizzleSchemaFile(database: DatabaseName): string {
|
|
8518
|
+
const dialect = drizzleDialects[database];
|
|
8519
|
+
const idColumn =
|
|
8520
|
+
database === "mysql"
|
|
8521
|
+
? (columnName: string) => `varchar("${columnName}", { length: 255 })`
|
|
8522
|
+
: (columnName: string) => `text("${columnName}")`;
|
|
8523
|
+
const imports =
|
|
8524
|
+
database === "mysql"
|
|
8525
|
+
? "mysqlTable, uniqueIndex, varchar"
|
|
8526
|
+
: `${dialect.tableFunction}, text, uniqueIndex`;
|
|
8527
|
+
|
|
8528
|
+
return `import type { BillingPlan, BillingStatus } from "@/features/billing/schemas";
|
|
8529
|
+
import { ${imports} } from "${dialect.columnModule}";
|
|
8530
|
+
|
|
8531
|
+
export const billingAccounts = ${dialect.tableFunction}(
|
|
8532
|
+
\t"billing_accounts",
|
|
8533
|
+
\t{
|
|
8534
|
+
\t\ttenantId: ${idColumn("tenant_id")}.primaryKey(),
|
|
8535
|
+
\t\tplan: ${idColumn("plan")}.$type<BillingPlan>().notNull(),
|
|
8536
|
+
\t\tstatus: ${idColumn("status")}.$type<BillingStatus>().notNull(),
|
|
8537
|
+
\t\tprovider: ${idColumn("provider")}.notNull(),
|
|
8538
|
+
\t\tcustomerId: ${idColumn("customer_id")},
|
|
8539
|
+
\t\tsubscriptionId: ${idColumn("subscription_id")},
|
|
8540
|
+
\t\tcurrentPeriodEnd: ${idColumn("current_period_end")},
|
|
8541
|
+
\t\tcancelAtPeriodEnd: ${idColumn("cancel_at_period_end")}.$type<"true" | "false">(),
|
|
8542
|
+
\t\tcheckoutSessionId: ${idColumn("checkout_session_id")},
|
|
8543
|
+
\t\tlastEventId: ${idColumn("last_event_id")},
|
|
8544
|
+
\t\tupdatedAt: ${idColumn("updated_at")}.notNull(),
|
|
8545
|
+
\t},
|
|
8546
|
+
\t(table) => ({
|
|
8547
|
+
\t\tproviderCustomerUnique: uniqueIndex(
|
|
8548
|
+
\t\t\t"billing_accounts_provider_customer_unique",
|
|
8549
|
+
\t\t).on(table.provider, table.customerId),
|
|
8550
|
+
\t\tproviderSubscriptionUnique: uniqueIndex(
|
|
8551
|
+
\t\t\t"billing_accounts_provider_subscription_unique",
|
|
8552
|
+
\t\t).on(table.provider, table.subscriptionId),
|
|
8553
|
+
\t}),
|
|
8554
|
+
);
|
|
8555
|
+
`;
|
|
8556
|
+
}
|
|
8557
|
+
|
|
8558
|
+
function billingDrizzleRepositoryFile(
|
|
8559
|
+
config: ResolvedBeignetConfig,
|
|
8560
|
+
database: DatabaseName,
|
|
8561
|
+
): string {
|
|
8562
|
+
const dialect = drizzleDialects[database];
|
|
8563
|
+
const billingPortPath = path.join(config.paths.features, "billing/ports.ts");
|
|
8564
|
+
const upsert =
|
|
8565
|
+
database === "mysql"
|
|
8566
|
+
? `\t\t\tconst row = toRow(input);
|
|
8567
|
+
\t\t\tawait db
|
|
8568
|
+
\t\t\t\t.insert(schema.billingAccounts)
|
|
8569
|
+
\t\t\t\t.values(row)
|
|
8570
|
+
\t\t\t\t.onDuplicateKeyUpdate({ set: row });
|
|
8571
|
+
|
|
8572
|
+
\t\t\tconst [saved] = await db
|
|
8573
|
+
\t\t\t\t.select()
|
|
8574
|
+
\t\t\t\t.from(schema.billingAccounts)
|
|
8575
|
+
\t\t\t\t.where(eq(schema.billingAccounts.tenantId, input.tenantId))
|
|
8576
|
+
\t\t\t\t.limit(1);
|
|
8577
|
+
|
|
8578
|
+
\t\t\tif (!saved) {
|
|
8579
|
+
\t\t\t\tthrow new Error("Billing account save did not return a row.");
|
|
8580
|
+
\t\t\t}
|
|
8581
|
+
|
|
8582
|
+
\t\t\treturn toBillingAccount(saved);`
|
|
8583
|
+
: `\t\t\tconst [row] = await db
|
|
8584
|
+
\t\t\t\t.insert(schema.billingAccounts)
|
|
8585
|
+
\t\t\t\t.values(toRow(input))
|
|
8586
|
+
\t\t\t\t.onConflictDoUpdate({
|
|
8587
|
+
\t\t\t\t\ttarget: schema.billingAccounts.tenantId,
|
|
8588
|
+
\t\t\t\t\tset: toRow(input),
|
|
8589
|
+
\t\t\t\t})
|
|
8590
|
+
\t\t\t\t.returning();
|
|
8591
|
+
|
|
8592
|
+
\t\t\tif (!row) {
|
|
8593
|
+
\t\t\t\tthrow new Error("Billing account save did not return a row.");
|
|
8594
|
+
\t\t\t}
|
|
8595
|
+
|
|
8596
|
+
\t\t\treturn toBillingAccount(row);`;
|
|
8597
|
+
|
|
8598
|
+
return `import type { ${dialect.dbTypeName} } from "@beignet/provider-db-drizzle/${dialect.subpath}";
|
|
8599
|
+
import { and, eq } from "drizzle-orm";
|
|
8600
|
+
import type {
|
|
8601
|
+
\tBillingAccount,
|
|
8602
|
+
\tBillingRepository,
|
|
8603
|
+
\tSaveBillingAccountInput,
|
|
8604
|
+
} from "${aliasModule(billingPortPath)}";
|
|
8605
|
+
import * as schema from "@/infra/db/schema";
|
|
8606
|
+
|
|
8607
|
+
type BillingAccountRow = typeof schema.billingAccounts.$inferSelect;
|
|
8608
|
+
|
|
8609
|
+
function toBillingAccount(row: BillingAccountRow): BillingAccount {
|
|
8610
|
+
\treturn {
|
|
8611
|
+
\t\ttenantId: row.tenantId,
|
|
8612
|
+
\t\tplan: row.plan,
|
|
8613
|
+
\t\tstatus: row.status,
|
|
8614
|
+
\t\tprovider: row.provider,
|
|
8615
|
+
\t\t...(row.customerId ? { customerId: row.customerId } : {}),
|
|
8616
|
+
\t\t...(row.subscriptionId ? { subscriptionId: row.subscriptionId } : {}),
|
|
8617
|
+
\t\t...(row.currentPeriodEnd ? { currentPeriodEnd: row.currentPeriodEnd } : {}),
|
|
8618
|
+
\t\t...(row.cancelAtPeriodEnd
|
|
8619
|
+
\t\t\t? { cancelAtPeriodEnd: row.cancelAtPeriodEnd === "true" }
|
|
8620
|
+
\t\t\t: {}),
|
|
8621
|
+
\t\t...(row.checkoutSessionId
|
|
8622
|
+
\t\t\t? { checkoutSessionId: row.checkoutSessionId }
|
|
8623
|
+
\t\t\t: {}),
|
|
8624
|
+
\t\t...(row.lastEventId ? { lastEventId: row.lastEventId } : {}),
|
|
8625
|
+
\t\tupdatedAt: row.updatedAt,
|
|
8626
|
+
\t};
|
|
8627
|
+
}
|
|
8628
|
+
|
|
8629
|
+
function toRow(input: SaveBillingAccountInput) {
|
|
8630
|
+
\treturn {
|
|
8631
|
+
\t\ttenantId: input.tenantId,
|
|
8632
|
+
\t\tplan: input.plan,
|
|
8633
|
+
\t\tstatus: input.status,
|
|
8634
|
+
\t\tprovider: input.provider,
|
|
8635
|
+
\t\tcustomerId: input.customerId ?? null,
|
|
8636
|
+
\t\tsubscriptionId: input.subscriptionId ?? null,
|
|
8637
|
+
\t\tcurrentPeriodEnd: input.currentPeriodEnd ?? null,
|
|
8638
|
+
\t\tcancelAtPeriodEnd:
|
|
8639
|
+
\t\t\tinput.cancelAtPeriodEnd === undefined
|
|
8640
|
+
\t\t\t\t? null
|
|
8641
|
+
\t\t\t\t: input.cancelAtPeriodEnd
|
|
8642
|
+
\t\t\t\t\t? ("true" as const)
|
|
8643
|
+
\t\t\t\t\t: ("false" as const),
|
|
8644
|
+
\t\tcheckoutSessionId: input.checkoutSessionId ?? null,
|
|
8645
|
+
\t\tlastEventId: input.lastEventId ?? null,
|
|
8646
|
+
\t\tupdatedAt: input.updatedAt,
|
|
8647
|
+
\t};
|
|
8648
|
+
}
|
|
8649
|
+
|
|
8650
|
+
export function createDrizzleBillingRepository(
|
|
8651
|
+
\tdb: ${dialect.dbTypeName}<typeof schema>,
|
|
8652
|
+
): BillingRepository {
|
|
8653
|
+
\treturn {
|
|
8654
|
+
\t\tasync findByTenantId(tenantId) {
|
|
8655
|
+
\t\t\tconst [row] = await db
|
|
8656
|
+
\t\t\t\t.select()
|
|
8657
|
+
\t\t\t\t.from(schema.billingAccounts)
|
|
8658
|
+
\t\t\t\t.where(eq(schema.billingAccounts.tenantId, tenantId))
|
|
8659
|
+
\t\t\t\t.limit(1);
|
|
8660
|
+
|
|
8661
|
+
\t\t\treturn row ? toBillingAccount(row) : null;
|
|
8662
|
+
\t\t},
|
|
8663
|
+
|
|
8664
|
+
\t\tasync findByCustomerId(customerId, provider) {
|
|
8665
|
+
\t\t\tconst rows = await db
|
|
8666
|
+
\t\t\t\t.select()
|
|
8667
|
+
\t\t\t\t.from(schema.billingAccounts)
|
|
8668
|
+
\t\t\t\t.where(
|
|
8669
|
+
\t\t\t\t\tand(
|
|
8670
|
+
\t\t\t\t\t\teq(schema.billingAccounts.customerId, customerId),
|
|
8671
|
+
\t\t\t\t\t\teq(schema.billingAccounts.provider, provider),
|
|
8672
|
+
\t\t\t\t\t),
|
|
8673
|
+
\t\t\t\t)
|
|
8674
|
+
\t\t\t\t.limit(2);
|
|
8675
|
+
|
|
8676
|
+
\t\t\tif (rows.length > 1) {
|
|
8677
|
+
\t\t\t\tthrow new Error(
|
|
8678
|
+
\t\t\t\t\t\`Multiple billing accounts found for provider "\${provider}" customer "\${customerId}".\`,
|
|
8679
|
+
\t\t\t\t);
|
|
8680
|
+
\t\t\t}
|
|
8681
|
+
|
|
8682
|
+
\t\t\treturn rows[0] ? toBillingAccount(rows[0]) : null;
|
|
8683
|
+
\t\t},
|
|
8684
|
+
|
|
8685
|
+
\t\tasync save(input) {
|
|
8686
|
+
${upsert}
|
|
8687
|
+
\t\t},
|
|
8688
|
+
\t};
|
|
8689
|
+
}
|
|
8690
|
+
`;
|
|
8691
|
+
}
|
|
8692
|
+
|
|
8693
|
+
function billingWebhookRouteFile(): string {
|
|
8694
|
+
return `import { createPaymentWebhookRoute } from "@beignet/next";
|
|
8695
|
+
import { handlePaymentWebhookUseCase } from "@/features/billing/use-cases";
|
|
8696
|
+
import { server } from "@/server";
|
|
8697
|
+
|
|
8698
|
+
export const runtime = "nodejs";
|
|
8699
|
+
|
|
8700
|
+
export const { POST } = createPaymentWebhookRoute({
|
|
8701
|
+
\tserver,
|
|
8702
|
+
\thandle: async ({ ctx, event }) => {
|
|
8703
|
+
\t\tawait handlePaymentWebhookUseCase.run({ ctx, input: event });
|
|
8704
|
+
\t\treturn {
|
|
8705
|
+
\t\t\tstatus: 200,
|
|
8706
|
+
\t\t\tbody: {
|
|
8707
|
+
\t\t\t\treceived: true,
|
|
8708
|
+
\t\t\t},
|
|
8709
|
+
\t\t};
|
|
8710
|
+
\t},
|
|
8711
|
+
});
|
|
8712
|
+
`;
|
|
8713
|
+
}
|
|
8714
|
+
|
|
6892
8715
|
function featureUiComponentFile(
|
|
6893
8716
|
names: FeatureUiNames,
|
|
6894
8717
|
config: ResolvedBeignetConfig,
|