stripe-experiment-sync 1.0.6 → 1.0.8

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.
@@ -0,0 +1,4592 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // ../../node_modules/.pnpm/tsup@8.5.0_postcss@8.5.6_tsx@4.21.0_typescript@5.9.3_yaml@2.8.1/node_modules/tsup/assets/cjs_shims.js
27
+ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.src || new URL("main.js", document.baseURI).href;
28
+ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
29
+
30
+ // src/cli/index.ts
31
+ var import_commander = require("commander");
32
+
33
+ // package.json
34
+ var package_default = {
35
+ name: "stripe-experiment-sync",
36
+ version: "1.0.8-beta.1765856228",
37
+ private: false,
38
+ description: "Stripe Sync Engine to sync Stripe data to Postgres",
39
+ type: "module",
40
+ main: "./dist/index.cjs",
41
+ bin: "./dist/cli/index.js",
42
+ exports: {
43
+ ".": {
44
+ types: "./dist/index.d.ts",
45
+ import: "./dist/index.js",
46
+ require: "./dist/index.cjs"
47
+ },
48
+ "./supabase": {
49
+ types: "./dist/supabase/index.d.ts",
50
+ import: "./dist/supabase/index.js",
51
+ require: "./dist/supabase/index.cjs"
52
+ },
53
+ "./cli": {
54
+ types: "./dist/cli/lib.d.ts",
55
+ import: "./dist/cli/lib.js",
56
+ require: "./dist/cli/lib.cjs"
57
+ }
58
+ },
59
+ scripts: {
60
+ clean: "rimraf dist",
61
+ prebuild: "npm run clean",
62
+ build: "tsup src/index.ts src/supabase/index.ts src/cli/index.ts src/cli/lib.ts --format esm,cjs --dts --shims && cp -r src/database/migrations dist/migrations",
63
+ lint: "eslint src --ext .ts",
64
+ test: "vitest"
65
+ },
66
+ files: [
67
+ "dist"
68
+ ],
69
+ dependencies: {
70
+ "@ngrok/ngrok": "^1.4.1",
71
+ chalk: "^5.3.0",
72
+ commander: "^12.1.0",
73
+ dotenv: "^16.4.7",
74
+ express: "^4.18.2",
75
+ inquirer: "^12.3.0",
76
+ pg: "^8.16.3",
77
+ "pg-node-migrations": "0.0.8",
78
+ stripe: "^17.7.0",
79
+ "supabase-management-js": "^0.1.6",
80
+ ws: "^8.18.0",
81
+ yesql: "^7.0.0"
82
+ },
83
+ devDependencies: {
84
+ "@types/express": "^4.17.21",
85
+ "@types/inquirer": "^9.0.7",
86
+ "@types/node": "^24.10.1",
87
+ "@types/pg": "^8.15.5",
88
+ "@types/ws": "^8.5.13",
89
+ "@types/yesql": "^4.1.4",
90
+ "@vitest/ui": "^4.0.9",
91
+ tsx: "^4.19.2",
92
+ vitest: "^3.2.4"
93
+ },
94
+ repository: {
95
+ type: "git",
96
+ url: "https://github.com/tx-stripe/stripe-sync-engine.git"
97
+ },
98
+ homepage: "https://github.com/tx-stripe/stripe-sync-engine#readme",
99
+ bugs: {
100
+ url: "https://github.com/tx-stripe/stripe-sync-engine/issues"
101
+ },
102
+ keywords: [
103
+ "stripe",
104
+ "postgres",
105
+ "sync",
106
+ "webhooks",
107
+ "supabase",
108
+ "billing",
109
+ "database",
110
+ "typescript"
111
+ ],
112
+ author: "Supabase <https://supabase.com/>"
113
+ };
114
+
115
+ // src/cli/commands.ts
116
+ var import_chalk3 = __toESM(require("chalk"), 1);
117
+ var import_express = __toESM(require("express"), 1);
118
+ var import_dotenv2 = __toESM(require("dotenv"), 1);
119
+
120
+ // src/cli/config.ts
121
+ var import_dotenv = __toESM(require("dotenv"), 1);
122
+ var import_inquirer = __toESM(require("inquirer"), 1);
123
+ var import_chalk = __toESM(require("chalk"), 1);
124
+ async function loadConfig(options) {
125
+ import_dotenv.default.config();
126
+ const config = {};
127
+ config.stripeApiKey = options.stripeKey || process.env.STRIPE_API_KEY || "";
128
+ config.ngrokAuthToken = options.ngrokToken || process.env.NGROK_AUTH_TOKEN || "";
129
+ config.databaseUrl = options.databaseUrl || process.env.DATABASE_URL || "";
130
+ const questions = [];
131
+ if (!config.stripeApiKey) {
132
+ questions.push({
133
+ type: "password",
134
+ name: "stripeApiKey",
135
+ message: "Enter your Stripe API key:",
136
+ mask: "*",
137
+ validate: (input) => {
138
+ if (!input || input.trim() === "") {
139
+ return "Stripe API key is required";
140
+ }
141
+ if (!input.startsWith("sk_")) {
142
+ return 'Stripe API key should start with "sk_"';
143
+ }
144
+ return true;
145
+ }
146
+ });
147
+ }
148
+ if (!config.databaseUrl) {
149
+ questions.push({
150
+ type: "password",
151
+ name: "databaseUrl",
152
+ message: "Enter your Postgres DATABASE_URL:",
153
+ mask: "*",
154
+ validate: (input) => {
155
+ if (!input || input.trim() === "") {
156
+ return "DATABASE_URL is required";
157
+ }
158
+ if (!input.startsWith("postgres://") && !input.startsWith("postgresql://")) {
159
+ return 'DATABASE_URL should start with "postgres://" or "postgresql://"';
160
+ }
161
+ return true;
162
+ }
163
+ });
164
+ }
165
+ if (questions.length > 0) {
166
+ console.log(import_chalk.default.yellow("\nMissing required configuration. Please provide:"));
167
+ const answers = await import_inquirer.default.prompt(questions);
168
+ Object.assign(config, answers);
169
+ }
170
+ return config;
171
+ }
172
+
173
+ // src/stripeSync.ts
174
+ var import_stripe2 = __toESM(require("stripe"), 1);
175
+ var import_yesql2 = require("yesql");
176
+
177
+ // src/database/postgres.ts
178
+ var import_pg = __toESM(require("pg"), 1);
179
+ var import_yesql = require("yesql");
180
+ var ORDERED_STRIPE_TABLES = [
181
+ "subscription_items",
182
+ "subscriptions",
183
+ "subscription_schedules",
184
+ "checkout_session_line_items",
185
+ "checkout_sessions",
186
+ "tax_ids",
187
+ "charges",
188
+ "refunds",
189
+ "credit_notes",
190
+ "disputes",
191
+ "early_fraud_warnings",
192
+ "invoices",
193
+ "payment_intents",
194
+ "payment_methods",
195
+ "setup_intents",
196
+ "prices",
197
+ "plans",
198
+ "products",
199
+ "features",
200
+ "active_entitlements",
201
+ "reviews",
202
+ "_managed_webhooks",
203
+ "customers",
204
+ "_sync_obj_runs",
205
+ // Must be deleted before _sync_runs (foreign key)
206
+ "_sync_runs"
207
+ ];
208
+ var TABLES_WITH_ACCOUNT_ID = /* @__PURE__ */ new Set(["_managed_webhooks"]);
209
+ var PostgresClient = class {
210
+ constructor(config) {
211
+ this.config = config;
212
+ this.pool = new import_pg.default.Pool(config.poolConfig);
213
+ }
214
+ pool;
215
+ async delete(table, id) {
216
+ const prepared = (0, import_yesql.pg)(`
217
+ delete from "${this.config.schema}"."${table}"
218
+ where id = :id
219
+ returning id;
220
+ `)({ id });
221
+ const { rows } = await this.query(prepared.text, prepared.values);
222
+ return rows.length > 0;
223
+ }
224
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
225
+ async query(text, params) {
226
+ return this.pool.query(text, params);
227
+ }
228
+ async upsertMany(entries, table) {
229
+ if (!entries.length) return [];
230
+ const chunkSize = 5;
231
+ const results = [];
232
+ for (let i = 0; i < entries.length; i += chunkSize) {
233
+ const chunk = entries.slice(i, i + chunkSize);
234
+ const queries = [];
235
+ chunk.forEach((entry) => {
236
+ const rawData = JSON.stringify(entry);
237
+ const upsertSql = `
238
+ INSERT INTO "${this.config.schema}"."${table}" ("_raw_data")
239
+ VALUES ($1::jsonb)
240
+ ON CONFLICT (id)
241
+ DO UPDATE SET
242
+ "_raw_data" = EXCLUDED."_raw_data"
243
+ RETURNING *
244
+ `;
245
+ queries.push(this.pool.query(upsertSql, [rawData]));
246
+ });
247
+ results.push(...await Promise.all(queries));
248
+ }
249
+ return results.flatMap((it) => it.rows);
250
+ }
251
+ async upsertManyWithTimestampProtection(entries, table, accountId, syncTimestamp) {
252
+ const timestamp = syncTimestamp || (/* @__PURE__ */ new Date()).toISOString();
253
+ if (!entries.length) return [];
254
+ const chunkSize = 5;
255
+ const results = [];
256
+ for (let i = 0; i < entries.length; i += chunkSize) {
257
+ const chunk = entries.slice(i, i + chunkSize);
258
+ const queries = [];
259
+ chunk.forEach((entry) => {
260
+ if (table.startsWith("_")) {
261
+ const columns = Object.keys(entry).filter(
262
+ (k) => k !== "last_synced_at" && k !== "account_id"
263
+ );
264
+ const upsertSql = `
265
+ INSERT INTO "${this.config.schema}"."${table}" (
266
+ ${columns.map((c) => `"${c}"`).join(", ")}, "last_synced_at", "account_id"
267
+ )
268
+ VALUES (
269
+ ${columns.map((c) => `:${c}`).join(", ")}, :last_synced_at, :account_id
270
+ )
271
+ ON CONFLICT ("id")
272
+ DO UPDATE SET
273
+ ${columns.map((c) => `"${c}" = EXCLUDED."${c}"`).join(", ")},
274
+ "last_synced_at" = :last_synced_at,
275
+ "account_id" = EXCLUDED."account_id"
276
+ WHERE "${table}"."last_synced_at" IS NULL
277
+ OR "${table}"."last_synced_at" < :last_synced_at
278
+ RETURNING *
279
+ `;
280
+ const cleansed = this.cleanseArrayField(entry);
281
+ cleansed.last_synced_at = timestamp;
282
+ cleansed.account_id = accountId;
283
+ const prepared = (0, import_yesql.pg)(upsertSql, { useNullForMissing: true })(cleansed);
284
+ queries.push(this.pool.query(prepared.text, prepared.values));
285
+ } else {
286
+ const rawData = JSON.stringify(entry);
287
+ const upsertSql = `
288
+ INSERT INTO "${this.config.schema}"."${table}" ("_raw_data", "_last_synced_at", "_account_id")
289
+ VALUES ($1::jsonb, $2, $3)
290
+ ON CONFLICT (id)
291
+ DO UPDATE SET
292
+ "_raw_data" = EXCLUDED."_raw_data",
293
+ "_last_synced_at" = $2,
294
+ "_account_id" = EXCLUDED."_account_id"
295
+ WHERE "${table}"."_last_synced_at" IS NULL
296
+ OR "${table}"."_last_synced_at" < $2
297
+ RETURNING *
298
+ `;
299
+ queries.push(this.pool.query(upsertSql, [rawData, timestamp, accountId]));
300
+ }
301
+ });
302
+ results.push(...await Promise.all(queries));
303
+ }
304
+ return results.flatMap((it) => it.rows);
305
+ }
306
+ cleanseArrayField(obj) {
307
+ const cleansed = { ...obj };
308
+ Object.keys(cleansed).map((k) => {
309
+ const data = cleansed[k];
310
+ if (Array.isArray(data)) {
311
+ cleansed[k] = JSON.stringify(data);
312
+ }
313
+ });
314
+ return cleansed;
315
+ }
316
+ async findMissingEntries(table, ids) {
317
+ if (!ids.length) return [];
318
+ const prepared = (0, import_yesql.pg)(`
319
+ select id from "${this.config.schema}"."${table}"
320
+ where id=any(:ids::text[]);
321
+ `)({ ids });
322
+ const { rows } = await this.query(prepared.text, prepared.values);
323
+ const existingIds = rows.map((it) => it.id);
324
+ const missingIds = ids.filter((it) => !existingIds.includes(it));
325
+ return missingIds;
326
+ }
327
+ // Account management methods
328
+ async upsertAccount(accountData, apiKeyHash) {
329
+ const rawData = JSON.stringify(accountData.raw_data);
330
+ await this.query(
331
+ `INSERT INTO "${this.config.schema}"."accounts" ("_raw_data", "api_key_hashes", "first_synced_at", "_last_synced_at")
332
+ VALUES ($1::jsonb, ARRAY[$2], now(), now())
333
+ ON CONFLICT (id)
334
+ DO UPDATE SET
335
+ "_raw_data" = EXCLUDED."_raw_data",
336
+ "api_key_hashes" = (
337
+ SELECT ARRAY(
338
+ SELECT DISTINCT unnest(
339
+ COALESCE("${this.config.schema}"."accounts"."api_key_hashes", '{}') || ARRAY[$2]
340
+ )
341
+ )
342
+ ),
343
+ "_last_synced_at" = now(),
344
+ "_updated_at" = now()`,
345
+ [rawData, apiKeyHash]
346
+ );
347
+ }
348
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
349
+ async getAllAccounts() {
350
+ const result = await this.query(
351
+ `SELECT _raw_data FROM "${this.config.schema}"."accounts"
352
+ ORDER BY _last_synced_at DESC`
353
+ );
354
+ return result.rows.map((row) => row._raw_data);
355
+ }
356
+ /**
357
+ * Looks up an account ID by API key hash
358
+ * Uses the GIN index on api_key_hashes for fast lookups
359
+ * @param apiKeyHash - SHA-256 hash of the Stripe API key
360
+ * @returns Account ID if found, null otherwise
361
+ */
362
+ async getAccountIdByApiKeyHash(apiKeyHash) {
363
+ const result = await this.query(
364
+ `SELECT id FROM "${this.config.schema}"."accounts"
365
+ WHERE $1 = ANY(api_key_hashes)
366
+ LIMIT 1`,
367
+ [apiKeyHash]
368
+ );
369
+ return result.rows.length > 0 ? result.rows[0].id : null;
370
+ }
371
+ /**
372
+ * Looks up full account data by API key hash
373
+ * @param apiKeyHash - SHA-256 hash of the Stripe API key
374
+ * @returns Account raw data if found, null otherwise
375
+ */
376
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
377
+ async getAccountByApiKeyHash(apiKeyHash) {
378
+ const result = await this.query(
379
+ `SELECT _raw_data FROM "${this.config.schema}"."accounts"
380
+ WHERE $1 = ANY(api_key_hashes)
381
+ LIMIT 1`,
382
+ [apiKeyHash]
383
+ );
384
+ return result.rows.length > 0 ? result.rows[0]._raw_data : null;
385
+ }
386
+ getAccountIdColumn(table) {
387
+ return TABLES_WITH_ACCOUNT_ID.has(table) ? "account_id" : "_account_id";
388
+ }
389
+ async getAccountRecordCounts(accountId) {
390
+ const counts = {};
391
+ for (const table of ORDERED_STRIPE_TABLES) {
392
+ const accountIdColumn = this.getAccountIdColumn(table);
393
+ const result = await this.query(
394
+ `SELECT COUNT(*) as count FROM "${this.config.schema}"."${table}"
395
+ WHERE "${accountIdColumn}" = $1`,
396
+ [accountId]
397
+ );
398
+ counts[table] = parseInt(result.rows[0].count);
399
+ }
400
+ return counts;
401
+ }
402
+ async deleteAccountWithCascade(accountId, useTransaction) {
403
+ const deletionCounts = {};
404
+ try {
405
+ if (useTransaction) {
406
+ await this.query("BEGIN");
407
+ }
408
+ for (const table of ORDERED_STRIPE_TABLES) {
409
+ const accountIdColumn = this.getAccountIdColumn(table);
410
+ const result = await this.query(
411
+ `DELETE FROM "${this.config.schema}"."${table}"
412
+ WHERE "${accountIdColumn}" = $1`,
413
+ [accountId]
414
+ );
415
+ deletionCounts[table] = result.rowCount || 0;
416
+ }
417
+ const accountResult = await this.query(
418
+ `DELETE FROM "${this.config.schema}"."accounts"
419
+ WHERE "id" = $1`,
420
+ [accountId]
421
+ );
422
+ deletionCounts["accounts"] = accountResult.rowCount || 0;
423
+ if (useTransaction) {
424
+ await this.query("COMMIT");
425
+ }
426
+ } catch (error) {
427
+ if (useTransaction) {
428
+ await this.query("ROLLBACK");
429
+ }
430
+ throw error;
431
+ }
432
+ return deletionCounts;
433
+ }
434
+ /**
435
+ * Hash a string to a 32-bit integer for use with PostgreSQL advisory locks.
436
+ * Uses a simple hash algorithm that produces consistent results.
437
+ */
438
+ hashToInt32(key) {
439
+ let hash = 0;
440
+ for (let i = 0; i < key.length; i++) {
441
+ const char = key.charCodeAt(i);
442
+ hash = (hash << 5) - hash + char;
443
+ hash = hash & hash;
444
+ }
445
+ return hash;
446
+ }
447
+ /**
448
+ * Acquire a PostgreSQL advisory lock for the given key.
449
+ * This lock is automatically released when the connection is closed or explicitly released.
450
+ * Advisory locks are session-level and will block until the lock is available.
451
+ *
452
+ * @param key - A string key to lock on (will be hashed to an integer)
453
+ */
454
+ async acquireAdvisoryLock(key) {
455
+ const lockId = this.hashToInt32(key);
456
+ await this.query("SELECT pg_advisory_lock($1)", [lockId]);
457
+ }
458
+ /**
459
+ * Release a PostgreSQL advisory lock for the given key.
460
+ *
461
+ * @param key - The same string key used to acquire the lock
462
+ */
463
+ async releaseAdvisoryLock(key) {
464
+ const lockId = this.hashToInt32(key);
465
+ await this.query("SELECT pg_advisory_unlock($1)", [lockId]);
466
+ }
467
+ /**
468
+ * Execute a function while holding an advisory lock.
469
+ * The lock is automatically released after the function completes (success or error).
470
+ *
471
+ * IMPORTANT: This acquires a dedicated connection from the pool and holds it for the
472
+ * duration of the function execution. PostgreSQL advisory locks are session-level,
473
+ * so we must use the same connection for lock acquisition, operations, and release.
474
+ *
475
+ * @param key - A string key to lock on (will be hashed to an integer)
476
+ * @param fn - The function to execute while holding the lock
477
+ * @returns The result of the function
478
+ */
479
+ async withAdvisoryLock(key, fn) {
480
+ const lockId = this.hashToInt32(key);
481
+ const client = await this.pool.connect();
482
+ try {
483
+ await client.query("SELECT pg_advisory_lock($1)", [lockId]);
484
+ return await fn();
485
+ } finally {
486
+ try {
487
+ await client.query("SELECT pg_advisory_unlock($1)", [lockId]);
488
+ } finally {
489
+ client.release();
490
+ }
491
+ }
492
+ }
493
+ // =============================================================================
494
+ // Observable Sync System Methods
495
+ // =============================================================================
496
+ // These methods support long-running syncs with full observability.
497
+ // Uses two tables: _sync_runs (parent) and _sync_obj_runs (children)
498
+ // RunKey = (accountId, runStartedAt) - natural composite key
499
+ /**
500
+ * Cancel stale runs (running but no object updated in 5 minutes).
501
+ * Called before creating a new run to clean up crashed syncs.
502
+ * Only cancels runs that have objects AND none have recent activity.
503
+ * Runs without objects yet (just created) are not considered stale.
504
+ */
505
+ async cancelStaleRuns(accountId) {
506
+ await this.query(
507
+ `UPDATE "${this.config.schema}"."_sync_obj_runs" o
508
+ SET status = 'error',
509
+ error_message = 'Auto-cancelled: stale (no update in 5 min)',
510
+ completed_at = now()
511
+ WHERE o."_account_id" = $1
512
+ AND o.status = 'running'
513
+ AND o.updated_at < now() - interval '5 minutes'`,
514
+ [accountId]
515
+ );
516
+ await this.query(
517
+ `UPDATE "${this.config.schema}"."_sync_runs" r
518
+ SET closed_at = now()
519
+ WHERE r."_account_id" = $1
520
+ AND r.closed_at IS NULL
521
+ AND EXISTS (
522
+ SELECT 1 FROM "${this.config.schema}"."_sync_obj_runs" o
523
+ WHERE o."_account_id" = r."_account_id"
524
+ AND o.run_started_at = r.started_at
525
+ )
526
+ AND NOT EXISTS (
527
+ SELECT 1 FROM "${this.config.schema}"."_sync_obj_runs" o
528
+ WHERE o."_account_id" = r."_account_id"
529
+ AND o.run_started_at = r.started_at
530
+ AND o.status IN ('pending', 'running')
531
+ )`,
532
+ [accountId]
533
+ );
534
+ }
535
+ /**
536
+ * Get or create a sync run for this account.
537
+ * Returns existing run if one is active, otherwise creates new one.
538
+ * Auto-cancels stale runs before checking.
539
+ *
540
+ * @returns RunKey with isNew flag, or null if constraint violation (race condition)
541
+ */
542
+ async getOrCreateSyncRun(accountId, triggeredBy) {
543
+ await this.cancelStaleRuns(accountId);
544
+ const existing = await this.query(
545
+ `SELECT "_account_id", started_at FROM "${this.config.schema}"."_sync_runs"
546
+ WHERE "_account_id" = $1 AND closed_at IS NULL`,
547
+ [accountId]
548
+ );
549
+ if (existing.rows.length > 0) {
550
+ const row = existing.rows[0];
551
+ return { accountId: row._account_id, runStartedAt: row.started_at, isNew: false };
552
+ }
553
+ try {
554
+ const result = await this.query(
555
+ `INSERT INTO "${this.config.schema}"."_sync_runs" ("_account_id", triggered_by, started_at)
556
+ VALUES ($1, $2, date_trunc('milliseconds', now()))
557
+ RETURNING "_account_id", started_at`,
558
+ [accountId, triggeredBy ?? null]
559
+ );
560
+ const row = result.rows[0];
561
+ return { accountId: row._account_id, runStartedAt: row.started_at, isNew: true };
562
+ } catch (error) {
563
+ if (error instanceof Error && "code" in error && error.code === "23P01") {
564
+ return null;
565
+ }
566
+ throw error;
567
+ }
568
+ }
569
+ /**
570
+ * Get the active sync run for an account (if any).
571
+ */
572
+ async getActiveSyncRun(accountId) {
573
+ const result = await this.query(
574
+ `SELECT "_account_id", started_at FROM "${this.config.schema}"."_sync_runs"
575
+ WHERE "_account_id" = $1 AND closed_at IS NULL`,
576
+ [accountId]
577
+ );
578
+ if (result.rows.length === 0) return null;
579
+ const row = result.rows[0];
580
+ return { accountId: row._account_id, runStartedAt: row.started_at };
581
+ }
582
+ /**
583
+ * Get sync run config (for concurrency control).
584
+ * Status is derived from sync_runs view.
585
+ */
586
+ async getSyncRun(accountId, runStartedAt) {
587
+ const result = await this.query(
588
+ `SELECT "_account_id", started_at, max_concurrent, closed_at
589
+ FROM "${this.config.schema}"."_sync_runs"
590
+ WHERE "_account_id" = $1 AND started_at = $2`,
591
+ [accountId, runStartedAt]
592
+ );
593
+ if (result.rows.length === 0) return null;
594
+ const row = result.rows[0];
595
+ return {
596
+ accountId: row._account_id,
597
+ runStartedAt: row.started_at,
598
+ maxConcurrent: row.max_concurrent,
599
+ closedAt: row.closed_at
600
+ };
601
+ }
602
+ /**
603
+ * Close a sync run (mark as done).
604
+ * Status (complete/error) is derived from object run states.
605
+ */
606
+ async closeSyncRun(accountId, runStartedAt) {
607
+ await this.query(
608
+ `UPDATE "${this.config.schema}"."_sync_runs"
609
+ SET closed_at = now()
610
+ WHERE "_account_id" = $1 AND started_at = $2 AND closed_at IS NULL`,
611
+ [accountId, runStartedAt]
612
+ );
613
+ }
614
+ /**
615
+ * Create object run entries for a sync run.
616
+ * All objects start as 'pending'.
617
+ */
618
+ async createObjectRuns(accountId, runStartedAt, objects) {
619
+ if (objects.length === 0) return;
620
+ const values = objects.map((_, i) => `($1, $2, $${i + 3})`).join(", ");
621
+ await this.query(
622
+ `INSERT INTO "${this.config.schema}"."_sync_obj_runs" ("_account_id", run_started_at, object)
623
+ VALUES ${values}
624
+ ON CONFLICT ("_account_id", run_started_at, object) DO NOTHING`,
625
+ [accountId, runStartedAt, ...objects]
626
+ );
627
+ }
628
+ /**
629
+ * Try to start an object sync (respects max_concurrent).
630
+ * Returns true if claimed, false if already running or at concurrency limit.
631
+ *
632
+ * Note: There's a small race window where concurrent calls could result in
633
+ * max_concurrent + 1 objects running. This is acceptable behavior.
634
+ */
635
+ async tryStartObjectSync(accountId, runStartedAt, object) {
636
+ const run = await this.getSyncRun(accountId, runStartedAt);
637
+ if (!run) return false;
638
+ const runningCount = await this.countRunningObjects(accountId, runStartedAt);
639
+ if (runningCount >= run.maxConcurrent) return false;
640
+ const result = await this.query(
641
+ `UPDATE "${this.config.schema}"."_sync_obj_runs"
642
+ SET status = 'running', started_at = now(), updated_at = now()
643
+ WHERE "_account_id" = $1 AND run_started_at = $2 AND object = $3 AND status = 'pending'
644
+ RETURNING *`,
645
+ [accountId, runStartedAt, object]
646
+ );
647
+ return (result.rowCount ?? 0) > 0;
648
+ }
649
+ /**
650
+ * Get object run details.
651
+ */
652
+ async getObjectRun(accountId, runStartedAt, object) {
653
+ const result = await this.query(
654
+ `SELECT object, status, processed_count, cursor
655
+ FROM "${this.config.schema}"."_sync_obj_runs"
656
+ WHERE "_account_id" = $1 AND run_started_at = $2 AND object = $3`,
657
+ [accountId, runStartedAt, object]
658
+ );
659
+ if (result.rows.length === 0) return null;
660
+ const row = result.rows[0];
661
+ return {
662
+ object: row.object,
663
+ status: row.status,
664
+ processedCount: row.processed_count,
665
+ cursor: row.cursor
666
+ };
667
+ }
668
+ /**
669
+ * Update progress for an object sync.
670
+ * Also touches updated_at for stale detection.
671
+ */
672
+ async incrementObjectProgress(accountId, runStartedAt, object, count) {
673
+ await this.query(
674
+ `UPDATE "${this.config.schema}"."_sync_obj_runs"
675
+ SET processed_count = processed_count + $4, updated_at = now()
676
+ WHERE "_account_id" = $1 AND run_started_at = $2 AND object = $3`,
677
+ [accountId, runStartedAt, object, count]
678
+ );
679
+ }
680
+ /**
681
+ * Update the cursor for an object sync.
682
+ * Only updates if the new cursor is higher than the existing one (cursors should never decrease).
683
+ * For numeric cursors (timestamps), uses GREATEST to ensure monotonic increase.
684
+ * For non-numeric cursors, just sets the value directly.
685
+ */
686
+ async updateObjectCursor(accountId, runStartedAt, object, cursor) {
687
+ const isNumeric = cursor !== null && /^\d+$/.test(cursor);
688
+ if (isNumeric) {
689
+ await this.query(
690
+ `UPDATE "${this.config.schema}"."_sync_obj_runs"
691
+ SET cursor = GREATEST(COALESCE(cursor::bigint, 0), $4::bigint)::text,
692
+ updated_at = now()
693
+ WHERE "_account_id" = $1 AND run_started_at = $2 AND object = $3`,
694
+ [accountId, runStartedAt, object, cursor]
695
+ );
696
+ } else {
697
+ await this.query(
698
+ `UPDATE "${this.config.schema}"."_sync_obj_runs"
699
+ SET cursor = $4, updated_at = now()
700
+ WHERE "_account_id" = $1 AND run_started_at = $2 AND object = $3`,
701
+ [accountId, runStartedAt, object, cursor]
702
+ );
703
+ }
704
+ }
705
+ /**
706
+ * Get the highest cursor from previous syncs for an object type.
707
+ * This considers completed, error, AND running runs to ensure recovery syncs
708
+ * don't re-process data that was already synced before a crash.
709
+ * A 'running' status with a cursor means the process was killed mid-sync.
710
+ */
711
+ async getLastCompletedCursor(accountId, object) {
712
+ const result = await this.query(
713
+ `SELECT MAX(o.cursor::bigint)::text as cursor
714
+ FROM "${this.config.schema}"."_sync_obj_runs" o
715
+ WHERE o."_account_id" = $1
716
+ AND o.object = $2
717
+ AND o.cursor IS NOT NULL`,
718
+ [accountId, object]
719
+ );
720
+ return result.rows[0]?.cursor ?? null;
721
+ }
722
+ /**
723
+ * Delete all sync runs and object runs for an account.
724
+ * Useful for testing or resetting sync state.
725
+ */
726
+ async deleteSyncRuns(accountId) {
727
+ await this.query(
728
+ `DELETE FROM "${this.config.schema}"."_sync_obj_runs" WHERE "_account_id" = $1`,
729
+ [accountId]
730
+ );
731
+ await this.query(`DELETE FROM "${this.config.schema}"."_sync_runs" WHERE "_account_id" = $1`, [
732
+ accountId
733
+ ]);
734
+ }
735
+ /**
736
+ * Mark an object sync as complete.
737
+ * Auto-closes the run when all objects are done.
738
+ */
739
+ async completeObjectSync(accountId, runStartedAt, object) {
740
+ await this.query(
741
+ `UPDATE "${this.config.schema}"."_sync_obj_runs"
742
+ SET status = 'complete', completed_at = now()
743
+ WHERE "_account_id" = $1 AND run_started_at = $2 AND object = $3`,
744
+ [accountId, runStartedAt, object]
745
+ );
746
+ const allDone = await this.areAllObjectsComplete(accountId, runStartedAt);
747
+ if (allDone) {
748
+ await this.closeSyncRun(accountId, runStartedAt);
749
+ }
750
+ }
751
+ /**
752
+ * Mark an object sync as failed.
753
+ * Auto-closes the run when all objects are done.
754
+ */
755
+ async failObjectSync(accountId, runStartedAt, object, errorMessage) {
756
+ await this.query(
757
+ `UPDATE "${this.config.schema}"."_sync_obj_runs"
758
+ SET status = 'error', error_message = $4, completed_at = now()
759
+ WHERE "_account_id" = $1 AND run_started_at = $2 AND object = $3`,
760
+ [accountId, runStartedAt, object, errorMessage]
761
+ );
762
+ const allDone = await this.areAllObjectsComplete(accountId, runStartedAt);
763
+ if (allDone) {
764
+ await this.closeSyncRun(accountId, runStartedAt);
765
+ }
766
+ }
767
+ /**
768
+ * Check if any object in a run has errored.
769
+ */
770
+ async hasAnyObjectErrors(accountId, runStartedAt) {
771
+ const result = await this.query(
772
+ `SELECT COUNT(*) as count FROM "${this.config.schema}"."_sync_obj_runs"
773
+ WHERE "_account_id" = $1 AND run_started_at = $2 AND status = 'error'`,
774
+ [accountId, runStartedAt]
775
+ );
776
+ return parseInt(result.rows[0].count) > 0;
777
+ }
778
+ /**
779
+ * Count running objects in a run.
780
+ */
781
+ async countRunningObjects(accountId, runStartedAt) {
782
+ const result = await this.query(
783
+ `SELECT COUNT(*) as count FROM "${this.config.schema}"."_sync_obj_runs"
784
+ WHERE "_account_id" = $1 AND run_started_at = $2 AND status = 'running'`,
785
+ [accountId, runStartedAt]
786
+ );
787
+ return parseInt(result.rows[0].count);
788
+ }
789
+ /**
790
+ * Get the next pending object to process.
791
+ * Returns null if no pending objects or at concurrency limit.
792
+ */
793
+ async getNextPendingObject(accountId, runStartedAt) {
794
+ const run = await this.getSyncRun(accountId, runStartedAt);
795
+ if (!run) return null;
796
+ const runningCount = await this.countRunningObjects(accountId, runStartedAt);
797
+ if (runningCount >= run.maxConcurrent) return null;
798
+ const result = await this.query(
799
+ `SELECT object FROM "${this.config.schema}"."_sync_obj_runs"
800
+ WHERE "_account_id" = $1 AND run_started_at = $2 AND status = 'pending'
801
+ ORDER BY object
802
+ LIMIT 1`,
803
+ [accountId, runStartedAt]
804
+ );
805
+ return result.rows.length > 0 ? result.rows[0].object : null;
806
+ }
807
+ /**
808
+ * Check if all objects in a run are complete (or error).
809
+ */
810
+ async areAllObjectsComplete(accountId, runStartedAt) {
811
+ const result = await this.query(
812
+ `SELECT COUNT(*) as count FROM "${this.config.schema}"."_sync_obj_runs"
813
+ WHERE "_account_id" = $1 AND run_started_at = $2 AND status IN ('pending', 'running')`,
814
+ [accountId, runStartedAt]
815
+ );
816
+ return parseInt(result.rows[0].count) === 0;
817
+ }
818
+ /**
819
+ * Closes the database connection pool and cleans up resources.
820
+ * Call this when you're done using the PostgresClient instance.
821
+ */
822
+ async close() {
823
+ await this.pool.end();
824
+ }
825
+ };
826
+
827
+ // src/schemas/managed_webhook.ts
828
+ var managedWebhookSchema = {
829
+ properties: [
830
+ "id",
831
+ "object",
832
+ "url",
833
+ "enabled_events",
834
+ "description",
835
+ "enabled",
836
+ "livemode",
837
+ "metadata",
838
+ "secret",
839
+ "status",
840
+ "api_version",
841
+ "created",
842
+ "account_id"
843
+ ]
844
+ };
845
+
846
+ // src/utils/retry.ts
847
+ var import_stripe = __toESM(require("stripe"), 1);
848
+ var DEFAULT_RETRY_CONFIG = {
849
+ maxRetries: 5,
850
+ initialDelayMs: 1e3,
851
+ // 1 second
852
+ maxDelayMs: 6e4,
853
+ // 60 seconds
854
+ jitterMs: 500
855
+ // randomization to prevent thundering herd
856
+ };
857
+ function isRetryableError(error) {
858
+ if (error instanceof import_stripe.default.errors.StripeRateLimitError) {
859
+ return true;
860
+ }
861
+ if (error instanceof import_stripe.default.errors.StripeAPIError) {
862
+ const statusCode = error.statusCode;
863
+ if (statusCode && [500, 502, 503, 504, 424].includes(statusCode)) {
864
+ return true;
865
+ }
866
+ }
867
+ if (error instanceof import_stripe.default.errors.StripeConnectionError) {
868
+ return true;
869
+ }
870
+ return false;
871
+ }
872
+ function getRetryAfterMs(error) {
873
+ if (!(error instanceof import_stripe.default.errors.StripeRateLimitError)) {
874
+ return null;
875
+ }
876
+ const retryAfterHeader = error.headers?.["retry-after"];
877
+ if (!retryAfterHeader) {
878
+ return null;
879
+ }
880
+ const retryAfterSeconds = Number(retryAfterHeader);
881
+ if (isNaN(retryAfterSeconds) || retryAfterSeconds <= 0) {
882
+ return null;
883
+ }
884
+ return retryAfterSeconds * 1e3;
885
+ }
886
+ function calculateDelay(attempt, config, retryAfterMs) {
887
+ if (retryAfterMs !== null && retryAfterMs !== void 0) {
888
+ const jitter2 = Math.random() * config.jitterMs;
889
+ return retryAfterMs + jitter2;
890
+ }
891
+ const exponentialDelay = Math.min(config.initialDelayMs * Math.pow(2, attempt), config.maxDelayMs);
892
+ const jitter = Math.random() * config.jitterMs;
893
+ return exponentialDelay + jitter;
894
+ }
895
+ function sleep(ms) {
896
+ return new Promise((resolve) => setTimeout(resolve, ms));
897
+ }
898
+ function getErrorType(error) {
899
+ if (error instanceof import_stripe.default.errors.StripeRateLimitError) {
900
+ return "rate_limit";
901
+ }
902
+ if (error instanceof import_stripe.default.errors.StripeAPIError) {
903
+ return `api_error_${error.statusCode}`;
904
+ }
905
+ if (error instanceof import_stripe.default.errors.StripeConnectionError) {
906
+ return "connection_error";
907
+ }
908
+ return "unknown";
909
+ }
910
+ async function withRetry(fn, config = {}, logger) {
911
+ const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...config };
912
+ let lastError;
913
+ for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) {
914
+ try {
915
+ return await fn();
916
+ } catch (error) {
917
+ lastError = error;
918
+ if (!isRetryableError(error)) {
919
+ throw error;
920
+ }
921
+ if (attempt >= retryConfig.maxRetries) {
922
+ logger?.error(
923
+ {
924
+ error: error instanceof Error ? error.message : String(error),
925
+ errorType: getErrorType(error),
926
+ attempt: attempt + 1,
927
+ maxRetries: retryConfig.maxRetries
928
+ },
929
+ "Max retries exhausted for Stripe error"
930
+ );
931
+ throw error;
932
+ }
933
+ const retryAfterMs = getRetryAfterMs(error);
934
+ const delay = calculateDelay(attempt, retryConfig, retryAfterMs);
935
+ logger?.warn(
936
+ {
937
+ error: error instanceof Error ? error.message : String(error),
938
+ errorType: getErrorType(error),
939
+ attempt: attempt + 1,
940
+ maxRetries: retryConfig.maxRetries,
941
+ delayMs: Math.round(delay),
942
+ retryAfterMs: retryAfterMs ?? void 0,
943
+ nextAttempt: attempt + 2
944
+ },
945
+ "Transient Stripe error, retrying after delay"
946
+ );
947
+ await sleep(delay);
948
+ }
949
+ }
950
+ throw lastError;
951
+ }
952
+
953
+ // src/utils/stripeClientWrapper.ts
954
+ function createRetryableStripeClient(stripe, retryConfig = {}, logger) {
955
+ return new Proxy(stripe, {
956
+ get(target, prop, receiver) {
957
+ const original = Reflect.get(target, prop, receiver);
958
+ if (original && typeof original === "object" && !isPromise(original)) {
959
+ return wrapResource(original, retryConfig, logger);
960
+ }
961
+ return original;
962
+ }
963
+ });
964
+ }
965
+ function wrapResource(resource, retryConfig, logger) {
966
+ return new Proxy(resource, {
967
+ get(target, prop, receiver) {
968
+ const original = Reflect.get(target, prop, receiver);
969
+ if (typeof original === "function") {
970
+ return function(...args) {
971
+ const result = original.apply(target, args);
972
+ if (result && typeof result === "object" && Symbol.asyncIterator in result) {
973
+ return result;
974
+ }
975
+ if (isPromise(result)) {
976
+ return withRetry(() => Promise.resolve(result), retryConfig, logger);
977
+ }
978
+ return result;
979
+ };
980
+ }
981
+ if (original && typeof original === "object" && !isPromise(original)) {
982
+ return wrapResource(original, retryConfig, logger);
983
+ }
984
+ return original;
985
+ }
986
+ });
987
+ }
988
+ function isPromise(value) {
989
+ return value !== null && typeof value === "object" && typeof value.then === "function";
990
+ }
991
+
992
+ // src/utils/hashApiKey.ts
993
+ var import_crypto = require("crypto");
994
+ function hashApiKey(apiKey) {
995
+ return (0, import_crypto.createHash)("sha256").update(apiKey).digest("hex");
996
+ }
997
+
998
+ // src/stripeSync.ts
999
+ function getUniqueIds(entries, key) {
1000
+ const set = new Set(
1001
+ entries.map((subscription) => subscription?.[key]?.toString()).filter((it) => Boolean(it))
1002
+ );
1003
+ return Array.from(set);
1004
+ }
1005
+ var StripeSync = class {
1006
+ constructor(config) {
1007
+ this.config = config;
1008
+ const baseStripe = new import_stripe2.default(config.stripeSecretKey, {
1009
+ // https://github.com/stripe/stripe-node#configuration
1010
+ // @ts-ignore
1011
+ apiVersion: config.stripeApiVersion,
1012
+ appInfo: {
1013
+ name: "Stripe Postgres Sync"
1014
+ }
1015
+ });
1016
+ this.stripe = createRetryableStripeClient(baseStripe, {}, config.logger);
1017
+ this.config.logger = config.logger ?? console;
1018
+ this.config.logger?.info(
1019
+ { autoExpandLists: config.autoExpandLists, stripeApiVersion: config.stripeApiVersion },
1020
+ "StripeSync initialized"
1021
+ );
1022
+ const poolConfig = config.poolConfig ?? {};
1023
+ if (config.databaseUrl) {
1024
+ poolConfig.connectionString = config.databaseUrl;
1025
+ }
1026
+ if (config.maxPostgresConnections) {
1027
+ poolConfig.max = config.maxPostgresConnections;
1028
+ }
1029
+ if (poolConfig.max === void 0) {
1030
+ poolConfig.max = 10;
1031
+ }
1032
+ if (poolConfig.keepAlive === void 0) {
1033
+ poolConfig.keepAlive = true;
1034
+ }
1035
+ this.postgresClient = new PostgresClient({
1036
+ schema: "stripe",
1037
+ poolConfig
1038
+ });
1039
+ }
1040
+ stripe;
1041
+ postgresClient;
1042
+ /**
1043
+ * Get the Stripe account ID. Delegates to getCurrentAccount() for the actual lookup.
1044
+ */
1045
+ async getAccountId(objectAccountId) {
1046
+ const account = await this.getCurrentAccount(objectAccountId);
1047
+ if (!account) {
1048
+ throw new Error("Failed to retrieve Stripe account. Please ensure API key is valid.");
1049
+ }
1050
+ return account.id;
1051
+ }
1052
+ /**
1053
+ * Upsert Stripe account information to the database
1054
+ * @param account - Stripe account object
1055
+ * @param apiKeyHash - SHA-256 hash of API key to store for fast lookups
1056
+ */
1057
+ async upsertAccount(account, apiKeyHash) {
1058
+ try {
1059
+ await this.postgresClient.upsertAccount(
1060
+ {
1061
+ id: account.id,
1062
+ raw_data: account
1063
+ },
1064
+ apiKeyHash
1065
+ );
1066
+ } catch (error) {
1067
+ this.config.logger?.error(error, "Failed to upsert account to database");
1068
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
1069
+ throw new Error(`Failed to upsert account to database: ${errorMessage}`);
1070
+ }
1071
+ }
1072
+ /**
1073
+ * Get the current account being synced. Uses database lookup by API key hash,
1074
+ * with fallback to Stripe API if not found (first-time setup or new API key).
1075
+ * @param objectAccountId - Optional account ID from event data (Connect scenarios)
1076
+ */
1077
+ async getCurrentAccount(objectAccountId) {
1078
+ const apiKeyHash = hashApiKey(this.config.stripeSecretKey);
1079
+ try {
1080
+ const account = await this.postgresClient.getAccountByApiKeyHash(apiKeyHash);
1081
+ if (account) {
1082
+ return account;
1083
+ }
1084
+ } catch (error) {
1085
+ this.config.logger?.warn(
1086
+ error,
1087
+ "Failed to lookup account by API key hash, falling back to API"
1088
+ );
1089
+ }
1090
+ try {
1091
+ const accountIdParam = objectAccountId || this.config.stripeAccountId;
1092
+ const account = accountIdParam ? await this.stripe.accounts.retrieve(accountIdParam) : await this.stripe.accounts.retrieve();
1093
+ await this.upsertAccount(account, apiKeyHash);
1094
+ return account;
1095
+ } catch (error) {
1096
+ this.config.logger?.error(error, "Failed to retrieve account from Stripe API");
1097
+ return null;
1098
+ }
1099
+ }
1100
+ /**
1101
+ * Get all accounts that have been synced to the database
1102
+ */
1103
+ async getAllSyncedAccounts() {
1104
+ try {
1105
+ const accountsData = await this.postgresClient.getAllAccounts();
1106
+ return accountsData;
1107
+ } catch (error) {
1108
+ this.config.logger?.error(error, "Failed to retrieve accounts from database");
1109
+ throw new Error("Failed to retrieve synced accounts from database");
1110
+ }
1111
+ }
1112
+ /**
1113
+ * DANGEROUS: Delete an account and all associated data from the database
1114
+ * This operation cannot be undone!
1115
+ *
1116
+ * @param accountId - The Stripe account ID to delete
1117
+ * @param options - Options for deletion behavior
1118
+ * @param options.dryRun - If true, only count records without deleting (default: false)
1119
+ * @param options.useTransaction - If true, use transaction for atomic deletion (default: true)
1120
+ * @returns Deletion summary with counts and warnings
1121
+ */
1122
+ async dangerouslyDeleteSyncedAccountData(accountId, options) {
1123
+ const dryRun = options?.dryRun ?? false;
1124
+ const useTransaction = options?.useTransaction ?? true;
1125
+ this.config.logger?.info(
1126
+ `${dryRun ? "Preview" : "Deleting"} account ${accountId} (transaction: ${useTransaction})`
1127
+ );
1128
+ try {
1129
+ const counts = await this.postgresClient.getAccountRecordCounts(accountId);
1130
+ const warnings = [];
1131
+ let totalRecords = 0;
1132
+ for (const [table, count] of Object.entries(counts)) {
1133
+ if (count > 0) {
1134
+ totalRecords += count;
1135
+ warnings.push(`Will delete ${count} ${table} record${count !== 1 ? "s" : ""}`);
1136
+ }
1137
+ }
1138
+ if (totalRecords > 1e5) {
1139
+ warnings.push(
1140
+ `Large dataset detected (${totalRecords} total records). Consider using useTransaction: false for better performance.`
1141
+ );
1142
+ }
1143
+ if (dryRun) {
1144
+ this.config.logger?.info(`Dry-run complete: ${totalRecords} total records would be deleted`);
1145
+ return {
1146
+ deletedAccountId: accountId,
1147
+ deletedRecordCounts: counts,
1148
+ warnings
1149
+ };
1150
+ }
1151
+ const deletionCounts = await this.postgresClient.deleteAccountWithCascade(
1152
+ accountId,
1153
+ useTransaction
1154
+ );
1155
+ this.config.logger?.info(
1156
+ `Successfully deleted account ${accountId} with ${totalRecords} total records`
1157
+ );
1158
+ return {
1159
+ deletedAccountId: accountId,
1160
+ deletedRecordCounts: deletionCounts,
1161
+ warnings
1162
+ };
1163
+ } catch (error) {
1164
+ this.config.logger?.error(error, `Failed to delete account ${accountId}`);
1165
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
1166
+ throw new Error(`Failed to delete account ${accountId}: ${errorMessage}`);
1167
+ }
1168
+ }
1169
+ async processWebhook(payload, signature) {
1170
+ let webhookSecret = this.config.stripeWebhookSecret;
1171
+ if (!webhookSecret) {
1172
+ const accountId = await this.getAccountId();
1173
+ const result = await this.postgresClient.query(
1174
+ `SELECT secret FROM "stripe"."_managed_webhooks" WHERE account_id = $1 LIMIT 1`,
1175
+ [accountId]
1176
+ );
1177
+ if (result.rows.length > 0) {
1178
+ webhookSecret = result.rows[0].secret;
1179
+ }
1180
+ }
1181
+ if (!webhookSecret) {
1182
+ throw new Error(
1183
+ "No webhook secret provided. Either create a managed webhook or configure stripeWebhookSecret."
1184
+ );
1185
+ }
1186
+ const event = await this.stripe.webhooks.constructEventAsync(payload, signature, webhookSecret);
1187
+ return this.processEvent(event);
1188
+ }
1189
+ // Event handler registry - maps event types to handler functions
1190
+ // Note: Uses 'any' for event parameter to allow handlers with specific Stripe event types
1191
+ // (e.g., CustomerDeletedEvent, ProductDeletedEvent) which TypeScript won't accept
1192
+ // as contravariant parameters when using the base Stripe.Event type
1193
+ eventHandlers = {
1194
+ "charge.captured": this.handleChargeEvent.bind(this),
1195
+ "charge.expired": this.handleChargeEvent.bind(this),
1196
+ "charge.failed": this.handleChargeEvent.bind(this),
1197
+ "charge.pending": this.handleChargeEvent.bind(this),
1198
+ "charge.refunded": this.handleChargeEvent.bind(this),
1199
+ "charge.succeeded": this.handleChargeEvent.bind(this),
1200
+ "charge.updated": this.handleChargeEvent.bind(this),
1201
+ "customer.deleted": this.handleCustomerDeletedEvent.bind(this),
1202
+ "customer.created": this.handleCustomerEvent.bind(this),
1203
+ "customer.updated": this.handleCustomerEvent.bind(this),
1204
+ "checkout.session.async_payment_failed": this.handleCheckoutSessionEvent.bind(this),
1205
+ "checkout.session.async_payment_succeeded": this.handleCheckoutSessionEvent.bind(this),
1206
+ "checkout.session.completed": this.handleCheckoutSessionEvent.bind(this),
1207
+ "checkout.session.expired": this.handleCheckoutSessionEvent.bind(this),
1208
+ "customer.subscription.created": this.handleSubscriptionEvent.bind(this),
1209
+ "customer.subscription.deleted": this.handleSubscriptionEvent.bind(this),
1210
+ "customer.subscription.paused": this.handleSubscriptionEvent.bind(this),
1211
+ "customer.subscription.pending_update_applied": this.handleSubscriptionEvent.bind(this),
1212
+ "customer.subscription.pending_update_expired": this.handleSubscriptionEvent.bind(this),
1213
+ "customer.subscription.trial_will_end": this.handleSubscriptionEvent.bind(this),
1214
+ "customer.subscription.resumed": this.handleSubscriptionEvent.bind(this),
1215
+ "customer.subscription.updated": this.handleSubscriptionEvent.bind(this),
1216
+ "customer.tax_id.updated": this.handleTaxIdEvent.bind(this),
1217
+ "customer.tax_id.created": this.handleTaxIdEvent.bind(this),
1218
+ "customer.tax_id.deleted": this.handleTaxIdDeletedEvent.bind(this),
1219
+ "invoice.created": this.handleInvoiceEvent.bind(this),
1220
+ "invoice.deleted": this.handleInvoiceEvent.bind(this),
1221
+ "invoice.finalized": this.handleInvoiceEvent.bind(this),
1222
+ "invoice.finalization_failed": this.handleInvoiceEvent.bind(this),
1223
+ "invoice.paid": this.handleInvoiceEvent.bind(this),
1224
+ "invoice.payment_action_required": this.handleInvoiceEvent.bind(this),
1225
+ "invoice.payment_failed": this.handleInvoiceEvent.bind(this),
1226
+ "invoice.payment_succeeded": this.handleInvoiceEvent.bind(this),
1227
+ "invoice.upcoming": this.handleInvoiceEvent.bind(this),
1228
+ "invoice.sent": this.handleInvoiceEvent.bind(this),
1229
+ "invoice.voided": this.handleInvoiceEvent.bind(this),
1230
+ "invoice.marked_uncollectible": this.handleInvoiceEvent.bind(this),
1231
+ "invoice.updated": this.handleInvoiceEvent.bind(this),
1232
+ "product.created": this.handleProductEvent.bind(this),
1233
+ "product.updated": this.handleProductEvent.bind(this),
1234
+ "product.deleted": this.handleProductDeletedEvent.bind(this),
1235
+ "price.created": this.handlePriceEvent.bind(this),
1236
+ "price.updated": this.handlePriceEvent.bind(this),
1237
+ "price.deleted": this.handlePriceDeletedEvent.bind(this),
1238
+ "plan.created": this.handlePlanEvent.bind(this),
1239
+ "plan.updated": this.handlePlanEvent.bind(this),
1240
+ "plan.deleted": this.handlePlanDeletedEvent.bind(this),
1241
+ "setup_intent.canceled": this.handleSetupIntentEvent.bind(this),
1242
+ "setup_intent.created": this.handleSetupIntentEvent.bind(this),
1243
+ "setup_intent.requires_action": this.handleSetupIntentEvent.bind(this),
1244
+ "setup_intent.setup_failed": this.handleSetupIntentEvent.bind(this),
1245
+ "setup_intent.succeeded": this.handleSetupIntentEvent.bind(this),
1246
+ "subscription_schedule.aborted": this.handleSubscriptionScheduleEvent.bind(this),
1247
+ "subscription_schedule.canceled": this.handleSubscriptionScheduleEvent.bind(this),
1248
+ "subscription_schedule.completed": this.handleSubscriptionScheduleEvent.bind(this),
1249
+ "subscription_schedule.created": this.handleSubscriptionScheduleEvent.bind(this),
1250
+ "subscription_schedule.expiring": this.handleSubscriptionScheduleEvent.bind(this),
1251
+ "subscription_schedule.released": this.handleSubscriptionScheduleEvent.bind(this),
1252
+ "subscription_schedule.updated": this.handleSubscriptionScheduleEvent.bind(this),
1253
+ "payment_method.attached": this.handlePaymentMethodEvent.bind(this),
1254
+ "payment_method.automatically_updated": this.handlePaymentMethodEvent.bind(this),
1255
+ "payment_method.detached": this.handlePaymentMethodEvent.bind(this),
1256
+ "payment_method.updated": this.handlePaymentMethodEvent.bind(this),
1257
+ "charge.dispute.created": this.handleDisputeEvent.bind(this),
1258
+ "charge.dispute.funds_reinstated": this.handleDisputeEvent.bind(this),
1259
+ "charge.dispute.funds_withdrawn": this.handleDisputeEvent.bind(this),
1260
+ "charge.dispute.updated": this.handleDisputeEvent.bind(this),
1261
+ "charge.dispute.closed": this.handleDisputeEvent.bind(this),
1262
+ "payment_intent.amount_capturable_updated": this.handlePaymentIntentEvent.bind(this),
1263
+ "payment_intent.canceled": this.handlePaymentIntentEvent.bind(this),
1264
+ "payment_intent.created": this.handlePaymentIntentEvent.bind(this),
1265
+ "payment_intent.partially_funded": this.handlePaymentIntentEvent.bind(this),
1266
+ "payment_intent.payment_failed": this.handlePaymentIntentEvent.bind(this),
1267
+ "payment_intent.processing": this.handlePaymentIntentEvent.bind(this),
1268
+ "payment_intent.requires_action": this.handlePaymentIntentEvent.bind(this),
1269
+ "payment_intent.succeeded": this.handlePaymentIntentEvent.bind(this),
1270
+ "credit_note.created": this.handleCreditNoteEvent.bind(this),
1271
+ "credit_note.updated": this.handleCreditNoteEvent.bind(this),
1272
+ "credit_note.voided": this.handleCreditNoteEvent.bind(this),
1273
+ "radar.early_fraud_warning.created": this.handleEarlyFraudWarningEvent.bind(this),
1274
+ "radar.early_fraud_warning.updated": this.handleEarlyFraudWarningEvent.bind(this),
1275
+ "refund.created": this.handleRefundEvent.bind(this),
1276
+ "refund.failed": this.handleRefundEvent.bind(this),
1277
+ "refund.updated": this.handleRefundEvent.bind(this),
1278
+ "charge.refund.updated": this.handleRefundEvent.bind(this),
1279
+ "review.closed": this.handleReviewEvent.bind(this),
1280
+ "review.opened": this.handleReviewEvent.bind(this),
1281
+ "entitlements.active_entitlement_summary.updated": this.handleEntitlementSummaryEvent.bind(this)
1282
+ };
1283
+ // Resource registry - maps SyncObject → list/upsert operations for processNext()
1284
+ // Complements eventHandlers which maps event types → handlers for webhooks
1285
+ // Both registries share the same underlying upsert methods
1286
+ // Order field determines backfill sequence - parents before children for FK dependencies
1287
+ resourceRegistry = {
1288
+ product: {
1289
+ order: 1,
1290
+ // No dependencies
1291
+ listFn: (p) => this.stripe.products.list(p),
1292
+ upsertFn: (items, id) => this.upsertProducts(items, id),
1293
+ supportsCreatedFilter: true
1294
+ },
1295
+ price: {
1296
+ order: 2,
1297
+ // Depends on product
1298
+ listFn: (p) => this.stripe.prices.list(p),
1299
+ upsertFn: (items, id, bf) => this.upsertPrices(items, id, bf),
1300
+ supportsCreatedFilter: true
1301
+ },
1302
+ plan: {
1303
+ order: 3,
1304
+ // Depends on product
1305
+ listFn: (p) => this.stripe.plans.list(p),
1306
+ upsertFn: (items, id, bf) => this.upsertPlans(items, id, bf),
1307
+ supportsCreatedFilter: true
1308
+ },
1309
+ customer: {
1310
+ order: 4,
1311
+ // No dependencies
1312
+ listFn: (p) => this.stripe.customers.list(p),
1313
+ upsertFn: (items, id) => this.upsertCustomers(items, id),
1314
+ supportsCreatedFilter: true
1315
+ },
1316
+ subscription: {
1317
+ order: 5,
1318
+ // Depends on customer, price
1319
+ listFn: (p) => this.stripe.subscriptions.list(p),
1320
+ upsertFn: (items, id, bf) => this.upsertSubscriptions(items, id, bf),
1321
+ supportsCreatedFilter: true
1322
+ },
1323
+ subscription_schedules: {
1324
+ order: 6,
1325
+ // Depends on customer
1326
+ listFn: (p) => this.stripe.subscriptionSchedules.list(p),
1327
+ upsertFn: (items, id, bf) => this.upsertSubscriptionSchedules(items, id, bf),
1328
+ supportsCreatedFilter: true
1329
+ },
1330
+ invoice: {
1331
+ order: 7,
1332
+ // Depends on customer, subscription
1333
+ listFn: (p) => this.stripe.invoices.list(p),
1334
+ upsertFn: (items, id, bf) => this.upsertInvoices(items, id, bf),
1335
+ supportsCreatedFilter: true
1336
+ },
1337
+ charge: {
1338
+ order: 8,
1339
+ // Depends on customer, invoice
1340
+ listFn: (p) => this.stripe.charges.list(p),
1341
+ upsertFn: (items, id, bf) => this.upsertCharges(items, id, bf),
1342
+ supportsCreatedFilter: true
1343
+ },
1344
+ setup_intent: {
1345
+ order: 9,
1346
+ // Depends on customer
1347
+ listFn: (p) => this.stripe.setupIntents.list(p),
1348
+ upsertFn: (items, id, bf) => this.upsertSetupIntents(items, id, bf),
1349
+ supportsCreatedFilter: true
1350
+ },
1351
+ payment_method: {
1352
+ order: 10,
1353
+ // Depends on customer (special: iterates customers)
1354
+ listFn: (p) => this.stripe.paymentMethods.list(p),
1355
+ upsertFn: (items, id, bf) => this.upsertPaymentMethods(items, id, bf),
1356
+ supportsCreatedFilter: false
1357
+ // Requires customer param, can't filter by created
1358
+ },
1359
+ payment_intent: {
1360
+ order: 11,
1361
+ // Depends on customer
1362
+ listFn: (p) => this.stripe.paymentIntents.list(p),
1363
+ upsertFn: (items, id, bf) => this.upsertPaymentIntents(items, id, bf),
1364
+ supportsCreatedFilter: true
1365
+ },
1366
+ tax_id: {
1367
+ order: 12,
1368
+ // Depends on customer
1369
+ listFn: (p) => this.stripe.taxIds.list(p),
1370
+ upsertFn: (items, id, bf) => this.upsertTaxIds(items, id, bf),
1371
+ supportsCreatedFilter: false
1372
+ // taxIds don't support created filter
1373
+ },
1374
+ credit_note: {
1375
+ order: 13,
1376
+ // Depends on invoice
1377
+ listFn: (p) => this.stripe.creditNotes.list(p),
1378
+ upsertFn: (items, id, bf) => this.upsertCreditNotes(items, id, bf),
1379
+ supportsCreatedFilter: true
1380
+ // credit_notes support created filter
1381
+ },
1382
+ dispute: {
1383
+ order: 14,
1384
+ // Depends on charge
1385
+ listFn: (p) => this.stripe.disputes.list(p),
1386
+ upsertFn: (items, id, bf) => this.upsertDisputes(items, id, bf),
1387
+ supportsCreatedFilter: true
1388
+ },
1389
+ early_fraud_warning: {
1390
+ order: 15,
1391
+ // Depends on charge
1392
+ listFn: (p) => this.stripe.radar.earlyFraudWarnings.list(p),
1393
+ upsertFn: (items, id) => this.upsertEarlyFraudWarning(items, id),
1394
+ supportsCreatedFilter: true
1395
+ },
1396
+ refund: {
1397
+ order: 16,
1398
+ // Depends on charge
1399
+ listFn: (p) => this.stripe.refunds.list(p),
1400
+ upsertFn: (items, id, bf) => this.upsertRefunds(items, id, bf),
1401
+ supportsCreatedFilter: true
1402
+ },
1403
+ checkout_sessions: {
1404
+ order: 17,
1405
+ // Depends on customer (optional)
1406
+ listFn: (p) => this.stripe.checkout.sessions.list(p),
1407
+ upsertFn: (items, id) => this.upsertCheckoutSessions(items, id),
1408
+ supportsCreatedFilter: true
1409
+ }
1410
+ };
1411
+ async processEvent(event) {
1412
+ const objectAccountId = event.data?.object && typeof event.data.object === "object" && "account" in event.data.object ? event.data.object.account : void 0;
1413
+ const accountId = await this.getAccountId(objectAccountId);
1414
+ await this.getCurrentAccount();
1415
+ const handler = this.eventHandlers[event.type];
1416
+ if (handler) {
1417
+ const entityId = event.data?.object && typeof event.data.object === "object" && "id" in event.data.object ? event.data.object.id : "unknown";
1418
+ this.config.logger?.info(`Received webhook ${event.id}: ${event.type} for ${entityId}`);
1419
+ await handler(event, accountId);
1420
+ } else {
1421
+ this.config.logger?.warn(
1422
+ `Received unhandled webhook event: ${event.type} (${event.id}). Ignoring.`
1423
+ );
1424
+ }
1425
+ }
1426
+ /**
1427
+ * Returns an array of all webhook event types that this sync engine can handle.
1428
+ * Useful for configuring webhook endpoints with specific event subscriptions.
1429
+ */
1430
+ getSupportedEventTypes() {
1431
+ return Object.keys(
1432
+ this.eventHandlers
1433
+ ).sort();
1434
+ }
1435
+ /**
1436
+ * Returns an array of all object types that can be synced via processNext/processUntilDone.
1437
+ * Ordered for backfill: parents before children (products before prices, customers before subscriptions).
1438
+ * Order is determined by the `order` field in resourceRegistry.
1439
+ */
1440
+ getSupportedSyncObjects() {
1441
+ return Object.entries(this.resourceRegistry).sort(([, a], [, b]) => a.order - b.order).map(([key]) => key);
1442
+ }
1443
+ // Event handler methods
1444
+ async handleChargeEvent(event, accountId) {
1445
+ const { entity: charge, refetched } = await this.fetchOrUseWebhookData(
1446
+ event.data.object,
1447
+ (id) => this.stripe.charges.retrieve(id),
1448
+ (charge2) => charge2.status === "failed" || charge2.status === "succeeded"
1449
+ );
1450
+ await this.upsertCharges([charge], accountId, false, this.getSyncTimestamp(event, refetched));
1451
+ }
1452
+ async handleCustomerDeletedEvent(event, accountId) {
1453
+ const customer = {
1454
+ id: event.data.object.id,
1455
+ object: "customer",
1456
+ deleted: true
1457
+ };
1458
+ await this.upsertCustomers([customer], accountId, this.getSyncTimestamp(event, false));
1459
+ }
1460
+ async handleCustomerEvent(event, accountId) {
1461
+ const { entity: customer, refetched } = await this.fetchOrUseWebhookData(
1462
+ event.data.object,
1463
+ (id) => this.stripe.customers.retrieve(id),
1464
+ (customer2) => customer2.deleted === true
1465
+ );
1466
+ await this.upsertCustomers([customer], accountId, this.getSyncTimestamp(event, refetched));
1467
+ }
1468
+ async handleCheckoutSessionEvent(event, accountId) {
1469
+ const { entity: checkoutSession, refetched } = await this.fetchOrUseWebhookData(
1470
+ event.data.object,
1471
+ (id) => this.stripe.checkout.sessions.retrieve(id)
1472
+ );
1473
+ await this.upsertCheckoutSessions(
1474
+ [checkoutSession],
1475
+ accountId,
1476
+ false,
1477
+ this.getSyncTimestamp(event, refetched)
1478
+ );
1479
+ }
1480
+ async handleSubscriptionEvent(event, accountId) {
1481
+ const { entity: subscription, refetched } = await this.fetchOrUseWebhookData(
1482
+ event.data.object,
1483
+ (id) => this.stripe.subscriptions.retrieve(id),
1484
+ (subscription2) => subscription2.status === "canceled" || subscription2.status === "incomplete_expired"
1485
+ );
1486
+ await this.upsertSubscriptions(
1487
+ [subscription],
1488
+ accountId,
1489
+ false,
1490
+ this.getSyncTimestamp(event, refetched)
1491
+ );
1492
+ }
1493
+ async handleTaxIdEvent(event, accountId) {
1494
+ const { entity: taxId, refetched } = await this.fetchOrUseWebhookData(
1495
+ event.data.object,
1496
+ (id) => this.stripe.taxIds.retrieve(id)
1497
+ );
1498
+ await this.upsertTaxIds([taxId], accountId, false, this.getSyncTimestamp(event, refetched));
1499
+ }
1500
+ async handleTaxIdDeletedEvent(event, _accountId) {
1501
+ const taxId = event.data.object;
1502
+ await this.deleteTaxId(taxId.id);
1503
+ }
1504
+ async handleInvoiceEvent(event, accountId) {
1505
+ const { entity: invoice, refetched } = await this.fetchOrUseWebhookData(
1506
+ event.data.object,
1507
+ (id) => this.stripe.invoices.retrieve(id),
1508
+ (invoice2) => invoice2.status === "void"
1509
+ );
1510
+ await this.upsertInvoices([invoice], accountId, false, this.getSyncTimestamp(event, refetched));
1511
+ }
1512
+ async handleProductEvent(event, accountId) {
1513
+ try {
1514
+ const { entity: product, refetched } = await this.fetchOrUseWebhookData(
1515
+ event.data.object,
1516
+ (id) => this.stripe.products.retrieve(id)
1517
+ );
1518
+ await this.upsertProducts([product], accountId, this.getSyncTimestamp(event, refetched));
1519
+ } catch (err) {
1520
+ if (err instanceof import_stripe2.default.errors.StripeAPIError && err.code === "resource_missing") {
1521
+ const product = event.data.object;
1522
+ await this.deleteProduct(product.id);
1523
+ } else {
1524
+ throw err;
1525
+ }
1526
+ }
1527
+ }
1528
+ async handleProductDeletedEvent(event, _accountId) {
1529
+ const product = event.data.object;
1530
+ await this.deleteProduct(product.id);
1531
+ }
1532
+ async handlePriceEvent(event, accountId) {
1533
+ try {
1534
+ const { entity: price, refetched } = await this.fetchOrUseWebhookData(
1535
+ event.data.object,
1536
+ (id) => this.stripe.prices.retrieve(id)
1537
+ );
1538
+ await this.upsertPrices([price], accountId, false, this.getSyncTimestamp(event, refetched));
1539
+ } catch (err) {
1540
+ if (err instanceof import_stripe2.default.errors.StripeAPIError && err.code === "resource_missing") {
1541
+ const price = event.data.object;
1542
+ await this.deletePrice(price.id);
1543
+ } else {
1544
+ throw err;
1545
+ }
1546
+ }
1547
+ }
1548
+ async handlePriceDeletedEvent(event, _accountId) {
1549
+ const price = event.data.object;
1550
+ await this.deletePrice(price.id);
1551
+ }
1552
+ async handlePlanEvent(event, accountId) {
1553
+ try {
1554
+ const { entity: plan, refetched } = await this.fetchOrUseWebhookData(
1555
+ event.data.object,
1556
+ (id) => this.stripe.plans.retrieve(id)
1557
+ );
1558
+ await this.upsertPlans([plan], accountId, false, this.getSyncTimestamp(event, refetched));
1559
+ } catch (err) {
1560
+ if (err instanceof import_stripe2.default.errors.StripeAPIError && err.code === "resource_missing") {
1561
+ const plan = event.data.object;
1562
+ await this.deletePlan(plan.id);
1563
+ } else {
1564
+ throw err;
1565
+ }
1566
+ }
1567
+ }
1568
+ async handlePlanDeletedEvent(event, _accountId) {
1569
+ const plan = event.data.object;
1570
+ await this.deletePlan(plan.id);
1571
+ }
1572
+ async handleSetupIntentEvent(event, accountId) {
1573
+ const { entity: setupIntent, refetched } = await this.fetchOrUseWebhookData(
1574
+ event.data.object,
1575
+ (id) => this.stripe.setupIntents.retrieve(id),
1576
+ (setupIntent2) => setupIntent2.status === "canceled" || setupIntent2.status === "succeeded"
1577
+ );
1578
+ await this.upsertSetupIntents(
1579
+ [setupIntent],
1580
+ accountId,
1581
+ false,
1582
+ this.getSyncTimestamp(event, refetched)
1583
+ );
1584
+ }
1585
+ async handleSubscriptionScheduleEvent(event, accountId) {
1586
+ const { entity: subscriptionSchedule, refetched } = await this.fetchOrUseWebhookData(
1587
+ event.data.object,
1588
+ (id) => this.stripe.subscriptionSchedules.retrieve(id),
1589
+ (schedule) => schedule.status === "canceled" || schedule.status === "completed"
1590
+ );
1591
+ await this.upsertSubscriptionSchedules(
1592
+ [subscriptionSchedule],
1593
+ accountId,
1594
+ false,
1595
+ this.getSyncTimestamp(event, refetched)
1596
+ );
1597
+ }
1598
+ async handlePaymentMethodEvent(event, accountId) {
1599
+ const { entity: paymentMethod, refetched } = await this.fetchOrUseWebhookData(
1600
+ event.data.object,
1601
+ (id) => this.stripe.paymentMethods.retrieve(id)
1602
+ );
1603
+ await this.upsertPaymentMethods(
1604
+ [paymentMethod],
1605
+ accountId,
1606
+ false,
1607
+ this.getSyncTimestamp(event, refetched)
1608
+ );
1609
+ }
1610
+ async handleDisputeEvent(event, accountId) {
1611
+ const { entity: dispute, refetched } = await this.fetchOrUseWebhookData(
1612
+ event.data.object,
1613
+ (id) => this.stripe.disputes.retrieve(id),
1614
+ (dispute2) => dispute2.status === "won" || dispute2.status === "lost"
1615
+ );
1616
+ await this.upsertDisputes([dispute], accountId, false, this.getSyncTimestamp(event, refetched));
1617
+ }
1618
+ async handlePaymentIntentEvent(event, accountId) {
1619
+ const { entity: paymentIntent, refetched } = await this.fetchOrUseWebhookData(
1620
+ event.data.object,
1621
+ (id) => this.stripe.paymentIntents.retrieve(id),
1622
+ // Final states - do not re-fetch from API
1623
+ (entity) => entity.status === "canceled" || entity.status === "succeeded"
1624
+ );
1625
+ await this.upsertPaymentIntents(
1626
+ [paymentIntent],
1627
+ accountId,
1628
+ false,
1629
+ this.getSyncTimestamp(event, refetched)
1630
+ );
1631
+ }
1632
+ async handleCreditNoteEvent(event, accountId) {
1633
+ const { entity: creditNote, refetched } = await this.fetchOrUseWebhookData(
1634
+ event.data.object,
1635
+ (id) => this.stripe.creditNotes.retrieve(id),
1636
+ (creditNote2) => creditNote2.status === "void"
1637
+ );
1638
+ await this.upsertCreditNotes(
1639
+ [creditNote],
1640
+ accountId,
1641
+ false,
1642
+ this.getSyncTimestamp(event, refetched)
1643
+ );
1644
+ }
1645
+ async handleEarlyFraudWarningEvent(event, accountId) {
1646
+ const { entity: earlyFraudWarning, refetched } = await this.fetchOrUseWebhookData(
1647
+ event.data.object,
1648
+ (id) => this.stripe.radar.earlyFraudWarnings.retrieve(id)
1649
+ );
1650
+ await this.upsertEarlyFraudWarning(
1651
+ [earlyFraudWarning],
1652
+ accountId,
1653
+ false,
1654
+ this.getSyncTimestamp(event, refetched)
1655
+ );
1656
+ }
1657
+ async handleRefundEvent(event, accountId) {
1658
+ const { entity: refund, refetched } = await this.fetchOrUseWebhookData(
1659
+ event.data.object,
1660
+ (id) => this.stripe.refunds.retrieve(id)
1661
+ );
1662
+ await this.upsertRefunds([refund], accountId, false, this.getSyncTimestamp(event, refetched));
1663
+ }
1664
+ async handleReviewEvent(event, accountId) {
1665
+ const { entity: review, refetched } = await this.fetchOrUseWebhookData(
1666
+ event.data.object,
1667
+ (id) => this.stripe.reviews.retrieve(id)
1668
+ );
1669
+ await this.upsertReviews([review], accountId, false, this.getSyncTimestamp(event, refetched));
1670
+ }
1671
+ async handleEntitlementSummaryEvent(event, accountId) {
1672
+ const activeEntitlementSummary = event.data.object;
1673
+ let entitlements = activeEntitlementSummary.entitlements;
1674
+ let refetched = false;
1675
+ if (this.config.revalidateObjectsViaStripeApi?.includes("entitlements")) {
1676
+ const { lastResponse, ...rest } = await this.stripe.entitlements.activeEntitlements.list({
1677
+ customer: activeEntitlementSummary.customer
1678
+ });
1679
+ entitlements = rest;
1680
+ refetched = true;
1681
+ }
1682
+ await this.deleteRemovedActiveEntitlements(
1683
+ activeEntitlementSummary.customer,
1684
+ entitlements.data.map((entitlement) => entitlement.id)
1685
+ );
1686
+ await this.upsertActiveEntitlements(
1687
+ activeEntitlementSummary.customer,
1688
+ entitlements.data,
1689
+ accountId,
1690
+ false,
1691
+ this.getSyncTimestamp(event, refetched)
1692
+ );
1693
+ }
1694
+ getSyncTimestamp(event, refetched) {
1695
+ return refetched ? (/* @__PURE__ */ new Date()).toISOString() : new Date(event.created * 1e3).toISOString();
1696
+ }
1697
+ shouldRefetchEntity(entity) {
1698
+ return this.config.revalidateObjectsViaStripeApi?.includes(entity.object);
1699
+ }
1700
+ async fetchOrUseWebhookData(entity, fetchFn, entityInFinalState) {
1701
+ if (!entity.id) return { entity, refetched: false };
1702
+ if (entityInFinalState && entityInFinalState(entity)) return { entity, refetched: false };
1703
+ if (this.shouldRefetchEntity(entity)) {
1704
+ const fetchedEntity = await fetchFn(entity.id);
1705
+ return { entity: fetchedEntity, refetched: true };
1706
+ }
1707
+ return { entity, refetched: false };
1708
+ }
1709
+ async syncSingleEntity(stripeId) {
1710
+ const accountId = await this.getAccountId();
1711
+ if (stripeId.startsWith("cus_")) {
1712
+ return this.stripe.customers.retrieve(stripeId).then((it) => {
1713
+ if (!it || it.deleted) return;
1714
+ return this.upsertCustomers([it], accountId);
1715
+ });
1716
+ } else if (stripeId.startsWith("in_")) {
1717
+ return this.stripe.invoices.retrieve(stripeId).then((it) => this.upsertInvoices([it], accountId));
1718
+ } else if (stripeId.startsWith("price_")) {
1719
+ return this.stripe.prices.retrieve(stripeId).then((it) => this.upsertPrices([it], accountId));
1720
+ } else if (stripeId.startsWith("prod_")) {
1721
+ return this.stripe.products.retrieve(stripeId).then((it) => this.upsertProducts([it], accountId));
1722
+ } else if (stripeId.startsWith("sub_")) {
1723
+ return this.stripe.subscriptions.retrieve(stripeId).then((it) => this.upsertSubscriptions([it], accountId));
1724
+ } else if (stripeId.startsWith("seti_")) {
1725
+ return this.stripe.setupIntents.retrieve(stripeId).then((it) => this.upsertSetupIntents([it], accountId));
1726
+ } else if (stripeId.startsWith("pm_")) {
1727
+ return this.stripe.paymentMethods.retrieve(stripeId).then((it) => this.upsertPaymentMethods([it], accountId));
1728
+ } else if (stripeId.startsWith("dp_") || stripeId.startsWith("du_")) {
1729
+ return this.stripe.disputes.retrieve(stripeId).then((it) => this.upsertDisputes([it], accountId));
1730
+ } else if (stripeId.startsWith("ch_")) {
1731
+ return this.stripe.charges.retrieve(stripeId).then((it) => this.upsertCharges([it], accountId, true));
1732
+ } else if (stripeId.startsWith("pi_")) {
1733
+ return this.stripe.paymentIntents.retrieve(stripeId).then((it) => this.upsertPaymentIntents([it], accountId));
1734
+ } else if (stripeId.startsWith("txi_")) {
1735
+ return this.stripe.taxIds.retrieve(stripeId).then((it) => this.upsertTaxIds([it], accountId));
1736
+ } else if (stripeId.startsWith("cn_")) {
1737
+ return this.stripe.creditNotes.retrieve(stripeId).then((it) => this.upsertCreditNotes([it], accountId));
1738
+ } else if (stripeId.startsWith("issfr_")) {
1739
+ return this.stripe.radar.earlyFraudWarnings.retrieve(stripeId).then((it) => this.upsertEarlyFraudWarning([it], accountId));
1740
+ } else if (stripeId.startsWith("prv_")) {
1741
+ return this.stripe.reviews.retrieve(stripeId).then((it) => this.upsertReviews([it], accountId));
1742
+ } else if (stripeId.startsWith("re_")) {
1743
+ return this.stripe.refunds.retrieve(stripeId).then((it) => this.upsertRefunds([it], accountId));
1744
+ } else if (stripeId.startsWith("feat_")) {
1745
+ return this.stripe.entitlements.features.retrieve(stripeId).then((it) => this.upsertFeatures([it], accountId));
1746
+ } else if (stripeId.startsWith("cs_")) {
1747
+ return this.stripe.checkout.sessions.retrieve(stripeId).then((it) => this.upsertCheckoutSessions([it], accountId));
1748
+ }
1749
+ }
1750
+ /**
1751
+ * Process one page of items for the specified object type.
1752
+ * Returns the number of items processed and whether there are more pages.
1753
+ *
1754
+ * This method is designed for queue-based consumption where each page
1755
+ * is processed as a separate job. Uses the observable sync system for tracking.
1756
+ *
1757
+ * @param object - The Stripe object type to sync (e.g., 'customer', 'product')
1758
+ * @param params - Optional parameters for filtering and run context
1759
+ * @returns ProcessNextResult with processed count, hasMore flag, and runStartedAt
1760
+ *
1761
+ * @example
1762
+ * ```typescript
1763
+ * // Queue worker
1764
+ * const { hasMore, runStartedAt } = await stripeSync.processNext('customer')
1765
+ * if (hasMore) {
1766
+ * await queue.send({ object: 'customer', runStartedAt })
1767
+ * }
1768
+ * ```
1769
+ */
1770
+ async processNext(object, params) {
1771
+ await this.getCurrentAccount();
1772
+ const accountId = await this.getAccountId();
1773
+ const resourceName = this.getResourceName(object);
1774
+ let runStartedAt;
1775
+ if (params?.runStartedAt) {
1776
+ runStartedAt = params.runStartedAt;
1777
+ } else {
1778
+ const { runKey } = await this.joinOrCreateSyncRun(params?.triggeredBy ?? "processNext");
1779
+ runStartedAt = runKey.runStartedAt;
1780
+ }
1781
+ await this.postgresClient.createObjectRuns(accountId, runStartedAt, [resourceName]);
1782
+ const objRun = await this.postgresClient.getObjectRun(accountId, runStartedAt, resourceName);
1783
+ if (objRun?.status === "complete" || objRun?.status === "error") {
1784
+ return {
1785
+ processed: 0,
1786
+ hasMore: false,
1787
+ runStartedAt
1788
+ };
1789
+ }
1790
+ if (objRun?.status === "pending") {
1791
+ const started = await this.postgresClient.tryStartObjectSync(
1792
+ accountId,
1793
+ runStartedAt,
1794
+ resourceName
1795
+ );
1796
+ if (!started) {
1797
+ return {
1798
+ processed: 0,
1799
+ hasMore: true,
1800
+ runStartedAt
1801
+ };
1802
+ }
1803
+ }
1804
+ let cursor = null;
1805
+ if (!params?.created) {
1806
+ if (objRun?.cursor) {
1807
+ cursor = parseInt(objRun.cursor);
1808
+ } else {
1809
+ const lastCursor = await this.postgresClient.getLastCompletedCursor(accountId, resourceName);
1810
+ cursor = lastCursor ? parseInt(lastCursor) : null;
1811
+ }
1812
+ }
1813
+ const result = await this.fetchOnePage(
1814
+ object,
1815
+ accountId,
1816
+ resourceName,
1817
+ runStartedAt,
1818
+ cursor,
1819
+ params
1820
+ );
1821
+ return result;
1822
+ }
1823
+ /**
1824
+ * Get the database resource name for a SyncObject type
1825
+ */
1826
+ getResourceName(object) {
1827
+ const mapping = {
1828
+ customer: "customers",
1829
+ invoice: "invoices",
1830
+ price: "prices",
1831
+ product: "products",
1832
+ subscription: "subscriptions",
1833
+ subscription_schedules: "subscription_schedules",
1834
+ setup_intent: "setup_intents",
1835
+ payment_method: "payment_methods",
1836
+ dispute: "disputes",
1837
+ charge: "charges",
1838
+ payment_intent: "payment_intents",
1839
+ plan: "plans",
1840
+ tax_id: "tax_ids",
1841
+ credit_note: "credit_notes",
1842
+ early_fraud_warning: "early_fraud_warnings",
1843
+ refund: "refunds",
1844
+ checkout_sessions: "checkout_sessions"
1845
+ };
1846
+ return mapping[object] || object;
1847
+ }
1848
+ /**
1849
+ * Fetch one page of items from Stripe and upsert to database.
1850
+ * Uses resourceRegistry for DRY list/upsert operations.
1851
+ * Uses the observable sync system for tracking progress.
1852
+ */
1853
+ async fetchOnePage(object, accountId, resourceName, runStartedAt, cursor, params) {
1854
+ const limit = 100;
1855
+ if (object === "payment_method" || object === "tax_id") {
1856
+ this.config.logger?.warn(`processNext for ${object} requires customer context`);
1857
+ await this.postgresClient.completeObjectSync(accountId, runStartedAt, resourceName);
1858
+ return { processed: 0, hasMore: false, runStartedAt };
1859
+ }
1860
+ const config = this.resourceRegistry[object];
1861
+ if (!config) {
1862
+ throw new Error(`Unsupported object type for processNext: ${object}`);
1863
+ }
1864
+ try {
1865
+ const listParams = { limit };
1866
+ if (config.supportsCreatedFilter) {
1867
+ const created = params?.created ?? (cursor ? { gte: cursor } : void 0);
1868
+ if (created) {
1869
+ listParams.created = created;
1870
+ }
1871
+ }
1872
+ const response = await config.listFn(listParams);
1873
+ if (response.data.length > 0) {
1874
+ this.config.logger?.info(`processNext: upserting ${response.data.length} ${resourceName}`);
1875
+ await config.upsertFn(response.data, accountId, params?.backfillRelatedEntities);
1876
+ await this.postgresClient.incrementObjectProgress(
1877
+ accountId,
1878
+ runStartedAt,
1879
+ resourceName,
1880
+ response.data.length
1881
+ );
1882
+ const maxCreated = Math.max(
1883
+ ...response.data.map((i) => i.created || 0)
1884
+ );
1885
+ if (maxCreated > 0) {
1886
+ await this.postgresClient.updateObjectCursor(
1887
+ accountId,
1888
+ runStartedAt,
1889
+ resourceName,
1890
+ String(maxCreated)
1891
+ );
1892
+ }
1893
+ }
1894
+ if (!response.has_more) {
1895
+ await this.postgresClient.completeObjectSync(accountId, runStartedAt, resourceName);
1896
+ }
1897
+ return {
1898
+ processed: response.data.length,
1899
+ hasMore: response.has_more,
1900
+ runStartedAt
1901
+ };
1902
+ } catch (error) {
1903
+ await this.postgresClient.failObjectSync(
1904
+ accountId,
1905
+ runStartedAt,
1906
+ resourceName,
1907
+ error instanceof Error ? error.message : "Unknown error"
1908
+ );
1909
+ throw error;
1910
+ }
1911
+ }
1912
+ /**
1913
+ * Process all pages for all (or specified) object types until complete.
1914
+ *
1915
+ * @param params - Optional parameters for filtering and specifying object types
1916
+ * @returns SyncBackfill with counts for each synced resource type
1917
+ */
1918
+ /**
1919
+ * Process all pages for a single object type until complete.
1920
+ * Loops processNext() internally until hasMore is false.
1921
+ *
1922
+ * @param object - The object type to sync
1923
+ * @param runStartedAt - The sync run to use (for sharing across objects)
1924
+ * @param params - Optional sync parameters
1925
+ * @returns Sync result with count of synced items
1926
+ */
1927
+ async processObjectUntilDone(object, runStartedAt, params) {
1928
+ let totalSynced = 0;
1929
+ while (true) {
1930
+ const result = await this.processNext(object, {
1931
+ ...params,
1932
+ runStartedAt,
1933
+ triggeredBy: "processUntilDone"
1934
+ });
1935
+ totalSynced += result.processed;
1936
+ if (!result.hasMore) {
1937
+ break;
1938
+ }
1939
+ }
1940
+ return { synced: totalSynced };
1941
+ }
1942
+ /**
1943
+ * Join existing sync run or create a new one.
1944
+ * Returns sync run key and list of supported objects to sync.
1945
+ *
1946
+ * Cooperative behavior: If a sync run already exists, joins it instead of failing.
1947
+ * This is used by workers and background processes that should cooperate.
1948
+ *
1949
+ * @param triggeredBy - What triggered this sync (for observability)
1950
+ * @returns Run key and list of objects to sync
1951
+ */
1952
+ async joinOrCreateSyncRun(triggeredBy = "worker") {
1953
+ await this.getCurrentAccount();
1954
+ const accountId = await this.getAccountId();
1955
+ const result = await this.postgresClient.getOrCreateSyncRun(accountId, triggeredBy);
1956
+ if (!result) {
1957
+ const activeRun = await this.postgresClient.getActiveSyncRun(accountId);
1958
+ if (!activeRun) {
1959
+ throw new Error("Failed to get or create sync run");
1960
+ }
1961
+ return {
1962
+ runKey: { accountId: activeRun.accountId, runStartedAt: activeRun.runStartedAt },
1963
+ objects: this.getSupportedSyncObjects()
1964
+ };
1965
+ }
1966
+ const { accountId: runAccountId, runStartedAt } = result;
1967
+ return {
1968
+ runKey: { accountId: runAccountId, runStartedAt },
1969
+ objects: this.getSupportedSyncObjects()
1970
+ };
1971
+ }
1972
+ async processUntilDone(params) {
1973
+ const { object } = params ?? { object: "all" };
1974
+ const { runKey } = await this.joinOrCreateSyncRun("processUntilDone");
1975
+ return this.processUntilDoneWithRun(runKey.runStartedAt, object, params);
1976
+ }
1977
+ /**
1978
+ * Internal implementation of processUntilDone with an existing run.
1979
+ */
1980
+ async processUntilDoneWithRun(runStartedAt, object, params) {
1981
+ const accountId = await this.getAccountId();
1982
+ const results = {};
1983
+ try {
1984
+ const objectsToSync = object === "all" || object === void 0 ? this.getSupportedSyncObjects() : [object];
1985
+ for (const obj of objectsToSync) {
1986
+ this.config.logger?.info(`Syncing ${obj}`);
1987
+ if (obj === "payment_method") {
1988
+ results.paymentMethods = await this.syncPaymentMethodsWithRun(runStartedAt, params);
1989
+ } else {
1990
+ const result = await this.processObjectUntilDone(obj, runStartedAt, params);
1991
+ switch (obj) {
1992
+ case "product":
1993
+ results.products = result;
1994
+ break;
1995
+ case "price":
1996
+ results.prices = result;
1997
+ break;
1998
+ case "plan":
1999
+ results.plans = result;
2000
+ break;
2001
+ case "customer":
2002
+ results.customers = result;
2003
+ break;
2004
+ case "subscription":
2005
+ results.subscriptions = result;
2006
+ break;
2007
+ case "subscription_schedules":
2008
+ results.subscriptionSchedules = result;
2009
+ break;
2010
+ case "invoice":
2011
+ results.invoices = result;
2012
+ break;
2013
+ case "charge":
2014
+ results.charges = result;
2015
+ break;
2016
+ case "setup_intent":
2017
+ results.setupIntents = result;
2018
+ break;
2019
+ case "payment_intent":
2020
+ results.paymentIntents = result;
2021
+ break;
2022
+ case "tax_id":
2023
+ results.taxIds = result;
2024
+ break;
2025
+ case "credit_note":
2026
+ results.creditNotes = result;
2027
+ break;
2028
+ case "dispute":
2029
+ results.disputes = result;
2030
+ break;
2031
+ case "early_fraud_warning":
2032
+ results.earlyFraudWarnings = result;
2033
+ break;
2034
+ case "refund":
2035
+ results.refunds = result;
2036
+ break;
2037
+ case "checkout_sessions":
2038
+ results.checkoutSessions = result;
2039
+ break;
2040
+ }
2041
+ }
2042
+ }
2043
+ await this.postgresClient.closeSyncRun(accountId, runStartedAt);
2044
+ return results;
2045
+ } catch (error) {
2046
+ await this.postgresClient.closeSyncRun(accountId, runStartedAt);
2047
+ throw error;
2048
+ }
2049
+ }
2050
+ /**
2051
+ * Sync payment methods with an existing run (special case - iterates customers)
2052
+ */
2053
+ async syncPaymentMethodsWithRun(runStartedAt, syncParams) {
2054
+ const accountId = await this.getAccountId();
2055
+ const resourceName = "payment_methods";
2056
+ await this.postgresClient.createObjectRuns(accountId, runStartedAt, [resourceName]);
2057
+ await this.postgresClient.tryStartObjectSync(accountId, runStartedAt, resourceName);
2058
+ try {
2059
+ const prepared = (0, import_yesql2.pg)(
2060
+ `select id from "stripe"."customers" WHERE COALESCE(deleted, false) <> true;`
2061
+ )([]);
2062
+ const customerIds = await this.postgresClient.query(prepared.text, prepared.values).then(({ rows }) => rows.map((it) => it.id));
2063
+ this.config.logger?.info(`Getting payment methods for ${customerIds.length} customers`);
2064
+ let synced = 0;
2065
+ for (const customerIdChunk of chunkArray(customerIds, 10)) {
2066
+ await Promise.all(
2067
+ customerIdChunk.map(async (customerId) => {
2068
+ const CHECKPOINT_SIZE = 100;
2069
+ let currentBatch = [];
2070
+ for await (const item of this.stripe.paymentMethods.list({
2071
+ limit: 100,
2072
+ customer: customerId
2073
+ })) {
2074
+ currentBatch.push(item);
2075
+ if (currentBatch.length >= CHECKPOINT_SIZE) {
2076
+ await this.upsertPaymentMethods(
2077
+ currentBatch,
2078
+ accountId,
2079
+ syncParams?.backfillRelatedEntities
2080
+ );
2081
+ synced += currentBatch.length;
2082
+ await this.postgresClient.incrementObjectProgress(
2083
+ accountId,
2084
+ runStartedAt,
2085
+ resourceName,
2086
+ currentBatch.length
2087
+ );
2088
+ currentBatch = [];
2089
+ }
2090
+ }
2091
+ if (currentBatch.length > 0) {
2092
+ await this.upsertPaymentMethods(
2093
+ currentBatch,
2094
+ accountId,
2095
+ syncParams?.backfillRelatedEntities
2096
+ );
2097
+ synced += currentBatch.length;
2098
+ await this.postgresClient.incrementObjectProgress(
2099
+ accountId,
2100
+ runStartedAt,
2101
+ resourceName,
2102
+ currentBatch.length
2103
+ );
2104
+ }
2105
+ })
2106
+ );
2107
+ }
2108
+ await this.postgresClient.completeObjectSync(accountId, runStartedAt, resourceName);
2109
+ return { synced };
2110
+ } catch (error) {
2111
+ await this.postgresClient.failObjectSync(
2112
+ accountId,
2113
+ runStartedAt,
2114
+ resourceName,
2115
+ error instanceof Error ? error.message : "Unknown error"
2116
+ );
2117
+ throw error;
2118
+ }
2119
+ }
2120
+ async syncProducts(syncParams) {
2121
+ this.config.logger?.info("Syncing products");
2122
+ return this.withSyncRun("products", "syncProducts", async (cursor, runStartedAt) => {
2123
+ const accountId = await this.getAccountId();
2124
+ const params = { limit: 100 };
2125
+ if (syncParams?.created) {
2126
+ params.created = syncParams.created;
2127
+ } else if (cursor) {
2128
+ params.created = { gte: cursor };
2129
+ this.config.logger?.info(`Incremental sync from cursor: ${cursor}`);
2130
+ }
2131
+ return this.fetchAndUpsert(
2132
+ () => this.stripe.products.list(params),
2133
+ (products) => this.upsertProducts(products, accountId),
2134
+ accountId,
2135
+ "products",
2136
+ runStartedAt
2137
+ );
2138
+ });
2139
+ }
2140
+ async syncPrices(syncParams) {
2141
+ this.config.logger?.info("Syncing prices");
2142
+ return this.withSyncRun("prices", "syncPrices", async (cursor, runStartedAt) => {
2143
+ const accountId = await this.getAccountId();
2144
+ const params = { limit: 100 };
2145
+ if (syncParams?.created) {
2146
+ params.created = syncParams.created;
2147
+ } else if (cursor) {
2148
+ params.created = { gte: cursor };
2149
+ this.config.logger?.info(`Incremental sync from cursor: ${cursor}`);
2150
+ }
2151
+ return this.fetchAndUpsert(
2152
+ () => this.stripe.prices.list(params),
2153
+ (prices) => this.upsertPrices(prices, accountId, syncParams?.backfillRelatedEntities),
2154
+ accountId,
2155
+ "prices",
2156
+ runStartedAt
2157
+ );
2158
+ });
2159
+ }
2160
+ async syncPlans(syncParams) {
2161
+ this.config.logger?.info("Syncing plans");
2162
+ return this.withSyncRun("plans", "syncPlans", async (cursor, runStartedAt) => {
2163
+ const accountId = await this.getAccountId();
2164
+ const params = { limit: 100 };
2165
+ if (syncParams?.created) {
2166
+ params.created = syncParams.created;
2167
+ } else if (cursor) {
2168
+ params.created = { gte: cursor };
2169
+ this.config.logger?.info(`Incremental sync from cursor: ${cursor}`);
2170
+ }
2171
+ return this.fetchAndUpsert(
2172
+ () => this.stripe.plans.list(params),
2173
+ (plans) => this.upsertPlans(plans, accountId, syncParams?.backfillRelatedEntities),
2174
+ accountId,
2175
+ "plans",
2176
+ runStartedAt
2177
+ );
2178
+ });
2179
+ }
2180
+ async syncCustomers(syncParams) {
2181
+ this.config.logger?.info("Syncing customers");
2182
+ return this.withSyncRun("customers", "syncCustomers", async (cursor, runStartedAt) => {
2183
+ const accountId = await this.getAccountId();
2184
+ const params = { limit: 100 };
2185
+ if (syncParams?.created) {
2186
+ params.created = syncParams.created;
2187
+ } else if (cursor) {
2188
+ params.created = { gte: cursor };
2189
+ this.config.logger?.info(`Incremental sync from cursor: ${cursor}`);
2190
+ }
2191
+ return this.fetchAndUpsert(
2192
+ () => this.stripe.customers.list(params),
2193
+ // @ts-expect-error
2194
+ (items) => this.upsertCustomers(items, accountId),
2195
+ accountId,
2196
+ "customers",
2197
+ runStartedAt
2198
+ );
2199
+ });
2200
+ }
2201
+ async syncSubscriptions(syncParams) {
2202
+ this.config.logger?.info("Syncing subscriptions");
2203
+ return this.withSyncRun("subscriptions", "syncSubscriptions", async (cursor, runStartedAt) => {
2204
+ const accountId = await this.getAccountId();
2205
+ const params = { status: "all", limit: 100 };
2206
+ if (syncParams?.created) {
2207
+ params.created = syncParams.created;
2208
+ } else if (cursor) {
2209
+ params.created = { gte: cursor };
2210
+ this.config.logger?.info(`Incremental sync from cursor: ${cursor}`);
2211
+ }
2212
+ return this.fetchAndUpsert(
2213
+ () => this.stripe.subscriptions.list(params),
2214
+ (items) => this.upsertSubscriptions(items, accountId, syncParams?.backfillRelatedEntities),
2215
+ accountId,
2216
+ "subscriptions",
2217
+ runStartedAt
2218
+ );
2219
+ });
2220
+ }
2221
+ async syncSubscriptionSchedules(syncParams) {
2222
+ this.config.logger?.info("Syncing subscription schedules");
2223
+ return this.withSyncRun(
2224
+ "subscription_schedules",
2225
+ "syncSubscriptionSchedules",
2226
+ async (cursor, runStartedAt) => {
2227
+ const accountId = await this.getAccountId();
2228
+ const params = { limit: 100 };
2229
+ if (syncParams?.created) {
2230
+ params.created = syncParams.created;
2231
+ } else if (cursor) {
2232
+ params.created = { gte: cursor };
2233
+ this.config.logger?.info(`Incremental sync from cursor: ${cursor}`);
2234
+ }
2235
+ return this.fetchAndUpsert(
2236
+ () => this.stripe.subscriptionSchedules.list(params),
2237
+ (items) => this.upsertSubscriptionSchedules(items, accountId, syncParams?.backfillRelatedEntities),
2238
+ accountId,
2239
+ "subscription_schedules",
2240
+ runStartedAt
2241
+ );
2242
+ }
2243
+ );
2244
+ }
2245
+ async syncInvoices(syncParams) {
2246
+ this.config.logger?.info("Syncing invoices");
2247
+ return this.withSyncRun("invoices", "syncInvoices", async (cursor, runStartedAt) => {
2248
+ const accountId = await this.getAccountId();
2249
+ const params = { limit: 100 };
2250
+ if (syncParams?.created) {
2251
+ params.created = syncParams.created;
2252
+ } else if (cursor) {
2253
+ params.created = { gte: cursor };
2254
+ this.config.logger?.info(`Incremental sync from cursor: ${cursor}`);
2255
+ }
2256
+ return this.fetchAndUpsert(
2257
+ () => this.stripe.invoices.list(params),
2258
+ (items) => this.upsertInvoices(items, accountId, syncParams?.backfillRelatedEntities),
2259
+ accountId,
2260
+ "invoices",
2261
+ runStartedAt
2262
+ );
2263
+ });
2264
+ }
2265
+ async syncCharges(syncParams) {
2266
+ this.config.logger?.info("Syncing charges");
2267
+ return this.withSyncRun("charges", "syncCharges", async (cursor, runStartedAt) => {
2268
+ const accountId = await this.getAccountId();
2269
+ const params = { limit: 100 };
2270
+ if (syncParams?.created) {
2271
+ params.created = syncParams.created;
2272
+ } else if (cursor) {
2273
+ params.created = { gte: cursor };
2274
+ this.config.logger?.info(`Incremental sync from cursor: ${cursor}`);
2275
+ }
2276
+ return this.fetchAndUpsert(
2277
+ () => this.stripe.charges.list(params),
2278
+ (items) => this.upsertCharges(items, accountId, syncParams?.backfillRelatedEntities),
2279
+ accountId,
2280
+ "charges",
2281
+ runStartedAt
2282
+ );
2283
+ });
2284
+ }
2285
+ async syncSetupIntents(syncParams) {
2286
+ this.config.logger?.info("Syncing setup_intents");
2287
+ return this.withSyncRun("setup_intents", "syncSetupIntents", async (cursor, runStartedAt) => {
2288
+ const accountId = await this.getAccountId();
2289
+ const params = { limit: 100 };
2290
+ if (syncParams?.created) {
2291
+ params.created = syncParams.created;
2292
+ } else if (cursor) {
2293
+ params.created = { gte: cursor };
2294
+ this.config.logger?.info(`Incremental sync from cursor: ${cursor}`);
2295
+ }
2296
+ return this.fetchAndUpsert(
2297
+ () => this.stripe.setupIntents.list(params),
2298
+ (items) => this.upsertSetupIntents(items, accountId, syncParams?.backfillRelatedEntities),
2299
+ accountId,
2300
+ "setup_intents",
2301
+ runStartedAt
2302
+ );
2303
+ });
2304
+ }
2305
+ async syncPaymentIntents(syncParams) {
2306
+ this.config.logger?.info("Syncing payment_intents");
2307
+ return this.withSyncRun(
2308
+ "payment_intents",
2309
+ "syncPaymentIntents",
2310
+ async (cursor, runStartedAt) => {
2311
+ const accountId = await this.getAccountId();
2312
+ const params = { limit: 100 };
2313
+ if (syncParams?.created) {
2314
+ params.created = syncParams.created;
2315
+ } else if (cursor) {
2316
+ params.created = { gte: cursor };
2317
+ this.config.logger?.info(`Incremental sync from cursor: ${cursor}`);
2318
+ }
2319
+ return this.fetchAndUpsert(
2320
+ () => this.stripe.paymentIntents.list(params),
2321
+ (items) => this.upsertPaymentIntents(items, accountId, syncParams?.backfillRelatedEntities),
2322
+ accountId,
2323
+ "payment_intents",
2324
+ runStartedAt
2325
+ );
2326
+ }
2327
+ );
2328
+ }
2329
+ async syncTaxIds(syncParams) {
2330
+ this.config.logger?.info("Syncing tax_ids");
2331
+ return this.withSyncRun("tax_ids", "syncTaxIds", async (_cursor, runStartedAt) => {
2332
+ const accountId = await this.getAccountId();
2333
+ const params = { limit: 100 };
2334
+ return this.fetchAndUpsert(
2335
+ () => this.stripe.taxIds.list(params),
2336
+ (items) => this.upsertTaxIds(items, accountId, syncParams?.backfillRelatedEntities),
2337
+ accountId,
2338
+ "tax_ids",
2339
+ runStartedAt
2340
+ );
2341
+ });
2342
+ }
2343
+ async syncPaymentMethods(syncParams) {
2344
+ this.config.logger?.info("Syncing payment method");
2345
+ return this.withSyncRun(
2346
+ "payment_methods",
2347
+ "syncPaymentMethods",
2348
+ async (_cursor, runStartedAt) => {
2349
+ const accountId = await this.getAccountId();
2350
+ const prepared = (0, import_yesql2.pg)(
2351
+ `select id from "stripe"."customers" WHERE COALESCE(deleted, false) <> true;`
2352
+ )([]);
2353
+ const customerIds = await this.postgresClient.query(prepared.text, prepared.values).then(({ rows }) => rows.map((it) => it.id));
2354
+ this.config.logger?.info(`Getting payment methods for ${customerIds.length} customers`);
2355
+ let synced = 0;
2356
+ for (const customerIdChunk of chunkArray(customerIds, 10)) {
2357
+ await Promise.all(
2358
+ customerIdChunk.map(async (customerId) => {
2359
+ const CHECKPOINT_SIZE = 100;
2360
+ let currentBatch = [];
2361
+ for await (const item of this.stripe.paymentMethods.list({
2362
+ limit: 100,
2363
+ customer: customerId
2364
+ })) {
2365
+ currentBatch.push(item);
2366
+ if (currentBatch.length >= CHECKPOINT_SIZE) {
2367
+ await this.upsertPaymentMethods(
2368
+ currentBatch,
2369
+ accountId,
2370
+ syncParams?.backfillRelatedEntities
2371
+ );
2372
+ synced += currentBatch.length;
2373
+ await this.postgresClient.incrementObjectProgress(
2374
+ accountId,
2375
+ runStartedAt,
2376
+ "payment_methods",
2377
+ currentBatch.length
2378
+ );
2379
+ currentBatch = [];
2380
+ }
2381
+ }
2382
+ if (currentBatch.length > 0) {
2383
+ await this.upsertPaymentMethods(
2384
+ currentBatch,
2385
+ accountId,
2386
+ syncParams?.backfillRelatedEntities
2387
+ );
2388
+ synced += currentBatch.length;
2389
+ await this.postgresClient.incrementObjectProgress(
2390
+ accountId,
2391
+ runStartedAt,
2392
+ "payment_methods",
2393
+ currentBatch.length
2394
+ );
2395
+ }
2396
+ })
2397
+ );
2398
+ }
2399
+ await this.postgresClient.completeObjectSync(accountId, runStartedAt, "payment_methods");
2400
+ return { synced };
2401
+ }
2402
+ );
2403
+ }
2404
+ async syncDisputes(syncParams) {
2405
+ this.config.logger?.info("Syncing disputes");
2406
+ return this.withSyncRun("disputes", "syncDisputes", async (cursor, runStartedAt) => {
2407
+ const accountId = await this.getAccountId();
2408
+ const params = { limit: 100 };
2409
+ if (syncParams?.created) {
2410
+ params.created = syncParams.created;
2411
+ } else if (cursor) {
2412
+ params.created = { gte: cursor };
2413
+ this.config.logger?.info(`Incremental sync from cursor: ${cursor}`);
2414
+ }
2415
+ return this.fetchAndUpsert(
2416
+ () => this.stripe.disputes.list(params),
2417
+ (items) => this.upsertDisputes(items, accountId, syncParams?.backfillRelatedEntities),
2418
+ accountId,
2419
+ "disputes",
2420
+ runStartedAt
2421
+ );
2422
+ });
2423
+ }
2424
+ async syncEarlyFraudWarnings(syncParams) {
2425
+ this.config.logger?.info("Syncing early fraud warnings");
2426
+ return this.withSyncRun(
2427
+ "early_fraud_warnings",
2428
+ "syncEarlyFraudWarnings",
2429
+ async (cursor, runStartedAt) => {
2430
+ const accountId = await this.getAccountId();
2431
+ const params = { limit: 100 };
2432
+ if (syncParams?.created) {
2433
+ params.created = syncParams.created;
2434
+ } else if (cursor) {
2435
+ params.created = { gte: cursor };
2436
+ this.config.logger?.info(`Incremental sync from cursor: ${cursor}`);
2437
+ }
2438
+ return this.fetchAndUpsert(
2439
+ () => this.stripe.radar.earlyFraudWarnings.list(params),
2440
+ (items) => this.upsertEarlyFraudWarning(items, accountId, syncParams?.backfillRelatedEntities),
2441
+ accountId,
2442
+ "early_fraud_warnings",
2443
+ runStartedAt
2444
+ );
2445
+ }
2446
+ );
2447
+ }
2448
+ async syncRefunds(syncParams) {
2449
+ this.config.logger?.info("Syncing refunds");
2450
+ return this.withSyncRun("refunds", "syncRefunds", async (cursor, runStartedAt) => {
2451
+ const accountId = await this.getAccountId();
2452
+ const params = { limit: 100 };
2453
+ if (syncParams?.created) {
2454
+ params.created = syncParams.created;
2455
+ } else if (cursor) {
2456
+ params.created = { gte: cursor };
2457
+ this.config.logger?.info(`Incremental sync from cursor: ${cursor}`);
2458
+ }
2459
+ return this.fetchAndUpsert(
2460
+ () => this.stripe.refunds.list(params),
2461
+ (items) => this.upsertRefunds(items, accountId, syncParams?.backfillRelatedEntities),
2462
+ accountId,
2463
+ "refunds",
2464
+ runStartedAt
2465
+ );
2466
+ });
2467
+ }
2468
+ async syncCreditNotes(syncParams) {
2469
+ this.config.logger?.info("Syncing credit notes");
2470
+ return this.withSyncRun("credit_notes", "syncCreditNotes", async (cursor, runStartedAt) => {
2471
+ const accountId = await this.getAccountId();
2472
+ const params = { limit: 100 };
2473
+ if (syncParams?.created) {
2474
+ params.created = syncParams.created;
2475
+ } else if (cursor) {
2476
+ params.created = { gte: cursor };
2477
+ this.config.logger?.info(`Incremental sync from cursor: ${cursor}`);
2478
+ }
2479
+ return this.fetchAndUpsert(
2480
+ () => this.stripe.creditNotes.list(params),
2481
+ (creditNotes) => this.upsertCreditNotes(creditNotes, accountId),
2482
+ accountId,
2483
+ "credit_notes",
2484
+ runStartedAt
2485
+ );
2486
+ });
2487
+ }
2488
+ async syncFeatures(syncParams) {
2489
+ this.config.logger?.info("Syncing features");
2490
+ return this.withSyncRun("features", "syncFeatures", async (cursor, runStartedAt) => {
2491
+ const accountId = await this.getAccountId();
2492
+ const params = {
2493
+ limit: 100,
2494
+ ...syncParams?.pagination
2495
+ };
2496
+ return this.fetchAndUpsert(
2497
+ () => this.stripe.entitlements.features.list(params),
2498
+ (features) => this.upsertFeatures(features, accountId),
2499
+ accountId,
2500
+ "features",
2501
+ runStartedAt
2502
+ );
2503
+ });
2504
+ }
2505
+ async syncEntitlements(customerId, syncParams) {
2506
+ this.config.logger?.info("Syncing entitlements");
2507
+ return this.withSyncRun(
2508
+ "active_entitlements",
2509
+ "syncEntitlements",
2510
+ async (cursor, runStartedAt) => {
2511
+ const accountId = await this.getAccountId();
2512
+ const params = {
2513
+ customer: customerId,
2514
+ limit: 100,
2515
+ ...syncParams?.pagination
2516
+ };
2517
+ return this.fetchAndUpsert(
2518
+ () => this.stripe.entitlements.activeEntitlements.list(params),
2519
+ (entitlements) => this.upsertActiveEntitlements(customerId, entitlements, accountId),
2520
+ accountId,
2521
+ "active_entitlements",
2522
+ runStartedAt
2523
+ );
2524
+ }
2525
+ );
2526
+ }
2527
+ async syncCheckoutSessions(syncParams) {
2528
+ this.config.logger?.info("Syncing checkout sessions");
2529
+ return this.withSyncRun(
2530
+ "checkout_sessions",
2531
+ "syncCheckoutSessions",
2532
+ async (cursor, runStartedAt) => {
2533
+ const accountId = await this.getAccountId();
2534
+ const params = { limit: 100 };
2535
+ if (syncParams?.created) {
2536
+ params.created = syncParams.created;
2537
+ } else if (cursor) {
2538
+ params.created = { gte: cursor };
2539
+ this.config.logger?.info(`Incremental sync from cursor: ${cursor}`);
2540
+ }
2541
+ return this.fetchAndUpsert(
2542
+ () => this.stripe.checkout.sessions.list(params),
2543
+ (items) => this.upsertCheckoutSessions(items, accountId, syncParams?.backfillRelatedEntities),
2544
+ accountId,
2545
+ "checkout_sessions",
2546
+ runStartedAt
2547
+ );
2548
+ }
2549
+ );
2550
+ }
2551
+ /**
2552
+ * Helper to wrap a sync operation in the observable sync system.
2553
+ * Creates/gets a sync run, sets up the object run, gets cursor, and handles completion.
2554
+ *
2555
+ * @param resourceName - The resource being synced (e.g., 'products', 'customers')
2556
+ * @param triggeredBy - What triggered this sync (for observability)
2557
+ * @param fn - The sync function to execute, receives cursor and runStartedAt
2558
+ * @returns The result of the sync function
2559
+ */
2560
+ async withSyncRun(resourceName, triggeredBy, fn) {
2561
+ const accountId = await this.getAccountId();
2562
+ const lastCursor = await this.postgresClient.getLastCompletedCursor(accountId, resourceName);
2563
+ const cursor = lastCursor ? parseInt(lastCursor) : null;
2564
+ const runKey = await this.postgresClient.getOrCreateSyncRun(accountId, triggeredBy);
2565
+ if (!runKey) {
2566
+ const activeRun = await this.postgresClient.getActiveSyncRun(accountId);
2567
+ if (!activeRun) {
2568
+ throw new Error("Failed to get or create sync run");
2569
+ }
2570
+ throw new Error("Another sync is already running for this account");
2571
+ }
2572
+ const { runStartedAt } = runKey;
2573
+ await this.postgresClient.createObjectRuns(accountId, runStartedAt, [resourceName]);
2574
+ await this.postgresClient.tryStartObjectSync(accountId, runStartedAt, resourceName);
2575
+ try {
2576
+ const result = await fn(cursor, runStartedAt);
2577
+ await this.postgresClient.completeObjectSync(accountId, runStartedAt, resourceName);
2578
+ return result;
2579
+ } catch (error) {
2580
+ await this.postgresClient.failObjectSync(
2581
+ accountId,
2582
+ runStartedAt,
2583
+ resourceName,
2584
+ error instanceof Error ? error.message : "Unknown error"
2585
+ );
2586
+ throw error;
2587
+ }
2588
+ }
2589
+ async fetchAndUpsert(fetch2, upsert, accountId, resourceName, runStartedAt) {
2590
+ const CHECKPOINT_SIZE = 100;
2591
+ let totalSynced = 0;
2592
+ let currentBatch = [];
2593
+ try {
2594
+ this.config.logger?.info("Fetching items to sync from Stripe");
2595
+ try {
2596
+ for await (const item of fetch2()) {
2597
+ currentBatch.push(item);
2598
+ if (currentBatch.length >= CHECKPOINT_SIZE) {
2599
+ this.config.logger?.info(`Upserting batch of ${currentBatch.length} items`);
2600
+ await upsert(currentBatch, accountId);
2601
+ totalSynced += currentBatch.length;
2602
+ await this.postgresClient.incrementObjectProgress(
2603
+ accountId,
2604
+ runStartedAt,
2605
+ resourceName,
2606
+ currentBatch.length
2607
+ );
2608
+ const maxCreated = Math.max(
2609
+ ...currentBatch.map((i) => i.created || 0)
2610
+ );
2611
+ if (maxCreated > 0) {
2612
+ await this.postgresClient.updateObjectCursor(
2613
+ accountId,
2614
+ runStartedAt,
2615
+ resourceName,
2616
+ String(maxCreated)
2617
+ );
2618
+ this.config.logger?.info(`Checkpoint: cursor updated to ${maxCreated}`);
2619
+ }
2620
+ currentBatch = [];
2621
+ }
2622
+ }
2623
+ if (currentBatch.length > 0) {
2624
+ this.config.logger?.info(`Upserting final batch of ${currentBatch.length} items`);
2625
+ await upsert(currentBatch, accountId);
2626
+ totalSynced += currentBatch.length;
2627
+ await this.postgresClient.incrementObjectProgress(
2628
+ accountId,
2629
+ runStartedAt,
2630
+ resourceName,
2631
+ currentBatch.length
2632
+ );
2633
+ const maxCreated = Math.max(
2634
+ ...currentBatch.map((i) => i.created || 0)
2635
+ );
2636
+ if (maxCreated > 0) {
2637
+ await this.postgresClient.updateObjectCursor(
2638
+ accountId,
2639
+ runStartedAt,
2640
+ resourceName,
2641
+ String(maxCreated)
2642
+ );
2643
+ }
2644
+ }
2645
+ } catch (error) {
2646
+ if (currentBatch.length > 0) {
2647
+ this.config.logger?.info(
2648
+ `Error occurred, saving partial progress: ${currentBatch.length} items`
2649
+ );
2650
+ await upsert(currentBatch, accountId);
2651
+ totalSynced += currentBatch.length;
2652
+ await this.postgresClient.incrementObjectProgress(
2653
+ accountId,
2654
+ runStartedAt,
2655
+ resourceName,
2656
+ currentBatch.length
2657
+ );
2658
+ const maxCreated = Math.max(
2659
+ ...currentBatch.map((i) => i.created || 0)
2660
+ );
2661
+ if (maxCreated > 0) {
2662
+ await this.postgresClient.updateObjectCursor(
2663
+ accountId,
2664
+ runStartedAt,
2665
+ resourceName,
2666
+ String(maxCreated)
2667
+ );
2668
+ }
2669
+ }
2670
+ throw error;
2671
+ }
2672
+ await this.postgresClient.completeObjectSync(accountId, runStartedAt, resourceName);
2673
+ this.config.logger?.info(`Sync complete: ${totalSynced} items synced`);
2674
+ return { synced: totalSynced };
2675
+ } catch (error) {
2676
+ await this.postgresClient.failObjectSync(
2677
+ accountId,
2678
+ runStartedAt,
2679
+ resourceName,
2680
+ error instanceof Error ? error.message : "Unknown error"
2681
+ );
2682
+ throw error;
2683
+ }
2684
+ }
2685
+ async upsertCharges(charges, accountId, backfillRelatedEntities, syncTimestamp) {
2686
+ if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
2687
+ await Promise.all([
2688
+ this.backfillCustomers(getUniqueIds(charges, "customer"), accountId),
2689
+ this.backfillInvoices(getUniqueIds(charges, "invoice"), accountId)
2690
+ ]);
2691
+ }
2692
+ await this.expandEntity(
2693
+ charges,
2694
+ "refunds",
2695
+ (id) => this.stripe.refunds.list({ charge: id, limit: 100 })
2696
+ );
2697
+ return this.postgresClient.upsertManyWithTimestampProtection(
2698
+ charges,
2699
+ "charges",
2700
+ accountId,
2701
+ syncTimestamp
2702
+ );
2703
+ }
2704
+ async backfillCharges(chargeIds, accountId) {
2705
+ const missingChargeIds = await this.postgresClient.findMissingEntries("charges", chargeIds);
2706
+ await this.fetchMissingEntities(
2707
+ missingChargeIds,
2708
+ (id) => this.stripe.charges.retrieve(id)
2709
+ ).then((charges) => this.upsertCharges(charges, accountId));
2710
+ }
2711
+ async backfillPaymentIntents(paymentIntentIds, accountId) {
2712
+ const missingIds = await this.postgresClient.findMissingEntries(
2713
+ "payment_intents",
2714
+ paymentIntentIds
2715
+ );
2716
+ await this.fetchMissingEntities(
2717
+ missingIds,
2718
+ (id) => this.stripe.paymentIntents.retrieve(id)
2719
+ ).then((paymentIntents) => this.upsertPaymentIntents(paymentIntents, accountId));
2720
+ }
2721
+ async upsertCreditNotes(creditNotes, accountId, backfillRelatedEntities, syncTimestamp) {
2722
+ if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
2723
+ await Promise.all([
2724
+ this.backfillCustomers(getUniqueIds(creditNotes, "customer"), accountId),
2725
+ this.backfillInvoices(getUniqueIds(creditNotes, "invoice"), accountId)
2726
+ ]);
2727
+ }
2728
+ await this.expandEntity(
2729
+ creditNotes,
2730
+ "lines",
2731
+ (id) => this.stripe.creditNotes.listLineItems(id, { limit: 100 })
2732
+ );
2733
+ return this.postgresClient.upsertManyWithTimestampProtection(
2734
+ creditNotes,
2735
+ "credit_notes",
2736
+ accountId,
2737
+ syncTimestamp
2738
+ );
2739
+ }
2740
+ async upsertCheckoutSessions(checkoutSessions, accountId, backfillRelatedEntities, syncTimestamp) {
2741
+ if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
2742
+ await Promise.all([
2743
+ this.backfillCustomers(getUniqueIds(checkoutSessions, "customer"), accountId),
2744
+ this.backfillSubscriptions(getUniqueIds(checkoutSessions, "subscription"), accountId),
2745
+ this.backfillPaymentIntents(getUniqueIds(checkoutSessions, "payment_intent"), accountId),
2746
+ this.backfillInvoices(getUniqueIds(checkoutSessions, "invoice"), accountId)
2747
+ ]);
2748
+ }
2749
+ const rows = await this.postgresClient.upsertManyWithTimestampProtection(
2750
+ checkoutSessions,
2751
+ "checkout_sessions",
2752
+ accountId,
2753
+ syncTimestamp
2754
+ );
2755
+ await this.fillCheckoutSessionsLineItems(
2756
+ checkoutSessions.map((cs) => cs.id),
2757
+ accountId,
2758
+ syncTimestamp
2759
+ );
2760
+ return rows;
2761
+ }
2762
+ async upsertEarlyFraudWarning(earlyFraudWarnings, accountId, backfillRelatedEntities, syncTimestamp) {
2763
+ if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
2764
+ await Promise.all([
2765
+ this.backfillPaymentIntents(getUniqueIds(earlyFraudWarnings, "payment_intent"), accountId),
2766
+ this.backfillCharges(getUniqueIds(earlyFraudWarnings, "charge"), accountId)
2767
+ ]);
2768
+ }
2769
+ return this.postgresClient.upsertManyWithTimestampProtection(
2770
+ earlyFraudWarnings,
2771
+ "early_fraud_warnings",
2772
+ accountId,
2773
+ syncTimestamp
2774
+ );
2775
+ }
2776
+ async upsertRefunds(refunds, accountId, backfillRelatedEntities, syncTimestamp) {
2777
+ if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
2778
+ await Promise.all([
2779
+ this.backfillPaymentIntents(getUniqueIds(refunds, "payment_intent"), accountId),
2780
+ this.backfillCharges(getUniqueIds(refunds, "charge"), accountId)
2781
+ ]);
2782
+ }
2783
+ return this.postgresClient.upsertManyWithTimestampProtection(
2784
+ refunds,
2785
+ "refunds",
2786
+ accountId,
2787
+ syncTimestamp
2788
+ );
2789
+ }
2790
+ async upsertReviews(reviews, accountId, backfillRelatedEntities, syncTimestamp) {
2791
+ if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
2792
+ await Promise.all([
2793
+ this.backfillPaymentIntents(getUniqueIds(reviews, "payment_intent"), accountId),
2794
+ this.backfillCharges(getUniqueIds(reviews, "charge"), accountId)
2795
+ ]);
2796
+ }
2797
+ return this.postgresClient.upsertManyWithTimestampProtection(
2798
+ reviews,
2799
+ "reviews",
2800
+ accountId,
2801
+ syncTimestamp
2802
+ );
2803
+ }
2804
+ async upsertCustomers(customers, accountId, syncTimestamp) {
2805
+ const deletedCustomers = customers.filter((customer) => customer.deleted);
2806
+ const nonDeletedCustomers = customers.filter((customer) => !customer.deleted);
2807
+ await this.postgresClient.upsertManyWithTimestampProtection(
2808
+ nonDeletedCustomers,
2809
+ "customers",
2810
+ accountId,
2811
+ syncTimestamp
2812
+ );
2813
+ await this.postgresClient.upsertManyWithTimestampProtection(
2814
+ deletedCustomers,
2815
+ "customers",
2816
+ accountId,
2817
+ syncTimestamp
2818
+ );
2819
+ return customers;
2820
+ }
2821
+ async backfillCustomers(customerIds, accountId) {
2822
+ const missingIds = await this.postgresClient.findMissingEntries("customers", customerIds);
2823
+ await this.fetchMissingEntities(missingIds, (id) => this.stripe.customers.retrieve(id)).then((entries) => this.upsertCustomers(entries, accountId)).catch((err) => {
2824
+ this.config.logger?.error(err, "Failed to backfill");
2825
+ throw err;
2826
+ });
2827
+ }
2828
+ async upsertDisputes(disputes, accountId, backfillRelatedEntities, syncTimestamp) {
2829
+ if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
2830
+ await this.backfillCharges(getUniqueIds(disputes, "charge"), accountId);
2831
+ }
2832
+ return this.postgresClient.upsertManyWithTimestampProtection(
2833
+ disputes,
2834
+ "disputes",
2835
+ accountId,
2836
+ syncTimestamp
2837
+ );
2838
+ }
2839
+ async upsertInvoices(invoices, accountId, backfillRelatedEntities, syncTimestamp) {
2840
+ if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
2841
+ await Promise.all([
2842
+ this.backfillCustomers(getUniqueIds(invoices, "customer"), accountId),
2843
+ this.backfillSubscriptions(getUniqueIds(invoices, "subscription"), accountId)
2844
+ ]);
2845
+ }
2846
+ await this.expandEntity(
2847
+ invoices,
2848
+ "lines",
2849
+ (id) => this.stripe.invoices.listLineItems(id, { limit: 100 })
2850
+ );
2851
+ return this.postgresClient.upsertManyWithTimestampProtection(
2852
+ invoices,
2853
+ "invoices",
2854
+ accountId,
2855
+ syncTimestamp
2856
+ );
2857
+ }
2858
+ backfillInvoices = async (invoiceIds, accountId) => {
2859
+ const missingIds = await this.postgresClient.findMissingEntries("invoices", invoiceIds);
2860
+ await this.fetchMissingEntities(missingIds, (id) => this.stripe.invoices.retrieve(id)).then(
2861
+ (entries) => this.upsertInvoices(entries, accountId)
2862
+ );
2863
+ };
2864
+ backfillPrices = async (priceIds, accountId) => {
2865
+ const missingIds = await this.postgresClient.findMissingEntries("prices", priceIds);
2866
+ await this.fetchMissingEntities(missingIds, (id) => this.stripe.prices.retrieve(id)).then(
2867
+ (entries) => this.upsertPrices(entries, accountId)
2868
+ );
2869
+ };
2870
+ async upsertPlans(plans, accountId, backfillRelatedEntities, syncTimestamp) {
2871
+ if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
2872
+ await this.backfillProducts(getUniqueIds(plans, "product"), accountId);
2873
+ }
2874
+ return this.postgresClient.upsertManyWithTimestampProtection(
2875
+ plans,
2876
+ "plans",
2877
+ accountId,
2878
+ syncTimestamp
2879
+ );
2880
+ }
2881
+ async deletePlan(id) {
2882
+ return this.postgresClient.delete("plans", id);
2883
+ }
2884
+ async upsertPrices(prices, accountId, backfillRelatedEntities, syncTimestamp) {
2885
+ if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
2886
+ await this.backfillProducts(getUniqueIds(prices, "product"), accountId);
2887
+ }
2888
+ return this.postgresClient.upsertManyWithTimestampProtection(
2889
+ prices,
2890
+ "prices",
2891
+ accountId,
2892
+ syncTimestamp
2893
+ );
2894
+ }
2895
+ async deletePrice(id) {
2896
+ return this.postgresClient.delete("prices", id);
2897
+ }
2898
+ async upsertProducts(products, accountId, syncTimestamp) {
2899
+ return this.postgresClient.upsertManyWithTimestampProtection(
2900
+ products,
2901
+ "products",
2902
+ accountId,
2903
+ syncTimestamp
2904
+ );
2905
+ }
2906
+ async deleteProduct(id) {
2907
+ return this.postgresClient.delete("products", id);
2908
+ }
2909
+ async backfillProducts(productIds, accountId) {
2910
+ const missingProductIds = await this.postgresClient.findMissingEntries("products", productIds);
2911
+ await this.fetchMissingEntities(
2912
+ missingProductIds,
2913
+ (id) => this.stripe.products.retrieve(id)
2914
+ ).then((products) => this.upsertProducts(products, accountId));
2915
+ }
2916
+ async upsertPaymentIntents(paymentIntents, accountId, backfillRelatedEntities, syncTimestamp) {
2917
+ if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
2918
+ await Promise.all([
2919
+ this.backfillCustomers(getUniqueIds(paymentIntents, "customer"), accountId),
2920
+ this.backfillInvoices(getUniqueIds(paymentIntents, "invoice"), accountId)
2921
+ ]);
2922
+ }
2923
+ return this.postgresClient.upsertManyWithTimestampProtection(
2924
+ paymentIntents,
2925
+ "payment_intents",
2926
+ accountId,
2927
+ syncTimestamp
2928
+ );
2929
+ }
2930
+ async upsertPaymentMethods(paymentMethods, accountId, backfillRelatedEntities = false, syncTimestamp) {
2931
+ if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
2932
+ await this.backfillCustomers(getUniqueIds(paymentMethods, "customer"), accountId);
2933
+ }
2934
+ return this.postgresClient.upsertManyWithTimestampProtection(
2935
+ paymentMethods,
2936
+ "payment_methods",
2937
+ accountId,
2938
+ syncTimestamp
2939
+ );
2940
+ }
2941
+ async upsertSetupIntents(setupIntents, accountId, backfillRelatedEntities, syncTimestamp) {
2942
+ if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
2943
+ await this.backfillCustomers(getUniqueIds(setupIntents, "customer"), accountId);
2944
+ }
2945
+ return this.postgresClient.upsertManyWithTimestampProtection(
2946
+ setupIntents,
2947
+ "setup_intents",
2948
+ accountId,
2949
+ syncTimestamp
2950
+ );
2951
+ }
2952
+ async upsertTaxIds(taxIds, accountId, backfillRelatedEntities, syncTimestamp) {
2953
+ if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
2954
+ await this.backfillCustomers(getUniqueIds(taxIds, "customer"), accountId);
2955
+ }
2956
+ return this.postgresClient.upsertManyWithTimestampProtection(
2957
+ taxIds,
2958
+ "tax_ids",
2959
+ accountId,
2960
+ syncTimestamp
2961
+ );
2962
+ }
2963
+ async deleteTaxId(id) {
2964
+ return this.postgresClient.delete("tax_ids", id);
2965
+ }
2966
+ async upsertSubscriptionItems(subscriptionItems, accountId, syncTimestamp) {
2967
+ const modifiedSubscriptionItems = subscriptionItems.map((subscriptionItem) => {
2968
+ const priceId = subscriptionItem.price.id.toString();
2969
+ const deleted = subscriptionItem.deleted;
2970
+ const quantity = subscriptionItem.quantity;
2971
+ return {
2972
+ ...subscriptionItem,
2973
+ price: priceId,
2974
+ deleted: deleted ?? false,
2975
+ quantity: quantity ?? null
2976
+ };
2977
+ });
2978
+ await this.postgresClient.upsertManyWithTimestampProtection(
2979
+ modifiedSubscriptionItems,
2980
+ "subscription_items",
2981
+ accountId,
2982
+ syncTimestamp
2983
+ );
2984
+ }
2985
+ async fillCheckoutSessionsLineItems(checkoutSessionIds, accountId, syncTimestamp) {
2986
+ for (const checkoutSessionId of checkoutSessionIds) {
2987
+ const lineItemResponses = [];
2988
+ for await (const lineItem of this.stripe.checkout.sessions.listLineItems(checkoutSessionId, {
2989
+ limit: 100
2990
+ })) {
2991
+ lineItemResponses.push(lineItem);
2992
+ }
2993
+ await this.upsertCheckoutSessionLineItems(
2994
+ lineItemResponses,
2995
+ checkoutSessionId,
2996
+ accountId,
2997
+ syncTimestamp
2998
+ );
2999
+ }
3000
+ }
3001
+ async upsertCheckoutSessionLineItems(lineItems, checkoutSessionId, accountId, syncTimestamp) {
3002
+ await this.backfillPrices(
3003
+ lineItems.map((lineItem) => lineItem.price?.id?.toString() ?? void 0).filter((id) => id !== void 0),
3004
+ accountId
3005
+ );
3006
+ const modifiedLineItems = lineItems.map((lineItem) => {
3007
+ const priceId = typeof lineItem.price === "object" && lineItem.price?.id ? lineItem.price.id.toString() : lineItem.price?.toString() || null;
3008
+ return {
3009
+ ...lineItem,
3010
+ price: priceId,
3011
+ checkout_session: checkoutSessionId
3012
+ };
3013
+ });
3014
+ await this.postgresClient.upsertManyWithTimestampProtection(
3015
+ modifiedLineItems,
3016
+ "checkout_session_line_items",
3017
+ accountId,
3018
+ syncTimestamp
3019
+ );
3020
+ }
3021
+ async markDeletedSubscriptionItems(subscriptionId, currentSubItemIds) {
3022
+ let prepared = (0, import_yesql2.pg)(`
3023
+ select id from "stripe"."subscription_items"
3024
+ where subscription = :subscriptionId and COALESCE(deleted, false) = false;
3025
+ `)({ subscriptionId });
3026
+ const { rows } = await this.postgresClient.query(prepared.text, prepared.values);
3027
+ const deletedIds = rows.filter(
3028
+ ({ id }) => currentSubItemIds.includes(id) === false
3029
+ );
3030
+ if (deletedIds.length > 0) {
3031
+ const ids = deletedIds.map(({ id }) => id);
3032
+ prepared = (0, import_yesql2.pg)(`
3033
+ update "stripe"."subscription_items"
3034
+ set _raw_data = jsonb_set(_raw_data, '{deleted}', 'true'::jsonb)
3035
+ where id=any(:ids::text[]);
3036
+ `)({ ids });
3037
+ const { rowCount } = await this.postgresClient.query(prepared.text, prepared.values);
3038
+ return { rowCount: rowCount || 0 };
3039
+ } else {
3040
+ return { rowCount: 0 };
3041
+ }
3042
+ }
3043
+ async upsertSubscriptionSchedules(subscriptionSchedules, accountId, backfillRelatedEntities, syncTimestamp) {
3044
+ if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
3045
+ const customerIds = getUniqueIds(subscriptionSchedules, "customer");
3046
+ await this.backfillCustomers(customerIds, accountId);
3047
+ }
3048
+ const rows = await this.postgresClient.upsertManyWithTimestampProtection(
3049
+ subscriptionSchedules,
3050
+ "subscription_schedules",
3051
+ accountId,
3052
+ syncTimestamp
3053
+ );
3054
+ return rows;
3055
+ }
3056
+ async upsertSubscriptions(subscriptions, accountId, backfillRelatedEntities, syncTimestamp) {
3057
+ if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
3058
+ const customerIds = getUniqueIds(subscriptions, "customer");
3059
+ await this.backfillCustomers(customerIds, accountId);
3060
+ }
3061
+ await this.expandEntity(
3062
+ subscriptions,
3063
+ "items",
3064
+ (id) => this.stripe.subscriptionItems.list({ subscription: id, limit: 100 })
3065
+ );
3066
+ const rows = await this.postgresClient.upsertManyWithTimestampProtection(
3067
+ subscriptions,
3068
+ "subscriptions",
3069
+ accountId,
3070
+ syncTimestamp
3071
+ );
3072
+ const allSubscriptionItems = subscriptions.flatMap((subscription) => subscription.items.data);
3073
+ await this.upsertSubscriptionItems(allSubscriptionItems, accountId, syncTimestamp);
3074
+ const markSubscriptionItemsDeleted = [];
3075
+ for (const subscription of subscriptions) {
3076
+ const subscriptionItems = subscription.items.data;
3077
+ const subItemIds = subscriptionItems.map((x) => x.id);
3078
+ markSubscriptionItemsDeleted.push(
3079
+ this.markDeletedSubscriptionItems(subscription.id, subItemIds)
3080
+ );
3081
+ }
3082
+ await Promise.all(markSubscriptionItemsDeleted);
3083
+ return rows;
3084
+ }
3085
+ async deleteRemovedActiveEntitlements(customerId, currentActiveEntitlementIds) {
3086
+ const prepared = (0, import_yesql2.pg)(`
3087
+ delete from "stripe"."active_entitlements"
3088
+ where customer = :customerId and id <> ALL(:currentActiveEntitlementIds::text[]);
3089
+ `)({ customerId, currentActiveEntitlementIds });
3090
+ const { rowCount } = await this.postgresClient.query(prepared.text, prepared.values);
3091
+ return { rowCount: rowCount || 0 };
3092
+ }
3093
+ async upsertFeatures(features, accountId, syncTimestamp) {
3094
+ return this.postgresClient.upsertManyWithTimestampProtection(
3095
+ features,
3096
+ "features",
3097
+ accountId,
3098
+ syncTimestamp
3099
+ );
3100
+ }
3101
+ async backfillFeatures(featureIds, accountId) {
3102
+ const missingFeatureIds = await this.postgresClient.findMissingEntries("features", featureIds);
3103
+ await this.fetchMissingEntities(
3104
+ missingFeatureIds,
3105
+ (id) => this.stripe.entitlements.features.retrieve(id)
3106
+ ).then((features) => this.upsertFeatures(features, accountId)).catch((err) => {
3107
+ this.config.logger?.error(err, "Failed to backfill features");
3108
+ throw err;
3109
+ });
3110
+ }
3111
+ async upsertActiveEntitlements(customerId, activeEntitlements, accountId, backfillRelatedEntities, syncTimestamp) {
3112
+ if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) {
3113
+ await Promise.all([
3114
+ this.backfillCustomers(getUniqueIds(activeEntitlements, "customer"), accountId),
3115
+ this.backfillFeatures(getUniqueIds(activeEntitlements, "feature"), accountId)
3116
+ ]);
3117
+ }
3118
+ const entitlements = activeEntitlements.map((entitlement) => ({
3119
+ id: entitlement.id,
3120
+ object: entitlement.object,
3121
+ feature: typeof entitlement.feature === "string" ? entitlement.feature : entitlement.feature.id,
3122
+ customer: customerId,
3123
+ livemode: entitlement.livemode,
3124
+ lookup_key: entitlement.lookup_key
3125
+ }));
3126
+ return this.postgresClient.upsertManyWithTimestampProtection(
3127
+ entitlements,
3128
+ "active_entitlements",
3129
+ accountId,
3130
+ syncTimestamp
3131
+ );
3132
+ }
3133
+ async findOrCreateManagedWebhook(url, params) {
3134
+ const webhookParams = {
3135
+ enabled_events: this.getSupportedEventTypes(),
3136
+ ...params
3137
+ };
3138
+ const accountId = await this.getAccountId();
3139
+ const lockKey = `webhook:${accountId}:${url}`;
3140
+ return this.postgresClient.withAdvisoryLock(lockKey, async () => {
3141
+ const existingWebhook = await this.getManagedWebhookByUrl(url);
3142
+ if (existingWebhook) {
3143
+ try {
3144
+ const stripeWebhook = await this.stripe.webhookEndpoints.retrieve(existingWebhook.id);
3145
+ if (stripeWebhook.status === "enabled") {
3146
+ return stripeWebhook;
3147
+ }
3148
+ this.config.logger?.info(
3149
+ { webhookId: existingWebhook.id },
3150
+ "Webhook is disabled, deleting and will recreate"
3151
+ );
3152
+ await this.stripe.webhookEndpoints.del(existingWebhook.id);
3153
+ await this.postgresClient.delete("_managed_webhooks", existingWebhook.id);
3154
+ } catch (error) {
3155
+ const stripeError = error;
3156
+ if (stripeError?.statusCode === 404 || stripeError?.code === "resource_missing") {
3157
+ this.config.logger?.warn(
3158
+ { error, webhookId: existingWebhook.id },
3159
+ "Webhook not found in Stripe (404), removing from database"
3160
+ );
3161
+ await this.postgresClient.delete("_managed_webhooks", existingWebhook.id);
3162
+ } else {
3163
+ this.config.logger?.error(
3164
+ { error, webhookId: existingWebhook.id },
3165
+ "Error retrieving webhook from Stripe, keeping in database"
3166
+ );
3167
+ throw error;
3168
+ }
3169
+ }
3170
+ }
3171
+ const allDbWebhooks = await this.listManagedWebhooks();
3172
+ for (const dbWebhook of allDbWebhooks) {
3173
+ if (dbWebhook.url !== url) {
3174
+ this.config.logger?.info(
3175
+ { webhookId: dbWebhook.id, oldUrl: dbWebhook.url, newUrl: url },
3176
+ "Webhook URL mismatch, deleting"
3177
+ );
3178
+ try {
3179
+ await this.stripe.webhookEndpoints.del(dbWebhook.id);
3180
+ } catch (error) {
3181
+ this.config.logger?.warn(
3182
+ { error, webhookId: dbWebhook.id },
3183
+ "Failed to delete old webhook from Stripe"
3184
+ );
3185
+ }
3186
+ await this.postgresClient.delete("_managed_webhooks", dbWebhook.id);
3187
+ }
3188
+ }
3189
+ try {
3190
+ const stripeWebhooks = await this.stripe.webhookEndpoints.list({ limit: 100 });
3191
+ for (const stripeWebhook of stripeWebhooks.data) {
3192
+ const isManagedByMetadata = stripeWebhook.metadata?.managed_by?.toLowerCase().replace(/[\s\-]+/g, "") === "stripesync";
3193
+ const normalizedDescription = stripeWebhook.description?.toLowerCase().replace(/[\s\-]+/g, "") || "";
3194
+ const isManagedByDescription = normalizedDescription.includes("stripesync");
3195
+ if (isManagedByMetadata || isManagedByDescription) {
3196
+ const existsInDb = allDbWebhooks.some((dbWebhook) => dbWebhook.id === stripeWebhook.id);
3197
+ if (!existsInDb) {
3198
+ this.config.logger?.warn(
3199
+ { webhookId: stripeWebhook.id, url: stripeWebhook.url },
3200
+ "Found orphaned managed webhook in Stripe, deleting"
3201
+ );
3202
+ await this.stripe.webhookEndpoints.del(stripeWebhook.id);
3203
+ }
3204
+ }
3205
+ }
3206
+ } catch (error) {
3207
+ this.config.logger?.warn({ error }, "Failed to check for orphaned webhooks");
3208
+ }
3209
+ const webhook = await this.stripe.webhookEndpoints.create({
3210
+ ...webhookParams,
3211
+ url,
3212
+ // Always set metadata to identify managed webhooks
3213
+ metadata: {
3214
+ ...webhookParams.metadata,
3215
+ managed_by: "stripe-sync",
3216
+ version: package_default.version
3217
+ }
3218
+ });
3219
+ const accountId2 = await this.getAccountId();
3220
+ await this.upsertManagedWebhooks([webhook], accountId2);
3221
+ return webhook;
3222
+ });
3223
+ }
3224
+ async getManagedWebhook(id) {
3225
+ const accountId = await this.getAccountId();
3226
+ const result = await this.postgresClient.query(
3227
+ `SELECT * FROM "stripe"."_managed_webhooks" WHERE id = $1 AND "account_id" = $2`,
3228
+ [id, accountId]
3229
+ );
3230
+ return result.rows.length > 0 ? result.rows[0] : null;
3231
+ }
3232
+ /**
3233
+ * Get a managed webhook by URL and account ID.
3234
+ * Used for race condition recovery: when createManagedWebhook hits a unique constraint
3235
+ * violation (another instance created the webhook), we need to fetch the existing webhook
3236
+ * by URL since we only know the URL, not the ID of the webhook that won the race.
3237
+ */
3238
+ async getManagedWebhookByUrl(url) {
3239
+ const accountId = await this.getAccountId();
3240
+ const result = await this.postgresClient.query(
3241
+ `SELECT * FROM "stripe"."_managed_webhooks" WHERE url = $1 AND "account_id" = $2`,
3242
+ [url, accountId]
3243
+ );
3244
+ return result.rows.length > 0 ? result.rows[0] : null;
3245
+ }
3246
+ async listManagedWebhooks() {
3247
+ const accountId = await this.getAccountId();
3248
+ const result = await this.postgresClient.query(
3249
+ `SELECT * FROM "stripe"."_managed_webhooks" WHERE "account_id" = $1 ORDER BY created DESC`,
3250
+ [accountId]
3251
+ );
3252
+ return result.rows;
3253
+ }
3254
+ async updateManagedWebhook(id, params) {
3255
+ const webhook = await this.stripe.webhookEndpoints.update(id, params);
3256
+ const accountId = await this.getAccountId();
3257
+ await this.upsertManagedWebhooks([webhook], accountId);
3258
+ return webhook;
3259
+ }
3260
+ async deleteManagedWebhook(id) {
3261
+ await this.stripe.webhookEndpoints.del(id);
3262
+ return this.postgresClient.delete("_managed_webhooks", id);
3263
+ }
3264
+ async upsertManagedWebhooks(webhooks, accountId, syncTimestamp) {
3265
+ const filteredWebhooks = webhooks.map((webhook) => {
3266
+ const filtered = {};
3267
+ for (const prop of managedWebhookSchema.properties) {
3268
+ if (prop in webhook) {
3269
+ filtered[prop] = webhook[prop];
3270
+ }
3271
+ }
3272
+ return filtered;
3273
+ });
3274
+ return this.postgresClient.upsertManyWithTimestampProtection(
3275
+ filteredWebhooks,
3276
+ "_managed_webhooks",
3277
+ accountId,
3278
+ syncTimestamp
3279
+ );
3280
+ }
3281
+ async backfillSubscriptions(subscriptionIds, accountId) {
3282
+ const missingSubscriptionIds = await this.postgresClient.findMissingEntries(
3283
+ "subscriptions",
3284
+ subscriptionIds
3285
+ );
3286
+ await this.fetchMissingEntities(
3287
+ missingSubscriptionIds,
3288
+ (id) => this.stripe.subscriptions.retrieve(id)
3289
+ ).then((subscriptions) => this.upsertSubscriptions(subscriptions, accountId));
3290
+ }
3291
+ backfillSubscriptionSchedules = async (subscriptionIds, accountId) => {
3292
+ const missingSubscriptionIds = await this.postgresClient.findMissingEntries(
3293
+ "subscription_schedules",
3294
+ subscriptionIds
3295
+ );
3296
+ await this.fetchMissingEntities(
3297
+ missingSubscriptionIds,
3298
+ (id) => this.stripe.subscriptionSchedules.retrieve(id)
3299
+ ).then(
3300
+ (subscriptionSchedules) => this.upsertSubscriptionSchedules(subscriptionSchedules, accountId)
3301
+ );
3302
+ };
3303
+ /**
3304
+ * Stripe only sends the first 10 entries by default, the option will actively fetch all entries.
3305
+ */
3306
+ async expandEntity(entities, property, listFn) {
3307
+ if (!this.config.autoExpandLists) return;
3308
+ for (const entity of entities) {
3309
+ if (entity[property]?.has_more) {
3310
+ const allData = [];
3311
+ for await (const fetchedEntity of listFn(entity.id)) {
3312
+ allData.push(fetchedEntity);
3313
+ }
3314
+ entity[property] = {
3315
+ ...entity[property],
3316
+ data: allData,
3317
+ has_more: false
3318
+ };
3319
+ }
3320
+ }
3321
+ }
3322
+ async fetchMissingEntities(ids, fetch2) {
3323
+ if (!ids.length) return [];
3324
+ const entities = [];
3325
+ for (const id of ids) {
3326
+ const entity = await fetch2(id);
3327
+ entities.push(entity);
3328
+ }
3329
+ return entities;
3330
+ }
3331
+ /**
3332
+ * Closes the database connection pool and cleans up resources.
3333
+ * Call this when you're done using the StripeSync instance.
3334
+ */
3335
+ async close() {
3336
+ await this.postgresClient.pool.end();
3337
+ }
3338
+ };
3339
+ function chunkArray(array, chunkSize) {
3340
+ const result = [];
3341
+ for (let i = 0; i < array.length; i += chunkSize) {
3342
+ result.push(array.slice(i, i + chunkSize));
3343
+ }
3344
+ return result;
3345
+ }
3346
+
3347
+ // src/database/migrate.ts
3348
+ var import_pg2 = require("pg");
3349
+ var import_pg_node_migrations = require("pg-node-migrations");
3350
+ var import_node_fs = __toESM(require("fs"), 1);
3351
+ var import_node_path = __toESM(require("path"), 1);
3352
+ var import_node_url = require("url");
3353
+ var __filename2 = (0, import_node_url.fileURLToPath)(importMetaUrl);
3354
+ var __dirname = import_node_path.default.dirname(__filename2);
3355
+ async function doesTableExist(client, schema, tableName) {
3356
+ const result = await client.query(
3357
+ `SELECT EXISTS (
3358
+ SELECT 1
3359
+ FROM information_schema.tables
3360
+ WHERE table_schema = $1
3361
+ AND table_name = $2
3362
+ )`,
3363
+ [schema, tableName]
3364
+ );
3365
+ return result.rows[0]?.exists || false;
3366
+ }
3367
+ async function renameMigrationsTableIfNeeded(client, schema = "stripe", logger) {
3368
+ const oldTableExists = await doesTableExist(client, schema, "migrations");
3369
+ const newTableExists = await doesTableExist(client, schema, "_migrations");
3370
+ if (oldTableExists && !newTableExists) {
3371
+ logger?.info("Renaming migrations table to _migrations");
3372
+ await client.query(`ALTER TABLE "${schema}"."migrations" RENAME TO "_migrations"`);
3373
+ logger?.info("Successfully renamed migrations table");
3374
+ }
3375
+ }
3376
+ async function cleanupSchema(client, schema, logger) {
3377
+ logger?.warn(`Migrations table is empty - dropping and recreating schema "${schema}"`);
3378
+ await client.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
3379
+ await client.query(`CREATE SCHEMA "${schema}"`);
3380
+ logger?.info(`Schema "${schema}" has been reset`);
3381
+ }
3382
+ async function connectAndMigrate(client, migrationsDirectory, config, logOnError = false) {
3383
+ if (!import_node_fs.default.existsSync(migrationsDirectory)) {
3384
+ config.logger?.info(`Migrations directory ${migrationsDirectory} not found, skipping`);
3385
+ return;
3386
+ }
3387
+ const optionalConfig = {
3388
+ schemaName: "stripe",
3389
+ tableName: "_migrations"
3390
+ };
3391
+ try {
3392
+ await (0, import_pg_node_migrations.migrate)({ client }, migrationsDirectory, optionalConfig);
3393
+ } catch (error) {
3394
+ if (logOnError && error instanceof Error) {
3395
+ config.logger?.error(error, "Migration error:");
3396
+ } else {
3397
+ throw error;
3398
+ }
3399
+ }
3400
+ }
3401
+ async function runMigrations(config) {
3402
+ const client = new import_pg2.Client({
3403
+ connectionString: config.databaseUrl,
3404
+ ssl: config.ssl,
3405
+ connectionTimeoutMillis: 1e4
3406
+ });
3407
+ const schema = "stripe";
3408
+ try {
3409
+ await client.connect();
3410
+ await client.query(`CREATE SCHEMA IF NOT EXISTS ${schema};`);
3411
+ await renameMigrationsTableIfNeeded(client, schema, config.logger);
3412
+ const tableExists = await doesTableExist(client, schema, "_migrations");
3413
+ if (tableExists) {
3414
+ const migrationCount = await client.query(
3415
+ `SELECT COUNT(*) as count FROM "${schema}"."_migrations"`
3416
+ );
3417
+ const isEmpty = migrationCount.rows[0]?.count === "0";
3418
+ if (isEmpty) {
3419
+ await cleanupSchema(client, schema, config.logger);
3420
+ }
3421
+ }
3422
+ config.logger?.info("Running migrations");
3423
+ await connectAndMigrate(client, import_node_path.default.resolve(__dirname, "./migrations"), config);
3424
+ } catch (err) {
3425
+ config.logger?.error(err, "Error running migrations");
3426
+ throw err;
3427
+ } finally {
3428
+ await client.end();
3429
+ config.logger?.info("Finished migrations");
3430
+ }
3431
+ }
3432
+
3433
+ // src/websocket-client.ts
3434
+ var import_ws = __toESM(require("ws"), 1);
3435
+ var CLI_VERSION = "1.33.0";
3436
+ var PONG_WAIT = 10 * 1e3;
3437
+ var PING_PERIOD = PONG_WAIT * 2 / 10;
3438
+ function getClientUserAgent() {
3439
+ return JSON.stringify({
3440
+ name: "stripe-cli",
3441
+ version: CLI_VERSION,
3442
+ publisher: "stripe",
3443
+ os: process.platform
3444
+ });
3445
+ }
3446
+ async function createCliSession(stripeApiKey) {
3447
+ const params = new URLSearchParams();
3448
+ params.append("device_name", "stripe-sync-engine");
3449
+ params.append("websocket_features[]", "webhooks");
3450
+ const response = await fetch("https://api.stripe.com/v1/stripecli/sessions", {
3451
+ method: "POST",
3452
+ headers: {
3453
+ Authorization: `Bearer ${stripeApiKey}`,
3454
+ "Content-Type": "application/x-www-form-urlencoded",
3455
+ "User-Agent": `Stripe/v1 stripe-cli/${CLI_VERSION}`,
3456
+ "X-Stripe-Client-User-Agent": getClientUserAgent()
3457
+ },
3458
+ body: params.toString()
3459
+ });
3460
+ if (!response.ok) {
3461
+ const error = await response.text();
3462
+ throw new Error(`Failed to create CLI session: ${error}`);
3463
+ }
3464
+ return await response.json();
3465
+ }
3466
+ async function createStripeWebSocketClient(options) {
3467
+ const { stripeApiKey, onEvent, onReady, onError, onClose } = options;
3468
+ const session = await createCliSession(stripeApiKey);
3469
+ let ws = null;
3470
+ let pingInterval = null;
3471
+ let connected = false;
3472
+ let shouldReconnect = true;
3473
+ function connect() {
3474
+ const wsUrl = `${session.websocket_url}?websocket_feature=${encodeURIComponent(session.websocket_authorized_feature)}`;
3475
+ ws = new import_ws.default(wsUrl, {
3476
+ headers: {
3477
+ "Accept-Encoding": "identity",
3478
+ "User-Agent": `Stripe/v1 stripe-cli/${CLI_VERSION}`,
3479
+ "X-Stripe-Client-User-Agent": getClientUserAgent(),
3480
+ "Websocket-Id": session.websocket_id
3481
+ }
3482
+ });
3483
+ ws.on("pong", () => {
3484
+ });
3485
+ ws.on("open", () => {
3486
+ connected = true;
3487
+ pingInterval = setInterval(() => {
3488
+ if (ws && ws.readyState === import_ws.default.OPEN) {
3489
+ ws.ping();
3490
+ }
3491
+ }, PING_PERIOD);
3492
+ if (onReady) {
3493
+ onReady(session.secret);
3494
+ }
3495
+ });
3496
+ ws.on("message", async (data) => {
3497
+ try {
3498
+ const message = JSON.parse(data.toString());
3499
+ const ack = {
3500
+ type: "event_ack",
3501
+ event_id: message.webhook_id,
3502
+ webhook_conversation_id: message.webhook_conversation_id,
3503
+ webhook_id: message.webhook_id
3504
+ };
3505
+ if (ws && ws.readyState === import_ws.default.OPEN) {
3506
+ ws.send(JSON.stringify(ack));
3507
+ }
3508
+ let response;
3509
+ try {
3510
+ const result = await onEvent(message);
3511
+ response = {
3512
+ type: "webhook_response",
3513
+ webhook_id: message.webhook_id,
3514
+ webhook_conversation_id: message.webhook_conversation_id,
3515
+ forward_url: "stripe-sync-engine",
3516
+ status: result?.status ?? 200,
3517
+ http_headers: {},
3518
+ body: JSON.stringify({
3519
+ event_type: result?.event_type,
3520
+ event_id: result?.event_id,
3521
+ database_url: result?.databaseUrl,
3522
+ error: result?.error
3523
+ }),
3524
+ request_headers: message.http_headers,
3525
+ request_body: message.event_payload,
3526
+ notification_id: message.webhook_id
3527
+ };
3528
+ } catch (err) {
3529
+ const errorMessage = err instanceof Error ? err.message : String(err);
3530
+ response = {
3531
+ type: "webhook_response",
3532
+ webhook_id: message.webhook_id,
3533
+ webhook_conversation_id: message.webhook_conversation_id,
3534
+ forward_url: "stripe-sync-engine",
3535
+ status: 500,
3536
+ http_headers: {},
3537
+ body: JSON.stringify({ error: errorMessage }),
3538
+ request_headers: message.http_headers,
3539
+ request_body: message.event_payload,
3540
+ notification_id: message.webhook_id
3541
+ };
3542
+ if (onError) {
3543
+ onError(err instanceof Error ? err : new Error(errorMessage));
3544
+ }
3545
+ }
3546
+ if (ws && ws.readyState === import_ws.default.OPEN) {
3547
+ ws.send(JSON.stringify(response));
3548
+ }
3549
+ } catch (err) {
3550
+ if (onError) {
3551
+ onError(err instanceof Error ? err : new Error(String(err)));
3552
+ }
3553
+ }
3554
+ });
3555
+ ws.on("error", (error) => {
3556
+ if (onError) {
3557
+ onError(error);
3558
+ }
3559
+ });
3560
+ ws.on("close", (code, reason) => {
3561
+ connected = false;
3562
+ if (pingInterval) {
3563
+ clearInterval(pingInterval);
3564
+ pingInterval = null;
3565
+ }
3566
+ if (onClose) {
3567
+ onClose(code, reason.toString());
3568
+ }
3569
+ if (shouldReconnect) {
3570
+ const delay = (session.reconnect_delay || 5) * 1e3;
3571
+ setTimeout(() => {
3572
+ connect();
3573
+ }, delay);
3574
+ }
3575
+ });
3576
+ }
3577
+ connect();
3578
+ return {
3579
+ close: () => {
3580
+ shouldReconnect = false;
3581
+ if (pingInterval) {
3582
+ clearInterval(pingInterval);
3583
+ pingInterval = null;
3584
+ }
3585
+ if (ws) {
3586
+ ws.close(1e3, "Connection Done");
3587
+ ws = null;
3588
+ }
3589
+ },
3590
+ isConnected: () => connected
3591
+ };
3592
+ }
3593
+
3594
+ // src/index.ts
3595
+ var VERSION = package_default.version;
3596
+
3597
+ // src/cli/ngrok.ts
3598
+ var import_ngrok = __toESM(require("@ngrok/ngrok"), 1);
3599
+ var import_chalk2 = __toESM(require("chalk"), 1);
3600
+ async function createTunnel(port, authToken) {
3601
+ try {
3602
+ console.log(import_chalk2.default.blue(`
3603
+ Creating ngrok tunnel for port ${port}...`));
3604
+ const listener = await import_ngrok.default.forward({
3605
+ addr: port,
3606
+ authtoken: authToken
3607
+ });
3608
+ const url = listener.url();
3609
+ if (!url) {
3610
+ throw new Error("Failed to get ngrok URL");
3611
+ }
3612
+ console.log(import_chalk2.default.green(`\u2713 ngrok tunnel created: ${url}`));
3613
+ return {
3614
+ url,
3615
+ close: async () => {
3616
+ console.log(import_chalk2.default.blue("\nClosing ngrok tunnel..."));
3617
+ await listener.close();
3618
+ console.log(import_chalk2.default.green("\u2713 ngrok tunnel closed"));
3619
+ }
3620
+ };
3621
+ } catch (error) {
3622
+ console.error(import_chalk2.default.red("\nFailed to create ngrok tunnel:"));
3623
+ if (error instanceof Error) {
3624
+ console.error(import_chalk2.default.red(error.message));
3625
+ }
3626
+ throw error;
3627
+ }
3628
+ }
3629
+
3630
+ // src/supabase/supabase.ts
3631
+ var import_supabase_management_js = require("supabase-management-js");
3632
+
3633
+ // raw-ts:/Users/lfdepombo/src/stripe-sync-engine/packages/sync-engine/src/supabase/edge-functions/stripe-setup.ts
3634
+ var stripe_setup_default = "import { StripeSync, runMigrations } from 'npm:stripe-experiment-sync'\n\nDeno.serve(async (req) => {\n if (req.method !== 'POST') {\n return new Response('Method not allowed', { status: 405 })\n }\n\n const authHeader = req.headers.get('Authorization')\n if (!authHeader?.startsWith('Bearer ')) {\n return new Response('Unauthorized', { status: 401 })\n }\n\n let stripeSync = null\n try {\n // Get and validate database URL\n const rawDbUrl = Deno.env.get('SUPABASE_DB_URL')\n if (!rawDbUrl) {\n throw new Error('SUPABASE_DB_URL environment variable is not set')\n }\n // Remove sslmode from connection string (not supported by pg in Deno)\n const dbUrl = rawDbUrl.replace(/[?&]sslmode=[^&]*/g, '').replace(/[?&]$/, '')\n\n await runMigrations({ databaseUrl: dbUrl })\n\n stripeSync = new StripeSync({\n poolConfig: { connectionString: dbUrl, max: 2 }, // Need 2 for advisory lock + queries\n stripeSecretKey: Deno.env.get('STRIPE_SECRET_KEY'),\n })\n\n // Release any stale advisory locks from previous timeouts\n await stripeSync.postgresClient.query('SELECT pg_advisory_unlock_all()')\n\n // Construct webhook URL from SUPABASE_URL (available in all Edge Functions)\n const supabaseUrl = Deno.env.get('SUPABASE_URL')\n if (!supabaseUrl) {\n throw new Error('SUPABASE_URL environment variable is not set')\n }\n const webhookUrl = supabaseUrl + '/functions/v1/stripe-webhook'\n\n const webhook = await stripeSync.findOrCreateManagedWebhook(webhookUrl)\n\n await stripeSync.postgresClient.pool.end()\n\n return new Response(\n JSON.stringify({\n success: true,\n message: 'Setup complete',\n webhookId: webhook.id,\n }),\n {\n status: 200,\n headers: { 'Content-Type': 'application/json' },\n }\n )\n } catch (error) {\n console.error('Setup error:', error)\n // Cleanup on error\n if (stripeSync) {\n try {\n await stripeSync.postgresClient.query('SELECT pg_advisory_unlock_all()')\n await stripeSync.postgresClient.pool.end()\n } catch (cleanupErr) {\n console.warn('Cleanup failed:', cleanupErr)\n }\n }\n return new Response(JSON.stringify({ success: false, error: error.message }), {\n status: 500,\n headers: { 'Content-Type': 'application/json' },\n })\n }\n})\n";
3635
+
3636
+ // raw-ts:/Users/lfdepombo/src/stripe-sync-engine/packages/sync-engine/src/supabase/edge-functions/stripe-webhook.ts
3637
+ var stripe_webhook_default = "import { StripeSync } from 'npm:stripe-experiment-sync'\n\nDeno.serve(async (req) => {\n if (req.method !== 'POST') {\n return new Response('Method not allowed', { status: 405 })\n }\n\n const sig = req.headers.get('stripe-signature')\n if (!sig) {\n return new Response('Missing stripe-signature header', { status: 400 })\n }\n\n const rawDbUrl = Deno.env.get('SUPABASE_DB_URL')\n if (!rawDbUrl) {\n return new Response(JSON.stringify({ error: 'SUPABASE_DB_URL not set' }), { status: 500 })\n }\n const dbUrl = rawDbUrl.replace(/[?&]sslmode=[^&]*/g, '').replace(/[?&]$/, '')\n\n const stripeSync = new StripeSync({\n poolConfig: { connectionString: dbUrl, max: 1 },\n stripeSecretKey: Deno.env.get('STRIPE_SECRET_KEY')!,\n })\n\n try {\n const rawBody = new Uint8Array(await req.arrayBuffer())\n await stripeSync.processWebhook(rawBody, sig)\n return new Response(JSON.stringify({ received: true }), {\n status: 200,\n headers: { 'Content-Type': 'application/json' },\n })\n } catch (error) {\n console.error('Webhook processing error:', error)\n const isSignatureError =\n error.message?.includes('signature') || error.type === 'StripeSignatureVerificationError'\n const status = isSignatureError ? 400 : 500\n return new Response(JSON.stringify({ error: error.message }), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n } finally {\n await stripeSync.postgresClient.pool.end()\n }\n})\n";
3638
+
3639
+ // raw-ts:/Users/lfdepombo/src/stripe-sync-engine/packages/sync-engine/src/supabase/edge-functions/stripe-worker.ts
3640
+ var stripe_worker_default = "/**\n * Stripe Sync Worker\n *\n * Triggered by pg_cron every 10 seconds. Uses pgmq for durable work queue.\n *\n * Flow:\n * 1. Read batch of messages from pgmq (qty=10, vt=60s)\n * 2. If queue empty: enqueue all objects (continuous sync)\n * 3. Process messages in parallel (Promise.all):\n * - processNext(object)\n * - Delete message on success\n * - Re-enqueue if hasMore\n * 4. Return results summary\n *\n * Concurrency:\n * - Multiple workers can run concurrently via overlapping pg_cron triggers.\n * - Each worker processes its batch of messages in parallel (Promise.all).\n * - pgmq visibility timeout prevents duplicate message reads across workers.\n * - processNext() is idempotent (uses internal cursor tracking), so duplicate\n * processing on timeout/crash is safe.\n */\n\nimport { StripeSync } from 'npm:stripe-experiment-sync'\nimport postgres from 'npm:postgres'\n\nconst QUEUE_NAME = 'stripe_sync_work'\nconst VISIBILITY_TIMEOUT = 60 // seconds\nconst BATCH_SIZE = 10\n\nDeno.serve(async (req) => {\n const authHeader = req.headers.get('Authorization')\n if (!authHeader?.startsWith('Bearer ')) {\n return new Response('Unauthorized', { status: 401 })\n }\n\n const rawDbUrl = Deno.env.get('SUPABASE_DB_URL')\n if (!rawDbUrl) {\n return new Response(JSON.stringify({ error: 'SUPABASE_DB_URL not set' }), { status: 500 })\n }\n const dbUrl = rawDbUrl.replace(/[?&]sslmode=[^&]*/g, '').replace(/[?&]$/, '')\n\n let sql\n let stripeSync\n\n try {\n sql = postgres(dbUrl, { max: 1, prepare: false })\n } catch (error) {\n return new Response(\n JSON.stringify({\n error: 'Failed to create postgres connection',\n details: error.message,\n stack: error.stack,\n }),\n { status: 500, headers: { 'Content-Type': 'application/json' } }\n )\n }\n\n try {\n stripeSync = new StripeSync({\n poolConfig: { connectionString: dbUrl, max: 1 },\n stripeSecretKey: Deno.env.get('STRIPE_SECRET_KEY')!,\n })\n } catch (error) {\n await sql.end()\n return new Response(\n JSON.stringify({\n error: 'Failed to create StripeSync',\n details: error.message,\n stack: error.stack,\n }),\n { status: 500, headers: { 'Content-Type': 'application/json' } }\n )\n }\n\n try {\n // Read batch of messages from queue\n const messages = await sql`\n SELECT * FROM pgmq.read(${QUEUE_NAME}::text, ${VISIBILITY_TIMEOUT}::int, ${BATCH_SIZE}::int)\n `\n\n // If queue empty, enqueue all objects for continuous sync\n if (messages.length === 0) {\n // Create sync run to make enqueued work visible (status='pending')\n const { objects } = await stripeSync.joinOrCreateSyncRun('worker')\n const msgs = objects.map((object) => JSON.stringify({ object }))\n\n await sql`\n SELECT pgmq.send_batch(\n ${QUEUE_NAME}::text,\n ${sql.array(msgs)}::jsonb[]\n )\n `\n\n return new Response(JSON.stringify({ enqueued: objects.length, objects }), {\n status: 200,\n headers: { 'Content-Type': 'application/json' },\n })\n }\n\n // Process messages in parallel\n const results = await Promise.all(\n messages.map(async (msg) => {\n const { object } = msg.message as { object: string }\n\n try {\n const result = await stripeSync.processNext(object)\n\n // Delete message on success (cast to bigint to disambiguate overloaded function)\n await sql`SELECT pgmq.delete(${QUEUE_NAME}::text, ${msg.msg_id}::bigint)`\n\n // Re-enqueue if more pages\n if (result.hasMore) {\n await sql`SELECT pgmq.send(${QUEUE_NAME}::text, ${sql.json({ object })}::jsonb)`\n }\n\n return { object, ...result }\n } catch (error) {\n // Log error but continue to next message\n // Message will become visible again after visibility timeout\n console.error(`Error processing ${object}:`, error)\n return {\n object,\n processed: 0,\n hasMore: false,\n error: error.message,\n stack: error.stack,\n }\n }\n })\n )\n\n return new Response(JSON.stringify({ results }), {\n status: 200,\n headers: { 'Content-Type': 'application/json' },\n })\n } catch (error) {\n console.error('Worker error:', error)\n return new Response(JSON.stringify({ error: error.message, stack: error.stack }), {\n status: 500,\n headers: { 'Content-Type': 'application/json' },\n })\n } finally {\n if (sql) await sql.end()\n if (stripeSync) await stripeSync.postgresClient.pool.end()\n }\n})\n";
3641
+
3642
+ // src/supabase/edge-function-code.ts
3643
+ var setupFunctionCode = stripe_setup_default;
3644
+ var webhookFunctionCode = stripe_webhook_default;
3645
+ var workerFunctionCode = stripe_worker_default;
3646
+
3647
+ // src/supabase/supabase.ts
3648
+ var import_stripe3 = __toESM(require("stripe"), 1);
3649
+ var STRIPE_SCHEMA_COMMENT_PREFIX = "stripe-sync";
3650
+ var INSTALLATION_STARTED_SUFFIX = "installation:started";
3651
+ var INSTALLATION_ERROR_SUFFIX = "installation:error";
3652
+ var INSTALLATION_INSTALLED_SUFFIX = "installed";
3653
+ var SupabaseSetupClient = class {
3654
+ api;
3655
+ projectRef;
3656
+ projectBaseUrl;
3657
+ constructor(options) {
3658
+ this.api = new import_supabase_management_js.SupabaseManagementAPI({
3659
+ accessToken: options.accessToken,
3660
+ baseUrl: options.managementApiBaseUrl
3661
+ });
3662
+ this.projectRef = options.projectRef;
3663
+ this.projectBaseUrl = options.projectBaseUrl || process.env.SUPABASE_BASE_URL || "supabase.co";
3664
+ }
3665
+ /**
3666
+ * Validate that the project exists and we have access
3667
+ */
3668
+ async validateProject() {
3669
+ const projects = await this.api.getProjects();
3670
+ const project = projects?.find((p) => p.id === this.projectRef);
3671
+ if (!project) {
3672
+ throw new Error(`Project ${this.projectRef} not found or you don't have access`);
3673
+ }
3674
+ return {
3675
+ id: project.id,
3676
+ name: project.name,
3677
+ region: project.region
3678
+ };
3679
+ }
3680
+ /**
3681
+ * Deploy an Edge Function
3682
+ */
3683
+ async deployFunction(name, code) {
3684
+ const functions = await this.api.listFunctions(this.projectRef);
3685
+ const exists = functions?.some((f) => f.slug === name);
3686
+ if (exists) {
3687
+ await this.api.updateFunction(this.projectRef, name, {
3688
+ body: code,
3689
+ verify_jwt: false
3690
+ });
3691
+ } else {
3692
+ await this.api.createFunction(this.projectRef, {
3693
+ slug: name,
3694
+ name,
3695
+ body: code,
3696
+ verify_jwt: false
3697
+ });
3698
+ }
3699
+ }
3700
+ /**
3701
+ * Set secrets for Edge Functions
3702
+ */
3703
+ async setSecrets(secrets) {
3704
+ await this.api.createSecrets(this.projectRef, secrets);
3705
+ }
3706
+ /**
3707
+ * Run SQL against the database
3708
+ */
3709
+ async runSQL(sql3) {
3710
+ return await this.api.runQuery(this.projectRef, sql3);
3711
+ }
3712
+ /**
3713
+ * Setup pg_cron job to invoke worker function
3714
+ */
3715
+ async setupPgCronJob() {
3716
+ const serviceRoleKey = await this.getServiceRoleKey();
3717
+ const escapedServiceRoleKey = serviceRoleKey.replace(/'/g, "''");
3718
+ const sql3 = `
3719
+ -- Enable extensions
3720
+ CREATE EXTENSION IF NOT EXISTS pg_cron;
3721
+ CREATE EXTENSION IF NOT EXISTS pg_net;
3722
+ CREATE EXTENSION IF NOT EXISTS pgmq;
3723
+
3724
+ -- Create pgmq queue for sync work (idempotent)
3725
+ SELECT pgmq.create('stripe_sync_work')
3726
+ WHERE NOT EXISTS (
3727
+ SELECT 1 FROM pgmq.list_queues() WHERE queue_name = 'stripe_sync_work'
3728
+ );
3729
+
3730
+ -- Store service role key in vault for pg_cron to use
3731
+ -- Delete existing secret if it exists, then create new one
3732
+ DELETE FROM vault.secrets WHERE name = 'stripe_sync_service_role_key';
3733
+ SELECT vault.create_secret('${escapedServiceRoleKey}', 'stripe_sync_service_role_key');
3734
+
3735
+ -- Delete existing jobs if they exist
3736
+ SELECT cron.unschedule('stripe-sync-worker') WHERE EXISTS (
3737
+ SELECT 1 FROM cron.job WHERE jobname = 'stripe-sync-worker'
3738
+ );
3739
+ SELECT cron.unschedule('stripe-sync-scheduler') WHERE EXISTS (
3740
+ SELECT 1 FROM cron.job WHERE jobname = 'stripe-sync-scheduler'
3741
+ );
3742
+
3743
+ -- Create job to invoke worker every 10 seconds
3744
+ -- Worker reads from pgmq, enqueues objects if empty, and processes sync work
3745
+ SELECT cron.schedule(
3746
+ 'stripe-sync-worker',
3747
+ '10 seconds',
3748
+ $$
3749
+ SELECT net.http_post(
3750
+ url := 'https://${this.projectRef}.${this.projectBaseUrl}/functions/v1/stripe-worker',
3751
+ headers := jsonb_build_object(
3752
+ 'Authorization', 'Bearer ' || (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = 'stripe_sync_service_role_key')
3753
+ )
3754
+ )
3755
+ $$
3756
+ );
3757
+ `;
3758
+ await this.runSQL(sql3);
3759
+ }
3760
+ /**
3761
+ * Get the webhook URL for this project
3762
+ */
3763
+ getWebhookUrl() {
3764
+ return `https://${this.projectRef}.${this.projectBaseUrl}/functions/v1/stripe-webhook`;
3765
+ }
3766
+ /**
3767
+ * Get the service role key for this project (needed to invoke Edge Functions)
3768
+ */
3769
+ async getServiceRoleKey() {
3770
+ const apiKeys = await this.api.getProjectApiKeys(this.projectRef);
3771
+ const serviceRoleKey = apiKeys?.find((k) => k.name === "service_role");
3772
+ if (!serviceRoleKey) {
3773
+ throw new Error("Could not find service_role API key");
3774
+ }
3775
+ return serviceRoleKey.api_key;
3776
+ }
3777
+ /**
3778
+ * Get the anon key for this project (needed for Realtime subscriptions)
3779
+ */
3780
+ async getAnonKey() {
3781
+ const apiKeys = await this.api.getProjectApiKeys(this.projectRef);
3782
+ const anonKey = apiKeys?.find((k) => k.name === "anon");
3783
+ if (!anonKey) {
3784
+ throw new Error("Could not find anon API key");
3785
+ }
3786
+ return anonKey.api_key;
3787
+ }
3788
+ /**
3789
+ * Get the project URL
3790
+ */
3791
+ getProjectUrl() {
3792
+ return `https://${this.projectRef}.${this.projectBaseUrl}`;
3793
+ }
3794
+ /**
3795
+ * Invoke an Edge Function
3796
+ */
3797
+ async invokeFunction(name, serviceRoleKey) {
3798
+ const url = `https://${this.projectRef}.${this.projectBaseUrl}/functions/v1/${name}`;
3799
+ const response = await fetch(url, {
3800
+ method: "POST",
3801
+ headers: {
3802
+ Authorization: `Bearer ${serviceRoleKey}`,
3803
+ "Content-Type": "application/json"
3804
+ }
3805
+ });
3806
+ if (!response.ok) {
3807
+ const text = await response.text();
3808
+ return { success: false, error: `${response.status}: ${text}` };
3809
+ }
3810
+ const result = await response.json();
3811
+ if (result.success === false) {
3812
+ return { success: false, error: result.error };
3813
+ }
3814
+ return { success: true };
3815
+ }
3816
+ /**
3817
+ * Check if stripe-sync is installed in the database.
3818
+ *
3819
+ * Uses the Supabase Management API to run SQL queries.
3820
+ * Uses duck typing (schema + migrations table) combined with comment validation.
3821
+ * Throws error for legacy installations to prevent accidental corruption.
3822
+ *
3823
+ * @param schema The schema name to check (defaults to 'stripe')
3824
+ * @returns true if properly installed with comment marker, false if not installed
3825
+ * @throws Error if legacy installation detected (schema exists without comment)
3826
+ */
3827
+ async isInstalled(schema = "stripe") {
3828
+ try {
3829
+ const schemaCheck = await this.runSQL(
3830
+ `SELECT EXISTS (
3831
+ SELECT 1 FROM information_schema.schemata
3832
+ WHERE schema_name = '${schema}'
3833
+ ) as schema_exists`
3834
+ );
3835
+ const schemaExists = schemaCheck[0]?.rows?.[0]?.schema_exists === true;
3836
+ if (!schemaExists) {
3837
+ return false;
3838
+ }
3839
+ const migrationsCheck = await this.runSQL(
3840
+ `SELECT EXISTS (
3841
+ SELECT 1 FROM information_schema.tables
3842
+ WHERE table_schema = '${schema}' AND table_name IN ('migrations', '_migrations')
3843
+ ) as table_exists`
3844
+ );
3845
+ const migrationsTableExists = migrationsCheck[0]?.rows?.[0]?.table_exists === true;
3846
+ if (!migrationsTableExists) {
3847
+ return false;
3848
+ }
3849
+ const commentCheck = await this.runSQL(
3850
+ `SELECT obj_description(oid, 'pg_namespace') as comment
3851
+ FROM pg_namespace
3852
+ WHERE nspname = '${schema}'`
3853
+ );
3854
+ const comment = commentCheck[0]?.rows?.[0]?.comment;
3855
+ if (!comment || !comment.includes(STRIPE_SCHEMA_COMMENT_PREFIX)) {
3856
+ throw new Error(
3857
+ `Legacy installation detected: Schema '${schema}' and migrations table exist, but missing stripe-sync comment marker. This may be a legacy installation or manually created schema. Please contact support or manually drop the schema before proceeding.`
3858
+ );
3859
+ }
3860
+ if (comment.includes(INSTALLATION_STARTED_SUFFIX)) {
3861
+ return false;
3862
+ }
3863
+ if (comment.includes(INSTALLATION_ERROR_SUFFIX)) {
3864
+ throw new Error(
3865
+ `Installation failed: Schema '${schema}' exists but installation encountered an error. Comment: ${comment}. Please uninstall and install again.`
3866
+ );
3867
+ }
3868
+ return true;
3869
+ } catch (error) {
3870
+ if (error instanceof Error && (error.message.includes("Legacy installation detected") || error.message.includes("Installation failed"))) {
3871
+ throw error;
3872
+ }
3873
+ return false;
3874
+ }
3875
+ }
3876
+ /**
3877
+ * Update installation progress comment on the stripe schema
3878
+ */
3879
+ async updateInstallationComment(message) {
3880
+ const escapedMessage = message.replace(/'/g, "''");
3881
+ await this.runSQL(`COMMENT ON SCHEMA stripe IS '${escapedMessage}'`);
3882
+ }
3883
+ /**
3884
+ * Delete an Edge Function
3885
+ */
3886
+ async deleteFunction(name) {
3887
+ try {
3888
+ await this.api.deleteFunction(this.projectRef, name);
3889
+ } catch (err) {
3890
+ console.warn(`Could not delete function ${name}:`, err);
3891
+ }
3892
+ }
3893
+ /**
3894
+ * Delete a secret
3895
+ */
3896
+ async deleteSecret(name) {
3897
+ try {
3898
+ await this.api.deleteSecrets(this.projectRef, [name]);
3899
+ } catch (err) {
3900
+ console.warn(`Could not delete secret ${name}:`, err);
3901
+ }
3902
+ }
3903
+ /**
3904
+ * Uninstall stripe-sync from a Supabase project
3905
+ * Removes all Edge Functions, secrets, database resources, and Stripe webhooks
3906
+ */
3907
+ async uninstall(stripeSecretKey) {
3908
+ const stripe = new import_stripe3.default(stripeSecretKey, { apiVersion: "2025-02-24.acacia" });
3909
+ try {
3910
+ try {
3911
+ const webhookResult = await this.runSQL(`
3912
+ SELECT id FROM stripe._managed_webhooks WHERE id IS NOT NULL
3913
+ `);
3914
+ const webhookIds = webhookResult[0]?.rows?.map((r) => r.id) || [];
3915
+ for (const webhookId of webhookIds) {
3916
+ try {
3917
+ await stripe.webhookEndpoints.del(webhookId);
3918
+ } catch (err) {
3919
+ console.warn(`Could not delete Stripe webhook ${webhookId}:`, err);
3920
+ }
3921
+ }
3922
+ } catch (err) {
3923
+ console.warn("Could not query/delete webhooks:", err);
3924
+ }
3925
+ await this.deleteFunction("stripe-setup");
3926
+ await this.deleteFunction("stripe-webhook");
3927
+ await this.deleteFunction("stripe-worker");
3928
+ await this.deleteSecret("STRIPE_SECRET_KEY");
3929
+ try {
3930
+ await this.runSQL(`
3931
+ SELECT cron.unschedule('stripe-sync-worker')
3932
+ WHERE EXISTS (
3933
+ SELECT 1 FROM cron.job WHERE jobname = 'stripe-sync-worker'
3934
+ )
3935
+ `);
3936
+ } catch (err) {
3937
+ console.warn("Could not unschedule pg_cron job:", err);
3938
+ }
3939
+ try {
3940
+ await this.runSQL(`
3941
+ DELETE FROM vault.secrets
3942
+ WHERE name = 'stripe_sync_service_role_key'
3943
+ `);
3944
+ } catch (err) {
3945
+ console.warn("Could not delete vault secret:", err);
3946
+ }
3947
+ await this.runSQL(`DROP SCHEMA IF EXISTS stripe CASCADE`);
3948
+ } catch (error) {
3949
+ throw new Error(`Uninstall failed: ${error instanceof Error ? error.message : String(error)}`);
3950
+ }
3951
+ }
3952
+ /**
3953
+ * Inject package version into Edge Function code
3954
+ */
3955
+ injectPackageVersion(code, version) {
3956
+ if (version === "latest") {
3957
+ return code;
3958
+ }
3959
+ return code.replace(
3960
+ /from ['"]npm:stripe-experiment-sync['"]/g,
3961
+ `from 'npm:stripe-experiment-sync@${version}'`
3962
+ );
3963
+ }
3964
+ async install(stripeKey, packageVersion) {
3965
+ const trimmedStripeKey = stripeKey.trim();
3966
+ if (!trimmedStripeKey.startsWith("sk_") && !trimmedStripeKey.startsWith("rk_")) {
3967
+ throw new Error('Stripe key should start with "sk_" or "rk_"');
3968
+ }
3969
+ const version = packageVersion || "latest";
3970
+ try {
3971
+ await this.validateProject();
3972
+ await this.runSQL(`CREATE SCHEMA IF NOT EXISTS stripe`);
3973
+ await this.updateInstallationComment(
3974
+ `${STRIPE_SCHEMA_COMMENT_PREFIX} v${package_default.version} ${INSTALLATION_STARTED_SUFFIX}`
3975
+ );
3976
+ const versionedSetup = this.injectPackageVersion(setupFunctionCode, version);
3977
+ const versionedWebhook = this.injectPackageVersion(webhookFunctionCode, version);
3978
+ const versionedWorker = this.injectPackageVersion(workerFunctionCode, version);
3979
+ await this.deployFunction("stripe-setup", versionedSetup);
3980
+ await this.deployFunction("stripe-webhook", versionedWebhook);
3981
+ await this.deployFunction("stripe-worker", versionedWorker);
3982
+ await this.setSecrets([{ name: "STRIPE_SECRET_KEY", value: trimmedStripeKey }]);
3983
+ const serviceRoleKey = await this.getServiceRoleKey();
3984
+ const setupResult = await this.invokeFunction("stripe-setup", serviceRoleKey);
3985
+ if (!setupResult.success) {
3986
+ throw new Error(`Setup failed: ${setupResult.error}`);
3987
+ }
3988
+ await this.setupPgCronJob();
3989
+ await this.updateInstallationComment(
3990
+ `${STRIPE_SCHEMA_COMMENT_PREFIX} v${package_default.version} ${INSTALLATION_INSTALLED_SUFFIX}`
3991
+ );
3992
+ } catch (error) {
3993
+ await this.updateInstallationComment(
3994
+ `${STRIPE_SCHEMA_COMMENT_PREFIX} v${package_default.version} ${INSTALLATION_ERROR_SUFFIX} - ${error instanceof Error ? error.message : String(error)}`
3995
+ );
3996
+ throw error;
3997
+ }
3998
+ }
3999
+ };
4000
+ async function install(params) {
4001
+ const { supabaseAccessToken, supabaseProjectRef, stripeKey, packageVersion } = params;
4002
+ const client = new SupabaseSetupClient({
4003
+ accessToken: supabaseAccessToken,
4004
+ projectRef: supabaseProjectRef,
4005
+ projectBaseUrl: params.baseProjectUrl,
4006
+ managementApiBaseUrl: params.baseManagementApiUrl
4007
+ });
4008
+ await client.install(stripeKey, packageVersion);
4009
+ }
4010
+ async function uninstall(params) {
4011
+ const { supabaseAccessToken, supabaseProjectRef, stripeKey } = params;
4012
+ const trimmedStripeKey = stripeKey.trim();
4013
+ if (!trimmedStripeKey.startsWith("sk_") && !trimmedStripeKey.startsWith("rk_")) {
4014
+ throw new Error('Stripe key should start with "sk_" or "rk_"');
4015
+ }
4016
+ const client = new SupabaseSetupClient({
4017
+ accessToken: supabaseAccessToken,
4018
+ projectRef: supabaseProjectRef
4019
+ });
4020
+ await client.uninstall(trimmedStripeKey);
4021
+ }
4022
+
4023
+ // src/cli/commands.ts
4024
+ var VALID_SYNC_OBJECTS = [
4025
+ "all",
4026
+ "customer",
4027
+ "customer_with_entitlements",
4028
+ "invoice",
4029
+ "price",
4030
+ "product",
4031
+ "subscription",
4032
+ "subscription_schedules",
4033
+ "setup_intent",
4034
+ "payment_method",
4035
+ "dispute",
4036
+ "charge",
4037
+ "payment_intent",
4038
+ "plan",
4039
+ "tax_id",
4040
+ "credit_note",
4041
+ "early_fraud_warning",
4042
+ "refund",
4043
+ "checkout_sessions"
4044
+ ];
4045
+ async function backfillCommand(options, entityName) {
4046
+ let stripeSync = null;
4047
+ try {
4048
+ if (!VALID_SYNC_OBJECTS.includes(entityName)) {
4049
+ console.error(
4050
+ import_chalk3.default.red(
4051
+ `Error: Invalid entity name "${entityName}". Valid entities are: ${VALID_SYNC_OBJECTS.join(", ")}`
4052
+ )
4053
+ );
4054
+ process.exit(1);
4055
+ }
4056
+ import_dotenv2.default.config();
4057
+ let stripeApiKey = options.stripeKey || process.env.STRIPE_API_KEY || process.env.STRIPE_SECRET_KEY || "";
4058
+ let databaseUrl = options.databaseUrl || process.env.DATABASE_URL || "";
4059
+ if (!stripeApiKey || !databaseUrl) {
4060
+ const inquirer2 = (await import("inquirer")).default;
4061
+ const questions = [];
4062
+ if (!stripeApiKey) {
4063
+ questions.push({
4064
+ type: "password",
4065
+ name: "stripeApiKey",
4066
+ message: "Enter your Stripe API key:",
4067
+ mask: "*",
4068
+ validate: (input) => {
4069
+ if (!input || input.trim() === "") {
4070
+ return "Stripe API key is required";
4071
+ }
4072
+ if (!input.startsWith("sk_")) {
4073
+ return 'Stripe API key should start with "sk_"';
4074
+ }
4075
+ return true;
4076
+ }
4077
+ });
4078
+ }
4079
+ if (!databaseUrl) {
4080
+ questions.push({
4081
+ type: "password",
4082
+ name: "databaseUrl",
4083
+ message: "Enter your Postgres DATABASE_URL:",
4084
+ mask: "*",
4085
+ validate: (input) => {
4086
+ if (!input || input.trim() === "") {
4087
+ return "DATABASE_URL is required";
4088
+ }
4089
+ if (!input.startsWith("postgres://") && !input.startsWith("postgresql://")) {
4090
+ return 'DATABASE_URL should start with "postgres://" or "postgresql://"';
4091
+ }
4092
+ return true;
4093
+ }
4094
+ });
4095
+ }
4096
+ if (questions.length > 0) {
4097
+ console.log(import_chalk3.default.yellow("\nMissing required configuration. Please provide:"));
4098
+ const answers = await inquirer2.prompt(questions);
4099
+ if (answers.stripeApiKey) stripeApiKey = answers.stripeApiKey;
4100
+ if (answers.databaseUrl) databaseUrl = answers.databaseUrl;
4101
+ }
4102
+ }
4103
+ const config = {
4104
+ stripeApiKey,
4105
+ databaseUrl,
4106
+ ngrokAuthToken: ""
4107
+ // Not needed for backfill
4108
+ };
4109
+ console.log(import_chalk3.default.blue(`Backfilling ${entityName} from Stripe in 'stripe' schema...`));
4110
+ console.log(import_chalk3.default.gray(`Database: ${config.databaseUrl.replace(/:[^:@]+@/, ":****@")}`));
4111
+ try {
4112
+ await runMigrations({
4113
+ databaseUrl: config.databaseUrl
4114
+ });
4115
+ } catch (migrationError) {
4116
+ console.error(import_chalk3.default.red("Failed to run migrations:"));
4117
+ console.error(
4118
+ migrationError instanceof Error ? migrationError.message : String(migrationError)
4119
+ );
4120
+ throw migrationError;
4121
+ }
4122
+ const poolConfig = {
4123
+ max: 10,
4124
+ connectionString: config.databaseUrl,
4125
+ keepAlive: true
4126
+ };
4127
+ stripeSync = new StripeSync({
4128
+ databaseUrl: config.databaseUrl,
4129
+ stripeSecretKey: config.stripeApiKey,
4130
+ stripeApiVersion: process.env.STRIPE_API_VERSION || "2020-08-27",
4131
+ autoExpandLists: process.env.AUTO_EXPAND_LISTS === "true",
4132
+ backfillRelatedEntities: process.env.BACKFILL_RELATED_ENTITIES !== "false",
4133
+ poolConfig
4134
+ });
4135
+ const result = await stripeSync.processUntilDone({ object: entityName });
4136
+ const totalSynced = Object.values(result).reduce(
4137
+ (sum, syncResult) => sum + (syncResult?.synced || 0),
4138
+ 0
4139
+ );
4140
+ console.log(import_chalk3.default.green(`\u2713 Backfill complete: ${totalSynced} ${entityName} objects synced`));
4141
+ await stripeSync.close();
4142
+ } catch (error) {
4143
+ if (error instanceof Error) {
4144
+ console.error(import_chalk3.default.red(error.message));
4145
+ }
4146
+ if (stripeSync) {
4147
+ try {
4148
+ await stripeSync.close();
4149
+ } catch {
4150
+ }
4151
+ }
4152
+ process.exit(1);
4153
+ }
4154
+ }
4155
+ async function migrateCommand(options) {
4156
+ try {
4157
+ import_dotenv2.default.config();
4158
+ let databaseUrl = options.databaseUrl || process.env.DATABASE_URL || "";
4159
+ if (!databaseUrl) {
4160
+ const inquirer2 = (await import("inquirer")).default;
4161
+ const answers = await inquirer2.prompt([
4162
+ {
4163
+ type: "password",
4164
+ name: "databaseUrl",
4165
+ message: "Enter your Postgres DATABASE_URL:",
4166
+ mask: "*",
4167
+ validate: (input) => {
4168
+ if (!input || input.trim() === "") {
4169
+ return "DATABASE_URL is required";
4170
+ }
4171
+ if (!input.startsWith("postgres://") && !input.startsWith("postgresql://")) {
4172
+ return 'DATABASE_URL should start with "postgres://" or "postgresql://"';
4173
+ }
4174
+ return true;
4175
+ }
4176
+ }
4177
+ ]);
4178
+ databaseUrl = answers.databaseUrl;
4179
+ }
4180
+ console.log(import_chalk3.default.blue("Running database migrations in 'stripe' schema..."));
4181
+ console.log(import_chalk3.default.gray(`Database: ${databaseUrl.replace(/:[^:@]+@/, ":****@")}`));
4182
+ try {
4183
+ await runMigrations({
4184
+ databaseUrl
4185
+ });
4186
+ console.log(import_chalk3.default.green("\u2713 Migrations completed successfully"));
4187
+ } catch (migrationError) {
4188
+ console.warn(import_chalk3.default.yellow("Migrations failed."));
4189
+ if (migrationError instanceof Error) {
4190
+ const errorMsg = migrationError.message || migrationError.toString();
4191
+ console.warn("Migration error:", errorMsg);
4192
+ if (migrationError.stack) {
4193
+ console.warn(import_chalk3.default.gray(migrationError.stack));
4194
+ }
4195
+ } else {
4196
+ console.warn("Migration error:", String(migrationError));
4197
+ }
4198
+ throw migrationError;
4199
+ }
4200
+ } catch (error) {
4201
+ if (error instanceof Error) {
4202
+ console.error(import_chalk3.default.red(error.message));
4203
+ }
4204
+ process.exit(1);
4205
+ }
4206
+ }
4207
+ async function syncCommand(options) {
4208
+ let stripeSync = null;
4209
+ let tunnel = null;
4210
+ let server = null;
4211
+ let webhookId = null;
4212
+ let wsClient = null;
4213
+ const cleanup = async (signal) => {
4214
+ console.log(import_chalk3.default.blue(`
4215
+
4216
+ Cleaning up... (signal: ${signal || "manual"})`));
4217
+ if (wsClient) {
4218
+ try {
4219
+ wsClient.close();
4220
+ console.log(import_chalk3.default.green("\u2713 WebSocket closed"));
4221
+ } catch {
4222
+ console.log(import_chalk3.default.yellow("\u26A0 Could not close WebSocket"));
4223
+ }
4224
+ }
4225
+ const keepWebhooksOnShutdown = process.env.KEEP_WEBHOOKS_ON_SHUTDOWN === "true";
4226
+ if (webhookId && stripeSync && !keepWebhooksOnShutdown) {
4227
+ try {
4228
+ await stripeSync.deleteManagedWebhook(webhookId);
4229
+ console.log(import_chalk3.default.green("\u2713 Webhook cleanup complete"));
4230
+ } catch {
4231
+ console.log(import_chalk3.default.yellow("\u26A0 Could not delete webhook"));
4232
+ }
4233
+ }
4234
+ if (server) {
4235
+ try {
4236
+ await new Promise((resolve, reject) => {
4237
+ server.close((err) => {
4238
+ if (err) reject(err);
4239
+ else resolve();
4240
+ });
4241
+ });
4242
+ console.log(import_chalk3.default.green("\u2713 Server stopped"));
4243
+ } catch {
4244
+ console.log(import_chalk3.default.yellow("\u26A0 Server already stopped"));
4245
+ }
4246
+ }
4247
+ if (tunnel) {
4248
+ try {
4249
+ await tunnel.close();
4250
+ } catch {
4251
+ console.log(import_chalk3.default.yellow("\u26A0 Could not close tunnel"));
4252
+ }
4253
+ }
4254
+ if (stripeSync) {
4255
+ try {
4256
+ await stripeSync.close();
4257
+ console.log(import_chalk3.default.green("\u2713 Database pool closed"));
4258
+ } catch {
4259
+ console.log(import_chalk3.default.yellow("\u26A0 Could not close database pool"));
4260
+ }
4261
+ }
4262
+ process.exit(0);
4263
+ };
4264
+ process.on("SIGINT", () => cleanup("SIGINT"));
4265
+ process.on("SIGTERM", () => cleanup("SIGTERM"));
4266
+ try {
4267
+ const config = await loadConfig(options);
4268
+ const useWebSocketMode = !config.ngrokAuthToken;
4269
+ const modeLabel = useWebSocketMode ? "WebSocket" : "Webhook (ngrok)";
4270
+ console.log(import_chalk3.default.blue(`
4271
+ Mode: ${modeLabel}`));
4272
+ const maskedDbUrl = config.databaseUrl.replace(/:[^:@]+@/, ":****@");
4273
+ console.log(import_chalk3.default.gray(`Database: ${maskedDbUrl}`));
4274
+ try {
4275
+ await runMigrations({
4276
+ databaseUrl: config.databaseUrl
4277
+ });
4278
+ } catch (migrationError) {
4279
+ console.error(import_chalk3.default.red("Failed to run migrations:"));
4280
+ console.error(
4281
+ migrationError instanceof Error ? migrationError.message : String(migrationError)
4282
+ );
4283
+ throw migrationError;
4284
+ }
4285
+ const poolConfig = {
4286
+ max: 10,
4287
+ connectionString: config.databaseUrl,
4288
+ keepAlive: true
4289
+ };
4290
+ stripeSync = new StripeSync({
4291
+ databaseUrl: config.databaseUrl,
4292
+ stripeSecretKey: config.stripeApiKey,
4293
+ stripeApiVersion: process.env.STRIPE_API_VERSION || "2020-08-27",
4294
+ autoExpandLists: process.env.AUTO_EXPAND_LISTS === "true",
4295
+ backfillRelatedEntities: process.env.BACKFILL_RELATED_ENTITIES !== "false",
4296
+ poolConfig
4297
+ });
4298
+ const databaseUrlWithoutPassword = config.databaseUrl.replace(/:[^:@]+@/, ":****@");
4299
+ if (useWebSocketMode) {
4300
+ console.log(import_chalk3.default.blue("\nConnecting to Stripe WebSocket..."));
4301
+ wsClient = await createStripeWebSocketClient({
4302
+ stripeApiKey: config.stripeApiKey,
4303
+ onEvent: async (event) => {
4304
+ try {
4305
+ const payload = JSON.parse(event.event_payload);
4306
+ console.log(import_chalk3.default.cyan(`\u2190 ${payload.type}`) + import_chalk3.default.gray(` (${payload.id})`));
4307
+ if (stripeSync) {
4308
+ await stripeSync.processEvent(payload);
4309
+ return {
4310
+ status: 200,
4311
+ event_type: payload.type,
4312
+ event_id: payload.id,
4313
+ databaseUrl: databaseUrlWithoutPassword
4314
+ };
4315
+ }
4316
+ } catch (err) {
4317
+ console.error(import_chalk3.default.red("Error processing event:"), err);
4318
+ return {
4319
+ status: 500,
4320
+ databaseUrl: databaseUrlWithoutPassword,
4321
+ error: err instanceof Error ? err.message : String(err)
4322
+ };
4323
+ }
4324
+ },
4325
+ onReady: (secret) => {
4326
+ console.log(import_chalk3.default.green("\u2713 Connected to Stripe WebSocket"));
4327
+ const maskedSecret = secret.length > 14 ? `${secret.slice(0, 10)}...${secret.slice(-4)}` : "****";
4328
+ console.log(import_chalk3.default.gray(` Webhook secret: ${maskedSecret}`));
4329
+ },
4330
+ onError: (error) => {
4331
+ console.error(import_chalk3.default.red("WebSocket error:"), error.message);
4332
+ },
4333
+ onClose: (code, reason) => {
4334
+ console.log(import_chalk3.default.yellow(`WebSocket closed: ${code} - ${reason}`));
4335
+ }
4336
+ });
4337
+ } else {
4338
+ const port = 3e3;
4339
+ tunnel = await createTunnel(port, config.ngrokAuthToken);
4340
+ const webhookPath = process.env.WEBHOOK_PATH || "/stripe-webhooks";
4341
+ console.log(import_chalk3.default.blue("\nCreating Stripe webhook endpoint..."));
4342
+ const webhook = await stripeSync.findOrCreateManagedWebhook(`${tunnel.url}${webhookPath}`);
4343
+ webhookId = webhook.id;
4344
+ const eventCount = webhook.enabled_events?.length || 0;
4345
+ console.log(import_chalk3.default.green(`\u2713 Webhook created: ${webhook.id}`));
4346
+ console.log(import_chalk3.default.cyan(` URL: ${webhook.url}`));
4347
+ console.log(import_chalk3.default.cyan(` Events: ${eventCount} supported events`));
4348
+ const app = (0, import_express.default)();
4349
+ const webhookRoute = webhookPath;
4350
+ app.use(webhookRoute, import_express.default.raw({ type: "application/json" }));
4351
+ app.post(webhookRoute, async (req, res) => {
4352
+ const sig = req.headers["stripe-signature"];
4353
+ if (!sig || typeof sig !== "string") {
4354
+ console.error("[Webhook] Missing stripe-signature header");
4355
+ return res.status(400).send({ error: "Missing stripe-signature header" });
4356
+ }
4357
+ const rawBody = req.body;
4358
+ if (!rawBody || !Buffer.isBuffer(rawBody)) {
4359
+ console.error("[Webhook] Body is not a Buffer!");
4360
+ return res.status(400).send({ error: "Missing raw body for signature verification" });
4361
+ }
4362
+ try {
4363
+ await stripeSync.processWebhook(rawBody, sig);
4364
+ return res.status(200).send({ received: true });
4365
+ } catch (error) {
4366
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
4367
+ console.error("[Webhook] Processing error:", errorMessage);
4368
+ return res.status(400).send({ error: errorMessage });
4369
+ }
4370
+ });
4371
+ app.use(import_express.default.json());
4372
+ app.use(import_express.default.urlencoded({ extended: false }));
4373
+ app.get("/health", async (req, res) => res.status(200).json({ status: "ok" }));
4374
+ console.log(import_chalk3.default.blue(`
4375
+ Starting server on port ${port}...`));
4376
+ await new Promise((resolve, reject) => {
4377
+ server = app.listen(port, "0.0.0.0", () => resolve());
4378
+ server.on("error", reject);
4379
+ });
4380
+ console.log(import_chalk3.default.green(`\u2713 Server started on port ${port}`));
4381
+ }
4382
+ if (process.env.SKIP_BACKFILL !== "true") {
4383
+ console.log(import_chalk3.default.blue("\nStarting initial sync of all Stripe data..."));
4384
+ const syncResult = await stripeSync.processUntilDone();
4385
+ const totalSynced = Object.values(syncResult).reduce(
4386
+ (sum, result) => sum + (result?.synced || 0),
4387
+ 0
4388
+ );
4389
+ console.log(import_chalk3.default.green(`\u2713 Sync complete: ${totalSynced} objects synced`));
4390
+ } else {
4391
+ console.log(import_chalk3.default.yellow("\n\u23ED\uFE0F Skipping initial sync (SKIP_BACKFILL=true)"));
4392
+ }
4393
+ console.log(
4394
+ import_chalk3.default.cyan("\n\u25CF Streaming live changes...") + import_chalk3.default.gray(" [press Ctrl-C to abort]")
4395
+ );
4396
+ await new Promise(() => {
4397
+ });
4398
+ } catch (error) {
4399
+ if (error instanceof Error) {
4400
+ console.error(import_chalk3.default.red(error.message));
4401
+ }
4402
+ await cleanup();
4403
+ process.exit(1);
4404
+ }
4405
+ }
4406
+ async function installCommand(options) {
4407
+ try {
4408
+ import_dotenv2.default.config();
4409
+ let accessToken = options.supabaseAccessToken || process.env.SUPABASE_ACCESS_TOKEN || "";
4410
+ let projectRef = options.supabaseProjectRef || process.env.SUPABASE_PROJECT_REF || "";
4411
+ let stripeKey = options.stripeKey || process.env.STRIPE_API_KEY || process.env.STRIPE_SECRET_KEY || "";
4412
+ if (!accessToken || !projectRef || !stripeKey) {
4413
+ const inquirer2 = (await import("inquirer")).default;
4414
+ const questions = [];
4415
+ if (!accessToken) {
4416
+ questions.push({
4417
+ type: "password",
4418
+ name: "accessToken",
4419
+ message: "Enter your Supabase access token (from supabase.com/dashboard/account/tokens):",
4420
+ mask: "*",
4421
+ validate: (input) => input.trim() !== "" || "Access token is required"
4422
+ });
4423
+ }
4424
+ if (!projectRef) {
4425
+ questions.push({
4426
+ type: "input",
4427
+ name: "projectRef",
4428
+ message: "Enter your Supabase project ref (e.g., abcdefghijklmnop):",
4429
+ validate: (input) => input.trim() !== "" || "Project ref is required"
4430
+ });
4431
+ }
4432
+ if (!stripeKey) {
4433
+ questions.push({
4434
+ type: "password",
4435
+ name: "stripeKey",
4436
+ message: "Enter your Stripe secret key:",
4437
+ mask: "*",
4438
+ validate: (input) => {
4439
+ if (!input.trim()) return "Stripe key is required";
4440
+ if (!input.startsWith("sk_")) return 'Stripe key should start with "sk_"';
4441
+ return true;
4442
+ }
4443
+ });
4444
+ }
4445
+ if (questions.length > 0) {
4446
+ console.log(import_chalk3.default.yellow("\nMissing required configuration. Please provide:"));
4447
+ const answers = await inquirer2.prompt(questions);
4448
+ if (answers.accessToken) accessToken = answers.accessToken;
4449
+ if (answers.projectRef) projectRef = answers.projectRef;
4450
+ if (answers.stripeKey) stripeKey = answers.stripeKey;
4451
+ }
4452
+ }
4453
+ console.log(import_chalk3.default.blue("\n\u{1F680} Installing Stripe Sync to Supabase Edge Functions...\n"));
4454
+ console.log(import_chalk3.default.gray("Validating project access..."));
4455
+ await install({
4456
+ supabaseAccessToken: accessToken,
4457
+ supabaseProjectRef: projectRef,
4458
+ stripeKey,
4459
+ packageVersion: options.packageVersion
4460
+ });
4461
+ console.log(import_chalk3.default.cyan("\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501"));
4462
+ console.log(import_chalk3.default.cyan.bold(" Installation Complete!"));
4463
+ console.log(import_chalk3.default.cyan("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n"));
4464
+ console.log(import_chalk3.default.gray("\n Your Stripe data will stay in sync to your Supabase database."));
4465
+ console.log(
4466
+ import_chalk3.default.gray(' View your data in the Supabase dashboard under the "stripe" schema.\n')
4467
+ );
4468
+ } catch (error) {
4469
+ if (error instanceof Error) {
4470
+ console.error(import_chalk3.default.red(`
4471
+ \u2717 Installation failed: ${error.message}`));
4472
+ }
4473
+ process.exit(1);
4474
+ }
4475
+ }
4476
+ async function uninstallCommand(options) {
4477
+ try {
4478
+ import_dotenv2.default.config();
4479
+ let accessToken = options.supabaseAccessToken || process.env.SUPABASE_ACCESS_TOKEN || "";
4480
+ let projectRef = options.supabaseProjectRef || process.env.SUPABASE_PROJECT_REF || "";
4481
+ let stripeKey = options.stripeKey || process.env.STRIPE_API_KEY || process.env.STRIPE_SECRET_KEY || "";
4482
+ if (!accessToken || !projectRef || !stripeKey) {
4483
+ const inquirer2 = (await import("inquirer")).default;
4484
+ const questions = [];
4485
+ if (!accessToken) {
4486
+ questions.push({
4487
+ type: "password",
4488
+ name: "accessToken",
4489
+ message: "Enter your Supabase access token (from supabase.com/dashboard/account/tokens):",
4490
+ mask: "*",
4491
+ validate: (input) => input.trim() !== "" || "Access token is required"
4492
+ });
4493
+ }
4494
+ if (!projectRef) {
4495
+ questions.push({
4496
+ type: "input",
4497
+ name: "projectRef",
4498
+ message: "Enter your Supabase project ref (e.g., abcdefghijklmnop):",
4499
+ validate: (input) => input.trim() !== "" || "Project ref is required"
4500
+ });
4501
+ }
4502
+ if (!stripeKey) {
4503
+ questions.push({
4504
+ type: "password",
4505
+ name: "stripeKey",
4506
+ message: "Enter your Stripe secret key:",
4507
+ mask: "*",
4508
+ validate: (input) => {
4509
+ if (!input.trim()) return "Stripe key is required";
4510
+ if (!input.startsWith("sk_")) return 'Stripe key should start with "sk_"';
4511
+ return true;
4512
+ }
4513
+ });
4514
+ }
4515
+ if (questions.length > 0) {
4516
+ console.log(import_chalk3.default.yellow("\nMissing required configuration. Please provide:"));
4517
+ const answers = await inquirer2.prompt(questions);
4518
+ if (answers.accessToken) accessToken = answers.accessToken;
4519
+ if (answers.projectRef) projectRef = answers.projectRef;
4520
+ if (answers.stripeKey) stripeKey = answers.stripeKey;
4521
+ }
4522
+ }
4523
+ console.log(import_chalk3.default.blue("\n\u{1F5D1}\uFE0F Uninstalling Stripe Sync from Supabase...\n"));
4524
+ console.log(import_chalk3.default.yellow("\u26A0\uFE0F Warning: This will delete all Stripe data from your database!\n"));
4525
+ console.log(import_chalk3.default.gray("Removing all resources..."));
4526
+ await uninstall({
4527
+ supabaseAccessToken: accessToken,
4528
+ supabaseProjectRef: projectRef,
4529
+ stripeKey
4530
+ });
4531
+ console.log(import_chalk3.default.cyan("\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501"));
4532
+ console.log(import_chalk3.default.cyan.bold(" Uninstall Complete!"));
4533
+ console.log(import_chalk3.default.cyan("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n"));
4534
+ console.log(
4535
+ import_chalk3.default.gray("\n All Stripe sync resources have been removed from your Supabase project.")
4536
+ );
4537
+ console.log(import_chalk3.default.gray(" - Edge Functions deleted"));
4538
+ console.log(import_chalk3.default.gray(" - Stripe webhooks removed"));
4539
+ console.log(import_chalk3.default.gray(" - Database schema dropped\n"));
4540
+ } catch (error) {
4541
+ if (error instanceof Error) {
4542
+ console.error(import_chalk3.default.red(`
4543
+ \u2717 Uninstall failed: ${error.message}`));
4544
+ }
4545
+ process.exit(1);
4546
+ }
4547
+ }
4548
+
4549
+ // src/cli/index.ts
4550
+ var program = new import_commander.Command();
4551
+ program.name("stripe-experiment-sync").description("CLI tool for syncing Stripe data to PostgreSQL").version(package_default.version);
4552
+ program.command("migrate").description("Run database migrations only").option("--database-url <url>", "Postgres DATABASE_URL (or DATABASE_URL env)").action(async (options) => {
4553
+ await migrateCommand({
4554
+ databaseUrl: options.databaseUrl
4555
+ });
4556
+ });
4557
+ program.command("start").description("Start Stripe sync").option("--stripe-key <key>", "Stripe API key (or STRIPE_API_KEY env)").option("--ngrok-token <token>", "ngrok auth token (or NGROK_AUTH_TOKEN env)").option("--database-url <url>", "Postgres DATABASE_URL (or DATABASE_URL env)").action(async (options) => {
4558
+ await syncCommand({
4559
+ stripeKey: options.stripeKey,
4560
+ ngrokToken: options.ngrokToken,
4561
+ databaseUrl: options.databaseUrl
4562
+ });
4563
+ });
4564
+ program.command("backfill <entityName>").description("Backfill a specific entity type from Stripe (e.g., customer, invoice, product)").option("--stripe-key <key>", "Stripe API key (or STRIPE_API_KEY env)").option("--database-url <url>", "Postgres DATABASE_URL (or DATABASE_URL env)").action(async (entityName, options) => {
4565
+ await backfillCommand(
4566
+ {
4567
+ stripeKey: options.stripeKey,
4568
+ databaseUrl: options.databaseUrl
4569
+ },
4570
+ entityName
4571
+ );
4572
+ });
4573
+ var supabase = program.command("supabase").description("Supabase Edge Functions commands");
4574
+ supabase.command("install").description("Install Stripe sync to Supabase Edge Functions").option("--token <token>", "Supabase access token (or SUPABASE_ACCESS_TOKEN env)").option("--project <ref>", "Supabase project ref (or SUPABASE_PROJECT_REF env)").option("--stripe-key <key>", "Stripe API key (or STRIPE_API_KEY env)").option(
4575
+ "--version <version>",
4576
+ "Package version to install (e.g., 1.0.8-beta.1, defaults to latest)"
4577
+ ).action(async (options) => {
4578
+ await installCommand({
4579
+ supabaseAccessToken: options.token,
4580
+ supabaseProjectRef: options.project,
4581
+ stripeKey: options.stripeKey,
4582
+ packageVersion: options.version
4583
+ });
4584
+ });
4585
+ supabase.command("uninstall").description("Uninstall Stripe sync from Supabase Edge Functions").option("--token <token>", "Supabase access token (or SUPABASE_ACCESS_TOKEN env)").option("--project <ref>", "Supabase project ref (or SUPABASE_PROJECT_REF env)").option("--stripe-key <key>", "Stripe API key (or STRIPE_API_KEY env)").action(async (options) => {
4586
+ await uninstallCommand({
4587
+ supabaseAccessToken: options.token,
4588
+ supabaseProjectRef: options.project,
4589
+ stripeKey: options.stripeKey
4590
+ });
4591
+ });
4592
+ program.parse();