@paymentsdb/sync-engine 0.0.5 → 0.0.6

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.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  package_default
3
- } from "./chunk-HXSDJSKR.js";
3
+ } from "./chunk-E6BGC7CB.js";
4
4
 
5
5
  // src/stripeSync.ts
6
6
  import Stripe3 from "stripe";
@@ -45310,6 +45310,13 @@ var StripeSync = class {
45310
45310
  listFn: (p) => this.stripe.checkout.sessions.list(p),
45311
45311
  upsertFn: (items, id) => this.upsertCheckoutSessions(items, id),
45312
45312
  supportsCreatedFilter: true
45313
+ },
45314
+ _event_catchup: {
45315
+ order: 99,
45316
+ // Always runs last — catches missed webhook events
45317
+ listFn: (_p) => Promise.resolve({ data: [], has_more: false }),
45318
+ upsertFn: async () => [],
45319
+ supportsCreatedFilter: true
45313
45320
  }
45314
45321
  };
45315
45322
  const maxOrder = Math.max(...Object.values(core).map((cfg) => cfg.order));
@@ -46076,7 +46083,8 @@ ${message}`;
46076
46083
  credit_note: "credit_notes",
46077
46084
  early_fraud_warning: "early_fraud_warnings",
46078
46085
  refund: "refunds",
46079
- checkout_sessions: "checkout_sessions"
46086
+ checkout_sessions: "checkout_sessions",
46087
+ _event_catchup: "_event_catchup"
46080
46088
  };
46081
46089
  return mapping[object] || object;
46082
46090
  }
@@ -46087,10 +46095,11 @@ ${message}`;
46087
46095
  */
