@beignet/cli 0.0.13 → 0.0.15

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/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,
@@ -7042,7 +8865,7 @@ import {
7042
8865
  ${mode === "resource" ? ` delete${names.singularPascal}UseCase,\n get${names.singularPascal}UseCase,\n` : ""} list${names.pluralPascal}UseCase,
7043
8866
  ${mode === "resource" ? ` update${names.singularPascal}UseCase,\n` : ""}} from "${relativeModule(routeFilePath, useCasesPath)}";
7044
8867
 
7045
- export const ${names.singularCamel}Routes = defineRouteGroup<AppContext>({
8868
+ export const ${names.singularCamel}Routes = defineRouteGroup<AppContext>()({
7046
8869
  name: "${names.pluralKebab}",
7047
8870
  routes: [
7048
8871
  { contract: list${names.pluralPascal}, useCase: list${names.pluralPascal}UseCase },