@paymentsdb/sync-engine 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/lib.cjs CHANGED
@@ -117,7 +117,7 @@ async function loadConfig(options) {
117
117
  // package.json
118
118
  var package_default = {
119
119
  name: "@paymentsdb/sync-engine",
120
- version: "0.0.4",
120
+ version: "0.0.7",
121
121
  private: false,
122
122
  description: "Stripe Sync Engine to sync Stripe data to Postgres",
123
123
  type: "module",
@@ -45515,6 +45515,13 @@ var StripeSync = class {
45515
45515
  listFn: (p) => this.stripe.checkout.sessions.list(p),
45516
45516
  upsertFn: (items, id) => this.upsertCheckoutSessions(items, id),
45517
45517
  supportsCreatedFilter: true
45518
+ },
45519
+ _event_catchup: {
45520
+ order: 99,
45521
+ // Always runs last — catches missed webhook events
45522
+ listFn: (_p) => Promise.resolve({ data: [], has_more: false }),
45523
+ upsertFn: async () => [],
45524
+ supportsCreatedFilter: true
45518
45525
  }
45519
45526
  };
45520
45527
  const maxOrder = Math.max(...Object.values(core).map((cfg) => cfg.order));
@@ -46281,7 +46288,8 @@ ${message}`;
46281
46288
  credit_note: "credit_notes",
46282
46289
  early_fraud_warning: "early_fraud_warnings",
46283
46290
  refund: "refunds",
46284
- checkout_sessions: "checkout_sessions"
46291
+ checkout_sessions: "checkout_sessions",
46292
+ _event_catchup: "_event_catchup"
46285
46293
  };
46286
46294
  return mapping[object] || object;
46287
46295
  }
@@ -46292,11 +46300,16 @@ ${message}`;
46292
46300
  */
46293
46301
  async fetchOnePage(object, accountId, resourceName, runStartedAt, cursor, pageCursor, params) {
46294
46302
  const limit = 100;
46295
- if (object === "payment_method" || object === "tax_id") {
46296
- this.config.logger?.warn(`processNext for ${object} requires customer context`);
46303
+ if (object === "payment_method") {
46304
+ return this.fetchOnePagePaymentMethods(accountId, resourceName, runStartedAt, cursor, pageCursor, params);
46305
+ }
46306
+ if (object === "tax_id") {
46297
46307
  await this.postgresClient.completeObjectSync(accountId, runStartedAt, resourceName);
46298
46308
  return { processed: 0, hasMore: false, runStartedAt };
46299
46309
  }
46310
+ if (object === "_event_catchup") {
46311
+ return this.fetchOnePageEventCatchup(accountId, resourceName, runStartedAt, cursor, pageCursor);
46312
+ }
46300
46313
  const config = this.resourceRegistry[object];
46301
46314
  if (!config) {
46302
46315
  throw new Error(`Unsupported object type for processNext: ${object}`);
@@ -46473,6 +46486,437 @@ ${message}`;
46473
46486
  }
46474
46487
  return { processed: entries.length, hasMore, runStartedAt };
46475
46488
  }
46489
+ /**
46490
+ * Fetch payment methods by iterating customers in batches.
46491
+ *
46492
+ * Most customers have 1-2 PMs, so processing one customer per processNext call
46493
+ * would be extremely wasteful (10,000 customers = 10,000 worker round-trips).
46494
+ * Instead, each call fetches a batch of customers (sized by maxConcurrentCustomers,
46495
+ * default 10) and lists+upserts PMs for all of them concurrently.
46496
+ *
46497
+ * Cursor semantics:
46498
+ * - cursor: max customer `created` timestamp — filters to only new customers on subsequent runs
46499
+ * - pageCursor: last processed customer ID — tracks position in customer iteration
46500
+ */
46501
+ async fetchOnePagePaymentMethods(accountId, resourceName, runStartedAt, cursor, pageCursor, params) {
46502
+ const batchSize = this.config.maxConcurrentCustomers ?? 10;
46503
+ try {
46504
+ const customerBatch = await this.findNextCustomerBatchForPmSync(
46505
+ accountId,
46506
+ pageCursor,
46507
+ cursor,
46508
+ batchSize
46509
+ );
46510
+ if (customerBatch.length === 0) {
46511
+ await this.postgresClient.completeObjectSync(accountId, runStartedAt, resourceName);
46512
+ return { processed: 0, hasMore: false, runStartedAt };
46513
+ }
46514
+ this.config.logger?.info(
46515
+ `processNext: fetching payment_methods for ${customerBatch.length} customers`
46516
+ );
46517
+ let totalProcessed = 0;
46518
+ await Promise.all(
46519
+ customerBatch.map(async (customer) => {
46520
+ const allPms = [];
46521
+ let hasMore = true;
46522
+ let startingAfter;
46523
+ while (hasMore) {
46524
+ const response = await this.stripe.paymentMethods.list({
46525
+ customer: customer.id,
46526
+ limit: 100,
46527
+ ...startingAfter ? { starting_after: startingAfter } : {}
46528
+ });
46529
+ allPms.push(...response.data);
46530
+ hasMore = response.has_more;
46531
+ if (response.data.length > 0) {
46532
+ startingAfter = response.data[response.data.length - 1].id;
46533
+ }
46534
+ }
46535
+ if (allPms.length > 0) {
46536
+ await this.upsertPaymentMethods(allPms, accountId, params?.backfillRelatedEntities);
46537
+ totalProcessed += allPms.length;
46538
+ }
46539
+ })
46540
+ );
46541
+ if (totalProcessed > 0) {
46542
+ await this.postgresClient.incrementObjectProgress(
46543
+ accountId,
46544
+ runStartedAt,
46545
+ resourceName,
46546
+ totalProcessed
46547
+ );
46548
+ }
46549
+ const maxCreated = Math.max(...customerBatch.map((c) => c.created).filter((c) => c != null));
46550
+ if (maxCreated > 0) {
46551
+ await this.postgresClient.updateObjectCursor(
46552
+ accountId,
46553
+ runStartedAt,
46554
+ resourceName,
46555
+ String(maxCreated)
46556
+ );
46557
+ }
46558
+ const lastCustomerId = customerBatch[customerBatch.length - 1].id;
46559
+ const nextBatch = await this.findNextCustomerBatchForPmSync(
46560
+ accountId,
46561
+ lastCustomerId,
46562
+ cursor,
46563
+ 1
46564
+ );
46565
+ if (nextBatch.length > 0) {
46566
+ await this.postgresClient.updateObjectPageCursor(
46567
+ accountId,
46568
+ runStartedAt,
46569
+ resourceName,
46570
+ lastCustomerId
46571
+ );
46572
+ return { processed: totalProcessed, hasMore: true, runStartedAt };
46573
+ }
46574
+ await this.postgresClient.completeObjectSync(accountId, runStartedAt, resourceName);
46575
+ return { processed: totalProcessed, hasMore: false, runStartedAt };
46576
+ } catch (error) {
46577
+ await this.postgresClient.failObjectSync(
46578
+ accountId,
46579
+ runStartedAt,
46580
+ resourceName,
46581
+ error instanceof Error ? error.message : "Unknown error"
46582
+ );
46583
+ throw error;
46584
+ }
46585
+ }
46586
+ /**
46587
+ * Fetch the next batch of non-deleted customers for PM sync.
46588
+ * Returns customer IDs and created timestamps, ordered by id ASC.
46589
+ */
46590
+ async findNextCustomerBatchForPmSync(accountId, afterCustomerId, cursor, limit) {
46591
+ let query = `SELECT id, created FROM stripe.customers WHERE _account_id = $1 AND COALESCE(deleted, false) <> true`;
46592
+ const params = [accountId];
46593
+ if (afterCustomerId) {
46594
+ params.push(afterCustomerId);
46595
+ query += ` AND id > $${params.length}`;
46596
+ }
46597
+ if (cursor && /^\d+$/.test(cursor)) {
46598
+ params.push(Number.parseInt(cursor, 10));
46599
+ query += ` AND created >= $${params.length}`;
46600
+ }
46601
+ params.push(limit);
46602
+ query += ` ORDER BY id ASC LIMIT $${params.length}`;
46603
+ const result = await this.postgresClient.query(query, params);
46604
+ return result.rows;
46605
+ }
46606
+ /**
46607
+ * Fetch one page of events from the Stripe Events API and reconcile affected entities.
46608
+ *
46609
+ * Instead of replaying events (which can resurrect deleted objects due to newest-first ordering),
46610
+ * we deduplicate by entity and re-fetch current state from Stripe for each affected entity.
46611
+ *
46612
+ * Cursor: event `created` timestamp. On first run, starts from the sync run's startedAt.
46613
+ * On subsequent runs, picks up where the last completed run left off.
46614
+ */
46615
+ async fetchOnePageEventCatchup(accountId, resourceName, runStartedAt, cursor, pageCursor) {
46616
+ try {
46617
+ let createdGte;
46618
+ if (cursor && /^\d+$/.test(cursor)) {
46619
+ createdGte = Number.parseInt(cursor, 10);
46620
+ } else {
46621
+ createdGte = Math.floor(runStartedAt.getTime() / 1e3);
46622
+ }
46623
+ const thirtyDaysAgo = Math.floor(Date.now() / 1e3) - 30 * 24 * 60 * 60;
46624
+ if (createdGte < thirtyDaysAgo) {
46625
+ this.config.logger?.warn(
46626
+ `_event_catchup: cursor ${createdGte} is older than 30 days, clamping to ${thirtyDaysAgo}`
46627
+ );
46628
+ createdGte = thirtyDaysAgo;
46629
+ }
46630
+ const listParams = {
46631
+ limit: 100,
46632
+ created: { gte: createdGte }
46633
+ };
46634
+ if (pageCursor) {
46635
+ listParams.starting_after = pageCursor;
46636
+ }
46637
+ const response = await this.stripe.events.list(listParams);
46638
+ if (response.data.length === 0) {
46639
+ await this.postgresClient.updateObjectCursor(
46640
+ accountId,
46641
+ runStartedAt,
46642
+ resourceName,
46643
+ String(createdGte)
46644
+ );
46645
+ await this.postgresClient.completeObjectSync(accountId, runStartedAt, resourceName);
46646
+ return { processed: 0, hasMore: false, runStartedAt };
46647
+ }
46648
+ const entityMap = /* @__PURE__ */ new Map();
46649
+ for (const event of response.data) {
46650
+ const obj = event.data.object;
46651
+ if (!obj?.id || !obj?.object) continue;
46652
+ const key = `${obj.object}:${obj.id}`;
46653
+ const existing = entityMap.get(key);
46654
+ if (!existing || event.created > existing.created) {
46655
+ entityMap.set(key, event);
46656
+ }
46657
+ }
46658
+ const hardDeleteEventTypes = /* @__PURE__ */ new Set([
46659
+ "product.deleted",
46660
+ "price.deleted",
46661
+ "plan.deleted",
46662
+ "customer.deleted",
46663
+ "customer.tax_id.deleted"
46664
+ ]);
46665
+ let processed = 0;
46666
+ let skipped = 0;
46667
+ const skipObjectTypes = /* @__PURE__ */ new Set(["tax_id"]);
46668
+ for (const [, event] of entityMap) {
46669
+ const obj = event.data.object;
46670
+ const eventType = event.type;
46671
+ if (skipObjectTypes.has(obj.object)) continue;
46672
+ try {
46673
+ if (!hardDeleteEventTypes.has(eventType)) {
46674
+ const tableName = eventObjectTypeToTable(obj.object);
46675
+ if (tableName) {
46676
+ const localRecord = await this.postgresClient.query(
46677
+ `SELECT "_last_synced_at" FROM stripe."${tableName}"
46678
+ WHERE id = $1 AND "_account_id" = $2 LIMIT 1`,
46679
+ [obj.id, accountId]
46680
+ );
46681
+ if (localRecord.rows.length > 0 && localRecord.rows[0]._last_synced_at != null) {
46682
+ const syncedAt = new Date(localRecord.rows[0]._last_synced_at).getTime() / 1e3;
46683
+ if (syncedAt >= event.created) {
46684
+ skipped++;
46685
+ continue;
46686
+ }
46687
+ }
46688
+ }
46689
+ }
46690
+ if (hardDeleteEventTypes.has(eventType)) {
46691
+ await this.handleEventCatchupDelete(obj.object, obj.id, accountId);
46692
+ } else {
46693
+ await this.handleEventCatchupUpsert(obj.object, obj.id, accountId);
46694
+ }
46695
+ processed++;
46696
+ } catch (err) {
46697
+ const errMsg = err instanceof Error ? err.message : String(err);
46698
+ this.config.logger?.warn(
46699
+ `_event_catchup: failed to process ${obj.object}:${obj.id} (event ${event.id}): ${errMsg}`
46700
+ );
46701
+ }
46702
+ }
46703
+ if (skipped > 0) {
46704
+ this.config.logger?.info(
46705
+ `_event_catchup: skipped ${skipped} entities already up-to-date, processed ${processed}`
46706
+ );
46707
+ }
46708
+ if (processed > 0) {
46709
+ await this.postgresClient.incrementObjectProgress(
46710
+ accountId,
46711
+ runStartedAt,
46712
+ resourceName,
46713
+ processed
46714
+ );
46715
+ }
46716
+ const maxCreated = Math.max(...response.data.map((e) => e.created));
46717
+ await this.postgresClient.updateObjectCursor(
46718
+ accountId,
46719
+ runStartedAt,
46720
+ resourceName,
46721
+ String(maxCreated)
46722
+ );
46723
+ const lastEventId = response.data[response.data.length - 1].id;
46724
+ if (response.has_more) {
46725
+ await this.postgresClient.updateObjectPageCursor(
46726
+ accountId,
46727
+ runStartedAt,
46728
+ resourceName,
46729
+ lastEventId
46730
+ );
46731
+ }
46732
+ if (!response.has_more) {
46733
+ await this.postgresClient.completeObjectSync(accountId, runStartedAt, resourceName);
46734
+ }
46735
+ return { processed, hasMore: response.has_more, runStartedAt };
46736
+ } catch (error) {
46737
+ await this.postgresClient.failObjectSync(
46738
+ accountId,
46739
+ runStartedAt,
46740
+ resourceName,
46741
+ error instanceof Error ? error.message : "Unknown error"
46742
+ );
46743
+ throw error;
46744
+ }
46745
+ }
46746
+ /**
46747
+ * Handle a delete for an entity discovered via event catch-up.
46748
+ * Maps Stripe object types to the appropriate delete method.
46749
+ */
46750
+ async handleEventCatchupDelete(objectType, entityId, accountId) {
46751
+ switch (objectType) {
46752
+ case "product":
46753
+ await this.deleteProduct(entityId);
46754
+ break;
46755
+ case "price":
46756
+ await this.deletePrice(entityId);
46757
+ break;
46758
+ case "plan":
46759
+ await this.deletePlan(entityId);
46760
+ break;
46761
+ case "customer": {
46762
+ const deletedCustomer = {
46763
+ id: entityId,
46764
+ object: "customer",
46765
+ deleted: true
46766
+ };
46767
+ await this.upsertCustomers([deletedCustomer], accountId);
46768
+ break;
46769
+ }
46770
+ case "tax_id":
46771
+ await this.deleteTaxId(entityId);
46772
+ break;
46773
+ default:
46774
+ this.config.logger?.warn(
46775
+ `_event_catchup: no delete handler for object type "${objectType}", skipping ${entityId}`
46776
+ );
46777
+ }
46778
+ }
46779
+ /**
46780
+ * Handle an upsert for an entity discovered via event catch-up.
46781
+ * Re-fetches the current state from Stripe and upserts it.
46782
+ * If the entity has been deleted (404), falls back to the delete handler.
46783
+ */
46784
+ async handleEventCatchupUpsert(objectType, entityId, accountId) {
46785
+ try {
46786
+ switch (objectType) {
46787
+ case "product": {
46788
+ const product = await this.stripe.products.retrieve(entityId);
46789
+ await this.upsertProducts([product], accountId);
46790
+ break;
46791
+ }
46792
+ case "price": {
46793
+ const price = await this.stripe.prices.retrieve(entityId);
46794
+ await this.upsertPrices([price], accountId);
46795
+ break;
46796
+ }
46797
+ case "plan": {
46798
+ const plan = await this.stripe.plans.retrieve(entityId);
46799
+ await this.upsertPlans([plan], accountId);
46800
+ break;
46801
+ }
46802
+ case "customer": {
46803
+ const customer = await this.stripe.customers.retrieve(entityId);
46804
+ await this.upsertCustomers([customer], accountId);
46805
+ break;
46806
+ }
46807
+ case "subscription": {
46808
+ const sub = await this.stripe.subscriptions.retrieve(entityId);
46809
+ await this.upsertSubscriptions([sub], accountId);
46810
+ break;
46811
+ }
46812
+ case "subscription_schedule": {
46813
+ const schedule = await this.stripe.subscriptionSchedules.retrieve(entityId);
46814
+ await this.upsertSubscriptionSchedules([schedule], accountId);
46815
+ break;
46816
+ }
46817
+ case "invoice": {
46818
+ const invoice = await this.stripe.invoices.retrieve(entityId);
46819
+ await this.upsertInvoices([invoice], accountId);
46820
+ break;
46821
+ }
46822
+ case "charge": {
46823
+ const charge = await this.stripe.charges.retrieve(entityId, {
46824
+ expand: ["balance_transaction"]
46825
+ });
46826
+ if (charge.balance_transaction && typeof charge.balance_transaction === "object") {
46827
+ await this.upsertBalanceTransactions(
46828
+ [charge.balance_transaction],
46829
+ accountId
46830
+ );
46831
+ }
46832
+ await this.upsertCharges([charge], accountId);
46833
+ break;
46834
+ }
46835
+ case "payment_intent": {
46836
+ const pi = await this.stripe.paymentIntents.retrieve(entityId);
46837
+ await this.upsertPaymentIntents([pi], accountId);
46838
+ break;
46839
+ }
46840
+ case "payment_method": {
46841
+ const pm = await this.stripe.paymentMethods.retrieve(entityId);
46842
+ await this.upsertPaymentMethods([pm], accountId);
46843
+ break;
46844
+ }
46845
+ case "setup_intent": {
46846
+ const si = await this.stripe.setupIntents.retrieve(entityId);
46847
+ await this.upsertSetupIntents([si], accountId);
46848
+ break;
46849
+ }
46850
+ case "dispute": {
46851
+ const dispute = await this.stripe.disputes.retrieve(entityId, {
46852
+ expand: ["balance_transactions"]
46853
+ });
46854
+ if (dispute.balance_transactions && Array.isArray(dispute.balance_transactions)) {
46855
+ const expandedBts = dispute.balance_transactions.filter(
46856
+ (bt) => typeof bt === "object"
46857
+ );
46858
+ if (expandedBts.length > 0) {
46859
+ await this.upsertBalanceTransactions(expandedBts, accountId);
46860
+ }
46861
+ }
46862
+ await this.upsertDisputes([dispute], accountId);
46863
+ break;
46864
+ }
46865
+ case "credit_note": {
46866
+ const cn = await this.stripe.creditNotes.retrieve(entityId);
46867
+ await this.upsertCreditNotes([cn], accountId);
46868
+ break;
46869
+ }
46870
+ case "refund": {
46871
+ const refund = await this.stripe.refunds.retrieve(entityId, {
46872
+ expand: ["balance_transaction"]
46873
+ });
46874
+ if (refund.balance_transaction && typeof refund.balance_transaction === "object") {
46875
+ await this.upsertBalanceTransactions(
46876
+ [refund.balance_transaction],
46877
+ accountId
46878
+ );
46879
+ }
46880
+ await this.upsertRefunds([refund], accountId);
46881
+ break;
46882
+ }
46883
+ case "tax_id": {
46884
+ const taxId = await this.stripe.taxIds.retrieve(entityId);
46885
+ await this.upsertTaxIds([taxId], accountId);
46886
+ break;
46887
+ }
46888
+ case "balance_transaction": {
46889
+ const bt = await this.stripe.balanceTransactions.retrieve(entityId);
46890
+ await this.upsertBalanceTransactions([bt], accountId);
46891
+ break;
46892
+ }
46893
+ case "checkout.session": {
46894
+ const session = await this.stripe.checkout.sessions.retrieve(entityId);
46895
+ await this.upsertCheckoutSessions([session], accountId);
46896
+ break;
46897
+ }
46898
+ case "radar.early_fraud_warning": {
46899
+ const efw = await this.stripe.radar.earlyFraudWarnings.retrieve(entityId);
46900
+ await this.upsertEarlyFraudWarning([efw], accountId);
46901
+ break;
46902
+ }
46903
+ default:
46904
+ this.config.logger?.warn(
46905
+ `_event_catchup: no upsert handler for object type "${objectType}", skipping ${entityId}`
46906
+ );
46907
+ }
46908
+ } catch (err) {
46909
+ const isNotFound = (err instanceof import_stripe3.default.errors.StripeInvalidRequestError || err instanceof import_stripe3.default.errors.StripeAPIError) && (err.code === "resource_missing" || err.statusCode === 404);
46910
+ if (isNotFound) {
46911
+ this.config.logger?.info(
46912
+ `_event_catchup: ${objectType}:${entityId} not found on Stripe, treating as deleted`
46913
+ );
46914
+ await this.handleEventCatchupDelete(objectType, entityId, accountId);
46915
+ return;
46916
+ }
46917
+ throw err;
46918
+ }
46919
+ }
46476
46920
  /**
46477
46921
  * Process all pages for all (or specified) object types until complete.
46478
46922
  *
@@ -46546,6 +46990,7 @@ ${message}`;
46546
46990
  };
46547
46991
  }
46548
46992
  applySyncBackfillResult(results, object, result) {
46993
+ if (object === "_event_catchup") return;
46549
46994
  if (this.isSigmaResource(object)) {
46550
46995
  results.sigma = results.sigma ?? {};
46551
46996
  results.sigma[object] = result;
@@ -46581,6 +47026,9 @@ ${message}`;
46581
47026
  case "setup_intent":
46582
47027
  results.setupIntents = result;
46583
47028
  break;
47029
+ case "payment_method":
47030
+ results.paymentMethods = result;
47031
+ break;
46584
47032
  case "payment_intent":
46585
47033
  results.paymentIntents = result;
46586
47034
  break;
@@ -48088,6 +48536,29 @@ function chunkArray(array, chunkSize) {
48088
48536
  }
48089
48537
  return result;
48090
48538
  }
48539
+ function eventObjectTypeToTable(objectType) {
48540
+ const mapping = {
48541
+ product: "products",
48542
+ price: "prices",
48543
+ plan: "plans",
48544
+ customer: "customers",
48545
+ subscription: "subscriptions",
48546
+ subscription_schedule: "subscription_schedules",
48547
+ invoice: "invoices",
48548
+ charge: "charges",
48549
+ balance_transaction: "balance_transactions",
48550
+ payment_intent: "payment_intents",
48551
+ payment_method: "payment_methods",
48552
+ setup_intent: "setup_intents",
48553
+ dispute: "disputes",
48554
+ credit_note: "credit_notes",
48555
+ refund: "refunds",
48556
+ tax_id: "tax_ids",
48557
+ "checkout.session": "checkout_sessions",
48558
+ "radar.early_fraud_warning": "early_fraud_warnings"
48559
+ };
48560
+ return mapping[objectType] ?? null;
48561
+ }
48091
48562
 
48092
48563
  // src/database/migrate.ts
48093
48564
  var import_pg2 = require("pg");
package/dist/cli/lib.js CHANGED
@@ -6,10 +6,10 @@ import {
6
6
  migrateCommand,
7
7
  syncCommand,
8
8
  uninstallCommand
9
- } from "../chunk-DZMKGCU5.js";
10
- import "../chunk-LB4HG4Q6.js";
11
- import "../chunk-Q3AGYUHN.js";
12
- import "../chunk-HXSDJSKR.js";
9
+ } from "../chunk-LN6KHV6O.js";
10
+ import "../chunk-IQ64IUIL.js";
11
+ import "../chunk-OLHHINPH.js";
12
+ import "../chunk-7DYBM7H3.js";
13
13
  export {
14
14
  backfillCommand,
15
15
  createTunnel,