@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/dist/make.js CHANGED
@@ -53,6 +53,56 @@ export async function makeFeature(options) {
53
53
  await makeFeatureSlice({ ...options, dryRun: true }, addons);
54
54
  return makeFeatureSlice(options, addons);
55
55
  }
56
+ export async function makePayments(options) {
57
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
58
+ const config = options.config
59
+ ? resolveConfig(options.config)
60
+ : await loadBeignetConfig(targetDir);
61
+ await assertStandardApp(targetDir, config);
62
+ const persistence = await detectResourcePersistence(targetDir, config);
63
+ if (persistence !== "drizzle") {
64
+ throw new Error("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.");
65
+ }
66
+ const database = await detectResourceDatabase(targetDir, config);
67
+ const generatedFiles = paymentsFiles(config, database);
68
+ const plannedFiles = await planGeneratedFiles(targetDir, generatedFiles, {
69
+ force: Boolean(options.force),
70
+ });
71
+ const billingNames = resourceNames("billing");
72
+ const plannedBillingUpdates = await updatePaymentsWiring(targetDir, billingNames, config, { dryRun: true });
73
+ const plannedPaymentsUpdates = (await wirePortProviders(targetDir, config, [paymentsPortWiring("make payments")], { dryRun: true })).updatedFiles;
74
+ const createdFiles = plannedFiles
75
+ .filter((file) => file.result === "created")
76
+ .map((file) => file.path);
77
+ const updatedFiles = plannedFiles
78
+ .filter((file) => file.result === "updated")
79
+ .map((file) => file.path);
80
+ const skippedFiles = plannedFiles
81
+ .filter((file) => file.result === "skipped")
82
+ .map((file) => file.path);
83
+ if (options.dryRun) {
84
+ updatedFiles.push(...plannedBillingUpdates, ...plannedPaymentsUpdates);
85
+ }
86
+ else {
87
+ for (const file of plannedFiles) {
88
+ await writePlannedGeneratedFile(targetDir, file);
89
+ }
90
+ updatedFiles.push(...(await updatePaymentsWiring(targetDir, billingNames, config, {
91
+ dryRun: false,
92
+ })));
93
+ updatedFiles.push(...(await wirePortProviders(targetDir, config, [paymentsPortWiring("make payments")], { dryRun: false })).updatedFiles);
94
+ }
95
+ return {
96
+ schemaVersion: 1,
97
+ name: "payments",
98
+ targetDir,
99
+ dryRun: Boolean(options.dryRun),
100
+ files: generatedFiles.map((file) => file.path),
101
+ createdFiles,
102
+ updatedFiles: uniqueStrings(updatedFiles),
103
+ skippedFiles,
104
+ };
105
+ }
56
106
  async function makeFeatureResource(options) {
57
107
  try {
58
108
  return await makeResourceSlice(options, "feature");
@@ -831,6 +881,16 @@ function notificationPortWirings(command) {
831
881
  },
832
882
  ];
833
883
  }
884
+ function paymentsPortWiring(command) {
885
+ return {
886
+ command,
887
+ portKey: "payments",
888
+ portType: "PaymentsPort",
889
+ portTypeModule: "@beignet/core/payments",
890
+ providerFactory: "createMemoryPaymentsProvider",
891
+ providerModule: "@beignet/core/payments",
892
+ };
893
+ }
834
894
  function providersFilePath(config) {
835
895
  return `${directoryPath(path.dirname(config.paths.server))}/providers.ts`;
836
896
  }
@@ -1369,6 +1429,61 @@ async function updateResourceWiring(targetDir, names, config, persistenceOrOptio
1369
1429
  updated.add(routesFile);
1370
1430
  return [...updated];
1371
1431
  }