46088
46096
  async fetchOnePage(object, accountId, resourceName, runStartedAt, cursor, pageCursor, params) {
46089
46097
  const limit = 100;
46090
- if (object === "payment_method" || object === "tax_id") {
46091
- this.config.logger?.warn(`processNext for ${object} requires customer context`);
46092
- await this.postgresClient.completeObjectSync(accountId, runStartedAt, resourceName);
46093
- return { processed: 0, hasMore: false, runStartedAt };
46098
+ if (object === "payment_method") {
46099
+ return this.fetchOnePagePaymentMethods(accountId, resourceName, runStartedAt, cursor, pageCursor, params);
46100
+ }
46101
+ if (object === "_event_catchup") {
46102
+ return this.fetchOnePageEventCatchup(accountId, resourceName, runStartedAt, cursor, pageCursor);
46094
46103
  }
46095
46104
  const config = this.resourceRegistry[object];
46096
46105
  if (!config) {
@@ -46268,6 +46277,435 @@ ${message}`;
46268
46277
  }
46269
46278
  return { processed: entries.length, hasMore, runStartedAt };
46270
46279
  }
46280
+ /**
46281
+ * Fetch payment methods by iterating customers in batches.
46282
+ *
46283
+ * Most customers have 1-2 PMs, so processing one customer per processNext call
46284
+ * would be extremely wasteful (10,000 customers = 10,000 worker round-trips).
46285
+ * Instead, each call fetches a batch of customers (sized by maxConcurrentCustomers,
46286
+ * default 10) and lists+upserts PMs for all of them concurrently.
46287
+ *
46288
+ * Cursor semantics:
46289
+ * - cursor: max customer `created` timestamp — filters to only new customers on subsequent runs
46290
+ * - pageCursor: last processed customer ID — tracks position in customer iteration
46291
+ */
46292
+ async fetchOnePagePaymentMethods(accountId, resourceName, runStartedAt, cursor, pageCursor, params) {
46293
+ const batchSize = this.config.maxConcurrentCustomers ?? 10;
46294
+ try {
46295
+ const customerBatch = await this.findNextCustomerBatchForPmSync(
46296
+ accountId,
46297
+ pageCursor,
46298
+ cursor,
46299
+ batchSize
46300
+ );
46301
+ if (customerBatch.length === 0) {
46302
+ await this.postgresClient.completeObjectSync(accountId, runStartedAt, resourceName);
46303
+ return { processed: 0, hasMore: false, runStartedAt };
46304
+ }
46305
+ this.config.logger?.info(
46306
+ `processNext: fetching payment_methods for ${customerBatch.length} customers`
46307
+ );
46308
+ let totalProcessed = 0;
46309
+ await Promise.all(
46310
+ customerBatch.map(async (customer) => {
46311
+ const allPms = [];
46312
+ let hasMore = true;
46313
+ let startingAfter;
46314
+ while (hasMore) {
46315
+ const response = await this.stripe.paymentMethods.list({
46316
+ customer: customer.id,
46317
+ limit: 100,
46318
+ ...startingAfter ? { starting_after: startingAfter } : {}
46319
+ });
46320
+ allPms.push(...response.data);
46321
+ hasMore = response.has_more;
46322
+ if (response.data.length > 0) {
46323
+ startingAfter = response.data[response.data.length - 1].id;
46324
+ }
46325
+ }
46326
+ if (allPms.length > 0) {
46327
+ await this.upsertPaymentMethods(allPms, accountId, params?.backfillRelatedEntities);
46328
+ totalProcessed += allPms.length;
46329
+ }
46330
+ })
46331
+ );
46332
+ if (totalProcessed > 0) {
46333
+ await this.postgresClient.incrementObjectProgress(
46334
+ accountId,
46335
+ runStartedAt,
46336
+ resourceName,
46337
+ totalProcessed
46338
+ );
46339
+ }
46340
+ const maxCreated = Math.max(...customerBatch.map((c) => c.created).filter((c) => c != null));
46341
+ if (maxCreated > 0) {
46342
+ await this.postgresClient.updateObjectCursor(
46343
+ accountId,
46344
+ runStartedAt,
46345
+ resourceName,
46346
+ String(maxCreated)
46347
+ );
46348
+ }
46349
+ const lastCustomerId = customerBatch[customerBatch.length - 1].id;
46350
+ const nextBatch = await this.findNextCustomerBatchForPmSync(
46351
+ accountId,
46352
+ lastCustomerId,
46353
+ cursor,
46354
+ 1
46355
+ );
46356
+ if (nextBatch.length > 0) {
46357
+ await this.postgresClient.updateObjectPageCursor(
46358
+ accountId,
46359
+ runStartedAt,
46360
+ resourceName,
46361
+ lastCustomerId
46362
+ );
46363
+ return { processed: totalProcessed, hasMore: true, runStartedAt };
46364
+ }
46365
+ await this.postgresClient.completeObjectSync(accountId, runStartedAt, resourceName);
46366
+ return { processed: totalProcessed, hasMore: false, runStartedAt };
46367
+ } catch (error) {
46368
+ await this.postgresClient.failObjectSync(
46369
+ accountId,
46370
+ runStartedAt,
46371
+ resourceName,
46372
+ error instanceof Error ? error.message : "Unknown error"
46373
+ );
46374
+ throw error;
46375
+ }
46376
+ }
46377
+ /**
46378
+ * Fetch the next batch of non-deleted customers for PM sync.
46379
+ * Returns customer IDs and created timestamps, ordered by id ASC.
46380
+ */
46381
+ async findNextCustomerBatchForPmSync(accountId, afterCustomerId, cursor, limit) {
46382
+ let query = `SELECT id, created FROM stripe.customers WHERE _account_id = $1 AND COALESCE(deleted, false) <> true`;
46383
+ const params = [accountId];
46384
+ if (afterCustomerId) {
46385
+ params.push(afterCustomerId);
46386
+ query += ` AND id > $${params.length}`;
46387
+ }
46388
+ if (cursor && /^\d+$/.test(cursor)) {
46389
+ params.push(Number.parseInt(cursor, 10));
46390
+ query += ` AND created >= $${params.length}`;
46391
+ }
46392
+ params.push(limit);
46393
+ query += ` ORDER BY id ASC LIMIT $${params.length}`;
46394
+ const result = await this.postgresClient.query(query, params);
46395
+ return result.rows;
46396
+ }
46397
+ /**
46398
+ * Fetch one page of events from the Stripe Events API and reconcile affected entities.
46399
+ *
46400
+ * Instead of replaying events (which can resurrect deleted objects due to newest-first ordering),
46401
+ * we deduplicate by entity and re-fetch current state from Stripe for each affected entity.
46402
+ *
46403
+ * Cursor: event `created` timestamp. On first run, starts from the sync run's startedAt.
46404
+ * On subsequent runs, picks up where the last completed run left off.
46405
+ */
46406
+ async fetchOnePageEventCatchup(accountId, resourceName, runStartedAt, cursor, pageCursor) {
46407
+ try {
46408
+ let createdGte;
46409
+ if (cursor && /^\d+$/.test(cursor)) {
46410
+ createdGte = Number.parseInt(cursor, 10);
46411
+ } else {
46412
+ createdGte = Math.floor(runStartedAt.getTime() / 1e3);
46413
+ }
46414
+ const thirtyDaysAgo = Math.floor(Date.now() / 1e3) - 30 * 24 * 60 * 60;
46415
+ if (createdGte < thirtyDaysAgo) {
46416
+ this.config.logger?.warn(
46417
+ `_event_catchup: cursor ${createdGte} is older than 30 days, clamping to ${thirtyDaysAgo}`
46418
+ );
46419
+ createdGte = thirtyDaysAgo;
46420
+ }
46421
+ const listParams = {
46422
+ limit: 100,
46423
+ created: { gte: createdGte }
46424
+ };
46425
+ if (pageCursor) {
46426
+ listParams.starting_after = pageCursor;
46427
+ }
46428
+ const response = await this.stripe.events.list(listParams);
46429
+ if (response.data.length === 0) {
46430
+ await this.postgresClient.updateObjectCursor(
46431
+ accountId,
46432
+ runStartedAt,
46433
+ resourceName,
46434
+ String(createdGte)
46435
+ );
46436
+ await this.postgresClient.completeObjectSync(accountId, runStartedAt, resourceName);
46437
+ return { processed: 0, hasMore: false, runStartedAt };
46438
+ }
46439
+ const entityMap = /* @__PURE__ */ new Map();
46440
+ for (const event of response.data) {
46441
+ const obj = event.data.object;
46442
+ if (!obj?.id || !obj?.object) continue;
46443
+ const key = `${obj.object}:${obj.id}`;
46444
+ const existing = entityMap.get(key);
46445
+ if (!existing || event.created > existing.created) {
46446
+ entityMap.set(key, event);
46447
+ }
46448
+ }
46449
+ const hardDeleteEventTypes = /* @__PURE__ */ new Set([
46450
+ "product.deleted",
46451
+ "price.deleted",
46452
+ "plan.deleted",
46453
+ "customer.deleted",
46454
+ "customer.tax_id.deleted"
46455
+ ]);
46456
+ let processed = 0;
46457
+ let skipped = 0;
46458
+ for (const [, event] of entityMap) {
46459
+ const obj = event.data.object;
46460
+ const eventType = event.type;
46461
+ try {
46462
+ if (!hardDeleteEventTypes.has(eventType)) {
46463
+ const tableName = eventObjectTypeToTable(obj.object);
46464
+ if (tableName) {
46465
+ const localRecord = await this.postgresClient.query(
46466
+ `SELECT "_last_synced_at" FROM stripe."${tableName}"
46467
+ WHERE id = $1 AND "_account_id" = $2 LIMIT 1`,
46468
+ [obj.id, accountId]
46469
+ );
46470
+ if (localRecord.rows.length > 0 && localRecord.rows[0]._last_synced_at != null) {
46471
+ const syncedAt = new Date(localRecord.rows[0]._last_synced_at).getTime() / 1e3;
46472
+ if (syncedAt >= event.created) {
46473
+ skipped++;
46474
+ continue;
46475
+ }
46476
+ }
46477
+ }
46478
+ }
46479
+ if (hardDeleteEventTypes.has(eventType)) {
46480
+ await this.handleEventCatchupDelete(obj.object, obj.id, accountId);
46481
+ } else {
46482
+ await this.handleEventCatchupUpsert(obj.object, obj.id, accountId);
46483
+ }
46484
+ processed++;
46485
+ } catch (err) {
46486
+ const errMsg = err instanceof Error ? err.message : String(err);
46487
+ this.config.logger?.warn(
46488
+ `_event_catchup: failed to process ${obj.object}:${obj.id} (event ${event.id}): ${errMsg}`
46489
+ );
46490
+ }
46491
+ }
46492
+ if (skipped > 0) {
46493
+ this.config.logger?.info(
46494
+ `_event_catchup: skipped ${skipped} entities already up-to-date, processed ${processed}`
46495
+ );
46496
+ }
46497
+ if (processed > 0) {
46498
+ await this.postgresClient.incrementObjectProgress(
46499
+ accountId,
46500
+ runStartedAt,
46501
+ resourceName,
46502
+ processed
46503
+ );
46504
+ }
46505
+ const maxCreated = Math.max(...response.data.map((e) => e.created));
46506
+ await this.postgresClient.updateObjectCursor(
46507
+ accountId,
46508
+ runStartedAt,
46509
+ resourceName,
46510
+ String(maxCreated)
46511
+ );
46512
+ const lastEventId = response.data[response.data.length - 1].id;
46513
+ if (response.has_more) {
46514
+ await this.postgresClient.updateObjectPageCursor(
46515
+ accountId,
46516
+ runStartedAt,
46517
+ resourceName,
46518
+ lastEventId
46519
+ );
46520
+ }
46521
+ if (!response.has_more) {
46522
+ await this.postgresClient.completeObjectSync(accountId, runStartedAt, resourceName);
46523
+ }
46524
+ return { processed, hasMore: response.has_more, runStartedAt };
46525
+ } catch (error) {
46526
+ await this.postgresClient.failObjectSync(
46527
+ accountId,
46528
+ runStartedAt,
46529
+ resourceName,
46530
+ error instanceof Error ? error.message : "Unknown error"
46531
+ );
46532
+ throw error;
46533
+ }
46534
+ }
46535
+ /**
46536
+ * Handle a delete for an entity discovered via event catch-up.
46537
+ * Maps Stripe object types to the appropriate delete method.
46538
+ */
46539
+ async handleEventCatchupDelete(objectType, entityId, accountId) {
46540
+ switch (objectType) {
46541
+ case "product":
46542
+ await this.deleteProduct(entityId);
46543
+ break;
46544
+ case "price":
46545
+ await this.deletePrice(entityId);
46546
+ break;
46547
+ case "plan":
46548
+ await this.deletePlan(entityId);
46549
+ break;
46550
+ case "customer": {
46551
+ const deletedCustomer = {
46552
+ id: entityId,
46553
+ object: "customer",
46554
+ deleted: true
46555
+ };
46556
+ await this.upsertCustomers([deletedCustomer], accountId);
46557
+ break;
46558
+ }
46559
+ case "tax_id":
46560
+ await this.deleteTaxId(entityId);
46561
+ break;
46562
+ default:
46563
+ this.config.logger?.warn(
46564
+ `_event_catchup: no delete handler for object type "${objectType}", skipping ${entityId}`
46565
+ );
46566
+ }
46567
+ }
46568
+ /**
46569
+ * Handle an upsert for an entity discovered via event catch-up.
46570
+ * Re-fetches the current state from Stripe and upserts it.
46571
+ * If the entity has been deleted (404), falls back to the delete handler.
46572
+ */
46573
+ async handleEventCatchupUpsert(objectType, entityId, accountId) {
46574
+ try {
46575
+ switch (objectType) {
46576
+ case "product": {
46577
+ const product = await this.stripe.products.retrieve(entityId);
46578
+ await this.upsertProducts([product], accountId);
46579
+ break;
46580
+ }
46581
+ case "price": {
46582
+ const price = await this.stripe.prices.retrieve(entityId);
46583
+ await this.upsertPrices([price], accountId);
46584
+ break;
46585
+ }
46586
+ case "plan": {
46587
+ const plan = await this.stripe.plans.retrieve(entityId);
46588
+ await this.upsertPlans([plan], accountId);
46589
+ break;
46590
+ }
46591
+ case "customer": {
46592
+ const customer = await this.stripe.customers.retrieve(entityId);
46593
+ await this.upsertCustomers([customer], accountId);
46594
+ break;
46595
+ }
46596
+ case "subscription": {
46597
+ const sub = await this.stripe.subscriptions.retrieve(entityId);
46598
+ await this.upsertSubscriptions([sub], accountId);
46599
+ break;
46600
+ }
46601
+ case "subscription_schedule": {
46602
+ const schedule = await this.stripe.subscriptionSchedules.retrieve(entityId);
46603
+ await this.upsertSubscriptionSchedules([schedule], accountId);
46604
+ break;
46605
+ }
46606
+ case "invoice": {
46607
+ const invoice = await this.stripe.invoices.retrieve(entityId);
46608
+ await this.upsertInvoices([invoice], accountId);
46609
+ break;
46610
+ }
46611
+ case "charge": {
46612
+ const charge = await this.stripe.charges.retrieve(entityId, {
46613
+ expand: ["balance_transaction"]
46614
+ });
46615
+ if (charge.balance_transaction && typeof charge.balance_transaction === "object") {
46616
+ await this.upsertBalanceTransactions(
46617
+ [charge.balance_transaction],
46618
+ accountId
46619
+ );
46620
+ }
46621
+ await this.upsertCharges([charge], accountId);
46622
+ break;
46623
+ }
46624
+ case "payment_intent": {
46625
+ const pi = await this.stripe.paymentIntents.retrieve(entityId);
46626
+ await this.upsertPaymentIntents([pi], accountId);
46627
+ break;
46628
+ }
46629
+ case "payment_method": {
46630
+ const pm = await this.stripe.paymentMethods.retrieve(entityId);
46631
+ await this.upsertPaymentMethods([pm], accountId);
46632
+ break;
46633
+ }
46634
+ case "setup_intent": {
46635
+ const si = await this.stripe.setupIntents.retrieve(entityId);
46636
+ await this.upsertSetupIntents([si], accountId);
46637
+ break;
46638
+ }
46639
+ case "dispute": {
46640
+ const dispute = await this.stripe.disputes.retrieve(entityId, {
46641
+ expand: ["balance_transactions"]
46642
+ });
46643
+ if (dispute.balance_transactions && Array.isArray(dispute.balance_transactions)) {
46644
+ const expandedBts = dispute.balance_transactions.filter(
46645
+ (bt) => typeof bt === "object"
46646
+ );
46647
+ if (expandedBts.length > 0) {
46648
+ await this.upsertBalanceTransactions(expandedBts, accountId);
46649
+ }
46650
+ }
46651
+ await this.upsertDisputes([dispute], accountId);
46652
+ break;
46653
+ }
46654
+ case "credit_note": {
46655
+ const cn = await this.stripe.creditNotes.retrieve(entityId);
46656
+ await this.upsertCreditNotes([cn], accountId);
46657
+ break;
46658
+ }
46659
+ case "refund": {
46660
+ const refund = await this.stripe.refunds.retrieve(entityId, {
46661
+ expand: ["balance_transaction"]
46662
+ });
46663
+ if (refund.balance_transaction && typeof refund.balance_transaction === "object") {
46664
+ await this.upsertBalanceTransactions(
46665
+ [refund.balance_transaction],
46666
+ accountId
46667
+ );
46668
+ }
46669
+ await this.upsertRefunds([refund], accountId);
46670
+ break;
46671
+ }
46672
+ case "tax_id": {
46673
+ const taxId = await this.stripe.taxIds.retrieve(entityId);
46674
+ await this.upsertTaxIds([taxId], accountId);
46675
+ break;
46676
+ }
46677
+ case "balance_transaction": {
46678
+ const bt = await this.stripe.balanceTransactions.retrieve(entityId);
46679
+ await this.upsertBalanceTransactions([bt], accountId);
46680
+ break;
46681
+ }
46682
+ case "checkout.session": {
46683
+ const session = await this.stripe.checkout.sessions.retrieve(entityId);
46684
+ await this.upsertCheckoutSessions([session], accountId);
46685
+ break;
46686
+ }
46687
+ case "radar.early_fraud_warning": {
46688
+ const efw = await this.stripe.radar.earlyFraudWarnings.retrieve(entityId);
46689
+ await this.upsertEarlyFraudWarning([efw], accountId);
46690
+ break;
46691
+ }
46692
+ default:
46693
+ this.config.logger?.warn(
46694
+ `_event_catchup: no upsert handler for object type "${objectType}", skipping ${entityId}`
46695
+ );
46696
+ }
46697
+ } catch (err) {
46698
+ const isNotFound = (err instanceof Stripe3.errors.StripeInvalidRequestError || err instanceof Stripe3.errors.StripeAPIError) && (err.code === "resource_missing" || err.statusCode === 404);
46699
+ if (isNotFound) {
46700
+ this.config.logger?.info(
46701
+ `_event_catchup: ${objectType}:${entityId} not found on Stripe, treating as deleted`
46702
+ );
46703
+ await this.handleEventCatchupDelete(objectType, entityId, accountId);
46704
+ return;
46705
+ }
46706
+ throw err;
46707
+ }
46708
+ }
46271
46709
  /**
46272
46710
  * Process all pages for all (or specified) object types until complete.
46273
46711
  *
@@ -46341,6 +46779,7 @@ ${message}`;
46341
46779
  };
46342
46780
  }
46343
46781
  applySyncBackfillResult(results, object, result) {
46782
+ if (object === "_event_catchup") return;
46344
46783
  if (this.isSigmaResource(object)) {
46345
46784
  results.sigma = results.sigma ?? {};
46346
46785
  results.sigma[object] = result;
@@ -46376,6 +46815,9 @@ ${message}`;
46376
46815
  case "setup_intent":
46377
46816
  results.setupIntents = result;
46378
46817
  break;
46818
+ case "payment_method":
46819
+ results.paymentMethods = result;
46820
+ break;
46379
46821
  case "payment_intent":
46380
46822
  results.paymentIntents = result;
46381
46823
  break;
@@ -47883,6 +48325,29 @@ function chunkArray(array, chunkSize) {
47883
48325
  }
47884
48326
  return result;
47885
48327
  }
48328
+ function eventObjectTypeToTable(objectType) {
48329
+ const mapping = {
48330
+ product: "products",
48331
+ price: "prices",
48332
+ plan: "plans",
48333
+ customer: "customers",
48334
+ subscription: "subscriptions",
48335
+ subscription_schedule: "subscription_schedules",
48336
+ invoice: "invoices",
48337
+ charge: "charges",
48338
+ balance_transaction: "balance_transactions",
48339
+ payment_intent: "payment_intents",
48340
+ payment_method: "payment_methods",
48341
+ setup_intent: "setup_intents",
48342
+ dispute: "disputes",
48343
+ credit_note: "credit_notes",
48344
+ refund: "refunds",
48345
+ tax_id: "tax_ids",
48346
+ "checkout.session": "checkout_sessions",
48347
+ "radar.early_fraud_warning": "early_fraud_warnings"
48348
+ };
48349
+ return mapping[objectType] ?? null;
48350
+ }
47886
48351
 
47887
48352
  // src/database/migrate.ts
47888
48353
  import { Client } from "pg";
@@ -1,7 +1,7 @@
1
1
  // package.json
2
2
  var package_default = {
3
3
  name: "@paymentsdb/sync-engine",
4
- version: "0.0.4",
4
+ version: "0.0.5",
5
5
  private: false,
6
6
  description: "Stripe Sync Engine to sync Stripe data to Postgres",
7
7
  type: "module",
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  package_default
3
- } from "./chunk-HXSDJSKR.js";
3
+ } from "./chunk-E6BGC7CB.js";
4
4
 
5
5
  // src/supabase/supabase.ts
6
6
  import { SupabaseManagementAPI } from "supabase-management-js";
@@ -3,11 +3,11 @@ import {
3
3
  StripeSync,
4
4
  createStripeWebSocketClient,
5
5
  runMigrations
6
- } from "./chunk-LB4HG4Q6.js";
6
+ } from "./chunk-2KB2ISYF.js";
7
7
  import {
8
8
  install,
9
9
  uninstall
10
- } from "./chunk-Q3AGYUHN.js";
10
+ } from "./chunk-GXIMCH5Y.js";
11
11
 
12
12
  // src/cli/config.ts
13
13
  import dotenv from "dotenv";