@somewhatintelligent/stripe 0.0.1-beta.1783705886.297c5be
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/package.json +33 -0
- package/src/catalog.ts +97 -0
- package/src/cli.ts +138 -0
- package/src/engine.ts +300 -0
- package/src/gate.ts +14 -0
- package/src/index.ts +20 -0
- package/src/render.ts +43 -0
- package/src/version.gen.ts +7 -0
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@somewhatintelligent/stripe",
|
|
3
|
+
"version": "0.0.1-beta.1783705886.297c5be",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/apostolos-geyer/platform.git",
|
|
12
|
+
"directory": "packages/stripe"
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./src/index.ts",
|
|
16
|
+
"./cli": "./src/cli.ts"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"test": "vitest run"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"stripe": "^20.4.1"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^25.5.2",
|
|
30
|
+
"typescript": "5.9.2",
|
|
31
|
+
"vitest": "^4.1.10"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The billing catalog: the consumer's declaration of every Stripe product
|
|
3
|
+
* and price the platform manages — the source of truth the engine
|
|
4
|
+
* (./engine.ts) reconciles a real Stripe account against, and the source
|
|
5
|
+
* the generated id module (./render.ts) is derived from.
|
|
6
|
+
*
|
|
7
|
+
* Identity model: every managed resource carries two metadata fields —
|
|
8
|
+
* `managed_by` (the catalog's `managedBy` value, so list() calls can
|
|
9
|
+
* filter to "ours" without touching unrelated resources on the account)
|
|
10
|
+
* and `config_key` (the OBJECT KEY from `products`/`prices`, never the
|
|
11
|
+
* display name). The key is the stable identity fetch/sync/validate match
|
|
12
|
+
* on, so renaming a product's `name` updates the existing Stripe resource
|
|
13
|
+
* instead of orphaning it.
|
|
14
|
+
*
|
|
15
|
+
* Prices are immutable in Stripe once created: changing `amount` or
|
|
16
|
+
* `interval` for an existing key does not update the live price — the
|
|
17
|
+
* engine reports it as drift. Bump a price by adding a new key (e.g.
|
|
18
|
+
* `member_monthly_v2`) and switching consumers to it; retire a key by
|
|
19
|
+
* moving it to `archived`, never by deleting it (see ./engine.ts).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const MANAGED_BY_KEY = "managed_by" as const;
|
|
23
|
+
export const CONFIG_KEY = "config_key" as const;
|
|
24
|
+
|
|
25
|
+
export type BillingInterval = "month" | "year";
|
|
26
|
+
|
|
27
|
+
export interface ProductConfig {
|
|
28
|
+
name: string;
|
|
29
|
+
description: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PriceConfig<PK extends string = string> {
|
|
33
|
+
/** Key of the product this price belongs to. */
|
|
34
|
+
product: PK;
|
|
35
|
+
/** Unit amount in the currency's minor unit (cents). */
|
|
36
|
+
amount: number;
|
|
37
|
+
interval: BillingInterval;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BillingCatalog<
|
|
41
|
+
P extends Record<string, ProductConfig> = Record<string, ProductConfig>,
|
|
42
|
+
R extends Record<string, PriceConfig<Extract<keyof P, string>>> = Record<
|
|
43
|
+
string,
|
|
44
|
+
PriceConfig<Extract<keyof P, string>>
|
|
45
|
+
>,
|
|
46
|
+
> {
|
|
47
|
+
/**
|
|
48
|
+
* `metadata.managed_by` marker for every resource this catalog owns
|
|
49
|
+
* (e.g. `"acme"`). Scopes the engine to this catalog's resources; two
|
|
50
|
+
* catalogs with different markers can share one Stripe account.
|
|
51
|
+
*/
|
|
52
|
+
managedBy: string;
|
|
53
|
+
/** ISO currency code for every price (e.g. `"usd"`, `"cad"`). */
|
|
54
|
+
currency: string;
|
|
55
|
+
products: P;
|
|
56
|
+
prices: R;
|
|
57
|
+
/**
|
|
58
|
+
* Managed resources that intentionally remain live in Stripe after
|
|
59
|
+
* leaving the active config (immutable prices, historic products needed
|
|
60
|
+
* for invoices). The explicit escape hatch for validate's otherwise
|
|
61
|
+
* FATAL orphan detection.
|
|
62
|
+
*/
|
|
63
|
+
archived?: {
|
|
64
|
+
products?: readonly string[];
|
|
65
|
+
prices?: readonly string[];
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Identity helper for declaring a catalog with full key inference:
|
|
71
|
+
* price→product references are checked at the type level AND at runtime
|
|
72
|
+
* (the runtime check covers dynamically built catalogs).
|
|
73
|
+
*/
|
|
74
|
+
export function defineBillingCatalog<
|
|
75
|
+
const P extends Record<string, ProductConfig>,
|
|
76
|
+
const R extends Record<string, PriceConfig<Extract<keyof P, string>>>,
|
|
77
|
+
>(catalog: BillingCatalog<P, R>): BillingCatalog<P, R> {
|
|
78
|
+
if (!catalog.managedBy) {
|
|
79
|
+
throw new Error("defineBillingCatalog: managedBy must be a non-empty marker string");
|
|
80
|
+
}
|
|
81
|
+
if (!catalog.currency) {
|
|
82
|
+
throw new Error("defineBillingCatalog: currency is required");
|
|
83
|
+
}
|
|
84
|
+
for (const [key, price] of Object.entries(catalog.prices)) {
|
|
85
|
+
if (!(price.product in catalog.products)) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`defineBillingCatalog: price "${key}" references unknown product "${price.product}"`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (!Number.isInteger(price.amount) || price.amount <= 0) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`defineBillingCatalog: price "${key}" amount must be a positive integer (minor units)`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return catalog;
|
|
97
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consumer-wired billing CLI. Lives on its own subpath
|
|
3
|
+
* (`@somewhatintelligent/stripe/cli`) because it touches `node:fs` and the Stripe
|
|
4
|
+
* SDK — worker bundles import only the package root. The consumer's
|
|
5
|
+
* script is the whole integration:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* #!/usr/bin/env bun
|
|
9
|
+
* import { runBillingCli } from "@somewhatintelligent/stripe/cli";
|
|
10
|
+
* import { catalog } from "../src/billing.catalog";
|
|
11
|
+
* process.exit(await runBillingCli(catalog, { generatedPath: "src/billing.gen.ts" }));
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* Commands:
|
|
15
|
+
* - `fetch` read-only id lookup → writes the generated module. WITHOUT
|
|
16
|
+
* `STRIPE_SECRET_KEY` it writes a typed stub (empty ids) and
|
|
17
|
+
* exits 0 — the offline/agent/CI path; `--strict` makes
|
|
18
|
+
* missing resources (or a missing key) fatal instead.
|
|
19
|
+
* - `sync` idempotent create/update, then writes the generated module.
|
|
20
|
+
* Requires the key. `--dry-run` reports without mutating.
|
|
21
|
+
* - `validate` read-only drift/orphan check. Requires the key. Orphans
|
|
22
|
+
* (live managed resources absent from the catalog) are FATAL
|
|
23
|
+
* unless listed in `catalog.archived` — this is the CD guard.
|
|
24
|
+
*/
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import type { BillingCatalog } from "./catalog";
|
|
28
|
+
import {
|
|
29
|
+
fetchCatalog,
|
|
30
|
+
syncCatalog,
|
|
31
|
+
validateCatalog,
|
|
32
|
+
type StripeCatalogClient,
|
|
33
|
+
} from "./engine";
|
|
34
|
+
import { renderCatalogModule } from "./render";
|
|
35
|
+
|
|
36
|
+
export interface RunBillingCliOptions {
|
|
37
|
+
/** Where the generated id module is written, relative to cwd. */
|
|
38
|
+
generatedPath: string;
|
|
39
|
+
/** Defaults to `process.argv.slice(2)`. */
|
|
40
|
+
argv?: string[];
|
|
41
|
+
/** Defaults to `process.env.STRIPE_SECRET_KEY`. */
|
|
42
|
+
secretKey?: string;
|
|
43
|
+
/** Injectable for tests; defaults to constructing the real SDK client. */
|
|
44
|
+
client?: StripeCatalogClient;
|
|
45
|
+
/** Defaults to console. */
|
|
46
|
+
log?: (line: string) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function runBillingCli(
|
|
50
|
+
catalog: BillingCatalog,
|
|
51
|
+
opts: RunBillingCliOptions,
|
|
52
|
+
): Promise<number> {
|
|
53
|
+
const argv = opts.argv ?? process.argv.slice(2);
|
|
54
|
+
const log = opts.log ?? ((line: string) => console.log(line));
|
|
55
|
+
// The command is REQUIRED: defaulting would let a dropped positional
|
|
56
|
+
// (e.g. `billing.ts --dry-run` in a CD lane) silently downgrade an
|
|
57
|
+
// intended sync/validate into a read-only fetch that exits 0.
|
|
58
|
+
const command = argv.find((a) => !a.startsWith("--"));
|
|
59
|
+
if (!command) {
|
|
60
|
+
log("missing command — expected fetch | sync | validate");
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
const strict = argv.includes("--strict");
|
|
64
|
+
const dryRun = argv.includes("--dry-run");
|
|
65
|
+
const secretKey = opts.secretKey ?? process.env.STRIPE_SECRET_KEY;
|
|
66
|
+
const generatedPath = path.resolve(opts.generatedPath);
|
|
67
|
+
|
|
68
|
+
const write = (content: string) => {
|
|
69
|
+
fs.mkdirSync(path.dirname(generatedPath), { recursive: true });
|
|
70
|
+
fs.writeFileSync(generatedPath, content);
|
|
71
|
+
log(`wrote ${opts.generatedPath}`);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (!secretKey) {
|
|
75
|
+
if (command === "fetch" && !strict) {
|
|
76
|
+
// Offline mode: a keyless clone still typechecks — the module has
|
|
77
|
+
// the right key set with empty ids, and never imports the SDK.
|
|
78
|
+
log("STRIPE_SECRET_KEY not set — writing offline stub (empty ids)");
|
|
79
|
+
write(renderCatalogModule(catalog, { note: "offline stub — no STRIPE_SECRET_KEY" }));
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
log(`STRIPE_SECRET_KEY is required for ${strict ? "strict " : ""}${command}`);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const client =
|
|
87
|
+
opts.client ??
|
|
88
|
+
// Lazy import: the SDK loads only on the keyed path.
|
|
89
|
+
(new (await import("stripe")).default(secretKey) as unknown as StripeCatalogClient);
|
|
90
|
+
|
|
91
|
+
const liveMode = secretKey.startsWith("sk_live_");
|
|
92
|
+
if (liveMode) log("WARNING: running against a LIVE Stripe account");
|
|
93
|
+
|
|
94
|
+
switch (command) {
|
|
95
|
+
case "fetch": {
|
|
96
|
+
const { ids, missing } = await fetchCatalog(client, catalog);
|
|
97
|
+
for (const m of missing) log(`missing: ${m} (run sync to create)`);
|
|
98
|
+
if (missing.length > 0 && strict) {
|
|
99
|
+
log("strict fetch failed — resources missing");
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
write(
|
|
103
|
+
renderCatalogModule(catalog, {
|
|
104
|
+
ids,
|
|
105
|
+
note: `${liveMode ? "LIVE" : "test"} mode${missing.length ? " — SOME IDS MISSING, run sync" : ""}`,
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
case "sync": {
|
|
111
|
+
const report = await syncCatalog(client, catalog, { dryRun });
|
|
112
|
+
for (const a of report.actions) {
|
|
113
|
+
log(`${a.kind}: ${a.key}${a.id ? ` (${a.id})` : ""}${a.detail ? ` — ${a.detail}` : ""}`);
|
|
114
|
+
}
|
|
115
|
+
if (dryRun) {
|
|
116
|
+
log("dry run — no writes");
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
write(renderCatalogModule(catalog, { ids: report.ids, note: `${liveMode ? "LIVE" : "test"} mode` }));
|
|
120
|
+
// Drift is a warning on sync (validate is the gate) — but surface it.
|
|
121
|
+
if (report.drift.length > 0) log(`NOTE: ${report.drift.length} price(s) drifted — see validate`);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
case "validate": {
|
|
125
|
+
const report = await validateCatalog(client, catalog);
|
|
126
|
+
for (const issue of report.issues) {
|
|
127
|
+
log(`${issue.kind}: ${issue.resource} ${issue.key} — ${issue.detail}`);
|
|
128
|
+
}
|
|
129
|
+
for (const seen of report.archivedSeen) log(`archived (ok): ${seen}`);
|
|
130
|
+
log(report.ok ? "validate: ok" : `validate: FAILED (${report.issues.length} issue(s))`);
|
|
131
|
+
return report.ok ? 0 : 1;
|
|
132
|
+
}
|
|
133
|
+
default: {
|
|
134
|
+
log(`unknown command "${command}" — expected fetch | sync | validate`);
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog reconciliation engine — pure functions over an injected Stripe
|
|
3
|
+
* client, so the whole engine tests offline against a fake and the CLI
|
|
4
|
+
* (./cli.ts) is a thin argv wrapper. Matching is by
|
|
5
|
+
* `metadata.config_key` within `metadata.managed_by === catalog.managedBy`
|
|
6
|
+
* (see ./catalog.ts for the identity model).
|
|
7
|
+
*/
|
|
8
|
+
import { CONFIG_KEY, MANAGED_BY_KEY, type BillingCatalog } from "./catalog";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The slice of the Stripe SDK the engine touches. Structural so tests
|
|
12
|
+
* inject a fake; `new Stripe(key)` satisfies it.
|
|
13
|
+
*/
|
|
14
|
+
export interface StripeCatalogClient {
|
|
15
|
+
products: {
|
|
16
|
+
list(params: { limit: number }): Promise<{ data: StripeProductRecord[] }>;
|
|
17
|
+
create(params: {
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
metadata: Record<string, string>;
|
|
21
|
+
}): Promise<{ id: string }>;
|
|
22
|
+
update(id: string, params: { name: string; description: string }): Promise<unknown>;
|
|
23
|
+
};
|
|
24
|
+
prices: {
|
|
25
|
+
list(params: { limit: number }): Promise<{ data: StripePriceRecord[] }>;
|
|
26
|
+
create(params: {
|
|
27
|
+
product: string;
|
|
28
|
+
unit_amount: number;
|
|
29
|
+
currency: string;
|
|
30
|
+
recurring: { interval: "month" | "year" };
|
|
31
|
+
metadata: Record<string, string>;
|
|
32
|
+
}): Promise<{ id: string }>;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface StripeProductRecord {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
description: string | null;
|
|
40
|
+
metadata: Record<string, string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface StripePriceRecord {
|
|
44
|
+
id: string;
|
|
45
|
+
unit_amount: number | null;
|
|
46
|
+
recurring: { interval: string } | null;
|
|
47
|
+
metadata: Record<string, string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CatalogIds {
|
|
51
|
+
products: Record<string, string>;
|
|
52
|
+
prices: Record<string, string>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// list() caps at 100 per page; a subscription catalog past that is a smell
|
|
56
|
+
// long before it is a pagination problem, so one page is deliberate.
|
|
57
|
+
const LIST_LIMIT = 100;
|
|
58
|
+
|
|
59
|
+
async function listManaged(client: StripeCatalogClient, managedBy: string) {
|
|
60
|
+
const [products, prices] = await Promise.all([
|
|
61
|
+
client.products.list({ limit: LIST_LIMIT }),
|
|
62
|
+
client.prices.list({ limit: LIST_LIMIT }),
|
|
63
|
+
]);
|
|
64
|
+
return {
|
|
65
|
+
products: products.data.filter((p) => p.metadata[MANAGED_BY_KEY] === managedBy),
|
|
66
|
+
prices: prices.data.filter((p) => p.metadata[MANAGED_BY_KEY] === managedBy),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// fetch — read-only id lookup
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
export interface FetchResult {
|
|
75
|
+
ids: CatalogIds;
|
|
76
|
+
/** Catalog keys with no live resource ("products.member", "prices.member_monthly"). */
|
|
77
|
+
missing: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function fetchCatalog(
|
|
81
|
+
client: StripeCatalogClient,
|
|
82
|
+
catalog: BillingCatalog,
|
|
83
|
+
): Promise<FetchResult> {
|
|
84
|
+
const live = await listManaged(client, catalog.managedBy);
|
|
85
|
+
const ids: CatalogIds = { products: {}, prices: {} };
|
|
86
|
+
const missing: string[] = [];
|
|
87
|
+
|
|
88
|
+
for (const key of Object.keys(catalog.products)) {
|
|
89
|
+
const found = live.products.find((p) => p.metadata[CONFIG_KEY] === key);
|
|
90
|
+
if (found) ids.products[key] = found.id;
|
|
91
|
+
else missing.push(`products.${key}`);
|
|
92
|
+
}
|
|
93
|
+
for (const key of Object.keys(catalog.prices)) {
|
|
94
|
+
const found = live.prices.find((p) => p.metadata[CONFIG_KEY] === key);
|
|
95
|
+
if (found) ids.prices[key] = found.id;
|
|
96
|
+
else missing.push(`prices.${key}`);
|
|
97
|
+
}
|
|
98
|
+
return { ids, missing };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// sync — idempotent create/update
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
export interface SyncAction {
|
|
106
|
+
kind:
|
|
107
|
+
| "product-exists"
|
|
108
|
+
| "product-created"
|
|
109
|
+
| "product-updated"
|
|
110
|
+
| "price-exists"
|
|
111
|
+
| "price-created"
|
|
112
|
+
| "price-drift";
|
|
113
|
+
key: string;
|
|
114
|
+
id?: string;
|
|
115
|
+
detail?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface SyncReport {
|
|
119
|
+
ids: CatalogIds;
|
|
120
|
+
actions: SyncAction[];
|
|
121
|
+
/** The `price-drift` subset — immutable live prices diverging from config. */
|
|
122
|
+
drift: SyncAction[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function syncCatalog(
|
|
126
|
+
client: StripeCatalogClient,
|
|
127
|
+
catalog: BillingCatalog,
|
|
128
|
+
opts: { dryRun?: boolean } = {},
|
|
129
|
+
): Promise<SyncReport> {
|
|
130
|
+
const live = await listManaged(client, catalog.managedBy);
|
|
131
|
+
const ids: CatalogIds = { products: {}, prices: {} };
|
|
132
|
+
const actions: SyncAction[] = [];
|
|
133
|
+
|
|
134
|
+
for (const [key, config] of Object.entries(catalog.products)) {
|
|
135
|
+
const existing = live.products.find((p) => p.metadata[CONFIG_KEY] === key);
|
|
136
|
+
if (existing) {
|
|
137
|
+
ids.products[key] = existing.id;
|
|
138
|
+
const stale = existing.name !== config.name || existing.description !== config.description;
|
|
139
|
+
if (stale && !opts.dryRun) {
|
|
140
|
+
await client.products.update(existing.id, {
|
|
141
|
+
name: config.name,
|
|
142
|
+
description: config.description,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
actions.push({
|
|
146
|
+
kind: stale ? "product-updated" : "product-exists",
|
|
147
|
+
key,
|
|
148
|
+
id: existing.id,
|
|
149
|
+
});
|
|
150
|
+
} else if (opts.dryRun) {
|
|
151
|
+
actions.push({ kind: "product-created", key, detail: "dry-run" });
|
|
152
|
+
} else {
|
|
153
|
+
const created = await client.products.create({
|
|
154
|
+
name: config.name,
|
|
155
|
+
description: config.description,
|
|
156
|
+
metadata: { [MANAGED_BY_KEY]: catalog.managedBy, [CONFIG_KEY]: key },
|
|
157
|
+
});
|
|
158
|
+
ids.products[key] = created.id;
|
|
159
|
+
actions.push({ kind: "product-created", key, id: created.id });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const [key, config] of Object.entries(catalog.prices)) {
|
|
164
|
+
const existing = live.prices.find((p) => p.metadata[CONFIG_KEY] === key);
|
|
165
|
+
if (existing) {
|
|
166
|
+
ids.prices[key] = existing.id;
|
|
167
|
+
const matches =
|
|
168
|
+
existing.unit_amount === config.amount && existing.recurring?.interval === config.interval;
|
|
169
|
+
actions.push(
|
|
170
|
+
matches
|
|
171
|
+
? { kind: "price-exists", key, id: existing.id }
|
|
172
|
+
: {
|
|
173
|
+
kind: "price-drift",
|
|
174
|
+
key,
|
|
175
|
+
id: existing.id,
|
|
176
|
+
detail: `config ${config.amount}/${config.interval}, live ${existing.unit_amount}/${existing.recurring?.interval}`,
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (opts.dryRun) {
|
|
182
|
+
actions.push({ kind: "price-created", key, detail: "dry-run" });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const productId = ids.products[config.product];
|
|
186
|
+
if (!productId) {
|
|
187
|
+
// Outside dry-run every product was resolved or created above, so a
|
|
188
|
+
// missing id is an invariant violation (e.g. a hand-built catalog
|
|
189
|
+
// that bypassed defineBillingCatalog) — fail, don't mislabel.
|
|
190
|
+
throw new Error(`syncCatalog: no product id for "${config.product}" (price "${key}")`);
|
|
191
|
+
}
|
|
192
|
+
const created = await client.prices.create({
|
|
193
|
+
product: productId,
|
|
194
|
+
unit_amount: config.amount,
|
|
195
|
+
currency: catalog.currency,
|
|
196
|
+
recurring: { interval: config.interval },
|
|
197
|
+
metadata: { [MANAGED_BY_KEY]: catalog.managedBy, [CONFIG_KEY]: key },
|
|
198
|
+
});
|
|
199
|
+
ids.prices[key] = created.id;
|
|
200
|
+
actions.push({ kind: "price-created", key, id: created.id });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { ids, actions, drift: actions.filter((a) => a.kind === "price-drift") };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// validate — read-only drift + orphan check (orphans are FATAL)
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
export interface ValidateIssue {
|
|
211
|
+
kind: "missing" | "drift" | "orphan";
|
|
212
|
+
resource: "product" | "price";
|
|
213
|
+
key: string;
|
|
214
|
+
detail: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface ValidateReport {
|
|
218
|
+
/** False when ANY issue exists — a CD guard that cannot fail guards nothing. */
|
|
219
|
+
ok: boolean;
|
|
220
|
+
issues: ValidateIssue[];
|
|
221
|
+
/** Archived keys seen live (informational, never an issue). */
|
|
222
|
+
archivedSeen: string[];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function validateCatalog(
|
|
226
|
+
client: StripeCatalogClient,
|
|
227
|
+
catalog: BillingCatalog,
|
|
228
|
+
): Promise<ValidateReport> {
|
|
229
|
+
const live = await listManaged(client, catalog.managedBy);
|
|
230
|
+
const issues: ValidateIssue[] = [];
|
|
231
|
+
const archivedSeen: string[] = [];
|
|
232
|
+
const archivedProducts = new Set(catalog.archived?.products ?? []);
|
|
233
|
+
const archivedPrices = new Set(catalog.archived?.prices ?? []);
|
|
234
|
+
|
|
235
|
+
for (const [key, config] of Object.entries(catalog.products)) {
|
|
236
|
+
const existing = live.products.find((p) => p.metadata[CONFIG_KEY] === key);
|
|
237
|
+
if (!existing) {
|
|
238
|
+
issues.push({ kind: "missing", resource: "product", key, detail: "not found in Stripe" });
|
|
239
|
+
} else if (existing.name !== config.name || existing.description !== config.description) {
|
|
240
|
+
issues.push({
|
|
241
|
+
kind: "drift",
|
|
242
|
+
resource: "product",
|
|
243
|
+
key,
|
|
244
|
+
detail: `live name/description differ (run sync to update)`,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for (const [key, config] of Object.entries(catalog.prices)) {
|
|
250
|
+
const existing = live.prices.find((p) => p.metadata[CONFIG_KEY] === key);
|
|
251
|
+
if (!existing) {
|
|
252
|
+
issues.push({ kind: "missing", resource: "price", key, detail: "not found in Stripe" });
|
|
253
|
+
} else if (
|
|
254
|
+
existing.unit_amount !== config.amount ||
|
|
255
|
+
existing.recurring?.interval !== config.interval
|
|
256
|
+
) {
|
|
257
|
+
issues.push({
|
|
258
|
+
kind: "drift",
|
|
259
|
+
resource: "price",
|
|
260
|
+
key,
|
|
261
|
+
detail: `config ${config.amount}/${config.interval}, live ${existing.unit_amount}/${existing.recurring?.interval} (prices are immutable — add a new key)`,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Orphans: live managed resources whose key left the config. Fatal unless
|
|
267
|
+
// archived — Stripe resources are permanent, so a silently dropped key is
|
|
268
|
+
// an operator mistake, not cleanup.
|
|
269
|
+
// Object.hasOwn, not `in`: config_key comes from Stripe metadata, and a
|
|
270
|
+
// key like "constructor" would match Object.prototype via `in`, silently
|
|
271
|
+
// skipping the orphan check.
|
|
272
|
+
for (const p of live.products) {
|
|
273
|
+
const key = p.metadata[CONFIG_KEY];
|
|
274
|
+
if (!key || Object.hasOwn(catalog.products, key)) continue;
|
|
275
|
+
if (archivedProducts.has(key)) archivedSeen.push(`products.${key}`);
|
|
276
|
+
else {
|
|
277
|
+
issues.push({
|
|
278
|
+
kind: "orphan",
|
|
279
|
+
resource: "product",
|
|
280
|
+
key,
|
|
281
|
+
detail: `live in Stripe (${p.id}) but absent from the catalog — archive the key, don't delete it`,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
for (const p of live.prices) {
|
|
286
|
+
const key = p.metadata[CONFIG_KEY];
|
|
287
|
+
if (!key || Object.hasOwn(catalog.prices, key)) continue;
|
|
288
|
+
if (archivedPrices.has(key)) archivedSeen.push(`prices.${key}`);
|
|
289
|
+
else {
|
|
290
|
+
issues.push({
|
|
291
|
+
kind: "orphan",
|
|
292
|
+
resource: "price",
|
|
293
|
+
key,
|
|
294
|
+
detail: `live in Stripe (${p.id}) but absent from the catalog — archive the key, don't delete it`,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return { ok: issues.length === 0, issues, archivedSeen };
|
|
300
|
+
}
|
package/src/gate.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single "is Stripe wired up" predicate: both the secret key and the
|
|
3
|
+
* webhook signing secret must be present for any Stripe surface to turn
|
|
4
|
+
* on. `@somewhatintelligent/auth`'s createPlatformAuth applies the same both-present
|
|
5
|
+
* gate inline (that module carries no workspace runtime deps by design);
|
|
6
|
+
* this export is for every OTHER consumer surface (checkout server fns,
|
|
7
|
+
* webhook routes, UI flags) so the boolean is never re-derived ad hoc.
|
|
8
|
+
*/
|
|
9
|
+
export function stripeConfigured(
|
|
10
|
+
secretKey: string | undefined,
|
|
11
|
+
webhookSecret: string | undefined,
|
|
12
|
+
): boolean {
|
|
13
|
+
return Boolean(secretKey && webhookSecret);
|
|
14
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { defineBillingCatalog, MANAGED_BY_KEY, CONFIG_KEY } from "./catalog";
|
|
2
|
+
export type { BillingCatalog, BillingInterval, PriceConfig, ProductConfig } from "./catalog";
|
|
3
|
+
|
|
4
|
+
export { fetchCatalog, syncCatalog, validateCatalog } from "./engine";
|
|
5
|
+
export type {
|
|
6
|
+
CatalogIds,
|
|
7
|
+
FetchResult,
|
|
8
|
+
StripeCatalogClient,
|
|
9
|
+
StripePriceRecord,
|
|
10
|
+
StripeProductRecord,
|
|
11
|
+
SyncAction,
|
|
12
|
+
SyncReport,
|
|
13
|
+
ValidateIssue,
|
|
14
|
+
ValidateReport,
|
|
15
|
+
} from "./engine";
|
|
16
|
+
|
|
17
|
+
export { renderCatalogModule } from "./render";
|
|
18
|
+
export type { RenderOptions } from "./render";
|
|
19
|
+
|
|
20
|
+
export { stripeConfigured } from "./gate";
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the consumer's generated id module (`billing.gen.ts`) from a
|
|
3
|
+
* catalog + resolved ids. With no ids (offline / pre-sync) every id is an
|
|
4
|
+
* empty string: the module still typechecks with the right key set, and
|
|
5
|
+
* any code that actually CALLS Stripe with a stub id fails loudly at
|
|
6
|
+
* runtime instead of silently doing the wrong thing.
|
|
7
|
+
*/
|
|
8
|
+
import type { BillingCatalog } from "./catalog";
|
|
9
|
+
import type { CatalogIds } from "./engine";
|
|
10
|
+
|
|
11
|
+
export interface RenderOptions {
|
|
12
|
+
/** Resolved ids from fetch/sync; omit for the offline stub. */
|
|
13
|
+
ids?: CatalogIds;
|
|
14
|
+
/** Free-text provenance line ("test mode", "offline stub", …). */
|
|
15
|
+
note?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function renderCatalogModule(catalog: BillingCatalog, opts: RenderOptions = {}): string {
|
|
19
|
+
// Keys are emitted QUOTED: catalog keys are arbitrary strings (kebab-case
|
|
20
|
+
// is natural), and an unquoted non-identifier key would render a module
|
|
21
|
+
// that fails to parse.
|
|
22
|
+
const productLines = Object.keys(catalog.products)
|
|
23
|
+
.map((key) => ` ${JSON.stringify(key)}: "${opts.ids?.products[key] ?? ""}",`)
|
|
24
|
+
.join("\n");
|
|
25
|
+
const priceLines = Object.keys(catalog.prices)
|
|
26
|
+
.map((key) => ` ${JSON.stringify(key)}: "${opts.ids?.prices[key] ?? ""}",`)
|
|
27
|
+
.join("\n");
|
|
28
|
+
|
|
29
|
+
return `// AUTO-GENERATED from the billing catalog - DO NOT EDIT
|
|
30
|
+
// Regenerate: run your billing script's \`fetch\` (or \`sync\`) command.
|
|
31
|
+
${opts.note ? `// ${opts.note}\n` : ""}
|
|
32
|
+
export const stripeProducts = {
|
|
33
|
+
${productLines}
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
export const stripePrices = {
|
|
37
|
+
${priceLines}
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
export type StripeProductId = (typeof stripeProducts)[keyof typeof stripeProducts];
|
|
41
|
+
export type StripePriceId = (typeof stripePrices)[keyof typeof stripePrices];
|
|
42
|
+
`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// AUTO-STAMPED by scripts/stamp-versions.ts at release — the checked-in
|
|
2
|
+
// values are dev placeholders, identical to the runtime fallbacks.
|
|
3
|
+
export const PKG = {
|
|
4
|
+
name: "@somewhatintelligent/stripe",
|
|
5
|
+
version: "0.0.1-beta.1783705886.297c5be",
|
|
6
|
+
commit: "297c5be7d3af822a7f5da906acf27a076332609c",
|
|
7
|
+
} as const;
|