UNPKG

@supabase/stripe-sync-engine

Version:

Stripe Sync Engine to sync Stripe data based on webhooks to Postgres

1,603 lines (1,577 loc) 69.9 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, runMigrations: () => runMigrations }); module.exports = __toCommonJS(index_exports); // src/stripeSync.ts var import_stripe = __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 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; } async query(text, params) { return this.pool.query(text, params); } async upsertMany(entries, table, tableSchema) { 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 cleansed = this.cleanseArrayField(entry); const upsertSql = this.constructUpsertSql(this.config.schema, table, tableSchema); const prepared = (0, import_yesql.pg)(upsertSql, { useNullForMissing: true })(cleansed); queries.push(this.pool.query(prepared.text, prepared.values)); }); results.push(...await Promise.all(queries)); } return results.flatMap((it) => it.rows); } async upsertManyWithTimestampProtection(entries, table, tableSchema, 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) => { const cleansed = this.cleanseArrayField(entry); cleansed.last_synced_at = timestamp; const upsertSql = this.constructUpsertWithTimestampProtectionSql( this.config.schema, table, tableSchema ); const prepared = (0, import_yesql.pg)(upsertSql, { useNullForMissing: true })(cleansed); queries.push(this.pool.query(prepared.text, prepared.values)); }); results.push(...await Promise.all(queries)); } return results.flatMap((it) => it.rows); } 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; } /** * Returns an (yesql formatted) upsert function based on the key/vals of an object. * eg, * insert into customers ("id", "name") * values (:id, :name) * on conflict (id) * do update set ( * "id" = :id, * "name" = :name * ) */ constructUpsertSql(schema, table, tableSchema, options) { const { conflict = "id" } = options || {}; const properties = tableSchema.properties; return ` insert into "${schema}"."${table}" ( ${properties.map((x) => `"${x}"`).join(",")} ) values ( ${properties.map((x) => `:${x}`).join(",")} ) on conflict ( ${conflict} ) do update set ${properties.map((x) => `"${x}" = :${x}`).join(",")} ;`; } /** * Returns an (yesql formatted) upsert function with timestamp protection. * * The WHERE clause in ON CONFLICT DO UPDATE only applies to the conflicting row * (the row being updated), not to all rows in the table. PostgreSQL ensures that * the condition is evaluated only for the specific row that conflicts with the INSERT. * * * eg: * INSERT INTO "stripe"."charges" ( * "id", "amount", "created", "last_synced_at" * ) * VALUES ( * :id, :amount, :created, :last_synced_at * ) * ON CONFLICT (id) DO UPDATE SET * "amount" = EXCLUDED."amount", * "created" = EXCLUDED."created", * last_synced_at = :last_synced_at * WHERE "charges"."last_synced_at" IS NULL * OR "charges"."last_synced_at" < :last_synced_at; */ constructUpsertWithTimestampProtectionSql = (schema, table, tableSchema) => { const conflict = "id"; const properties = tableSchema.properties; return ` INSERT INTO "${schema}"."${table}" ( ${properties.map((x) => `"${x}"`).join(",")}, "last_synced_at" ) VALUES ( ${properties.map((x) => `:${x}`).join(",")}, :last_synced_at ) ON CONFLICT (${conflict}) DO UPDATE SET ${properties.filter((x) => x !== "last_synced_at").map((x) => `"${x}" = EXCLUDED."${x}"`).join(",")}, last_synced_at = :last_synced_at WHERE "${table}"."last_synced_at" IS NULL OR "${table}"."last_synced_at" < :last_synced_at;`; }; /** * For array object field like invoice.custom_fields * ex: [{"name":"Project name","value":"Test Project"}] * * we need to stringify it first cos passing array object directly will end up with * { * invalid input syntax for type json * detail: 'Expected ":", but found "}".', * where: 'JSON data, line 1: ...\\":\\"Project name\\",\\"value\\":\\"Test Project\\"}"}', * } */ 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; } }; // src/schemas/charge.ts var chargeSchema = { properties: [ "id", "object", "paid", "order", "amount", "review", "source", "status", "created", "dispute", "invoice", "outcome", "refunds", "captured", "currency", "customer", "livemode", "metadata", "refunded", "shipping", "application", "description", "destination", "failure_code", "on_behalf_of", "fraud_details", "receipt_email", "payment_intent", "receipt_number", "transfer_group", "amount_refunded", "application_fee", "failure_message", "source_transfer", "balance_transaction", "statement_descriptor", "payment_method_details" ] }; // src/schemas/checkout_sessions.ts var checkoutSessionSchema = { properties: [ "id", "object", "adaptive_pricing", "after_expiration", "allow_promotion_codes", "amount_subtotal", "amount_total", "automatic_tax", "billing_address_collection", "cancel_url", "client_reference_id", "client_secret", "collected_information", "consent", "consent_collection", "created", "currency", "currency_conversion", "custom_fields", "custom_text", "customer", "customer_creation", "customer_details", "customer_email", "discounts", "expires_at", "invoice", "invoice_creation", "livemode", "locale", "metadata", "mode", "optional_items", "payment_intent", "payment_link", "payment_method_collection", "payment_method_configuration_details", "payment_method_options", "payment_method_types", "payment_status", "permissions", "phone_number_collection", "presentment_details", "recovered_from", "redirect_on_completion", "return_url", "saved_payment_method_options", "setup_intent", "shipping_address_collection", "shipping_cost", "shipping_details", "shipping_options", "status", "submit_type", "subscription", "success_url", "tax_id_collection", "total_details", "ui_mode", "url", "wallet_options" ] }; // src/schemas/checkout_session_line_items.ts var checkoutSessionLineItemSchema = { properties: [ "id", "object", "amount_discount", "amount_subtotal", "amount_tax", "amount_total", "currency", "description", "price", "quantity", "checkout_session" ] }; // src/schemas/credit_note.ts var creditNoteSchema = { properties: [ "id", "object", "amount", "amount_shipping", "created", "currency", "customer", "customer_balance_transaction", "discount_amount", "discount_amounts", "invoice", "lines", "livemode", "memo", "metadata", "number", "out_of_band_amount", "pdf", "reason", "refund", "shipping_cost", "status", "subtotal", "subtotal_excluding_tax", "tax_amounts", "total", "total_excluding_tax", "type", "voided_at" ] }; // src/schemas/customer.ts var customerSchema = { properties: [ "id", "object", "address", "description", "email", "metadata", "name", "phone", "shipping", "balance", "created", "currency", "default_source", "delinquent", "discount", "invoice_prefix", "invoice_settings", "livemode", "next_invoice_sequence", "preferred_locales", "tax_exempt" ] }; var customerDeletedSchema = { properties: ["id", "object", "deleted"] }; // src/schemas/dispute.ts var disputeSchema = { properties: [ "id", "object", "amount", "charge", "created", "currency", "balance_transactions", "evidence", "evidence_details", "is_charge_refundable", "livemode", "metadata", "payment_intent", "reason", "status" ] }; // src/schemas/invoice.ts var invoiceSchema = { properties: [ "id", "object", "auto_advance", "collection_method", "currency", "description", "hosted_invoice_url", "lines", "metadata", "period_end", "period_start", "status", "total", "account_country", "account_name", "account_tax_ids", "amount_due", "amount_paid", "amount_remaining", "application_fee_amount", "attempt_count", "attempted", "billing_reason", "created", "custom_fields", "customer_address", "customer_email", "customer_name", "customer_phone", "customer_shipping", "customer_tax_exempt", "customer_tax_ids", "default_tax_rates", "discount", "discounts", "due_date", "ending_balance", "footer", "invoice_pdf", "last_finalization_error", "livemode", "next_payment_attempt", "number", "paid", "payment_settings", "post_payment_credit_notes_amount", "pre_payment_credit_notes_amount", "receipt_number", "starting_balance", "statement_descriptor", "status_transitions", "subtotal", "tax", "total_discount_amounts", "total_tax_amounts", "transfer_data", "webhooks_delivered_at", "customer", "subscription", "payment_intent", "default_payment_method", "default_source", "on_behalf_of", "charge" ] }; // src/schemas/plan.ts var planSchema = { properties: [ "id", "object", "active", "amount", "created", "product", "currency", "interval", "livemode", "metadata", "nickname", "tiers_mode", "usage_type", "billing_scheme", "interval_count", "aggregate_usage", "transform_usage", "trial_period_days" ] }; // src/schemas/price.ts var priceSchema = { properties: [ "id", "object", "active", "currency", "metadata", "nickname", "recurring", "type", "unit_amount", "billing_scheme", "created", "livemode", "lookup_key", "tiers_mode", "transform_quantity", "unit_amount_decimal", "product" ] }; // src/schemas/product.ts var productSchema = { properties: [ "id", "object", "active", "default_price", "description", "metadata", "name", "created", "images", "marketing_features", "livemode", "package_dimensions", "shippable", "statement_descriptor", "unit_label", "updated", "url" ] }; // src/schemas/payment_intent.ts var paymentIntentSchema = { properties: [ "id", "object", "amount", "amount_capturable", "amount_details", "amount_received", "application", "application_fee_amount", "automatic_payment_methods", "canceled_at", "cancellation_reason", "capture_method", "client_secret", "confirmation_method", "created", "currency", "customer", "description", "invoice", "last_payment_error", "livemode", "metadata", "next_action", "on_behalf_of", "payment_method", "payment_method_options", "payment_method_types", "processing", "receipt_email", "review", "setup_future_usage", "shipping", "statement_descriptor", "statement_descriptor_suffix", "status", "transfer_data", "transfer_group" ] }; // src/schemas/payment_methods.ts var paymentMethodsSchema = { properties: [ "id", "object", "created", "customer", "type", "billing_details", "metadata", "card" ] }; // src/schemas/setup_intents.ts var setupIntentsSchema = { properties: [ "id", "object", "created", "customer", "description", "payment_method", "status", "usage", "cancellation_reason", "latest_attempt", "mandate", "single_use_mandate", "on_behalf_of" ] }; // src/schemas/tax_id.ts var taxIdSchema = { properties: [ "id", "country", "customer", "type", "value", "object", "created", "livemode", "owner" ] }; // src/schemas/subscription_item.ts var subscriptionItemSchema = { properties: [ "id", "object", "billing_thresholds", "created", "deleted", "metadata", "quantity", "price", "subscription", "tax_rates", "current_period_end", "current_period_start" ] }; // src/schemas/subscription_schedules.ts var subscriptionScheduleSchema = { properties: [ "id", "object", "application", "canceled_at", "completed_at", "created", "current_phase", "customer", "default_settings", "end_behavior", "livemode", "metadata", "phases", "released_at", "released_subscription", "status", "subscription", "test_clock" ] }; // src/schemas/subscription.ts var subscriptionSchema = { properties: [ "id", "object", "cancel_at_period_end", "current_period_end", "current_period_start", "default_payment_method", "items", "metadata", "pending_setup_intent", "pending_update", "status", "application_fee_percent", "billing_cycle_anchor", "billing_thresholds", "cancel_at", "canceled_at", "collection_method", "created", "days_until_due", "default_source", "default_tax_rates", "discount", "ended_at", "livemode", "next_pending_invoice_item_invoice", "pause_collection", "pending_invoice_item_interval", "start_date", "transfer_data", "trial_end", "trial_start", "schedule", "customer", "latest_invoice", "plan" ] }; // src/schemas/early_fraud_warning.ts var earlyFraudWarningSchema = { properties: [ "id", "object", "actionable", "charge", "created", "fraud_type", "livemode", "payment_intent" ] }; // src/schemas/review.ts var reviewSchema = { properties: [ "id", "object", "billing_zip", "created", "charge", "closed_reason", "livemode", "ip_address", "ip_address_location", "open", "opened_reason", "payment_intent", "reason", "session" ] }; // src/schemas/refund.ts var refundSchema = { properties: [ "id", "object", "amount", "balance_transaction", "charge", "created", "currency", "destination_details", "metadata", "payment_intent", "reason", "receipt_number", "source_transfer_reversal", "status", "transfer_reversal" ] }; // src/schemas/active_entitlement.ts var activeEntitlementSchema = { properties: ["id", "object", "feature", "lookup_key", "livemode", "customer"] }; // src/schemas/feature.ts var featureSchema = { properties: ["id", "object", "livemode", "name", "lookup_key", "active", "metadata"] }; // 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 DEFAULT_SCHEMA = "stripe"; var StripeSync = class { constructor(config) { this.config = config; this.stripe = new import_stripe.default(config.stripeSecretKey, { // https://github.com/stripe/stripe-node#configuration // @ts-ignore apiVersion: config.stripeApiVersion, appInfo: { name: "Stripe Postgres Sync" } }); 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: config.schema || DEFAULT_SCHEMA, poolConfig }); } stripe; postgresClient; async processWebhook(payload, signature) { const event = await this.stripe.webhooks.constructEventAsync( payload, signature, this.config.stripeWebhookSecret ); return this.processEvent(event); } async processEvent(event) { switch (event.type) { case "charge.captured": case "charge.expired": case "charge.failed": case "charge.pending": case "charge.refunded": case "charge.succeeded": case "charge.updated": { const { entity: charge, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.charges.retrieve(id), (charge2) => charge2.status === "failed" || charge2.status === "succeeded" ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for charge ${charge.id}` ); await this.upsertCharges([charge], false, this.getSyncTimestamp(event, refetched)); break; } case "customer.deleted": { const customer = { id: event.data.object.id, object: "customer", deleted: true }; this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for customer ${customer.id}` ); await this.upsertCustomers([customer], this.getSyncTimestamp(event, false)); break; } case "checkout.session.async_payment_failed": case "checkout.session.async_payment_succeeded": case "checkout.session.completed": case "checkout.session.expired": { const { entity: checkoutSession, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.checkout.sessions.retrieve(id) ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for checkout session ${checkoutSession.id}` ); await this.upsertCheckoutSessions( [checkoutSession], false, this.getSyncTimestamp(event, refetched) ); break; } case "customer.created": case "customer.updated": { const { entity: customer, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.customers.retrieve(id), (customer2) => customer2.deleted === true ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for customer ${customer.id}` ); await this.upsertCustomers([customer], this.getSyncTimestamp(event, refetched)); break; } case "customer.subscription.created": case "customer.subscription.deleted": // Soft delete using `status = canceled` case "customer.subscription.paused": case "customer.subscription.pending_update_applied": case "customer.subscription.pending_update_expired": case "customer.subscription.trial_will_end": case "customer.subscription.resumed": case "customer.subscription.updated": { const { entity: subscription, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.subscriptions.retrieve(id), (subscription2) => subscription2.status === "canceled" || subscription2.status === "incomplete_expired" ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for subscription ${subscription.id}` ); await this.upsertSubscriptions( [subscription], false, this.getSyncTimestamp(event, refetched) ); break; } case "customer.tax_id.updated": case "customer.tax_id.created": { const { entity: taxId, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.taxIds.retrieve(id) ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for taxId ${taxId.id}` ); await this.upsertTaxIds([taxId], false, this.getSyncTimestamp(event, refetched)); break; } case "customer.tax_id.deleted": { const taxId = event.data.object; this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for taxId ${taxId.id}` ); await this.deleteTaxId(taxId.id); break; } case "invoice.created": case "invoice.deleted": case "invoice.finalized": case "invoice.finalization_failed": case "invoice.paid": case "invoice.payment_action_required": case "invoice.payment_failed": case "invoice.payment_succeeded": case "invoice.upcoming": case "invoice.sent": case "invoice.voided": case "invoice.marked_uncollectible": case "invoice.updated": { const { entity: invoice, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.invoices.retrieve(id), (invoice2) => invoice2.status === "void" ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for invoice ${invoice.id}` ); await this.upsertInvoices([invoice], false, this.getSyncTimestamp(event, refetched)); break; } case "product.created": case "product.updated": { try { const { entity: product, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.products.retrieve(id) ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for product ${product.id}` ); await this.upsertProducts([product], this.getSyncTimestamp(event, refetched)); } catch (err) { if (err instanceof import_stripe.default.errors.StripeAPIError && err.code === "resource_missing") { await this.deleteProduct(event.data.object.id); } else { throw err; } } break; } case "product.deleted": { const product = event.data.object; this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for product ${product.id}` ); await this.deleteProduct(product.id); break; } case "price.created": case "price.updated": { try { const { entity: price, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.prices.retrieve(id) ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for price ${price.id}` ); await this.upsertPrices([price], false, this.getSyncTimestamp(event, refetched)); } catch (err) { if (err instanceof import_stripe.default.errors.StripeAPIError && err.code === "resource_missing") { await this.deletePrice(event.data.object.id); } else { throw err; } } break; } case "price.deleted": { const price = event.data.object; this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for price ${price.id}` ); await this.deletePrice(price.id); break; } case "plan.created": case "plan.updated": { try { const { entity: plan, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.plans.retrieve(id) ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for plan ${plan.id}` ); await this.upsertPlans([plan], false, this.getSyncTimestamp(event, refetched)); } catch (err) { if (err instanceof import_stripe.default.errors.StripeAPIError && err.code === "resource_missing") { await this.deletePlan(event.data.object.id); } else { throw err; } } break; } case "plan.deleted": { const plan = event.data.object; this.config.logger?.info(`Received webhook ${event.id}: ${event.type} for plan ${plan.id}`); await this.deletePlan(plan.id); break; } case "setup_intent.canceled": case "setup_intent.created": case "setup_intent.requires_action": case "setup_intent.setup_failed": case "setup_intent.succeeded": { const { entity: setupIntent, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.setupIntents.retrieve(id), (setupIntent2) => setupIntent2.status === "canceled" || setupIntent2.status === "succeeded" ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for setupIntent ${setupIntent.id}` ); await this.upsertSetupIntents([setupIntent], false, this.getSyncTimestamp(event, refetched)); break; } case "subscription_schedule.aborted": case "subscription_schedule.canceled": case "subscription_schedule.completed": case "subscription_schedule.created": case "subscription_schedule.expiring": case "subscription_schedule.released": case "subscription_schedule.updated": { const { entity: subscriptionSchedule, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.subscriptionSchedules.retrieve(id), (schedule) => schedule.status === "canceled" || schedule.status === "completed" ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for subscriptionSchedule ${subscriptionSchedule.id}` ); await this.upsertSubscriptionSchedules( [subscriptionSchedule], false, this.getSyncTimestamp(event, refetched) ); break; } case "payment_method.attached": case "payment_method.automatically_updated": case "payment_method.detached": case "payment_method.updated": { const { entity: paymentMethod, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.paymentMethods.retrieve(id) ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for paymentMethod ${paymentMethod.id}` ); await this.upsertPaymentMethods( [paymentMethod], false, this.getSyncTimestamp(event, refetched) ); break; } case "charge.dispute.created": case "charge.dispute.funds_reinstated": case "charge.dispute.funds_withdrawn": case "charge.dispute.updated": case "charge.dispute.closed": { const { entity: dispute, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.disputes.retrieve(id), (dispute2) => dispute2.status === "won" || dispute2.status === "lost" ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for dispute ${dispute.id}` ); await this.upsertDisputes([dispute], false, this.getSyncTimestamp(event, refetched)); break; } case "payment_intent.amount_capturable_updated": case "payment_intent.canceled": case "payment_intent.created": case "payment_intent.partially_funded": case "payment_intent.payment_failed": case "payment_intent.processing": case "payment_intent.requires_action": case "payment_intent.succeeded": { 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" ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for paymentIntent ${paymentIntent.id}` ); await this.upsertPaymentIntents( [paymentIntent], false, this.getSyncTimestamp(event, refetched) ); break; } case "credit_note.created": case "credit_note.updated": case "credit_note.voided": { const { entity: creditNote, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.creditNotes.retrieve(id), (creditNote2) => creditNote2.status === "void" ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for creditNote ${creditNote.id}` ); await this.upsertCreditNotes([creditNote], false, this.getSyncTimestamp(event, refetched)); break; } case "radar.early_fraud_warning.created": case "radar.early_fraud_warning.updated": { const { entity: earlyFraudWarning, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.radar.earlyFraudWarnings.retrieve(id) ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for earlyFraudWarning ${earlyFraudWarning.id}` ); await this.upsertEarlyFraudWarning( [earlyFraudWarning], false, this.getSyncTimestamp(event, refetched) ); break; } case "refund.created": case "refund.failed": case "refund.updated": case "charge.refund.updated": { const { entity: refund, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.refunds.retrieve(id) ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for refund ${refund.id}` ); await this.upsertRefunds([refund], false, this.getSyncTimestamp(event, refetched)); break; } case "review.closed": case "review.opened": { const { entity: review, refetched } = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.reviews.retrieve(id) ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for review ${review.id}` ); await this.upsertReviews([review], false, this.getSyncTimestamp(event, refetched)); break; } case "entitlements.active_entitlement_summary.updated": { 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; } this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for activeEntitlementSummary for customer ${activeEntitlementSummary.customer}` ); await this.deleteRemovedActiveEntitlements( activeEntitlementSummary.customer, entitlements.data.map((entitlement) => entitlement.id) ); await this.upsertActiveEntitlements( activeEntitlementSummary.customer, entitlements.data, false, this.getSyncTimestamp(event, refetched) ); break; } default: throw new Error("Unhandled webhook event"); } } 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) { if (stripeId.startsWith("cus_")) { return this.stripe.customers.retrieve(stripeId).then((it) => { if (!it || it.deleted) return; return this.upsertCustomers([it]); }); } else if (stripeId.startsWith("in_")) { return this.stripe.invoices.retrieve(stripeId).then((it) => this.upsertInvoices([it])); } else if (stripeId.startsWith("price_")) { return this.stripe.prices.retrieve(stripeId).then((it) => this.upsertPrices([it])); } else if (stripeId.startsWith("prod_")) { return this.stripe.products.retrieve(stripeId).then((it) => this.upsertProducts([it])); } else if (stripeId.startsWith("sub_")) { return this.stripe.subscriptions.retrieve(stripeId).then((it) => this.upsertSubscriptions([it])); } else if (stripeId.startsWith("seti_")) { return this.stripe.setupIntents.retrieve(stripeId).then((it) => this.upsertSetupIntents([it])); } else if (stripeId.startsWith("pm_")) { return this.stripe.paymentMethods.retrieve(stripeId).then((it) => this.upsertPaymentMethods([it])); } else if (stripeId.startsWith("dp_") || stripeId.startsWith("du_")) { return this.stripe.disputes.retrieve(stripeId).then((it) => this.upsertDisputes([it])); } else if (stripeId.startsWith("ch_")) { return this.stripe.charges.retrieve(stripeId).then((it) => this.upsertCharges([it], true)); } else if (stripeId.startsWith("pi_")) { return this.stripe.paymentIntents.retrieve(stripeId).then((it) => this.upsertPaymentIntents([it])); } else if (stripeId.startsWith("txi_")) { return this.stripe.taxIds.retrieve(stripeId).then((it) => this.upsertTaxIds([it])); } else if (stripeId.startsWith("cn_")) { return this.stripe.creditNotes.retrieve(stripeId).then((it) => this.upsertCreditNotes([it])); } else if (stripeId.startsWith("issfr_")) { return this.stripe.radar.earlyFraudWarnings.retrieve(stripeId).then((it) => this.upsertEarlyFraudWarning([it])); } else if (stripeId.startsWith("prv_")) { return this.stripe.reviews.retrieve(stripeId).then((it) => this.upsertReviews([it])); } else if (stripeId.startsWith("re_")) { return this.stripe.refunds.retrieve(stripeId).then((it) => this.upsertRefunds([it])); } else if (stripeId.startsWith("feat_")) { return this.stripe.entitlements.features.retrieve(stripeId).then((it) => this.upsertFeatures([it])); } else if (stripeId.startsWith("cs_")) { return this.stripe.checkout.sessions.retrieve(stripeId).then((it) => this.upsertCheckoutSessions([it])); } } async syncBackfill(params) { const { object } = params ?? {}; let products, prices, customers, checkoutSessions, subscriptions, subscriptionSchedules, invoices, setupIntents, paymentMethods, disputes, charges, paymentIntents, plans, taxIds, creditNotes, earlyFraudWarnings, refunds; 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 "early_fraud_warning": earlyFraudWarnings = await this.syncEarlyFraudWarnings(params); break; case "refund": refunds = await this.syncRefunds(params); break; case "checkout_sessions": checkoutSessions = await this.syncCheckoutSessions(params); break; default: break; } return { products, prices, customers, checkoutSessions, subscriptions, subscriptionSchedules, invoices, setupIntents, paymentMethods, disputes, charges, paymentIntents, plans, taxIds, creditNotes, earlyFraudWarnings, refunds }; } async syncProducts(syncParams) { this.config.logger?.info("Syncing products"); const params = { limit: 100 }; if (syncParams?.created) params.created = syncParams?.created; return this.fetchAndUpsert( () => this.stripe.products.list(params), (products) => this.upsertProducts(products) ); } async syncPrices(syncParams) { this.config.logger?.info("Syncing prices"); const params = { limit: 100 }; if (syncParams?.created) params.created = syncParams?.created; return this.fetchAndUpsert( () => this.stripe.prices.list(params), (prices) => this.upsertPrices(prices, syncParams?.backfillRelatedEntities) ); } async syncPlans(syncParams) { this.config.logger?.info("Syncing plans"); const params = { limit: 100 }; if (syncParams?.created) params.created = syncParams?.created; return this.fetchAndUpsert( () => this.stripe.plans.list(params), (plans) => this.upsertPlans(plans, syncParams?.backfillRelatedEntities) ); } async syncCustomers(syncParams) { this.config.logger?.info("Syncing customers"); const params = { limit: 100 }; if (syncParams?.created) params.created = syncParams.created; return this.fetchAndUpsert( () => this.stripe.customers.list(params), // @ts-expect-error (items) => this.upsertCustomers(items) ); } async syncSubscriptions(syncParams) { this.config.logger?.info("Syncing subscriptions"); const params = { status: "all", limit: 100 }; if (syncParams?.created) params.created = syncParams.created; return this.fetchAndUpsert( () => this.stripe.subscriptions.list(params), (items) => this.upsertSubscriptions(items, syncParams?.backfillRelatedEntities) ); } async syncSubscriptionSchedules(syncParams) { this.config.logger?.info("Syncing subscription schedules"); const params = { limit: 100 }; if (syncParams?.created) params.created = syncParams.created; return this.fetchAndUpsert( () => this.stripe.subscriptionSchedules.list(params), (items) => this.upsertSubscriptionSchedules(items, syncParams?.backfillRelatedEntities) ); } async syncInvoices(syncParams) { this.config.logger?.info("Syncing invoices"); const params = { limit: 100 }; if (syncParams?.created) params.created = syncParams.created; return this.fetchAndUpsert( () => this.stripe.invoices.list(params), (items) => this.upsertInvoices(items, syncParams?.backfillRelatedEntities) ); } async syncCharges(syncParams) { this.config.logger?.info("Syncing charges"); const params = { limit: 100 }; if (syncParams?.created) params.created = syncParams.created; return this.fetchAndUpsert( () => this.stripe.charges.list(params), (items) => this.upsertCharges(items, syncParams?.backfillRelatedEntities) ); } async syncSetupIntents(syncParams) { this.config.logger?.info("Syncing setup_intents"); const params = { limit: 100 }; if (syncParams?.created) params.created = syncParams.created; return this.fetchAndUpsert( () => this.stripe.setupIntents.list(params), (items) => this.upsertSetupIntents(items, syncParams?.backfillRelatedEntities) ); } async syncPaymentIntents(syncParams) { this.config.logger?.info("Syncing payment_intents"); const params = { limit: 100 }; if (syncParams?.created) params.created = syncParams.created; return this.fetchAndUpsert( () => this.stripe.paymentIntents.list(params), (items) => this.upsertPaymentIntents(items, syncParams?.backfillRelatedEntities) ); } async syncTaxIds(syncParams) { this.config.logger?.info("Syncing tax_ids"); const params = { limit: 100 }; return this.fetchAndUpsert( () => this.stripe.taxIds.list(params), (items) => this.upsertTaxIds(items, syncParams?.backfillRelatedEntities) ); } async syncPaymentMethods(syncParams) { this.config.logger?.info("Syncing payment method"); const prepared = (0, import_yesql2.pg)( `select id from "${this.config.schema}"."customers" WHERE deleted <> true;` )([]); const customerIds = await this.postgresClient.query(prepared.text, prepared.values).then(({ rows }) => rows.map((it) => it.id)); this.config.logger?.info(`Getting payment methods for ${customerIds.length} customers`); let synced = 0; for (const customerIdChunk of chunkArray(customerIds, 10)) { await Promise.all( customerIdChunk.map(async (customerId) => { const syncResult = await this.fetchAndUpsert( () => this.stripe.paymentMethods.list({ limit: 100, customer: customerId }), (items) => this.upsertPaymentMethods(items, syncParams?.backfillRelatedEntities) ); synced += syncResult.synced; }) ); } return { synced }; } async syncDisputes(syncParams) { const params = { limit: 100 }; if (syncParams?.created) params.created = syncParams.created; return this.fetchAndUpsert( () => this.stripe.disputes.list(params), (items) => this.upsertDisputes(items, syncParams?.backfillRelatedEntities) ); } async syncEarlyFraudWarnings(syncParams) { this.config.logger?.info("Syncing early fraud warnings"); const params = { limit: 100 }; if (syncParams?.created) params.created = syncParams.created; return this.fetchAndUpsert( () => this.stripe.radar.earlyFraudWarnings.list(params), (items) => this.upsertEarlyFraudWarning(items, syncParams?.backfillRelatedEntities) ); } async syncRefunds(syncParams) { this.config.logger?.info("Syncing refunds"); const params = { limit: 100 }; if (syncParams?.created) params.created = syncParams.created; return this.fetchAndUpsert( () => this.stripe.refunds.list(params), (items) => this.upsertRefunds(items, syncParams?.backfillRelatedEntities) ); } async syncCreditNotes(syncParams) { this.config.logger?.info("Syncing credit notes"); const params = { limit: 100 }; if (syncParams?.created) params.created = syncParams?.created; return this.fetchAndUpsert( () => this.stripe.creditNotes.list(params), (creditNotes) => this.upsertCreditNotes(creditNotes) ); } async syncFeatures(syncParams) { this.config.logger?.info("Syncing features"); const params = { limit: 100, ...syncParams?.pagination }; return this.fetchAndUpsert( () => this.stripe.entitlements.features.list(params), (features) => this.upsertFeatures(features) ); } async syncEntitlements(customerId, syncParams) { this.config.logger?.info("Syncing entitlements"); const params = { customer: customerId, limit: 100, ...syncParams?.pagination }; return this.fetchAndUpsert( () => this.stripe.entitlements.activeEntitlements.list(params), (entitlements) => this.upsertActiveEntitlements(customerId, entitlements) ); } async syncCheckoutSessions(syncParams) { this.config.logger?.info("Syncing checkout sessions"); const params = { limit: 100 }; if (syncParams?.created) params.created = syncParams.created; return this.fetchAndUpsert( () => this.stripe.checkout.sessions.list(params), (items) => this.upsertCheckoutSessions(items, syncParams?.backfillRelatedEntities) ); } async fetchAndUpsert(fetch, upsert) { const items = []; this.config.logger?.info("Fetching items to sync from Stripe"); for await (const item of fetch()) { items.push(item); } if (!items.length) return { synced: 0 }; this.config.logger?.info(`Upserting ${items.length} items`); const chunkSize = 250; for (let i = 0; i < items.length; i += chunkSize) { const chunk = items.slice(i, i + chunkSize); await upsert(chunk); } this.config.logger?.info("Upserted items"); return { synced: items.length }; } async upsertCharges(charges, backfillRelatedEntities, syncTimestamp) { if (backfillRelatedEnti