1432
+ async function updatePaymentsWiring(targetDir, billingNames, config, options) {
1433
+ const updated = new Set();
1434
+ if (await updatePackageJson(targetDir, options)) {
1435
+ updated.add("package.json");
1436
+ }
1437
+ if (await updatePortsIndex(targetDir, billingNames, config, {
1438
+ ...options,
1439
+ generationOptions: {
1440
+ auth: false,
1441
+ tenant: false,
1442
+ events: false,
1443
+ softDelete: false,
1444
+ },
1445
+ })) {
1446
+ updated.add(config.paths.ports);
1447
+ }
1448
+ if (await updatePaymentsEntitlementsPort(targetDir, config, options)) {
1449
+ updated.add(config.paths.ports);
1450
+ updated.add(config.paths.infrastructurePorts);
1451
+ }
1452
+ if (await updateInfrastructureDeferredPorts(targetDir, billingNames, config, options)) {
1453
+ updated.add(config.paths.infrastructurePorts);
1454
+ }
1455
+ if (await updateBillingDrizzleSchemaIndex(targetDir, config, options)) {
1456
+ updated.add(drizzleSchemaIndexPath(config));
1457
+ }
1458
+ if (await updateDrizzleRepositories(targetDir, billingNames, config, options)) {
1459
+ updated.add(drizzleRepositoriesPath(config));
1460
+ }
1461
+ if (await updateDatabaseProviderPick(targetDir, config, options)) {
1462
+ updated.add(path.join(infrastructureDir(config), "db/provider.ts"));
1463
+ }
1464
+ if (await updateDatabaseProviderEntitlements(targetDir, config, options)) {
1465
+ updated.add(path.join(infrastructureDir(config), "db/provider.ts"));
1466
+ }
1467
+ if (await updateBillingTransactionPorts(targetDir, config, options)) {
1468
+ updated.add(path.join(infrastructureDir(config), "db/transaction-ports.ts"));
1469
+ }
1470
+ if (await updateDatabaseResetTables(targetDir, config, options)) {
1471
+ updated.add(path.join(infrastructureDir(config), "db/reset.ts"));
1472
+ }
1473
+ if (await updateBillingSharedErrors(targetDir, config, options)) {
1474
+ updated.add(resourceSharedErrorsPath(config));
1475
+ }
1476
+ if (await updateBillingEnv(targetDir, options)) {
1477
+ updated.add("lib/env.ts");
1478
+ }
1479
+ if (await updateBillingEnvExample(targetDir, options)) {
1480
+ updated.add(".env.example");
1481
+ }
1482
+ const routesFile = await updateServerRoutes(targetDir, billingNames, config, options);
1483
+ if (routesFile)
1484
+ updated.add(routesFile);
1485
+ return [...updated];
1486
+ }
1372
1487
  async function updatePackageJson(targetDir, options) {
1373
1488
  const filePath = path.join(targetDir, "package.json");
1374
1489
  const original = await readFile(filePath, "utf8");
@@ -1440,6 +1555,34 @@ async function updatePortIndex(targetDir, names, config, options) {
1440
1555
  await writeFile(filePath, next);
1441
1556
  return true;
1442
1557
  }
1558
+ async function updatePaymentsEntitlementsPort(targetDir, config, options) {
1559
+ let changed = false;
1560
+ const portsFilePath = path.join(targetDir, config.paths.ports);
1561
+ const portsOriginal = await readFile(portsFilePath, "utf8");
1562
+ let portsNext = addNamedTypeImport(portsOriginal, "EntitlementsPort", "@beignet/core/entitlements");
1563
+ if (!/\bentitlements\s*:/.test(portsNext)) {
1564
+ portsNext = insertTypeProperty(portsNext, "AppPorts", "entitlements: EntitlementsPort;", `Could not find AppPorts in ${config.paths.ports}. Add entitlements: EntitlementsPort; manually, or restore the generated ports file before running make payments.`);
1565
+ }
1566
+ if (portsNext !== portsOriginal) {
1567
+ changed = true;
1568
+ if (!options.dryRun)
1569
+ await writeFile(portsFilePath, portsNext);
1570
+ }
1571
+ const infrastructureFilePath = path.join(targetDir, config.paths.infrastructurePorts);
1572
+ const infrastructureOriginal = await readFile(infrastructureFilePath, "utf8");
1573
+ const infrastructureNext = appendDeferredPortKey(infrastructureOriginal, "entitlements");
1574
+ if (infrastructureNext === infrastructureOriginal &&
1575
+ !/["']entitlements["']/.test(infrastructureOriginal)) {
1576
+ throw new Error(`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.`);
1577
+ }
1578
+ if (infrastructureNext !== infrastructureOriginal) {
1579
+ changed = true;
1580
+ if (!options.dryRun) {
1581
+ await writeFile(infrastructureFilePath, infrastructureNext);
1582
+ }
1583
+ }
1584
+ return changed;
1585
+ }
1443
1586
  async function updateInfrastructurePortStub(targetDir, names, config, options) {
1444
1587
  const filePath = path.join(targetDir, config.paths.infrastructurePorts);
1445
1588
  const original = await readFile(filePath, "utf8");
@@ -1549,6 +1692,19 @@ async function updateDrizzleSchemaIndex(targetDir, names, config, options) {
1549
1692
  await writeFile(filePath, next);
1550
1693
  return true;
1551
1694
  }
1695
+ async function updateBillingDrizzleSchemaIndex(targetDir, config, options) {
1696
+ const filePath = path.join(targetDir, drizzleSchemaIndexPath(config));
1697
+ if (!(await fileExists(filePath)))
1698
+ return false;
1699
+ const original = await readFile(filePath, "utf8");
1700
+ const exportLine = 'export { billingAccounts } from "./billing";';
1701
+ if (original.includes(exportLine))
1702
+ return false;
1703
+ const next = `${original.trimEnd()}\n${exportLine}\n`;
1704
+ if (!options.dryRun)
1705
+ await writeFile(filePath, next);
1706
+ return true;
1707
+ }
1552
1708
  async function updateDrizzleRepositories(targetDir, names, config, options) {
1553
1709
  const filePath = path.join(targetDir, drizzleRepositoriesPath(config));
1554
1710
  if (!(await fileExists(filePath)))
@@ -1574,6 +1730,96 @@ async function updateDrizzleRepositories(targetDir, names, config, options) {
1574
1730
  await writeFile(filePath, next);
1575
1731
  return true;
1576
1732
  }
1733
+ async function updateDatabaseProviderPick(targetDir, config, options) {
1734
+ const file = path.join(infrastructureDir(config), "db/provider.ts");
1735
+ const filePath = path.join(targetDir, file);
1736
+ if (!(await fileExists(filePath)))
1737
+ return false;
1738
+ const original = await readFile(filePath, "utf8");
1739
+ const next = appendPickStringLiteralMember(original, "AppPorts", "billing");
1740
+ if (next === original)
1741
+ return false;
1742
+ if (!options.dryRun)
1743
+ await writeFile(filePath, next);
1744
+ return true;
1745
+ }
1746
+ async function updateDatabaseProviderEntitlements(targetDir, config, options) {
1747
+ const file = path.join(infrastructureDir(config), "db/provider.ts");
1748
+ const filePath = path.join(targetDir, file);
1749
+ if (!(await fileExists(filePath)))
1750
+ return false;
1751
+ const original = await readFile(filePath, "utf8");
1752
+ let next = original;
1753
+ const importLine = 'import { createBillingEntitlements } from "@/features/billing/entitlements";';
1754
+ if (!next.includes(importLine)) {
1755
+ next = insertAfterImports(next, importLine);
1756
+ }
1757
+ next = appendPickStringLiteralMember(next, "AppPorts", "entitlements");
1758
+ if (!/\bconst\s+entitlements\s*=/.test(next)) {
1759
+ const withEntitlementsConst = next.replace(/(\n\s*const repositories = createRepositories\(dbPort\.db\);)/, `$1\n\t\tconst entitlements = createBillingEntitlements(repositories.billing);`);
1760
+ if (withEntitlementsConst === next) {
1761
+ throw new Error(`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.`);
1762
+ }
1763
+ next = withEntitlementsConst;
1764
+ }
1765
+ if (!/\bentitlements\s*,/.test(next)) {
1766
+ const withEntitlementsPort = next.replace(/(\n\s*\.\.\.repositories,\n)/, `$1\t\t\tentitlements,\n`);
1767
+ if (withEntitlementsPort === next) {
1768
+ throw new Error(`Could not find the repository spread in ${file}. Add entitlements to providedPorts manually, or restore the generated database provider before running make payments.`);
1769
+ }
1770
+ next = withEntitlementsPort;
1771
+ }
1772
+ if (next === original)
1773
+ return false;
1774
+ if (!options.dryRun)
1775
+ await writeFile(filePath, next);
1776
+ return true;
1777
+ }
1778
+ async function updateBillingTransactionPorts(targetDir, config, options) {
1779
+ const file = path.join(infrastructureDir(config), "db/transaction-ports.ts");
1780
+ const filePath = path.join(targetDir, file);
1781
+ if (!(await fileExists(filePath)))
1782
+ return false;
1783
+ const original = await readFile(filePath, "utf8");
1784
+ if (/Pick<AppTransactionPorts,\s*["']billing["']>/.test(original)) {
1785
+ return false;
1786
+ }
1787
+ const repositoryProperty = /repositories:\s*([^;\n]+);/.exec(original);
1788
+ if (!repositoryProperty) {
1789
+ throw new Error(`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.`);
1790
+ }
1791
+ const repositoryType = repositoryProperty[1]?.trim();
1792
+ if (!repositoryType) {
1793
+ throw new Error(`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.`);
1794
+ }
1795
+ const next = original.replace(repositoryProperty[0], `repositories: ${repositoryType} & Pick<AppTransactionPorts, "billing">;`);
1796
+ if (next === original)
1797
+ return false;
1798
+ if (!options.dryRun)
1799
+ await writeFile(filePath, next);
1800
+ return true;
1801
+ }
1802
+ async function updateDatabaseResetTables(targetDir, config, options) {
1803
+ const file = path.join(infrastructureDir(config), "db/reset.ts");
1804
+ const filePath = path.join(targetDir, file);
1805
+ if (!(await fileExists(filePath)))
1806
+ return false;
1807
+ const original = await readFile(filePath, "utf8");
1808
+ if (/["']billing_accounts["']/.test(original))
1809
+ return false;
1810
+ const tables = arrayInitializerInfo(original, "tables");
1811
+ if (!tables)
1812
+ return false;
1813
+ const nextArray = appendToArrayExpression(tables.text, [
1814
+ '"billing_accounts"',
1815
+ ]);
1816
+ const next = `${original.slice(0, tables.start)}${nextArray}${original.slice(tables.end)}`;
1817
+ if (next === original)
1818
+ return false;
1819
+ if (!options.dryRun)
1820
+ await writeFile(filePath, next);
1821
+ return true;
1822
+ }
1577
1823
  async function updateInfrastructureDeferredPorts(targetDir, names, config, options) {
1578
1824
  const filePath = path.join(targetDir, config.paths.infrastructurePorts);
1579
1825
  const original = await readFile(filePath, "utf8");
@@ -1679,6 +1925,102 @@ async function updateSharedErrors(targetDir, names, config, options) {
1679
1925
  await writeFile(filePath, next);
1680
1926
  return true;
1681
1927
  }
1928
+ async function updateBillingSharedErrors(targetDir, config, options) {
1929
+ const file = resourceSharedErrorsPath(config);
1930
+ const filePath = path.join(targetDir, file);
1931
+ if (!(await fileExists(filePath)))
1932
+ return false;
1933
+ const original = await readFile(filePath, "utf8");
1934
+ const missingEntries = [
1935
+ {
1936
+ name: "BillingAccountNotFound",
1937
+ entry: `BillingAccountNotFound: {
1938
+ \t\tcode: "BILLING_ACCOUNT_NOT_FOUND",
1939
+ \t\tstatus: 404,
1940
+ \t\tmessage: "Billing account not found",
1941
+ \t},`,
1942
+ },
1943
+ {
1944
+ name: "IdempotencyConflict",
1945
+ entry: `IdempotencyConflict: {
1946
+ \t\tcode: "IDEMPOTENCY_CONFLICT",
1947
+ \t\tstatus: 409,
1948
+ \t\tmessage: "Idempotency key was already used with a different request",
1949
+ \t},`,
1950
+ },
1951
+ {
1952
+ name: "IdempotencyInProgress",
1953
+ entry: `IdempotencyInProgress: {
1954
+ \t\tcode: "IDEMPOTENCY_IN_PROGRESS",
1955
+ \t\tstatus: 409,
1956
+ \t\tmessage: "Idempotency key is already being processed",
1957
+ \t},`,
1958
+ },
1959
+ {
1960
+ name: "BillingCheckoutUnavailable",
1961
+ entry: `BillingCheckoutUnavailable: {
1962
+ \t\tcode: "BILLING_CHECKOUT_UNAVAILABLE",
1963
+ \t\tstatus: 502,
1964
+ \t\tmessage: "Billing checkout is unavailable",
1965
+ \t},`,
1966
+ },
1967
+ ].filter(({ name }) => !new RegExp(`\\b${name}\\b`).test(original));
1968
+ if (missingEntries.length === 0)
1969
+ return false;
1970
+ const defineErrorsStart = original.indexOf("defineErrors(");
1971
+ if (defineErrorsStart === -1) {
1972
+ throw new Error(`Could not find defineErrors({ in ${file}. Add BillingAccountNotFound and BillingCheckoutUnavailable manually, or restore the generated shared error catalog before running make payments.`);
1973
+ }
1974
+ const openBrace = original.indexOf("{", defineErrorsStart);
1975
+ if (openBrace === -1) {
1976
+ throw new Error(`Could not find defineErrors({ in ${file}. Add BillingAccountNotFound and BillingCheckoutUnavailable manually, or restore the generated shared error catalog before running make payments.`);
1977
+ }
1978
+ const closeBrace = matchingDelimiterIndex(original, openBrace, "{", "}");
1979
+ if (closeBrace === -1) {
1980
+ throw new Error(`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.`);
1981
+ }
1982
+ const next = insertBeforeClosingBrace(original, openBrace, closeBrace, missingEntries.map(({ entry }) => entry).join("\n"));
1983
+ if (next === original)
1984
+ return false;
1985
+ if (!options.dryRun)
1986
+ await writeFile(filePath, next);
1987
+ return true;
1988
+ }
1989
+ async function updateBillingEnv(targetDir, options) {
1990
+ const file = "lib/env.ts";
1991
+ const filePath = path.join(targetDir, file);
1992
+ const original = await readOptionalFile(filePath);
1993
+ if (original === undefined) {
1994
+ throw new Error(`Could not find ${file}. Add BILLING_TEAM_PRICE_ID to your env schema manually, or restore the generated env file before running make payments.`);
1995
+ }
1996
+ if (/\bBILLING_TEAM_PRICE_ID\b/.test(original))
1997
+ return false;
1998
+ const match = /\n([\t ]*)LOG_LEVEL:/.exec(original);
1999
+ if (!match) {
2000
+ throw new Error(`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.`);
2001
+ }
2002
+ const entry = `\n${match[1]}BILLING_TEAM_PRICE_ID: z.string().min(1).default("price_team_example"),`;
2003
+ const next = `${original.slice(0, match.index)}${entry}${original.slice(match.index)}`;
2004
+ if (!options.dryRun)
2005
+ await writeFile(filePath, next);
2006
+ return true;
2007
+ }
2008
+ async function updateBillingEnvExample(targetDir, options) {
2009
+ const file = ".env.example";
2010
+ const filePath = path.join(targetDir, file);
2011
+ const original = await readOptionalFile(filePath);
2012
+ if (original === undefined)
2013
+ return false;
2014
+ if (/^BILLING_TEAM_PRICE_ID=/m.test(original))
2015
+ return false;
2016
+ const match = /\nLOG_LEVEL=/.exec(original);
2017
+ const next = match
2018
+ ? `${original.slice(0, match.index)}\nBILLING_TEAM_PRICE_ID=price_team_example${original.slice(match.index)}`
2019
+ : `${original.trimEnd()}\nBILLING_TEAM_PRICE_ID=price_team_example\n`;
2020
+ if (!options.dryRun)
2021
+ await writeFile(filePath, next);
2022
+ return true;
2023
+ }
1682
2024
  async function updateServerRoutes(targetDir, names, config, options) {
1683
2025
  const filePath = path.join(targetDir, config.paths.server);
1684
2026
  const original = await readFile(filePath, "utf8");
@@ -1907,6 +2249,20 @@ function appendUnionTypeMember(source, typeName, member) {
1907
2249
  const nextDeclaration = declaration.replace(/;\s*$/, ` | ${member};`);
1908
2250
  return `${source.slice(0, match.index)}${nextDeclaration}${source.slice(match.index + declaration.length)}`;
1909
2251
  }
2252
+ function appendPickStringLiteralMember(source, typeName, member) {
2253
+ const match = new RegExp(`Pick<\\s*${typeName}\\s*,([\\s\\S]*?)>`).exec(source);
2254
+ if (!match)
2255
+ return source;
2256
+ const literal = `"${member}"`;
2257
+ if (match[1].includes(literal))
2258
+ return source;
2259
+ const union = match[1];
2260
+ const indent = union.match(/\n([\t ]*)\|/)?.[1] ?? "\t";
2261
+ const nextUnion = union.includes("\n")
2262
+ ? `${union.replace(/\s*$/, "")}\n${indent}| ${literal}\n`
2263
+ : `${union.trimEnd()} | ${literal}`;
2264
+ return `${source.slice(0, match.index)}Pick<${typeName},${nextUnion}>${source.slice(match.index + match[0].length)}`;
2265
+ }
1910
2266
  function appendTupleTypeMember(source, typeName, member) {
1911
2267
  const match = new RegExp(`export\\s+type\\s+${typeName}\\s*=\\s*`).exec(source);
1912
2268
  if (!match)
@@ -2718,6 +3074,54 @@ function resourceFiles(names, config, persistence = "memory", mode = "feature",
2718
3074
  }
2719
3075
  return files;
2720
3076
  }
3077
+ function paymentsFiles(config, database) {
3078
+ return [
3079
+ {
3080
+ path: path.join(config.paths.features, "billing/schemas.ts"),
3081
+ content: billingSchemasFile(),
3082
+ },
3083
+ {
3084
+ path: path.join(config.paths.features, "billing/pricing.ts"),
3085
+ content: billingPricingFile(),
3086
+ },
3087
+ {
3088
+ path: path.join(config.paths.features, "billing/entitlements.ts"),
3089
+ content: billingEntitlementsFile(),
3090
+ },
3091
+ {
3092
+ path: path.join(config.paths.features, "billing/ports.ts"),
3093
+ content: billingPortsFile(),
3094
+ },
3095
+ {
3096
+ path: path.join(config.paths.features, "billing/contracts.ts"),
3097
+ content: billingContractsFile(),
3098
+ },
3099
+ {
3100
+ path: path.join(config.paths.features, "billing/use-cases/index.ts"),
3101
+ content: billingUseCasesFile(config),
3102
+ },
3103
+ {
3104
+ path: path.join(config.paths.features, "billing/routes.ts"),
3105
+ content: billingRoutesFile(config),
3106
+ },
3107
+ {
3108
+ path: path.join(config.paths.features, "billing/tests/billing.test.ts"),
3109
+ content: billingTestFile(config),
3110
+ },
3111
+ {
3112
+ path: path.join(infrastructureDir(config), "billing/drizzle-billing-repository.ts"),
3113
+ content: billingDrizzleRepositoryFile(config, database),
3114
+ },
3115
+ {
3116
+ path: path.join(infrastructureDir(config), "db/schema/billing.ts"),
3117
+ content: billingDrizzleSchemaFile(database),
3118
+ },
3119
+ {
3120
+ path: "app/api/webhooks/payments/route.ts",
3121
+ content: billingWebhookRouteFile(),
3122
+ },
3123
+ ];
3124
+ }
2721
3125
  function featureUiComponentFiles(names, config) {
2722
3126
  const componentPath = featureUiComponentFilePath(names, config);
2723
3127
  return [
@@ -4740,6 +5144,1210 @@ export const ${names.uploadExportName} = defineUpload<
4740
5144
  });
4741
5145
  `;
4742
5146
  }
5147
+ function billingSchemasFile() {
5148
+ return `import { z } from "zod";
5149
+
5150
+ export const billingPlanSchema = z.enum(["team"]);
5151
+
5152
+ export const billingStatusSchema = z.enum([
5153
+ \t"inactive",
5154
+ \t"trialing",
5155
+ \t"active",
5156
+ \t"past_due",
5157
+ \t"canceled",
5158
+ ]);
5159
+
5160
+ export const createCheckoutSessionInputSchema = z.object({
5161
+ \tplan: billingPlanSchema,
5162
+ });
5163
+
5164
+ export const createCheckoutSessionOutputSchema = z.object({
5165
+ \tsessionId: z.string(),
5166
+ \turl: z.string().url(),
5167
+ });
5168
+
5169
+ export const createBillingPortalSessionOutputSchema = z.object({
5170
+ \turl: z.string().url(),
5171
+ });
5172
+
5173
+ export const billingStatusOutputSchema = z.object({
5174
+ \ttenantId: z.string(),
5175
+ \tplan: billingPlanSchema,
5176
+ \tstatus: billingStatusSchema,
5177
+ \tactive: z.boolean(),
5178
+ \tprovider: z.string().optional(),
5179
+ \tcustomerId: z.string().optional(),
5180
+ \tsubscriptionId: z.string().optional(),
5181
+ \tcurrentPeriodEnd: z.string().optional(),
5182
+ \tcancelAtPeriodEnd: z.boolean().optional(),
5183
+ \tcheckoutSessionId: z.string().optional(),
5184
+ \tlastEventId: z.string().optional(),
5185
+ \tupdatedAt: z.string().optional(),
5186
+ });
5187
+
5188
+ export type BillingPlan = z.infer<typeof billingPlanSchema>;
5189
+ export type BillingStatus = z.infer<typeof billingStatusSchema>;
5190
+ export type BillingStatusOutput = z.infer<typeof billingStatusOutputSchema>;
5191
+ `;
5192
+ }
5193
+ function billingPricingFile() {
5194
+ return `import type {
5195
+ \tPaymentCheckoutLineItem,
5196
+ \tPaymentCheckoutMode,
5197
+ } from "@beignet/core/payments";
5198
+ import { env } from "@/lib/env";
5199
+ import type { BillingPlan } from "./schemas";
5200
+
5201
+ export type BillingEntitlement = "todos.create";
5202
+
5203
+ export type BillingPlanDefinition = {
5204
+ \tid: BillingPlan;
5205
+ \tlabel: string;
5206
+ \tmode: PaymentCheckoutMode;
5207
+ \tlineItems: readonly PaymentCheckoutLineItem[];
5208
+ \tentitlements: readonly BillingEntitlement[];
5209
+ };
5210
+
5211
+ export const billingPlans = {
5212
+ \tteam: {
5213
+ \t\tid: "team",
5214
+ \t\tlabel: "Team",
5215
+ \t\tmode: "subscription",
5216
+ \t\tlineItems: [{ priceId: env.BILLING_TEAM_PRICE_ID, quantity: 1 }],
5217
+ \t\tentitlements: ["todos.create"],
5218
+ \t},
5219
+ } as const satisfies Record<BillingPlan, BillingPlanDefinition>;
5220
+
5221
+ export function getBillingPlan(plan: BillingPlan): BillingPlanDefinition {
5222
+ \treturn billingPlans[plan];
5223
+ }
5224
+
5225
+ export function billingPlanIncludesEntitlement(
5226
+ \tplan: BillingPlan,
5227
+ \tentitlement: string,
5228
+ ): entitlement is BillingEntitlement {
5229
+ \treturn getBillingPlan(plan).entitlements.includes(
5230
+ \t\tentitlement as BillingEntitlement,
5231
+ \t);
5232
+ }
5233
+ `;
5234
+ }
5235
+ function billingEntitlementsFile() {
5236
+ return `import {
5237
+ \tcreateEntitlements,
5238
+ \tdenyEntitlement,
5239
+ } from "@beignet/core/entitlements";
5240
+ import { billingPlanIncludesEntitlement } from "./pricing";
5241
+ import { isBillingAccountActive, type BillingRepository } from "./ports";
5242
+
5243
+ export function createBillingEntitlements(billing: BillingRepository) {
5244
+ \treturn createEntitlements({
5245
+ \t\tasync inspect(input) {
5246
+ \t\t\tif (input.subject.type !== "tenant") {
5247
+ \t\t\t\treturn denyEntitlement({
5248
+ \t\t\t\t\treason: "Billing entitlements require a tenant subject.",
5249
+ \t\t\t\t\tcode: "ENTITLEMENT_SUBJECT_UNSUPPORTED",
5250
+ \t\t\t\t\tdetails: { subject: input.subject },
5251
+ \t\t\t\t});
5252
+ \t\t\t}
5253
+
5254
+ \t\t\tconst account = await billing.findByTenantId(input.subject.id);
5255
+ \t\t\tif (!isBillingAccountActive(account)) {
5256
+ \t\t\t\treturn denyEntitlement({
5257
+ \t\t\t\t\treason: "An active billing plan is required for this action.",
5258
+ \t\t\t\t\tcode: "BILLING_INACTIVE",
5259
+ \t\t\t\t\tdetails: {
5260
+ \t\t\t\t\t\tentitlement: input.entitlement,
5261
+ \t\t\t\t\t\tstatus: account?.status ?? "inactive",
5262
+ \t\t\t\t\t\tsubject: input.subject,
5263
+ \t\t\t\t\t},
5264
+ \t\t\t\t});
5265
+ \t\t\t}
5266
+
5267
+ \t\t\tif (!billingPlanIncludesEntitlement(account.plan, input.entitlement)) {
5268
+ \t\t\t\treturn denyEntitlement({
5269
+ \t\t\t\t\treason:
5270
+ \t\t\t\t\t\t"The current billing plan does not include this entitlement.",
5271
+ \t\t\t\t\tcode: "ENTITLEMENT_NOT_INCLUDED",
5272
+ \t\t\t\t\tdetails: {
5273
+ \t\t\t\t\t\tentitlement: input.entitlement,
5274
+ \t\t\t\t\t\tplan: account.plan,
5275
+ \t\t\t\t\t\tsubject: input.subject,
5276
+ \t\t\t\t\t},
5277
+ \t\t\t\t});
5278
+ \t\t\t}
5279
+
5280
+ \t\t\treturn true;
5281
+ \t\t},
5282
+ \t});
5283
+ }
5284
+ `;
5285
+ }
5286
+ function billingPortsFile() {
5287
+ return `import type { BillingPlan, BillingStatus } from "./schemas";
5288
+
5289
+ export type BillingAccount = {
5290
+ \ttenantId: string;
5291
+ \tplan: BillingPlan;
5292
+ \tstatus: BillingStatus;
5293
+ \tprovider: string;
5294
+ \tcustomerId?: string;
5295
+ \tsubscriptionId?: string;
5296
+ \tcurrentPeriodEnd?: string;
5297
+ \tcancelAtPeriodEnd?: boolean;
5298
+ \tcheckoutSessionId?: string;
5299
+ \tlastEventId?: string;
5300
+ \tupdatedAt: string;
5301
+ };
5302
+
5303
+ export type SaveBillingAccountInput = BillingAccount;
5304
+
5305
+ export interface BillingRepository {
5306
+ \tfindByTenantId(tenantId: string): Promise<BillingAccount | null>;
5307
+ \tfindByCustomerId(
5308
+ \t\tcustomerId: string,
5309
+ \t\tprovider: string,
5310
+ \t): Promise<BillingAccount | null>;
5311
+ \tsave(input: SaveBillingAccountInput): Promise<BillingAccount>;
5312
+ }
5313
+
5314
+ export function isBillingAccountActive(
5315
+ \taccount: BillingAccount | null,
5316
+ ): account is BillingAccount & { status: "active" | "trialing" } {
5317
+ \treturn account?.status === "active" || account?.status === "trialing";
5318
+ }
5319
+ `;
5320
+ }
5321
+ function billingContractsFile() {
5322
+ return `import { defineContractGroup } from "@beignet/core/contracts";
5323
+ import { z } from "zod";
5324
+ import {
5325
+ \tbillingStatusOutputSchema,
5326
+ \tcreateBillingPortalSessionOutputSchema,
5327
+ \tcreateCheckoutSessionInputSchema,
5328
+ \tcreateCheckoutSessionOutputSchema,
5329
+ } from "@/features/billing/schemas";
5330
+ import { errors } from "@/features/shared/errors";
5331
+
5332
+ const billing = defineContractGroup()
5333
+ \t.namespace("billing")
5334
+ \t.prefix("/api/billing");
5335
+ const checkoutHeadersSchema = z.object({
5336
+ \t"idempotency-key": z.string().min(1),
5337
+ });
5338
+ const checkoutBilling = billing.headers(checkoutHeadersSchema);
5339
+
5340
+ export const getBillingStatus = billing
5341
+ \t.get("/")
5342
+ \t.query(z.object({}))
5343
+ \t.meta({ auth: "required" })
5344
+ \t.errors({ Unauthorized: errors.Unauthorized })
5345
+ \t.responses({
5346
+ \t\t200: billingStatusOutputSchema,
5347
+ \t})
5348
+ \t.openapi({ summary: "Get billing status", tags: ["billing"] });
5349
+
5350
+ export const createCheckoutSession = checkoutBilling
5351
+ \t.post("/checkout")
5352
+ \t.body(createCheckoutSessionInputSchema)
5353
+ \t.meta({
5354
+ \t\tauth: "required",
5355
+ \t\tidempotency: {
5356
+ \t\t\trequired: true,
5357
+ \t\t\theader: "idempotency-key",
5358
+ \t\t\tscope: "actor-tenant",
5359
+ \t\t\tttlSec: 86_400,
5360
+ \t\t},
5361
+ \t})
5362
+ \t.errors({
5363
+ \t\tUnauthorized: errors.Unauthorized,
5364
+ \t\tIdempotencyConflict: errors.IdempotencyConflict,
5365
+ \t\tIdempotencyInProgress: errors.IdempotencyInProgress,
5366
+ \t\tBillingCheckoutUnavailable: errors.BillingCheckoutUnavailable,
5367
+ \t})
5368
+ \t.responses({
5369
+ \t\t201: createCheckoutSessionOutputSchema,
5370
+ \t})
5371
+ \t.openapi({ summary: "Create a checkout session", tags: ["billing"] });
5372
+
5373
+ export const createBillingPortalSession = billing
5374
+ \t.post("/portal")
5375
+ \t.body(z.object({}))
5376
+ \t.meta({ auth: "required" })
5377
+ \t.errors({
5378
+ \t\tUnauthorized: errors.Unauthorized,
5379
+ \t\tBillingAccountNotFound: errors.BillingAccountNotFound,
5380
+ \t})
5381
+ \t.responses({
5382
+ \t\t200: createBillingPortalSessionOutputSchema,
5383
+ \t})
5384
+ \t.openapi({ summary: "Create a billing portal session", tags: ["billing"] });
5385
+
5386
+ export const billingContracts = [
5387
+ \tgetBillingStatus,
5388
+ \tcreateCheckoutSession,
5389
+ \tcreateBillingPortalSession,
5390
+ ];
5391
+ `;
5392
+ }
5393
+ function billingUseCasesFile(config) {
5394
+ return `import {
5395
+ \tcreateIdempotencyFingerprint,
5396
+ \trunIdempotently,
5397
+ } from "@beignet/core/idempotency";
5398
+ import type { PaymentWebhookEvent } from "@beignet/core/payments";
5399
+ import { requireUser } from "@beignet/core/ports";
5400
+ import { z } from "zod";
5401
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
5402
+ import { appError } from "@/features/shared/errors";
5403
+ import { env } from "@/lib/env";
5404
+ import { useCase } from "@/lib/use-case";
5405
+ import { getBillingPlan } from "../pricing";
5406
+ import type { BillingAccount } from "../ports";
5407
+ import { isBillingAccountActive } from "../ports";
5408
+ import {
5409
+ \ttype BillingPlan,
5410
+ \ttype BillingStatus,
5411
+ \tbillingStatusOutputSchema,
5412
+ \tcreateBillingPortalSessionOutputSchema,
5413
+ \tcreateCheckoutSessionInputSchema,
5414
+ \tcreateCheckoutSessionOutputSchema,
5415
+ } from "../schemas";
5416
+
5417
+ type ProviderPayload = Record<string, unknown>;
5418
+
5419
+ const paymentWebhookEventSchema = z.custom<PaymentWebhookEvent>(
5420
+ \t(value): value is PaymentWebhookEvent =>
5421
+ \t\ttypeof value === "object" &&
5422
+ \t\tvalue !== null &&
5423
+ \t\ttypeof (value as PaymentWebhookEvent).id === "string" &&
5424
+ \t\ttypeof (value as PaymentWebhookEvent).type === "string" &&
5425
+ \t\ttypeof (value as PaymentWebhookEvent).provider === "string",
5426
+ );
5427
+ const paymentWebhookOutputSchema = z.object({
5428
+ \treceived: z.boolean(),
5429
+ \taccountUpdated: z.boolean(),
5430
+ });
5431
+
5432
+ function nowIso(): string {
5433
+ \treturn new Date().toISOString();
5434
+ }
5435
+
5436
+ function record(value: unknown): ProviderPayload {
5437
+ \treturn value && typeof value === "object" && !Array.isArray(value)
5438
+ \t\t? (value as ProviderPayload)
5439
+ \t\t: {};
5440
+ }
5441
+
5442
+ function stringValue(value: unknown): string | undefined {
5443
+ \treturn typeof value === "string" && value.length > 0 ? value : undefined;
5444
+ }
5445
+
5446
+ function providerId(value: unknown): string | undefined {
5447
+ \tif (typeof value === "string") return stringValue(value);
5448
+ \tconst payload = record(value);
5449
+ \treturn stringValue(payload.id);
5450
+ }
5451
+
5452
+ function metadataValue(data: ProviderPayload, key: string): string | undefined {
5453
+ \treturn stringValue(record(data.metadata)[key]);
5454
+ }
5455
+
5456
+ function tenantIdFromPayload(data: ProviderPayload): string | undefined {
5457
+ \treturn (
5458
+ \t\tstringValue(data.client_reference_id) ??
5459
+ \t\tmetadataValue(data, "tenantId") ??
5460
+ \t\tmetadataValue(data, "tenant_id")
5461
+ \t);
5462
+ }
5463
+
5464
+ function planFromPayload(data: ProviderPayload): BillingPlan {
5465
+ \tconst plan = metadataValue(data, "plan");
5466
+ \treturn plan === "team" ? plan : "team";
5467
+ }
5468
+
5469
+ function statusFromPayload(
5470
+ \tdata: ProviderPayload,
5471
+ \tfallback: BillingStatus,
5472
+ ): BillingStatus {
5473
+ \tconst status = stringValue(data.status);
5474
+ \tswitch (status) {
5475
+ \t\tcase "active":
5476
+ \t\tcase "trialing":
5477
+ \t\tcase "past_due":
5478
+ \t\tcase "canceled":
5479
+ \t\tcase "inactive":
5480
+ \t\t\treturn status;
5481
+ \t\tcase "unpaid":
5482
+ \t\t\treturn "past_due";
5483
+ \t\tcase "incomplete":
5484
+ \t\t\treturn "inactive";
5485
+ \t\tcase "incomplete_expired":
5486
+ \t\t\treturn "canceled";
5487
+ \t\tcase "paused":
5488
+ \t\t\treturn "inactive";
5489
+ \t\tdefault:
5490
+ \t\t\treturn fallback;
5491
+ \t}
5492
+ }
5493
+
5494
+ function periodEndFromPayload(data: ProviderPayload): string | undefined {
5495
+ \tconst value = data.current_period_end;
5496
+ \treturn typeof value === "number"
5497
+ \t\t? new Date(value * 1000).toISOString()
5498
+ \t\t: stringValue(value);
5499
+ }
5500
+
5501
+ function cancelAtPeriodEndFromPayload(
5502
+ \tdata: ProviderPayload,
5503
+ ): boolean | undefined {
5504
+ \treturn typeof data.cancel_at_period_end === "boolean"
5505
+ \t\t? data.cancel_at_period_end
5506
+ \t\t: undefined;
5507
+ }
5508
+
5509
+ function billingTenantId(ctx: AppContext): string {
5510
+ \tconst user = requireUser(ctx);
5511
+ \treturn ctx.tenant?.id ?? user.id;
5512
+ }
5513
+
5514
+ function billingStatusOutput(tenantId: string, account: BillingAccount | null) {
5515
+ \treturn {
5516
+ \t\ttenantId,
5517
+ \t\tplan: account?.plan ?? "team",
5518
+ \t\tstatus: account?.status ?? "inactive",
5519
+ \t\tactive: isBillingAccountActive(account),
5520
+ \t\t...(account?.provider ? { provider: account.provider } : {}),
5521
+ \t\t...(account?.customerId ? { customerId: account.customerId } : {}),
5522
+ \t\t...(account?.subscriptionId
5523
+ \t\t\t? { subscriptionId: account.subscriptionId }
5524
+ \t\t\t: {}),
5525
+ \t\t...(account?.currentPeriodEnd
5526
+ \t\t\t? { currentPeriodEnd: account.currentPeriodEnd }
5527
+ \t\t\t: {}),
5528
+ \t\t...(account?.cancelAtPeriodEnd !== undefined
5529
+ \t\t\t? { cancelAtPeriodEnd: account.cancelAtPeriodEnd }
5530
+ \t\t\t: {}),
5531
+ \t\t...(account?.checkoutSessionId
5532
+ \t\t\t? { checkoutSessionId: account.checkoutSessionId }
5533
+ \t\t\t: {}),
5534
+ \t\t...(account?.lastEventId ? { lastEventId: account.lastEventId } : {}),
5535
+ \t\t...(account?.updatedAt ? { updatedAt: account.updatedAt } : {}),
5536
+ \t};
5537
+ }
5538
+
5539
+ async function saveBillingAccount(args: {
5540
+ \texisting: BillingAccount | null;
5541
+ \ttenantId: string;
5542
+ \tprovider: string;
5543
+ \tplan?: BillingPlan;
5544
+ \tstatus?: BillingStatus;
5545
+ \tcustomerId?: string;
5546
+ \tsubscriptionId?: string;
5547
+ \tcurrentPeriodEnd?: string;
5548
+ \tcancelAtPeriodEnd?: boolean;
5549
+ \tcheckoutSessionId?: string;
5550
+ \tlastEventId?: string;
5551
+ \tsave: (account: BillingAccount) => Promise<BillingAccount>;
5552
+ }) {
5553
+ \treturn args.save({
5554
+ \t\ttenantId: args.tenantId,
5555
+ \t\tprovider: args.provider,
5556
+ \t\tplan: args.plan ?? args.existing?.plan ?? "team",
5557
+ \t\tstatus: args.status ?? args.existing?.status ?? "inactive",
5558
+ \t\tcustomerId: args.customerId ?? args.existing?.customerId,
5559
+ \t\tsubscriptionId: args.subscriptionId ?? args.existing?.subscriptionId,
5560
+ \t\tcurrentPeriodEnd:
5561
+ \t\t\targs.currentPeriodEnd ?? args.existing?.currentPeriodEnd,
5562
+ \t\tcancelAtPeriodEnd:
5563
+ \t\t\targs.cancelAtPeriodEnd ?? args.existing?.cancelAtPeriodEnd,
5564
+ \t\tcheckoutSessionId:
5565
+ \t\t\targs.checkoutSessionId ?? args.existing?.checkoutSessionId,
5566
+ \t\tlastEventId: args.lastEventId ?? args.existing?.lastEventId,
5567
+ \t\tupdatedAt: nowIso(),
5568
+ \t});
5569
+ }
5570
+
5571
+ async function applyCheckoutCompleted(
5572
+ \tevent: PaymentWebhookEvent,
5573
+ \tsave: (account: BillingAccount) => Promise<BillingAccount>,
5574
+ \tfindByTenantId: (tenantId: string) => Promise<BillingAccount | null>,
5575
+ ) {
5576
+ \tconst data = record(event.data);
5577
+ \tconst tenantId = tenantIdFromPayload(data);
5578
+ \tif (!tenantId) return null;
5579
+
5580
+ \tconst existing = await findByTenantId(tenantId);
5581
+ \treturn saveBillingAccount({
5582
+ \t\texisting,
5583
+ \t\ttenantId,
5584
+ \t\tprovider: event.provider,
5585
+ \t\tplan: planFromPayload(data),
5586
+ \t\tstatus: "active",
5587
+ \t\tcustomerId: providerId(data.customer),
5588
+ \t\tsubscriptionId: providerId(data.subscription),
5589
+ \t\tcheckoutSessionId: stringValue(data.id),
5590
+ \t\tlastEventId: event.id,
5591
+ \t\tsave,
5592
+ \t});
5593
+ }
5594
+
5595
+ async function applySubscriptionEvent(
5596
+ \tevent: PaymentWebhookEvent,
5597
+ \tsave: (account: BillingAccount) => Promise<BillingAccount>,
5598
+ \tfindByCustomerId: (
5599
+ \t\tcustomerId: string,
5600
+ \t\tprovider: string,
5601
+ \t) => Promise<BillingAccount | null>,
5602
+ \tfindByTenantId: (tenantId: string) => Promise<BillingAccount | null>,
5603
+ ) {
5604
+ \tconst data = record(event.data);
5605
+ \tconst customerId = providerId(data.customer);
5606
+ \tconst explicitTenantId = tenantIdFromPayload(data);
5607
+ \tconst existing = explicitTenantId
5608
+ \t\t? await findByTenantId(explicitTenantId)
5609
+ \t\t: customerId
5610
+ \t\t\t? await findByCustomerId(customerId, event.provider)
5611
+ \t\t\t: null;
5612
+ \tconst tenantId = explicitTenantId ?? existing?.tenantId;
5613
+ \tif (!tenantId) return null;
5614
+
5615
+ \treturn saveBillingAccount({
5616
+ \t\texisting,
5617
+ \t\ttenantId,
5618
+ \t\tprovider: event.provider,
5619
+ \t\tplan: planFromPayload(data),
5620
+ \t\tstatus:
5621
+ \t\t\tevent.type === "customer.subscription.deleted"
5622
+ \t\t\t\t? "canceled"
5623
+ \t\t\t\t: statusFromPayload(data, existing?.status ?? "active"),
5624
+ \t\tcustomerId,
5625
+ \t\tsubscriptionId: providerId(data.id) ?? existing?.subscriptionId,
5626
+ \t\tcurrentPeriodEnd: periodEndFromPayload(data),
5627
+ \t\tcancelAtPeriodEnd:
5628
+ \t\t\tevent.type === "customer.subscription.deleted"
5629
+ \t\t\t\t? false
5630
+ \t\t\t\t: cancelAtPeriodEndFromPayload(data),
5631
+ \t\tlastEventId: event.id,
5632
+ \t\tsave,
5633
+ \t});
5634
+ }
5635
+
5636
+ async function applyInvoiceSucceeded(
5637
+ \tevent: PaymentWebhookEvent,
5638
+ \tsave: (account: BillingAccount) => Promise<BillingAccount>,
5639
+ \tfindByCustomerId: (
5640
+ \t\tcustomerId: string,
5641
+ \t\tprovider: string,
5642
+ \t) => Promise<BillingAccount | null>,
5643
+ ) {
5644
+ \tconst data = record(event.data);
5645
+ \tconst customerId = providerId(data.customer);
5646
+ \tif (!customerId) return null;
5647
+
5648
+ \tconst existing = await findByCustomerId(customerId, event.provider);
5649
+ \tif (!existing) return null;
5650
+ \tif (existing.status === "canceled") return null;
5651
+
5652
+ \treturn saveBillingAccount({
5653
+ \t\texisting,
5654
+ \t\ttenantId: existing.tenantId,
5655
+ \t\tprovider: event.provider,
5656
+ \t\tstatus: "active",
5657
+ \t\tcustomerId,
5658
+ \t\tsubscriptionId: providerId(data.subscription),
5659
+ \t\tcancelAtPeriodEnd: cancelAtPeriodEndFromPayload(data),
5660
+ \t\tlastEventId: event.id,
5661
+ \t\tsave,
5662
+ \t});
5663
+ }
5664
+
5665
+ async function applyInvoiceFailed(
5666
+ \tevent: PaymentWebhookEvent,
5667
+ \tsave: (account: BillingAccount) => Promise<BillingAccount>,
5668
+ \tfindByCustomerId: (
5669
+ \t\tcustomerId: string,
5670
+ \t\tprovider: string,
5671
+ \t) => Promise<BillingAccount | null>,
5672
+ ) {
5673
+ \tconst data = record(event.data);
5674
+ \tconst customerId = providerId(data.customer);
5675
+ \tif (!customerId) return null;
5676
+
5677
+ \tconst existing = await findByCustomerId(customerId, event.provider);
5678
+ \tif (!existing) return null;
5679
+ \tif (existing.status === "canceled") return null;
5680
+
5681
+ \treturn saveBillingAccount({
5682
+ \t\texisting,
5683
+ \t\ttenantId: existing.tenantId,
5684
+ \t\tprovider: event.provider,
5685
+ \t\tstatus: "past_due",
5686
+ \t\tcustomerId,
5687
+ \t\tsubscriptionId: providerId(data.subscription),
5688
+ \t\tlastEventId: event.id,
5689
+ \t\tsave,
5690
+ \t});
5691
+ }
5692
+
5693
+ export const getBillingStatusUseCase = useCase
5694
+ \t.query("billing.getStatus")
5695
+ \t.input(z.object({}))
5696
+ \t.output(billingStatusOutputSchema)
5697
+ \t.run(async ({ ctx }) => {
5698
+ \t\tconst tenantId = billingTenantId(ctx);
5699
+ \t\tconst account = await ctx.ports.billing.findByTenantId(tenantId);
5700
+ \t\treturn billingStatusOutput(tenantId, account);
5701
+ \t});
5702
+
5703
+ export const createCheckoutSessionUseCase = useCase
5704
+ \t.command("billing.createCheckoutSession")
5705
+ \t.input(createCheckoutSessionInputSchema)
5706
+ \t.output(createCheckoutSessionOutputSchema)
5707
+ \t.run(async ({ ctx, input }) => {
5708
+ \t\tconst tenantId = billingTenantId(ctx);
5709
+ \t\tconst account = await ctx.ports.billing.findByTenantId(tenantId);
5710
+ \t\tconst plan = getBillingPlan(input.plan);
5711
+ \t\tconst session = await ctx.ports.payments.createCheckoutSession({
5712
+ \t\t\tmode: plan.mode,
5713
+ \t\t\tlineItems: plan.lineItems,
5714
+ \t\t\tsuccessUrl: \`\${env.APP_URL}/billing?checkout=success\`,
5715
+ \t\t\tcancelUrl: \`\${env.APP_URL}/billing?checkout=cancelled\`,
5716
+ \t\t\tcustomerId: account?.customerId,
5717
+ \t\t\tclientReferenceId: tenantId,
5718
+ \t\t\tmetadata: {
5719
+ \t\t\t\ttenantId,
5720
+ \t\t\t\tplan: input.plan,
5721
+ \t\t\t},
5722
+ \t\t});
5723
+
5724
+ \t\tif (!session.url) {
5725
+ \t\t\tthrow appError("BillingCheckoutUnavailable", {
5726
+ \t\t\t\tdetails: { provider: session.provider, sessionId: session.id },
5727
+ \t\t\t});
5728
+ \t\t}
5729
+
5730
+ \t\tawait ctx.ports.billing.save({
5731
+ \t\t\ttenantId,
5732
+ \t\t\tprovider: session.provider,
5733
+ \t\t\tplan: input.plan,
5734
+ \t\t\tstatus: account?.status ?? "inactive",
5735
+ \t\t\tcustomerId: session.customerId ?? account?.customerId,
5736
+ \t\t\tsubscriptionId: account?.subscriptionId,
5737
+ \t\t\tcurrentPeriodEnd: account?.currentPeriodEnd,
5738
+ \t\t\tcancelAtPeriodEnd: account?.cancelAtPeriodEnd,
5739
+ \t\t\tcheckoutSessionId: session.id,
5740
+ \t\t\tlastEventId: account?.lastEventId,
5741
+ \t\t\tupdatedAt: nowIso(),
5742
+ \t\t});
5743
+
5744
+ \t\treturn {
5745
+ \t\t\tsessionId: session.id,
5746
+ \t\t\turl: session.url,
5747
+ \t\t};
5748
+ \t});
5749
+
5750
+ export const createBillingPortalSessionUseCase = useCase
5751
+ \t.command("billing.createPortalSession")
5752
+ \t.input(z.object({}))
5753
+ \t.output(createBillingPortalSessionOutputSchema)
5754
+ \t.run(async ({ ctx }) => {
5755
+ \t\tconst tenantId = billingTenantId(ctx);
5756
+ \t\tconst account = await ctx.ports.billing.findByTenantId(tenantId);
5757
+
5758
+ \t\tif (!account?.customerId) {
5759
+ \t\t\tthrow appError("BillingAccountNotFound", {
5760
+ \t\t\t\tdetails: { tenantId },
5761
+ \t\t\t});
5762
+ \t\t}
5763
+
5764
+ \t\tconst session = await ctx.ports.payments.createBillingPortalSession({
5765
+ \t\t\tcustomerId: account.customerId,
5766
+ \t\t\treturnUrl: \`\${env.APP_URL}/billing\`,
5767
+ \t\t});
5768
+
5769
+ \t\treturn { url: session.url };
5770
+ \t});
5771
+
5772
+ export const handlePaymentWebhookUseCase = useCase
5773
+ \t.command("billing.handlePaymentWebhook")
5774
+ \t.input(paymentWebhookEventSchema)
5775
+ \t.output(paymentWebhookOutputSchema)
5776
+ \t.run(async ({ ctx, input }) => {
5777
+ \t\tconst fingerprint = await createIdempotencyFingerprint({
5778
+ \t\t\ttype: input.type,
5779
+ \t\t\tdata: input.data,
5780
+ \t\t});
5781
+
5782
+ \t\treturn runIdempotently(ctx.ports.idempotency, {
5783
+ \t\t\tnamespace: "webhooks.payments",
5784
+ \t\t\tkey: input.id,
5785
+ \t\t\tscope: { provider: input.provider },
5786
+ \t\t\tfingerprint,
5787
+ \t\t\tttlSec: 60 * 60 * 24 * 30,
5788
+ \t\t\trun: () =>
5789
+ \t\t\t\tctx.ports.uow.transaction(async (tx) => {
5790
+ \t\t\t\t\tlet account: BillingAccount | null = null;
5791
+
5792
+ \t\t\t\t\tif (input.type === "checkout.session.completed") {
5793
+ \t\t\t\t\t\taccount = await applyCheckoutCompleted(
5794
+ \t\t\t\t\t\t\tinput,
5795
+ \t\t\t\t\t\t\ttx.billing.save,
5796
+ \t\t\t\t\t\t\ttx.billing.findByTenantId,
5797
+ \t\t\t\t\t\t);
5798
+ \t\t\t\t\t} else if (input.type.startsWith("customer.subscription.")) {
5799
+ \t\t\t\t\t\taccount = await applySubscriptionEvent(
5800
+ \t\t\t\t\t\t\tinput,
5801
+ \t\t\t\t\t\t\ttx.billing.save,
5802
+ \t\t\t\t\t\t\ttx.billing.findByCustomerId,
5803
+ \t\t\t\t\t\t\ttx.billing.findByTenantId,
5804
+ \t\t\t\t\t\t);
5805
+ \t\t\t\t\t} else if (input.type === "invoice.payment_succeeded") {
5806
+ \t\t\t\t\t\taccount = await applyInvoiceSucceeded(
5807
+ \t\t\t\t\t\t\tinput,
5808
+ \t\t\t\t\t\t\ttx.billing.save,
5809
+ \t\t\t\t\t\t\ttx.billing.findByCustomerId,
5810
+ \t\t\t\t\t\t);
5811
+ \t\t\t\t\t} else if (input.type === "invoice.payment_failed") {
5812
+ \t\t\t\t\t\taccount = await applyInvoiceFailed(
5813
+ \t\t\t\t\t\t\tinput,
5814
+ \t\t\t\t\t\t\ttx.billing.save,
5815
+ \t\t\t\t\t\t\ttx.billing.findByCustomerId,
5816
+ \t\t\t\t\t\t);
5817
+ \t\t\t\t\t}
5818
+
5819
+ \t\t\t\t\treturn {
5820
+ \t\t\t\t\t\treceived: true,
5821
+ \t\t\t\t\t\taccountUpdated: Boolean(account),
5822
+ \t\t\t\t\t};
5823
+ \t\t\t\t}),
5824
+ \t\t});
5825
+ \t});
5826
+ `;
5827
+ }
5828
+ function billingRoutesFile(config) {
5829
+ const routeFilePath = path.join(config.paths.features, "billing/routes.ts");
5830
+ return `import { defineRouteGroup } from "@beignet/next";
5831
+ import type { AppContext } from "${relativeModule(routeFilePath, config.paths.appContext)}";
5832
+ import * as billingContracts from "@/features/billing/contracts";
5833
+ import * as billingUseCases from "@/features/billing/use-cases";
5834
+
5835
+ export const billingRoutes = defineRouteGroup<AppContext>({
5836
+ \tname: "billing",
5837
+ \troutes: [
5838
+ \t\t{
5839
+ \t\t\tcontract: billingContracts.getBillingStatus,
5840
+ \t\t\tuseCase: billingUseCases.getBillingStatusUseCase,
5841
+ \t\t},
5842
+ \t\t{
5843
+ \t\t\tcontract: billingContracts.createCheckoutSession,
5844
+ \t\t\tuseCase: billingUseCases.createCheckoutSessionUseCase,
5845
+ \t\t},
5846
+ \t\t{
5847
+ \t\t\tcontract: billingContracts.createBillingPortalSession,
5848
+ \t\t\tuseCase: billingUseCases.createBillingPortalSessionUseCase,
5849
+ \t\t},
5850
+ \t],
5851
+ });
5852
+ `;
5853
+ }
5854
+ function billingTestFile(config) {
5855
+ return `import { describe, expect, it } from "bun:test";
5856
+ import { createUseCaseTester } from "@beignet/core/application";
5857
+ import { createAnonymousActor } from "@beignet/core/ports";
5858
+ import { createTestUserActor } from "@beignet/core/ports/testing";
5859
+ import {
5860
+ \tcreateTestContextFactory,
5861
+ \tcreateTestPorts,
5862
+ } from "@beignet/core/testing";
5863
+ import type { AppContext } from "${aliasModule(config.paths.appContext)}";
5864
+ import { appPorts } from "@/infra/app-ports";
5865
+ import type { AppTransactionPorts } from "@/ports";
5866
+ import { createBillingEntitlements } from "../entitlements";
5867
+ import type { BillingAccount, BillingRepository } from "../ports";
5868
+ import {
5869
+ \tcreateBillingPortalSessionUseCase,
5870
+ \tcreateCheckoutSessionUseCase,
5871
+ \tgetBillingStatusUseCase,
5872
+ \thandlePaymentWebhookUseCase,
5873
+ } from "../use-cases";
5874
+
5875
+ function createMemoryBillingRepository(): BillingRepository {
5876
+ \tconst accounts = new Map<string, BillingAccount>();
5877
+
5878
+ \treturn {
5879
+ \t\tasync findByTenantId(tenantId) {
5880
+ \t\t\treturn accounts.get(tenantId) ?? null;
5881
+ \t\t},
5882
+
5883
+ \t\tasync findByCustomerId(customerId, provider) {
5884
+ \t\t\treturn (
5885
+ \t\t\t\t[...accounts.values()].find(
5886
+ \t\t\t\t\t(account) =>
5887
+ \t\t\t\t\t\taccount.customerId === customerId &&
5888
+ \t\t\t\t\t\taccount.provider === provider,
5889
+ \t\t\t\t) ?? null
5890
+ \t\t\t);
5891
+ \t\t},
5892
+
5893
+ \t\tasync save(input) {
5894
+ \t\t\taccounts.set(input.tenantId, { ...input });
5895
+ \t\t\treturn input;
5896
+ \t\t},
5897
+ \t};
5898
+ }
5899
+
5900
+ function createHarness(options: { actor?: AppContext["actor"] } = {}) {
5901
+ \tconst billing = createMemoryBillingRepository();
5902
+ \tconst actor = options.actor ?? createTestUserActor("user_test");
5903
+ \tconst fixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
5904
+ \t\tbase: appPorts,
5905
+ \t\toverrides: {
5906
+ \t\t\tbilling,
5907
+ \t\t\tentitlements: createBillingEntitlements(billing),
5908
+ \t\t\tgate: appPorts.gate,
5909
+ \t\t},
5910
+ \t});
5911
+ \tconst createContext = createTestContextFactory<
5912
+ \t\tAppContext,
5913
+ \t\tAppContext["ports"]
5914
+ \t>({
5915
+ \t\tports: fixture.ports,
5916
+ \t\tactor,
5917
+ \t\tauth:
5918
+ \t\t\tactor.type === "user" && actor.id
5919
+ \t\t\t\t? { user: { id: actor.id }, session: { id: "session_test" } }
5920
+ \t\t\t\t: null,
5921
+ \t\trequestId: "test-request",
5922
+ \t\ttraceId: "test-trace",
5923
+ \t\ttenant: { id: "tenant_example" },
5924
+ \t});
5925
+
5926
+ \treturn {
5927
+ \t\tbilling,
5928
+ \t\tentitlements: fixture.ports.entitlements,
5929
+ \t\tpayments: fixture.payments,
5930
+ \t\ttester: createUseCaseTester<AppContext>(createContext),
5931
+ \t};
5932
+ }
5933
+
5934
+ describe("billing use cases", () => {
5935
+ \tit("requires an authenticated user to read billing status", async () => {
5936
+ \t\tconst harness = createHarness({ actor: createAnonymousActor() });
5937
+
5938
+ \t\tawait expect(
5939
+ \t\t\tharness.tester.run(getBillingStatusUseCase, {}),
5940
+ \t\t).rejects.toMatchObject({
5941
+ \t\t\tcode: "UNAUTHORIZED",
5942
+ \t\t});
5943
+ \t});
5944
+
5945
+ \tit("creates checkout sessions through the payments port and records billing state", async () => {
5946
+ \t\tconst harness = createHarness();
5947
+
5948
+ \t\tconst checkout = await harness.tester.run(createCheckoutSessionUseCase, {
5949
+ \t\t\tplan: "team",
5950
+ \t\t});
5951
+
5952
+ \t\texpect(checkout.sessionId).toMatch(/^checkout_/);
5953
+ \t\texpect(checkout.url).toContain("/checkout/");
5954
+ \t\texpect(harness.payments.checkoutSessions).toHaveLength(1);
5955
+ \t\texpect(harness.payments.checkoutSessions[0]?.input).toMatchObject({
5956
+ \t\t\tmode: "subscription",
5957
+ \t\t\tlineItems: [{ priceId: "price_team_example", quantity: 1 }],
5958
+ \t\t\tclientReferenceId: "tenant_example",
5959
+ \t\t\tmetadata: {
5960
+ \t\t\t\ttenantId: "tenant_example",
5961
+ \t\t\t\tplan: "team",
5962
+ \t\t\t},
5963
+ \t\t});
5964
+ \t\texpect(
5965
+ \t\t\tharness.payments.checkoutSessions[0]?.input.idempotencyKey,
5966
+ \t\t).toBe(undefined);
5967
+ \t\tawait expect(
5968
+ \t\t\tharness.billing.findByTenantId("tenant_example"),
5969
+ \t\t).resolves.toMatchObject({
5970
+ \t\t\tcheckoutSessionId: checkout.sessionId,
5971
+ \t\t\tplan: "team",
5972
+ \t\t\tstatus: "inactive",
5973
+ \t\t});
5974
+ \t});
5975
+
5976
+ \tit("creates billing portal sessions for tenants with provider customers", async () => {
5977
+ \t\tconst harness = createHarness();
5978
+ \t\tawait harness.billing.save({
5979
+ \t\t\ttenantId: "tenant_example",
5980
+ \t\t\tprovider: "memory",
5981
+ \t\t\tplan: "team",
5982
+ \t\t\tstatus: "active",
5983
+ \t\t\tcustomerId: "cus_123",
5984
+ \t\t\tupdatedAt: new Date(0).toISOString(),
5985
+ \t\t});
5986
+
5987
+ \t\tconst portal = await harness.tester.run(
5988
+ \t\t\tcreateBillingPortalSessionUseCase,
5989
+ \t\t\t{},
5990
+ \t\t);
5991
+
5992
+ \t\texpect(portal.url).toContain("/portal/");
5993
+ \t\texpect(harness.payments.billingPortalSessions).toHaveLength(1);
5994
+ \t\texpect(harness.payments.billingPortalSessions[0]?.input.customerId).toBe(
5995
+ \t\t\t"cus_123",
5996
+ \t\t);
5997
+ \t});
5998
+
5999
+ \tit.each(["active", "trialing"] as const)(
6000
+ \t\t"grants billing entitlements for %s accounts",
6001
+ \t\tasync (status) => {
6002
+ \t\t\tconst harness = createHarness();
6003
+ \t\t\tawait harness.billing.save({
6004
+ \t\t\t\ttenantId: "tenant_example",
6005
+ \t\t\t\tprovider: "stripe",
6006
+ \t\t\t\tplan: "team",
6007
+ \t\t\t\tstatus,
6008
+ \t\t\t\tupdatedAt: new Date(0).toISOString(),
6009
+ \t\t\t});
6010
+
6011
+ \t\t\tawait expect(
6012
+ \t\t\t\tharness.entitlements.can({
6013
+ \t\t\t\t\tentitlement: "todos.create",
6014
+ \t\t\t\t\tsubject: { type: "tenant", id: "tenant_example" },
6015
+ \t\t\t\t}),
6016
+ \t\t\t).resolves.toBe(true);
6017
+ \t\t},
6018
+ \t);
6019
+
6020
+ \tit.each(["inactive", "past_due", "canceled"] as const)(
6021
+ \t\t"denies billing entitlements for %s accounts",
6022
+ \t\tasync (status) => {
6023
+ \t\t\tconst harness = createHarness();
6024
+ \t\t\tawait harness.billing.save({
6025
+ \t\t\t\ttenantId: "tenant_example",
6026
+ \t\t\t\tprovider: "stripe",
6027
+ \t\t\t\tplan: "team",
6028
+ \t\t\t\tstatus,
6029
+ \t\t\t\tupdatedAt: new Date(0).toISOString(),
6030
+ \t\t\t});
6031
+
6032
+ \t\t\tawait expect(
6033
+ \t\t\t\tharness.entitlements.inspect({
6034
+ \t\t\t\t\tentitlement: "todos.create",
6035
+ \t\t\t\t\tsubject: { type: "tenant", id: "tenant_example" },
6036
+ \t\t\t\t}),
6037
+ \t\t\t).resolves.toMatchObject({
6038
+ \t\t\t\tallowed: false,
6039
+ \t\t\t\tcode: "BILLING_INACTIVE",
6040
+ \t\t\t});
6041
+ \t\t},
6042
+ \t);
6043
+
6044
+ \tit("handles checkout and subscription webhooks idempotently", async () => {
6045
+ \t\tconst harness = createHarness();
6046
+ \t\tconst checkoutEvent = {
6047
+ \t\t\tid: "evt_checkout_completed",
6048
+ \t\t\ttype: "checkout.session.completed",
6049
+ \t\t\tprovider: "stripe",
6050
+ \t\t\tdata: {
6051
+ \t\t\t\tid: "cs_123",
6052
+ \t\t\t\tclient_reference_id: "tenant_example",
6053
+ \t\t\t\tcustomer: "cus_123",
6054
+ \t\t\t\tsubscription: "sub_123",
6055
+ \t\t\t\tmetadata: {
6056
+ \t\t\t\t\ttenantId: "tenant_example",
6057
+ \t\t\t\t\tplan: "team",
6058
+ \t\t\t\t},
6059
+ \t\t\t},
6060
+ \t\t};
6061
+ \t\tconst subscriptionEvent = {
6062
+ \t\t\tid: "evt_subscription_updated",
6063
+ \t\t\ttype: "customer.subscription.updated",
6064
+ \t\t\tprovider: "stripe",
6065
+ \t\t\tdata: {
6066
+ \t\t\t\tid: "sub_123",
6067
+ \t\t\t\tcustomer: "cus_123",
6068
+ \t\t\t\tstatus: "active",
6069
+ \t\t\t\tcurrent_period_end: 1_799_798_400,
6070
+ \t\t\t\tcancel_at_period_end: true,
6071
+ \t\t\t},
6072
+ \t\t};
6073
+
6074
+ \t\tawait expect(
6075
+ \t\t\tharness.tester.run(handlePaymentWebhookUseCase, checkoutEvent),
6076
+ \t\t).resolves.toEqual({ received: true, accountUpdated: true });
6077
+ \t\tawait expect(
6078
+ \t\t\tharness.tester.run(handlePaymentWebhookUseCase, checkoutEvent),
6079
+ \t\t).resolves.toEqual({ received: true, accountUpdated: true });
6080
+ \t\tawait expect(
6081
+ \t\t\tharness.tester.run(handlePaymentWebhookUseCase, subscriptionEvent),
6082
+ \t\t).resolves.toEqual({ received: true, accountUpdated: true });
6083
+ \t\tawait expect(
6084
+ \t\t\tharness.tester.run(getBillingStatusUseCase, {}),
6085
+ \t\t).resolves.toMatchObject({
6086
+ \t\t\ttenantId: "tenant_example",
6087
+ \t\t\tactive: true,
6088
+ \t\t\tstatus: "active",
6089
+ \t\t\tcustomerId: "cus_123",
6090
+ \t\t\tsubscriptionId: "sub_123",
6091
+ \t\t\tcurrentPeriodEnd: "2027-01-13T00:00:00.000Z",
6092
+ \t\t\tcancelAtPeriodEnd: true,
6093
+ \t\t\tcheckoutSessionId: "cs_123",
6094
+ \t\t\tlastEventId: "evt_subscription_updated",
6095
+ \t\t});
6096
+ \t});
6097
+
6098
+ \tit("marks active subscriptions as past due when invoice payment fails", async () => {
6099
+ \t\tconst harness = createHarness();
6100
+ \t\tawait harness.billing.save({
6101
+ \t\t\ttenantId: "tenant_example",
6102
+ \t\t\tprovider: "stripe",
6103
+ \t\t\tplan: "team",
6104
+ \t\t\tstatus: "active",
6105
+ \t\t\tcustomerId: "cus_123",
6106
+ \t\t\tsubscriptionId: "sub_123",
6107
+ \t\t\tupdatedAt: new Date(0).toISOString(),
6108
+ \t\t});
6109
+
6110
+ \t\tawait expect(
6111
+ \t\t\tharness.tester.run(handlePaymentWebhookUseCase, {
6112
+ \t\t\t\tid: "evt_invoice_failed",
6113
+ \t\t\t\ttype: "invoice.payment_failed",
6114
+ \t\t\t\tprovider: "stripe",
6115
+ \t\t\t\tdata: {
6116
+ \t\t\t\t\tcustomer: "cus_123",
6117
+ \t\t\t\t\tsubscription: "sub_123",
6118
+ \t\t\t\t},
6119
+ \t\t\t}),
6120
+ \t\t).resolves.toEqual({ received: true, accountUpdated: true });
6121
+ \t\tawait expect(
6122
+ \t\t\tharness.billing.findByTenantId("tenant_example"),
6123
+ \t\t).resolves.toMatchObject({
6124
+ \t\t\tstatus: "past_due",
6125
+ \t\t\tlastEventId: "evt_invoice_failed",
6126
+ \t\t});
6127
+ \t});
6128
+
6129
+ \tit("preserves pending cancellation when an invoice succeeds", async () => {
6130
+ \t\tconst harness = createHarness();
6131
+ \t\tawait harness.billing.save({
6132
+ \t\t\ttenantId: "tenant_example",
6133
+ \t\t\tprovider: "stripe",
6134
+ \t\t\tplan: "team",
6135
+ \t\t\tstatus: "active",
6136
+ \t\t\tcustomerId: "cus_123",
6137
+ \t\t\tsubscriptionId: "sub_123",
6138
+ \t\t\tcancelAtPeriodEnd: true,
6139
+ \t\t\tupdatedAt: new Date(0).toISOString(),
6140
+ \t\t});
6141
+
6142
+ \t\tawait expect(
6143
+ \t\t\tharness.tester.run(handlePaymentWebhookUseCase, {
6144
+ \t\t\t\tid: "evt_invoice_succeeded",
6145
+ \t\t\t\ttype: "invoice.payment_succeeded",
6146
+ \t\t\t\tprovider: "stripe",
6147
+ \t\t\t\tdata: {
6148
+ \t\t\t\t\tcustomer: "cus_123",
6149
+ \t\t\t\t\tsubscription: "sub_123",
6150
+ \t\t\t\t},
6151
+ \t\t\t}),
6152
+ \t\t).resolves.toEqual({ received: true, accountUpdated: true });
6153
+ \t\tawait expect(
6154
+ \t\t\tharness.billing.findByTenantId("tenant_example"),
6155
+ \t\t).resolves.toMatchObject({
6156
+ \t\t\tstatus: "active",
6157
+ \t\t\tcancelAtPeriodEnd: true,
6158
+ \t\t\tlastEventId: "evt_invoice_succeeded",
6159
+ \t\t});
6160
+ \t});
6161
+ });
6162
+ `;
6163
+ }
6164
+ function billingDrizzleSchemaFile(database) {
6165
+ const dialect = drizzleDialects[database];
6166
+ const idColumn = database === "mysql"
6167
+ ? (columnName) => `varchar("${columnName}", { length: 255 })`
6168
+ : (columnName) => `text("${columnName}")`;
6169
+ const imports = database === "mysql"
6170
+ ? "mysqlTable, uniqueIndex, varchar"
6171
+ : `${dialect.tableFunction}, text, uniqueIndex`;
6172
+ return `import type { BillingPlan, BillingStatus } from "@/features/billing/schemas";
6173
+ import { ${imports} } from "${dialect.columnModule}";
6174
+
6175
+ export const billingAccounts = ${dialect.tableFunction}(
6176
+ \t"billing_accounts",
6177
+ \t{
6178
+ \t\ttenantId: ${idColumn("tenant_id")}.primaryKey(),
6179
+ \t\tplan: ${idColumn("plan")}.$type<BillingPlan>().notNull(),
6180
+ \t\tstatus: ${idColumn("status")}.$type<BillingStatus>().notNull(),
6181
+ \t\tprovider: ${idColumn("provider")}.notNull(),
6182
+ \t\tcustomerId: ${idColumn("customer_id")},
6183
+ \t\tsubscriptionId: ${idColumn("subscription_id")},
6184
+ \t\tcurrentPeriodEnd: ${idColumn("current_period_end")},
6185
+ \t\tcancelAtPeriodEnd: ${idColumn("cancel_at_period_end")}.$type<"true" | "false">(),
6186
+ \t\tcheckoutSessionId: ${idColumn("checkout_session_id")},
6187
+ \t\tlastEventId: ${idColumn("last_event_id")},
6188
+ \t\tupdatedAt: ${idColumn("updated_at")}.notNull(),
6189
+ \t},
6190
+ \t(table) => ({
6191
+ \t\tproviderCustomerUnique: uniqueIndex(
6192
+ \t\t\t"billing_accounts_provider_customer_unique",
6193
+ \t\t).on(table.provider, table.customerId),
6194
+ \t\tproviderSubscriptionUnique: uniqueIndex(
6195
+ \t\t\t"billing_accounts_provider_subscription_unique",
6196
+ \t\t).on(table.provider, table.subscriptionId),
6197
+ \t}),
6198
+ );
6199
+ `;
6200
+ }
6201
+ function billingDrizzleRepositoryFile(config, database) {
6202
+ const dialect = drizzleDialects[database];
6203
+ const billingPortPath = path.join(config.paths.features, "billing/ports.ts");
6204
+ const upsert = database === "mysql"
6205
+ ? `\t\t\tconst row = toRow(input);
6206
+ \t\t\tawait db
6207
+ \t\t\t\t.insert(schema.billingAccounts)
6208
+ \t\t\t\t.values(row)
6209
+ \t\t\t\t.onDuplicateKeyUpdate({ set: row });
6210
+
6211
+ \t\t\tconst [saved] = await db
6212
+ \t\t\t\t.select()
6213
+ \t\t\t\t.from(schema.billingAccounts)
6214
+ \t\t\t\t.where(eq(schema.billingAccounts.tenantId, input.tenantId))
6215
+ \t\t\t\t.limit(1);
6216
+
6217
+ \t\t\tif (!saved) {
6218
+ \t\t\t\tthrow new Error("Billing account save did not return a row.");
6219
+ \t\t\t}
6220
+
6221
+ \t\t\treturn toBillingAccount(saved);`
6222
+ : `\t\t\tconst [row] = await db
6223
+ \t\t\t\t.insert(schema.billingAccounts)
6224
+ \t\t\t\t.values(toRow(input))
6225
+ \t\t\t\t.onConflictDoUpdate({
6226
+ \t\t\t\t\ttarget: schema.billingAccounts.tenantId,
6227
+ \t\t\t\t\tset: toRow(input),
6228
+ \t\t\t\t})
6229
+ \t\t\t\t.returning();
6230
+
6231
+ \t\t\tif (!row) {
6232
+ \t\t\t\tthrow new Error("Billing account save did not return a row.");
6233
+ \t\t\t}
6234
+
6235
+ \t\t\treturn toBillingAccount(row);`;
6236
+ return `import type { ${dialect.dbTypeName} } from "@beignet/provider-db-drizzle/${dialect.subpath}";
6237
+ import { and, eq } from "drizzle-orm";
6238
+ import type {
6239
+ \tBillingAccount,
6240
+ \tBillingRepository,
6241
+ \tSaveBillingAccountInput,
6242
+ } from "${aliasModule(billingPortPath)}";
6243
+ import * as schema from "@/infra/db/schema";
6244
+
6245
+ type BillingAccountRow = typeof schema.billingAccounts.$inferSelect;
6246
+
6247
+ function toBillingAccount(row: BillingAccountRow): BillingAccount {
6248
+ \treturn {
6249
+ \t\ttenantId: row.tenantId,
6250
+ \t\tplan: row.plan,
6251
+ \t\tstatus: row.status,
6252
+ \t\tprovider: row.provider,
6253
+ \t\t...(row.customerId ? { customerId: row.customerId } : {}),
6254
+ \t\t...(row.subscriptionId ? { subscriptionId: row.subscriptionId } : {}),
6255
+ \t\t...(row.currentPeriodEnd ? { currentPeriodEnd: row.currentPeriodEnd } : {}),
6256
+ \t\t...(row.cancelAtPeriodEnd
6257
+ \t\t\t? { cancelAtPeriodEnd: row.cancelAtPeriodEnd === "true" }
6258
+ \t\t\t: {}),
6259
+ \t\t...(row.checkoutSessionId
6260
+ \t\t\t? { checkoutSessionId: row.checkoutSessionId }
6261
+ \t\t\t: {}),
6262
+ \t\t...(row.lastEventId ? { lastEventId: row.lastEventId } : {}),
6263
+ \t\tupdatedAt: row.updatedAt,
6264
+ \t};
6265
+ }
6266
+
6267
+ function toRow(input: SaveBillingAccountInput) {
6268
+ \treturn {
6269
+ \t\ttenantId: input.tenantId,
6270
+ \t\tplan: input.plan,
6271
+ \t\tstatus: input.status,
6272
+ \t\tprovider: input.provider,
6273
+ \t\tcustomerId: input.customerId ?? null,
6274
+ \t\tsubscriptionId: input.subscriptionId ?? null,
6275
+ \t\tcurrentPeriodEnd: input.currentPeriodEnd ?? null,
6276
+ \t\tcancelAtPeriodEnd:
6277
+ \t\t\tinput.cancelAtPeriodEnd === undefined
6278
+ \t\t\t\t? null
6279
+ \t\t\t\t: input.cancelAtPeriodEnd
6280
+ \t\t\t\t\t? ("true" as const)
6281
+ \t\t\t\t\t: ("false" as const),
6282
+ \t\tcheckoutSessionId: input.checkoutSessionId ?? null,
6283
+ \t\tlastEventId: input.lastEventId ?? null,
6284
+ \t\tupdatedAt: input.updatedAt,
6285
+ \t};
6286
+ }
6287
+
6288
+ export function createDrizzleBillingRepository(
6289
+ \tdb: ${dialect.dbTypeName}<typeof schema>,
6290
+ ): BillingRepository {
6291
+ \treturn {
6292
+ \t\tasync findByTenantId(tenantId) {
6293
+ \t\t\tconst [row] = await db
6294
+ \t\t\t\t.select()
6295
+ \t\t\t\t.from(schema.billingAccounts)
6296
+ \t\t\t\t.where(eq(schema.billingAccounts.tenantId, tenantId))
6297
+ \t\t\t\t.limit(1);
6298
+
6299
+ \t\t\treturn row ? toBillingAccount(row) : null;
6300
+ \t\t},
6301
+
6302
+ \t\tasync findByCustomerId(customerId, provider) {
6303
+ \t\t\tconst rows = await db
6304
+ \t\t\t\t.select()
6305
+ \t\t\t\t.from(schema.billingAccounts)
6306
+ \t\t\t\t.where(
6307
+ \t\t\t\t\tand(
6308
+ \t\t\t\t\t\teq(schema.billingAccounts.customerId, customerId),
6309
+ \t\t\t\t\t\teq(schema.billingAccounts.provider, provider),
6310
+ \t\t\t\t\t),
6311
+ \t\t\t\t)
6312
+ \t\t\t\t.limit(2);
6313
+
6314
+ \t\t\tif (rows.length > 1) {
6315
+ \t\t\t\tthrow new Error(
6316
+ \t\t\t\t\t\`Multiple billing accounts found for provider "\${provider}" customer "\${customerId}".\`,
6317
+ \t\t\t\t);
6318
+ \t\t\t}
6319
+
6320
+ \t\t\treturn rows[0] ? toBillingAccount(rows[0]) : null;
6321
+ \t\t},
6322
+
6323
+ \t\tasync save(input) {
6324
+ ${upsert}
6325
+ \t\t},
6326
+ \t};
6327
+ }
6328
+ `;
6329
+ }
6330
+ function billingWebhookRouteFile() {
6331
+ return `import { createPaymentWebhookRoute } from "@beignet/next";
6332
+ import { handlePaymentWebhookUseCase } from "@/features/billing/use-cases";
6333
+ import { server } from "@/server";
6334
+
6335
+ export const runtime = "nodejs";
6336
+
6337
+ export const { POST } = createPaymentWebhookRoute({
6338
+ \tserver,
6339
+ \thandle: async ({ ctx, event }) => {
6340
+ \t\tawait handlePaymentWebhookUseCase.run({ ctx, input: event });
6341
+ \t\treturn {
6342
+ \t\t\tstatus: 200,
6343
+ \t\t\tbody: {
6344
+ \t\t\t\treceived: true,
6345
+ \t\t\t},
6346
+ \t\t};
6347
+ \t},
6348
+ });
6349
+ `;
6350
+ }
4743
6351
  function featureUiComponentFile(names, config) {
4744
6352
  return `"use client";
4745
6353