@paymentsdb/sync-engine 0.0.1

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