UNPKG

stripe-replit-sync

Version:

Stripe Sync Engine to sync Stripe data to Postgres

1,265 lines (1,256 loc) 95.2 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { PostgresClient: () => PostgresClient, StripeSync: () => StripeSync, hashApiKey: () => hashApiKey, runMigrations: () => runMigrations }); module.exports = __toCommonJS(index_exports); // ../../node_modules/.pnpm/tsup@8.5.0_postcss@8.5.6_tsx@4.20.6_typescript@5.9.3_yaml@2.8.1/node_modules/tsup/assets/cjs_shims.js var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.src || new URL("main.js", document.baseURI).href; var importMetaUrl = /* @__PURE__ */ getImportMetaUrl(); // src/stripeSync.ts var import_stripe2 = __toESM(require("stripe"), 1); var import_yesql2 = require("yesql"); // src/database/postgres.ts var import_pg = __toESM(require("pg"), 1); var import_yesql = require("yesql"); var ORDERED_STRIPE_TABLES = [ "subscription_items", "subscriptions", "subscription_schedules", "checkout_session_line_items", "checkout_sessions", "tax_ids", "charges", "refunds", "credit_notes", "disputes", "early_fraud_warnings", "invoices", "payment_intents", "payment_methods", "setup_intents", "prices", "plans", "products", "features", "active_entitlements", "reviews", "_managed_webhooks", "customers", "_sync_status" ]; var PostgresClient = class { constructor(config) { this.config = config; this.pool = new import_pg.default.Pool(config.poolConfig); } pool; async delete(table, id) { const prepared = (0, import_yesql.pg)(` delete from "${this.config.schema}"."${table}" where id = :id returning id; `)({ id }); const { rows } = await this.query(prepared.text, prepared.values); return rows.length > 0; } // eslint-disable-next-line @typescript-eslint/no-explicit-any async query(text, params) { return this.pool.query(text, params); } async upsertMany(entries, table) { if (!entries.length) return []; const chunkSize = 5; const results = []; for (let i = 0; i < entries.length; i += chunkSize) { const chunk = entries.slice(i, i + chunkSize); const queries = []; chunk.forEach((entry) => { const rawData = JSON.stringify(entry); const upsertSql = ` INSERT INTO "${this.config.schema}"."${table}" ("_raw_data") VALUES ($1::jsonb) ON CONFLICT (id) DO UPDATE SET "_raw_data" = EXCLUDED."_raw_data" RETURNING * `; queries.push(this.pool.query(upsertSql, [rawData])); }); results.push(...await Promise.all(queries)); } return results.flatMap((it) => it.rows); } async upsertManyWithTimestampProtection(entries, table, accountId, syncTimestamp) { const timestamp = syncTimestamp || (/* @__PURE__ */ new Date()).toISOString(); if (!entries.length) return []; const chunkSize = 5; const results = []; for (let i = 0; i < entries.length; i += chunkSize) { const chunk = entries.slice(i, i + chunkSize); const queries = []; chunk.forEach((entry) => { if (table.startsWith("_")) { const columns = Object.keys(entry).filter( (k) => k !== "last_synced_at" && k !== "account_id" ); const upsertSql = ` INSERT INTO "${this.config.schema}"."${table}" ( ${columns.map((c) => `"${c}"`).join(", ")}, "last_synced_at", "account_id" ) VALUES ( ${columns.map((c) => `:${c}`).join(", ")}, :last_synced_at, :account_id ) ON CONFLICT ("id") DO UPDATE SET ${columns.map((c) => `"${c}" = EXCLUDED."${c}"`).join(", ")}, "last_synced_at" = :last_synced_at, "account_id" = EXCLUDED."account_id" WHERE "${table}"."last_synced_at" IS NULL OR "${table}"."last_synced_at" < :last_synced_at RETURNING * `; const cleansed = this.cleanseArrayField(entry); cleansed.last_synced_at = timestamp; cleansed.account_id = accountId; const prepared = (0, import_yesql.pg)(upsertSql, { useNullForMissing: true })(cleansed); queries.push(this.pool.query(prepared.text, prepared.values)); } else { const rawData = JSON.stringify(entry); const upsertSql = ` INSERT INTO "${this.config.schema}"."${table}" ("_raw_data", "_last_synced_at", "_account_id") VALUES ($1::jsonb, $2, $3) ON CONFLICT (id) DO UPDATE SET "_raw_data" = EXCLUDED."_raw_data", "_last_synced_at" = $2, "_account_id" = EXCLUDED."_account_id" WHERE "${table}"."_last_synced_at" IS NULL OR "${table}"."_last_synced_at" < $2 RETURNING * `; queries.push(this.pool.query(upsertSql, [rawData, timestamp, accountId])); } }); results.push(...await Promise.all(queries)); } return results.flatMap((it) => it.rows); } cleanseArrayField(obj) { const cleansed = { ...obj }; Object.keys(cleansed).map((k) => { const data = cleansed[k]; if (Array.isArray(data)) { cleansed[k] = JSON.stringify(data); } }); return cleansed; } async findMissingEntries(table, ids) { if (!ids.length) return []; const prepared = (0, import_yesql.pg)(` select id from "${this.config.schema}"."${table}" where id=any(:ids::text[]); `)({ ids }); const { rows } = await this.query(prepared.text, prepared.values); const existingIds = rows.map((it) => it.id); const missingIds = ids.filter((it) => !existingIds.includes(it)); return missingIds; } // Sync status tracking methods for incremental backfill async getSyncCursor(resource, accountId) { const result = await this.query( `SELECT EXTRACT(EPOCH FROM last_incremental_cursor)::integer as cursor FROM "${this.config.schema}"."_sync_status" WHERE resource = $1 AND "account_id" = $2`, [resource, accountId] ); const cursor = result.rows[0]?.cursor ?? null; return cursor; } async updateSyncCursor(resource, accountId, cursor) { await this.query( `INSERT INTO "${this.config.schema}"."_sync_status" (resource, "account_id", last_incremental_cursor, status, last_synced_at) VALUES ($1, $2, to_timestamp($3), 'running', now()) ON CONFLICT (resource, "account_id") DO UPDATE SET last_incremental_cursor = GREATEST( COALESCE("${this.config.schema}"."_sync_status".last_incremental_cursor, to_timestamp(0)), to_timestamp($3) ), last_synced_at = now(), updated_at = now()`, [resource, accountId, cursor.toString()] ); } async markSyncRunning(resource, accountId) { await this.query( `INSERT INTO "${this.config.schema}"."_sync_status" (resource, "account_id", status) VALUES ($1, $2, 'running') ON CONFLICT (resource, "account_id") DO UPDATE SET status = 'running', updated_at = now()`, [resource, accountId] ); } async markSyncComplete(resource, accountId) { await this.query( `UPDATE "${this.config.schema}"."_sync_status" SET status = 'complete', error_message = NULL, updated_at = now() WHERE resource = $1 AND "account_id" = $2`, [resource, accountId] ); } async markSyncError(resource, accountId, errorMessage) { await this.query( `UPDATE "${this.config.schema}"."_sync_status" SET status = 'error', error_message = $3, updated_at = now() WHERE resource = $1 AND "account_id" = $2`, [resource, accountId, errorMessage] ); } // Account management methods async upsertAccount(accountData, apiKeyHash) { const rawData = JSON.stringify(accountData.raw_data); await this.query( `INSERT INTO "${this.config.schema}"."accounts" ("_raw_data", "api_key_hashes", "first_synced_at", "_last_synced_at") VALUES ($1::jsonb, ARRAY[$2], now(), now()) ON CONFLICT (id) DO UPDATE SET "_raw_data" = EXCLUDED."_raw_data", "api_key_hashes" = ( SELECT ARRAY( SELECT DISTINCT unnest( COALESCE("${this.config.schema}"."accounts"."api_key_hashes", '{}') || ARRAY[$2] ) ) ), "_last_synced_at" = now(), "_updated_at" = now()`, [rawData, apiKeyHash] ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any async getAllAccounts() { const result = await this.query( `SELECT _raw_data FROM "${this.config.schema}"."accounts" ORDER BY _last_synced_at DESC` ); return result.rows.map((row) => row._raw_data); } /** * Looks up an account ID by API key hash * Uses the GIN index on api_key_hashes for fast lookups * @param apiKeyHash - SHA-256 hash of the Stripe API key * @returns Account ID if found, null otherwise */ async getAccountIdByApiKeyHash(apiKeyHash) { const result = await this.query( `SELECT id FROM "${this.config.schema}"."accounts" WHERE $1 = ANY(api_key_hashes) LIMIT 1`, [apiKeyHash] ); return result.rows.length > 0 ? result.rows[0].id : null; } async getAccountRecordCounts(accountId) { const counts = {}; for (const table of ORDERED_STRIPE_TABLES) { const accountIdColumn = table.startsWith("_") ? "account_id" : "_account_id"; const result = await this.query( `SELECT COUNT(*) as count FROM "${this.config.schema}"."${table}" WHERE "${accountIdColumn}" = $1`, [accountId] ); counts[table] = parseInt(result.rows[0].count); } return counts; } async deleteAccountWithCascade(accountId, useTransaction) { const deletionCounts = {}; try { if (useTransaction) { await this.query("BEGIN"); } for (const table of ORDERED_STRIPE_TABLES) { const accountIdColumn = table.startsWith("_") ? "account_id" : "_account_id"; const result = await this.query( `DELETE FROM "${this.config.schema}"."${table}" WHERE "${accountIdColumn}" = $1`, [accountId] ); deletionCounts[table] = result.rowCount || 0; } const accountResult = await this.query( `DELETE FROM "${this.config.schema}"."accounts" WHERE "id" = $1`, [accountId] ); deletionCounts["accounts"] = accountResult.rowCount || 0; if (useTransaction) { await this.query("COMMIT"); } } catch (error) { if (useTransaction) { await this.query("ROLLBACK"); } throw error; } return deletionCounts; } /** * Hash a string to a 32-bit integer for use with PostgreSQL advisory locks. * Uses a simple hash algorithm that produces consistent results. */ hashToInt32(key) { let hash = 0; for (let i = 0; i < key.length; i++) { const char = key.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; } return hash; } /** * Acquire a PostgreSQL advisory lock for the given key. * This lock is automatically released when the connection is closed or explicitly released. * Advisory locks are session-level and will block until the lock is available. * * @param key - A string key to lock on (will be hashed to an integer) */ async acquireAdvisoryLock(key) { const lockId = this.hashToInt32(key); await this.query("SELECT pg_advisory_lock($1)", [lockId]); } /** * Release a PostgreSQL advisory lock for the given key. * * @param key - The same string key used to acquire the lock */ async releaseAdvisoryLock(key) { const lockId = this.hashToInt32(key); await this.query("SELECT pg_advisory_unlock($1)", [lockId]); } /** * Execute a function while holding an advisory lock. * The lock is automatically released after the function completes (success or error). * * IMPORTANT: This acquires a dedicated connection from the pool and holds it for the * duration of the function execution. PostgreSQL advisory locks are session-level, * so we must use the same connection for lock acquisition, operations, and release. * * @param key - A string key to lock on (will be hashed to an integer) * @param fn - The function to execute while holding the lock * @returns The result of the function */ async withAdvisoryLock(key, fn) { const lockId = this.hashToInt32(key); const client = await this.pool.connect(); try { await client.query("SELECT pg_advisory_lock($1)", [lockId]); return await fn(); } finally { try { await client.query("SELECT pg_advisory_unlock($1)", [lockId]); } finally { client.release(); } } } }; // src/schemas/managed_webhook.ts var managedWebhookSchema = { properties: [ "id", "object", "url", "enabled_events", "description", "enabled", "livemode", "metadata", "secret", "status", "api_version", "created", "account_id" ] }; // src/utils/retry.ts var import_stripe = __toESM(require("stripe"), 1); var DEFAULT_RETRY_CONFIG = { maxRetries: 5, initialDelayMs: 1e3, // 1 second maxDelayMs: 6e4, // 60 seconds jitterMs: 500 // randomization to prevent thundering herd }; function isRetryableError(error) { if (error instanceof import_stripe.default.errors.StripeRateLimitError) { return true; } if (error instanceof import_stripe.default.errors.StripeAPIError) { const statusCode = error.statusCode; if (statusCode && [500, 502, 503, 504, 424].includes(statusCode)) { return true; } } if (error instanceof import_stripe.default.errors.StripeConnectionError) { return true; } return false; } function getRetryAfterMs(error) { if (!(error instanceof import_stripe.default.errors.StripeRateLimitError)) { return null; } const retryAfterHeader = error.headers?.["retry-after"]; if (!retryAfterHeader) { return null; } const retryAfterSeconds = Number(retryAfterHeader); if (isNaN(retryAfterSeconds) || retryAfterSeconds <= 0) { return null; } return retryAfterSeconds * 1e3; } function calculateDelay(attempt, config, retryAfterMs) { if (retryAfterMs !== null && retryAfterMs !== void 0) { const jitter2 = Math.random() * config.jitterMs; return retryAfterMs + jitter2; } const exponentialDelay = Math.min(config.initialDelayMs * Math.pow(2, attempt), config.maxDelayMs); const jitter = Math.random() * config.jitterMs; return exponentialDelay + jitter; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function getErrorType(error) { if (error instanceof import_stripe.default.errors.StripeRateLimitError) { return "rate_limit"; } if (error instanceof import_stripe.default.errors.StripeAPIError) { return `api_error_${error.statusCode}`; } if (error instanceof import_stripe.default.errors.StripeConnectionError) { return "connection_error"; } return "unknown"; } async function withRetry(fn, config = {}, logger) { const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...config }; let lastError; for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) { try { return await fn(); } catch (error) { lastError = error; if (!isRetryableError(error)) { throw error; } if (attempt >= retryConfig.maxRetries) { logger?.error( { error: error instanceof Error ? error.message : String(error), errorType: getErrorType(error), attempt: attempt + 1, maxRetries: retryConfig.maxRetries }, "Max retries exhausted for Stripe error" ); throw error; } const retryAfterMs = getRetryAfterMs(error); const delay = calculateDelay(attempt, retryConfig, retryAfterMs); logger?.warn( { error: error instanceof Error ? error.message : String(error), errorType: getErrorType(error), attempt: attempt + 1, maxRetries: retryConfig.maxRetries, delayMs: Math.round(delay), retryAfterMs: retryAfterMs ?? void 0, nextAttempt: attempt + 2 }, "Transient Stripe error, retrying after delay" ); await sleep(delay); } } throw lastError; } // src/utils/stripeClientWrapper.ts function createRetryableStripeClient(stripe, retryConfig = {}, logger) { const isTest = process.env.NODE_ENV === "test" || process.env.VITEST === "true" || process.env.JEST_WORKER_ID !== void 0; if (isTest) { return stripe; } return new Proxy(stripe, { get(target, prop, receiver) { const original = Reflect.get(target, prop, receiver); if (original && typeof original === "object" && !isPromise(original)) { return wrapResource(original, retryConfig, logger); } return original; } }); } function wrapResource(resource, retryConfig, logger) { return new Proxy(resource, { get(target, prop, receiver) { const original = Reflect.get(target, prop, receiver); if (typeof original === "function") { return function(...args) { const result = original.apply(target, args); if (result && typeof result === "object" && Symbol.asyncIterator in result) { return result; } if (isPromise(result)) { return withRetry(() => Promise.resolve(result), retryConfig, logger); } return result; }; } if (original && typeof original === "object" && !isPromise(original)) { return wrapResource(original, retryConfig, logger); } return original; } }); } function isPromise(value) { return value !== null && typeof value === "object" && typeof value.then === "function"; } // src/utils/hashApiKey.ts var import_crypto = require("crypto"); function hashApiKey(apiKey) { return (0, import_crypto.createHash)("sha256").update(apiKey).digest("hex"); } // src/stripeSync.ts function getUniqueIds(entries, key) { const set = new Set( entries.map((subscription) => subscription?.[key]?.toString()).filter((it) => Boolean(it)) ); return Array.from(set); } var StripeSync = class { constructor(config) { this.config = config; const baseStripe = new import_stripe2.default(config.stripeSecretKey, { // https://github.com/stripe/stripe-node#configuration // @ts-ignore apiVersion: config.stripeApiVersion, appInfo: { name: "Stripe Postgres Sync" } }); this.stripe = createRetryableStripeClient(baseStripe, {}, config.logger); this.config.logger = config.logger ?? console; this.config.logger?.info( { autoExpandLists: config.autoExpandLists, stripeApiVersion: config.stripeApiVersion }, "StripeSync initialized" ); const poolConfig = config.poolConfig ?? {}; if (config.databaseUrl) { poolConfig.connectionString = config.databaseUrl; } if (config.maxPostgresConnections) { poolConfig.max = config.maxPostgresConnections; } if (poolConfig.max === void 0) { poolConfig.max = 10; } if (poolConfig.keepAlive === void 0) { poolConfig.keepAlive = true; } this.postgresClient = new PostgresClient({ schema: "stripe", poolConfig }); } stripe; postgresClient; cachedAccount = null; /** * Get the Stripe account ID. Uses database lookup by API key hash for fast lookups, * with fallback to Stripe API if not found (first-time setup or new API key). */ async getAccountId(objectAccountId) { if (this.cachedAccount?.id) { return this.cachedAccount.id; } const apiKeyHash = hashApiKey(this.config.stripeSecretKey); try { const accountId = await this.postgresClient.getAccountIdByApiKeyHash(apiKeyHash); if (accountId) { return accountId; } } catch (error) { this.config.logger?.warn( error, "Failed to lookup account by API key hash, falling back to API" ); } let account; try { const accountIdParam = objectAccountId || this.config.stripeAccountId; account = accountIdParam ? await this.stripe.accounts.retrieve(accountIdParam) : await this.stripe.accounts.retrieve(); } catch (error) { this.config.logger?.error(error, "Failed to retrieve account from Stripe API"); throw new Error("Failed to retrieve Stripe account. Please ensure API key is valid."); } this.cachedAccount = account; await this.upsertAccount(account, apiKeyHash); return account.id; } /** * Upsert Stripe account information to the database * @param account - Stripe account object * @param apiKeyHash - SHA-256 hash of API key to store for fast lookups */ async upsertAccount(account, apiKeyHash) { try { await this.postgresClient.upsertAccount( { id: account.id, raw_data: account }, apiKeyHash ); } catch (error) { this.config.logger?.error(error, "Failed to upsert account to database"); const errorMessage = error instanceof Error ? error.message : "Unknown error"; throw new Error(`Failed to upsert account to database: ${errorMessage}`); } } /** * Get the current account being synced */ async getCurrentAccount() { if (this.cachedAccount) { return this.cachedAccount; } await this.getAccountId(); return this.cachedAccount; } /** * Get all accounts that have been synced to the database */ async getAllSyncedAccounts() { try { const accountsData = await this.postgresClient.getAllAccounts(); return accountsData; } catch (error) { this.config.logger?.error(error, "Failed to retrieve accounts from database"); throw new Error("Failed to retrieve synced accounts from database"); } } /** * DANGEROUS: Delete an account and all associated data from the database * This operation cannot be undone! * * @param accountId - The Stripe account ID to delete * @param options - Options for deletion behavior * @param options.dryRun - If true, only count records without deleting (default: false) * @param options.useTransaction - If true, use transaction for atomic deletion (default: true) * @returns Deletion summary with counts and warnings */ async dangerouslyDeleteSyncedAccountData(accountId, options) { const dryRun = options?.dryRun ?? false; const useTransaction = options?.useTransaction ?? true; this.config.logger?.info( `${dryRun ? "Preview" : "Deleting"} account ${accountId} (transaction: ${useTransaction})` ); try { const counts = await this.postgresClient.getAccountRecordCounts(accountId); const warnings = []; let totalRecords = 0; for (const [table, count] of Object.entries(counts)) { if (count > 0) { totalRecords += count; warnings.push(`Will delete ${count} ${table} record${count !== 1 ? "s" : ""}`); } } if (totalRecords > 1e5) { warnings.push( `Large dataset detected (${totalRecords} total records). Consider using useTransaction: false for better performance.` ); } if (this.cachedAccount?.id === accountId) { warnings.push( "Warning: Deleting the current account. Cache will be cleared after deletion." ); } if (dryRun) { this.config.logger?.info(`Dry-run complete: ${totalRecords} total records would be deleted`); return { deletedAccountId: accountId, deletedRecordCounts: counts, warnings }; } const deletionCounts = await this.postgresClient.deleteAccountWithCascade( accountId, useTransaction ); if (this.cachedAccount?.id === accountId) { this.cachedAccount = null; } this.config.logger?.info( `Successfully deleted account ${accountId} with ${totalRecords} total records` ); return { deletedAccountId: accountId, deletedRecordCounts: deletionCounts, warnings }; } catch (error) { this.config.logger?.error(error, `Failed to delete account ${accountId}`); const errorMessage = error instanceof Error ? error.message : "Unknown error"; throw new Error(`Failed to delete account ${accountId}: ${errorMessage}`); } } async processWebhook(payload, signature) { let webhookSecret = this.config.stripeWebhookSecret; if (!webhookSecret) { const accountId = await this.getAccountId(); const result = await this.postgresClient.query( `SELECT secret FROM "stripe"."_managed_webhooks" WHERE account_id = $1 LIMIT 1`, [accountId] ); if (result.rows.length > 0) { webhookSecret = result.rows[0].secret; } } if (!webhookSecret) { throw new Error( "No webhook secret provided. Either create a managed webhook or configure stripeWebhookSecret." ); } const event = await this.stripe.webhooks.constructEventAsync(payload, signature, webhookSecret); return this.processEvent(event); } // Event handler registry - maps event types to handler functions // Note: Uses 'any' for event parameter to allow handlers with specific Stripe event types // (e.g., CustomerDeletedEvent, ProductDeletedEvent) which TypeScript won't accept // as contravariant parameters when using the base Stripe.Event type eventHandlers = { "charge.captured": this.handleChargeEvent.bind(this), "charge.expired": this.handleChargeEvent.bind(this), "charge.failed": this.handleChargeEvent.bind(this), "charge.pending": this.handleChargeEvent.bind(this), "charge.refunded": this.handleChargeEvent.bind(this), "charge.succeeded": this.handleChargeEvent.bind(this), "charge.updated": this.handleChargeEvent.bind(this), "customer.deleted": this.handleCustomerDeletedEvent.bind(this), "customer.created": this.handleCustomerEvent.bind(this), "customer.updated": this.handleCustomerEvent.bind(this), "checkout.session.async_payment_failed": this.handleCheckoutSessionEvent.bind(this), "checkout.session.async_payment_succeeded": this.handleCheckoutSessionEvent.bind(this), "checkout.session.completed": this.handleCheckoutSessionEvent.bind(this), "checkout.session.expired": this.handleCheckoutSessionEvent.bind(this), "customer.subscription.created": this.handleSubscriptionEvent.bind(this), "customer.subscription.deleted": this.handleSubscriptionEvent.bind(this), "customer.subscription.paused": this.handleSubscriptionEvent.bind(this), "customer.subscription.pending_update_applied": this.handleSubscriptionEvent.bind(this), "customer.subscription.pending_update_expired": this.handleSubscriptionEvent.bind(this), "customer.subscription.trial_will_end": this.handleSubscriptionEvent.bind(this), "customer.subscription.resumed": this.handleSubscriptionEvent.bind(this), "customer.subscription.updated": this.handleSubscriptionEvent.bind(this), "customer.tax_id.updated": this.handleTaxIdEvent.bind(this), "customer.tax_id.created": this.handleTaxIdEvent.bind(this), "customer.tax_id.deleted": this.handleTaxIdDeletedEvent.bind(this), "invoice.created": this.handleInvoiceEvent.bind(this), "invoice.deleted": this.handleInvoiceEvent.bind(this), "invoice.finalized": this.handleInvoiceEvent.bind(this), "invoice.finalization_failed": this.handleInvoiceEvent.bind(this), "invoice.paid": this.handleInvoiceEvent.bind(this), "invoice.payment_action_required": this.handleInvoiceEvent.bind(this), "invoice.payment_failed": this.handleInvoiceEvent.bind(this), "invoice.payment_succeeded": this.handleInvoiceEvent.bind(this), "invoice.upcoming": this.handleInvoiceEvent.bind(this), "invoice.sent": this.handleInvoiceEvent.bind(this), "invoice.voided": this.handleInvoiceEvent.bind(this), "invoice.marked_uncollectible": this.handleInvoiceEvent.bind(this), "invoice.updated": this.handleInvoiceEvent.bind(this), "product.created": this.handleProductEvent.bind(this), "product.updated": this.handleProductEvent.bind(this), "product.deleted": this.handleProductDeletedEvent.bind(this), "price.created": this.handlePriceEvent.bind(this), "price.updated": this.handlePriceEvent.bind(this), "price.deleted": this.handlePriceDeletedEvent.bind(this), "plan.created": this.handlePlanEvent.bind(this), "plan.updated": this.handlePlanEvent.bind(this), "plan.deleted": this.handlePlanDeletedEvent.bind(this), "setup_intent.canceled": this.handleSetupIntentEvent.bind(this), "setup_intent.created": this.handleSetupIntentEvent.bind(this), "setup_intent.requires_action": this.handleSetupIntentEvent.bind(this), "setup_intent.setup_failed": this.handleSetupIntentEvent.bind(this), "setup_intent.succeeded": this.handleSetupIntentEvent.bind(this), "subscription_schedule.aborted": this.handleSubscriptionScheduleEvent.bind(this), "subscription_schedule.canceled": this.handleSubscriptionScheduleEvent.bind(this), "subscription_schedule.completed": this.handleSubscriptionScheduleEvent.bind(this), "subscription_schedule.created": this.handleSubscriptionScheduleEvent.bind(this), "subscription_schedule.expiring": this.handleSubscriptionScheduleEvent.bind(this), "subscription_schedule.released": this.handleSubscriptionScheduleEvent.bind(this), "subscription_schedule.updated": this.handleSubscriptionScheduleEvent.bind(this), "payment_method.attached": this.handlePaymentMethodEvent.bind(this), "payment_method.automatically_updated": this.handlePaymentMethodEvent.bind(this), "payment_method.detached": this.handlePaymentMethodEvent.bind(this), "payment_method.updated": this.handlePaymentMethodEvent.bind(this), "charge.dispute.created": this.handleDisputeEvent.bind(this), "charge.dispute.funds_reinstated": this.handleDisputeEvent.bind(this), "charge.dispute.funds_withdrawn": this.handleDisputeEvent.bind(this), "charge.dispute.updated": this.handleDisputeEvent.bind(this), "charge.dispute.closed": this.handleDisputeEvent.bind(this), "payment_intent.amount_capturable_updated": this.handlePaymentIntentEvent.bind(this), "payment_intent.canceled": this.handlePaymentIntentEvent.bind(this), "payment_intent.created": this.handlePaymentIntentEvent.bind(this), "payment_intent.partially_funded": this.handlePaymentIntentEvent.bind(this), "payment_intent.payment_failed": this.handlePaymentIntentEvent.bind(this), "payment_intent.processing": this.handlePaymentIntentEvent.bind(this), "payment_intent.requires_action": this.handlePaymentIntentEvent.bind(this), "payment_intent.succeeded": this.handlePaymentIntentEvent.bind(this), "credit_note.created": this.handleCreditNoteEvent.bind(this), "credit_note.updated": this.handleCreditNoteEvent.bind(this), "credit_note.voided": this.handleCreditNoteEvent.bind(this), "radar.early_fraud_warning.created": this.handleEarlyFraudWarningEvent.bind(this), "radar.early_fraud_warning.updated": this.handleEarlyFraudWarningEvent.bind(this), "refund.created": this.handleRefundEvent.bind(this), "refund.failed": this.handleRefundEvent.bind(this), "refund.updated": this.handleRefundEvent.bind(this), "charge.refund.updated": this.handleRefundEvent.bind(this), "review.closed": this.handleReviewEvent.bind(this), "review.opened": this.handleReviewEvent.bind(this), "entitlements.active_entitlement_summary.updated": this.handleEntitlementSummaryEvent.bind(this) }; async processEvent(event) { const objectAccountId = event.data?.object && typeof event.data.object === "object" && "account" in event.data.object ? event.data.object.account : void 0; const accountId = await this.getAccountId(objectAccountId); await this.getCurrentAccount(); const handler = this.eventHandlers[event.type]; if (handler) { const entityId = event.data?.object && typeof event.data.object === "object" && "id" in event.data.object ? event.data.object.id : "unknown"; this.config.logger?.info(`Received webhook ${event.id}: ${event.type} for ${entityId}`); await handler(event, accountId); } else { this.config.logger?.warn( `Received unhandled webhook event: ${event.type} (${event.id}). Ignoring.` ); } } /** * Returns an array of all webhook event types that this sync engine can handle. * Useful for configuring webhook endpoints with specific event subscriptions. */ getSupportedEventTypes() { return Object.keys( this.eventHandlers ).sort(); } // Event handler methods async handleChargeEvent(event, accountId) { const { entity: charge, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.charges.retrieve(id), (charge2) => charge2.status === "failed" || charge2.status === "succeeded" ); await this.upsertCharges([charge], accountId, false, this.getSyncTimestamp(event, refetched)); } async handleCustomerDeletedEvent(event, accountId) { const customer = { id: event.data.object.id, object: "customer", deleted: true }; await this.upsertCustomers([customer], accountId, this.getSyncTimestamp(event, false)); } async handleCustomerEvent(event, accountId) { const { entity: customer, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.customers.retrieve(id), (customer2) => customer2.deleted === true ); await this.upsertCustomers([customer], accountId, this.getSyncTimestamp(event, refetched)); } async handleCheckoutSessionEvent(event, accountId) { const { entity: checkoutSession, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.checkout.sessions.retrieve(id) ); await this.upsertCheckoutSessions( [checkoutSession], accountId, false, this.getSyncTimestamp(event, refetched) ); } async handleSubscriptionEvent(event, accountId) { const { entity: subscription, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.subscriptions.retrieve(id), (subscription2) => subscription2.status === "canceled" || subscription2.status === "incomplete_expired" ); await this.upsertSubscriptions( [subscription], accountId, false, this.getSyncTimestamp(event, refetched) ); } async handleTaxIdEvent(event, accountId) { const { entity: taxId, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.taxIds.retrieve(id) ); await this.upsertTaxIds([taxId], accountId, false, this.getSyncTimestamp(event, refetched)); } async handleTaxIdDeletedEvent(event, _accountId) { const taxId = event.data.object; await this.deleteTaxId(taxId.id); } async handleInvoiceEvent(event, accountId) { const { entity: invoice, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.invoices.retrieve(id), (invoice2) => invoice2.status === "void" ); await this.upsertInvoices([invoice], accountId, false, this.getSyncTimestamp(event, refetched)); } async handleProductEvent(event, accountId) { try { const { entity: product, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.products.retrieve(id) ); await this.upsertProducts([product], accountId, this.getSyncTimestamp(event, refetched)); } catch (err) { if (err instanceof import_stripe2.default.errors.StripeAPIError && err.code === "resource_missing") { const product = event.data.object; await this.deleteProduct(product.id); } else { throw err; } } } async handleProductDeletedEvent(event, _accountId) { const product = event.data.object; await this.deleteProduct(product.id); } async handlePriceEvent(event, accountId) { try { const { entity: price, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.prices.retrieve(id) ); await this.upsertPrices([price], accountId, false, this.getSyncTimestamp(event, refetched)); } catch (err) { if (err instanceof import_stripe2.default.errors.StripeAPIError && err.code === "resource_missing") { const price = event.data.object; await this.deletePrice(price.id); } else { throw err; } } } async handlePriceDeletedEvent(event, _accountId) { const price = event.data.object; await this.deletePrice(price.id); } async handlePlanEvent(event, accountId) { try { const { entity: plan, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.plans.retrieve(id) ); await this.upsertPlans([plan], accountId, false, this.getSyncTimestamp(event, refetched)); } catch (err) { if (err instanceof import_stripe2.default.errors.StripeAPIError && err.code === "resource_missing") { const plan = event.data.object; await this.deletePlan(plan.id); } else { throw err; } } } async handlePlanDeletedEvent(event, _accountId) { const plan = event.data.object; await this.deletePlan(plan.id); } async handleSetupIntentEvent(event, accountId) { const { entity: setupIntent, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.setupIntents.retrieve(id), (setupIntent2) => setupIntent2.status === "canceled" || setupIntent2.status === "succeeded" ); await this.upsertSetupIntents( [setupIntent], accountId, false, this.getSyncTimestamp(event, refetched) ); } async handleSubscriptionScheduleEvent(event, accountId) { const { entity: subscriptionSchedule, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.subscriptionSchedules.retrieve(id), (schedule) => schedule.status === "canceled" || schedule.status === "completed" ); await this.upsertSubscriptionSchedules( [subscriptionSchedule], accountId, false, this.getSyncTimestamp(event, refetched) ); } async handlePaymentMethodEvent(event, accountId) { const { entity: paymentMethod, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.paymentMethods.retrieve(id) ); await this.upsertPaymentMethods( [paymentMethod], accountId, false, this.getSyncTimestamp(event, refetched) ); } async handleDisputeEvent(event, accountId) { const { entity: dispute, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.disputes.retrieve(id), (dispute2) => dispute2.status === "won" || dispute2.status === "lost" ); await this.upsertDisputes([dispute], accountId, false, this.getSyncTimestamp(event, refetched)); } async handlePaymentIntentEvent(event, accountId) { const { entity: paymentIntent, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.paymentIntents.retrieve(id), // Final states - do not re-fetch from API (entity) => entity.status === "canceled" || entity.status === "succeeded" ); await this.upsertPaymentIntents( [paymentIntent], accountId, false, this.getSyncTimestamp(event, refetched) ); } async handleCreditNoteEvent(event, accountId) { const { entity: creditNote, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.creditNotes.retrieve(id), (creditNote2) => creditNote2.status === "void" ); await this.upsertCreditNotes( [creditNote], accountId, false, this.getSyncTimestamp(event, refetched) ); } async handleEarlyFraudWarningEvent(event, accountId) { const { entity: earlyFraudWarning, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.radar.earlyFraudWarnings.retrieve(id) ); await this.upsertEarlyFraudWarning( [earlyFraudWarning], accountId, false, this.getSyncTimestamp(event, refetched) ); } async handleRefundEvent(event, accountId) { const { entity: refund, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.refunds.retrieve(id) ); await this.upsertRefunds([refund], accountId, false, this.getSyncTimestamp(event, refetched)); } async handleReviewEvent(event, accountId) { const { entity: review, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.reviews.retrieve(id) ); await this.upsertReviews([review], accountId, false, this.getSyncTimestamp(event, refetched)); } async handleEntitlementSummaryEvent(event, accountId) { const activeEntitlementSummary = event.data.object; let entitlements = activeEntitlementSummary.entitlements; let refetched = false; if (this.config.revalidateObjectsViaStripeApi?.includes("entitlements")) { const { lastResponse, ...rest } = await this.stripe.entitlements.activeEntitlements.list({ customer: activeEntitlementSummary.customer }); entitlements = rest; refetched = true; } await this.deleteRemovedActiveEntitlements( activeEntitlementSummary.customer, entitlements.data.map((entitlement) => entitlement.id) ); await this.upsertActiveEntitlements( activeEntitlementSummary.customer, entitlements.data, accountId, false, this.getSyncTimestamp(event, refetched) ); } getSyncTimestamp(event, refetched) { return refetched ? (/* @__PURE__ */ new Date()).toISOString() : new Date(event.created * 1e3).toISOString(); } shouldRefetchEntity(entity) { return this.config.revalidateObjectsViaStripeApi?.includes(entity.object); } async fetchOrUseWebhookData(entity, fetchFn, entityInFinalState) { if (!entity.id) return { entity, refetched: false }; if (entityInFinalState && entityInFinalState(entity)) return { entity, refetched: false }; if (this.shouldRefetchEntity(entity)) { const fetchedEntity = await fetchFn(entity.id); return { entity: fetchedEntity, refetched: true }; } return { entity, refetched: false }; } async syncSingleEntity(stripeId) { const accountId = await this.getAccountId(); if (stripeId.startsWith("cus_")) { return this.stripe.customers.retrieve(stripeId).then((it) => { if (!it || it.deleted) return; return this.upsertCustomers([it], accountId); }); } else if (stripeId.startsWith("in_")) { return this.stripe.invoices.retrieve(stripeId).then((it) => this.upsertInvoices([it], accountId)); } else if (stripeId.startsWith("price_")) { return this.stripe.prices.retrieve(stripeId).then((it) => this.upsertPrices([it], accountId)); } else if (stripeId.startsWith("prod_")) { return this.stripe.products.retrieve(stripeId).then((it) => this.upsertProducts([it], accountId)); } else if (stripeId.startsWith("sub_")) { return this.stripe.subscriptions.retrieve(stripeId).then((it) => this.upsertSubscriptions([it], accountId)); } else if (stripeId.startsWith("seti_")) { return this.stripe.setupIntents.retrieve(stripeId).then((it) => this.upsertSetupIntents([it], accountId)); } else if (stripeId.startsWith("pm_")) { return this.stripe.paymentMethods.retrieve(stripeId).then((it) => this.upsertPaymentMethods([it], accountId)); } else if (stripeId.startsWith("dp_") || stripeId.startsWith("du_")) { return this.stripe.disputes.retrieve(stripeId).then((it) => this.upsertDisputes([it], accountId)); } else if (stripeId.startsWith("ch_")) { return this.stripe.charges.retrieve(stripeId).then((it) => this.upsertCharges([it], accountId, true)); } else if (stripeId.startsWith("pi_")) { return this.stripe.paymentIntents.retrieve(stripeId).then((it) => this.upsertPaymentIntents([it], accountId)); } else if (stripeId.startsWith("txi_")) { return this.stripe.taxIds.retrieve(stripeId).then((it) => this.upsertTaxIds([it], accountId)); } else if (stripeId.startsWith("cn_")) { return this.stripe.creditNotes.retrieve(stripeId).then((it) => this.upsertCreditNotes([it], accountId)); } else if (stripeId.startsWith("issfr_")) { return this.stripe.radar.earlyFraudWarnings.retrieve(stripeId).then((it) => this.upsertEarlyFraudWarning([it], accountId)); } else if (stripeId.startsWith("prv_")) { return this.stripe.reviews.retrieve(stripeId).then((it) => this.upsertReviews([it], accountId)); } else if (stripeId.startsWith("re_")) { return this.stripe.refunds.retrieve(stripeId).then((it) => this.upsertRefunds([it], accountId)); } else if (stripeId.startsWith("feat_")) { return this.stripe.entitlements.features.retrieve(stripeId).then((it) => this.upsertFeatures([it], accountId)); } else if (stripeId.startsWith("cs_")) { return this.stripe.checkout.sessions.retrieve(stripeId).then((it) => this.upsertCheckoutSessions([it], accountId)); } } async syncBackfill(params) { const { object } = params ?? { object: this.getSupportedEventTypes }; let products, prices, customers, checkoutSessions, subscriptions, subscriptionSchedules, invoices, setupIntents, paymentMethods, disputes, charges, paymentIntents, plans, taxIds, creditNotes, earlyFraudWarnings, refunds; await this.getCurrentAccount(); switch (object) { case "all": products = await this.syncProducts(params); prices = await this.syncPrices(params); plans = await this.syncPlans(params); customers = await this.syncCustomers(params); subscriptions = await this.syncSubscriptions(params); subscriptionSchedules = await this.syncSubscriptionSchedules(params); invoices = await this.syncInvoices(params); charges = await this.syncCharges(params); setupIntents = await this.syncSetupIntents(params); paymentMethods = await this.syncPaymentMethods(params); paymentIntents = await this.syncPaymentIntents(params); taxIds = await this.syncTaxIds(params); creditNotes = await this.syncCreditNotes(params); disputes = await this.syncDisputes(params); earlyFraudWarnings = await this.syncEarlyFraudWarnings(params); refunds = await this.syncRefunds(params); checkoutSessions = await this.syncCheckoutSessions(params); break; case "customer": customers = await this.syncCustomers(params); break; case "invoice": invoices = await this.syncInvoices(params); break; case "price": prices = await this.syncPrices(params); break; case "product": products = await this.syncProducts(params); break; case "subscription": subscriptions = await this.syncSubscriptions(params); break; case "subscription_schedules": subscriptionSchedules = await this.syncSubscriptionSchedules(params); break; case "setup_intent": setupIntents = await this.syncSetupIntents(params); break; case "payment_method": paymentMethods = await this.syncPaymentMethods(params); break; case "dispute": disputes = await this.syncDisputes(params); break; case "charge": charges = await this.syncCharges(params); break; case "payment_intent": paymentIntents = await this.syncPaymentIntents(params); case "plan": plans = await this.syncPlans(params); break; case "tax_id": taxIds = await this.syncTaxIds(params); break; case "credit_note": creditNotes = await this.syncCreditNotes(params); break; case "ea