@beignet/cli 0.0.12 → 0.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +24 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -1
- package/dist/inspect.js +174 -0
- package/dist/inspect.js.map +1 -1
- package/dist/lint.d.ts.map +1 -1
- package/dist/lint.js +22 -3
- package/dist/lint.js.map +1 -1
- package/dist/make.d.ts +11 -0
- package/dist/make.d.ts.map +1 -1
- package/dist/make.js +1608 -0
- package/dist/make.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +14 -2
- package/dist/mcp.js.map +1 -1
- package/dist/templates/agents.d.ts +4 -3
- package/dist/templates/agents.d.ts.map +1 -1
- package/dist/templates/agents.js +4 -3
- package/dist/templates/agents.js.map +1 -1
- package/dist/templates/index.d.ts.map +1 -1
- package/dist/templates/index.js +1 -0
- package/dist/templates/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +35 -0
- package/src/inspect.ts +361 -0
- package/src/lint.ts +28 -3
- package/src/make.ts +1823 -0
- package/src/mcp.ts +16 -2
- package/src/templates/agents.ts +4 -3
- package/src/templates/index.ts +1 -0
package/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
|
|