UNPKG

@supabase/stripe-sync-engine

Version:

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

1,575 lines (1,553 loc) 53 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, { 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({ connectionString: config.databaseUrl, max: config.maxConnections || 10, keepAlive: true }); } 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 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(",")} ;`; } /** * 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/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/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" ); this.postgresClient = new PostgresClient({ databaseUrl: config.databaseUrl, schema: config.schema || DEFAULT_SCHEMA, maxConnections: config.maxPostgresConnections }); } stripe; postgresClient; async processWebhook(payload, signature) { const event = await this.stripe.webhooks.constructEventAsync( payload, signature, this.config.stripeWebhookSecret ); 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 charge = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.charges.retrieve(id) ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for charge ${charge.id}` ); await this.upsertCharges([charge]); 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]); break; } case "customer.created": case "customer.updated": { const customer = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.customers.retrieve(id) ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for customer ${customer.id}` ); await this.upsertCustomers([customer]); 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 subscription = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.subscriptions.retrieve(id) ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for subscription ${subscription.id}` ); await this.upsertSubscriptions([subscription]); break; } case "customer.tax_id.updated": case "customer.tax_id.created": { const taxId = 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]); 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 invoice = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.invoices.retrieve(id) ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for invoice ${invoice.id}` ); await this.upsertInvoices([invoice]); break; } case "product.created": case "product.updated": { try { const product = 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]); } 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 price = 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]); } 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 plan = 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]); } 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 setupIntent = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.setupIntents.retrieve(id) ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for setupIntent ${setupIntent.id}` ); await this.upsertSetupIntents([setupIntent]); 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 subscriptionSchedule = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.subscriptionSchedules.retrieve(id) ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for subscriptionSchedule ${subscriptionSchedule.id}` ); await this.upsertSubscriptionSchedules([subscriptionSchedule]); break; } case "payment_method.attached": case "payment_method.automatically_updated": case "payment_method.detached": case "payment_method.updated": { const paymentMethod = 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]); break; } case "charge.dispute.created": case "charge.dispute.funds_reinstated": case "charge.dispute.funds_withdrawn": case "charge.dispute.updated": case "charge.dispute.closed": { const dispute = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.disputes.retrieve(id) ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for dispute ${dispute.id}` ); await this.upsertDisputes([dispute]); 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 paymentIntent = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.paymentIntents.retrieve(id) ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for paymentIntent ${paymentIntent.id}` ); await this.upsertPaymentIntents([paymentIntent]); break; } case "credit_note.created": case "credit_note.updated": case "credit_note.voided": { const creditNote = await this.fetchOrUseWebhookData( event.data.object, (id) => this.stripe.creditNotes.retrieve(id) ); this.config.logger?.info( `Received webhook ${event.id}: ${event.type} for creditNote ${creditNote.id}` ); await this.upsertCreditNotes([creditNote]); break; } case "radar.early_fraud_warning.created": case "radar.early_fraud_warning.updated": { const earlyFraudWarning = 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]); break; } case "refund.created": case "refund.failed": case "refund.updated": case "charge.refund.updated": { const refund = 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]); break; } case "review.closed": case "review.opened": { const review = 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]); break; } default: throw new Error("Unhandled webhook event"); } } async fetchOrUseWebhookData(entity, fetchFn) { if (!entity.id) return entity; if (this.config.revalidateEntityViaStripeApi) { return fetchFn(entity.id); } return entity; } 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])); } } async syncBackfill(params) { const { object } = params ?? {}; let products, prices, customers, 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); 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; default: break; } return { products, prices, customers, 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 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) { if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) { await Promise.all([ this.backfillCustomers(getUniqueIds(charges, "customer")), this.backfillInvoices(getUniqueIds(charges, "invoice")) ]); } await this.expandEntity( charges, "refunds", (id) => this.stripe.refunds.list({ charge: id, limit: 100 }) ); return this.postgresClient.upsertMany(charges, "charges", chargeSchema); } async backfillCharges(chargeIds) { const missingChargeIds = await this.postgresClient.findMissingEntries("charges", chargeIds); await this.fetchMissingEntities( missingChargeIds, (id) => this.stripe.charges.retrieve(id) ).then((charges) => this.upsertCharges(charges)); } async backfillPaymentIntents(paymentIntentIds) { const missingIds = await this.postgresClient.findMissingEntries( "payment_intents", paymentIntentIds ); await this.fetchMissingEntities( missingIds, (id) => this.stripe.paymentIntents.retrieve(id) ).then((paymentIntents) => this.upsertPaymentIntents(paymentIntents)); } async upsertCreditNotes(creditNotes, backfillRelatedEntities) { if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) { await Promise.all([ this.backfillCustomers(getUniqueIds(creditNotes, "customer")), this.backfillInvoices(getUniqueIds(creditNotes, "invoice")) ]); } await this.expandEntity( creditNotes, "lines", (id) => this.stripe.creditNotes.listLineItems(id, { limit: 100 }) ); return this.postgresClient.upsertMany(creditNotes, "credit_notes", creditNoteSchema); } async upsertEarlyFraudWarning(earlyFraudWarnings, backfillRelatedEntities) { if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) { await Promise.all([ this.backfillPaymentIntents(getUniqueIds(earlyFraudWarnings, "payment_intent")), this.backfillCharges(getUniqueIds(earlyFraudWarnings, "charge")) ]); } return this.postgresClient.upsertMany( earlyFraudWarnings, "early_fraud_warnings", earlyFraudWarningSchema ); } async upsertRefunds(refunds, backfillRelatedEntities) { if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) { await Promise.all([ this.backfillPaymentIntents(getUniqueIds(refunds, "payment_intent")), this.backfillCharges(getUniqueIds(refunds, "charge")) ]); } return this.postgresClient.upsertMany(refunds, "refunds", refundSchema); } async upsertReviews(reviews, backfillRelatedEntities) { if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) { await Promise.all([ this.backfillPaymentIntents(getUniqueIds(reviews, "payment_intent")), this.backfillCharges(getUniqueIds(reviews, "charge")) ]); } return this.postgresClient.upsertMany(reviews, "reviews", reviewSchema); } async upsertCustomers(customers) { const deletedCustomers = customers.filter((customer) => customer.deleted); const nonDeletedCustomers = customers.filter((customer) => !customer.deleted); await this.postgresClient.upsertMany(nonDeletedCustomers, "customers", customerSchema); await this.postgresClient.upsertMany(deletedCustomers, "customers", customerDeletedSchema); return customers; } async backfillCustomers(customerIds) { const missingIds = await this.postgresClient.findMissingEntries("customers", customerIds); await this.fetchMissingEntities(missingIds, (id) => this.stripe.customers.retrieve(id)).then((entries) => this.upsertCustomers(entries)).catch((err) => { this.config.logger?.error(err, "Failed to backfill"); throw err; }); } async upsertDisputes(disputes, backfillRelatedEntities) { if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) { await this.backfillCharges(getUniqueIds(disputes, "charge")); } return this.postgresClient.upsertMany(disputes, "disputes", disputeSchema); } async upsertInvoices(invoices, backfillRelatedEntities) { if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) { await Promise.all([ this.backfillCustomers(getUniqueIds(invoices, "customer")), this.backfillSubscriptions(getUniqueIds(invoices, "subscription")) ]); } await this.expandEntity( invoices, "lines", (id) => this.stripe.invoices.listLineItems(id, { limit: 100 }) ); return this.postgresClient.upsertMany(invoices, "invoices", invoiceSchema); } backfillInvoices = async (invoiceIds) => { const missingIds = await this.postgresClient.findMissingEntries("invoices", invoiceIds); await this.fetchMissingEntities(missingIds, (id) => this.stripe.invoices.retrieve(id)).then( (entries) => this.upsertInvoices(entries) ); }; async upsertPlans(plans, backfillRelatedEntities) { if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) { await this.backfillProducts(getUniqueIds(plans, "product")); } return this.postgresClient.upsertMany(plans, "plans", planSchema); } async deletePlan(id) { return this.postgresClient.delete("plans", id); } async upsertPrices(prices, backfillRelatedEntities) { if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) { await this.backfillProducts(getUniqueIds(prices, "product")); } return this.postgresClient.upsertMany(prices, "prices", priceSchema); } async deletePrice(id) { return this.postgresClient.delete("prices", id); } async upsertProducts(products) { return this.postgresClient.upsertMany(products, "products", productSchema); } async deleteProduct(id) { return this.postgresClient.delete("products", id); } async backfillProducts(productIds) { const missingProductIds = await this.postgresClient.findMissingEntries("products", productIds); await this.fetchMissingEntities( missingProductIds, (id) => this.stripe.products.retrieve(id) ).then((products) => this.upsertProducts(products)); } async upsertPaymentIntents(paymentIntents, backfillRelatedEntities) { if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) { await Promise.all([ this.backfillCustomers(getUniqueIds(paymentIntents, "customer")), this.backfillInvoices(getUniqueIds(paymentIntents, "invoice")) ]); } return this.postgresClient.upsertMany(paymentIntents, "payment_intents", paymentIntentSchema); } async upsertPaymentMethods(paymentMethods, backfillRelatedEntities = false) { if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) { await this.backfillCustomers(getUniqueIds(paymentMethods, "customer")); } return this.postgresClient.upsertMany(paymentMethods, "payment_methods", paymentMethodsSchema); } async upsertSetupIntents(setupIntents, backfillRelatedEntities) { if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) { await this.backfillCustomers(getUniqueIds(setupIntents, "customer")); } return this.postgresClient.upsertMany(setupIntents, "setup_intents", setupIntentsSchema); } async upsertTaxIds(taxIds, backfillRelatedEntities) { if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) { await this.backfillCustomers(getUniqueIds(taxIds, "customer")); } return this.postgresClient.upsertMany(taxIds, "tax_ids", taxIdSchema); } async deleteTaxId(id) { return this.postgresClient.delete("tax_ids", id); } async upsertSubscriptionItems(subscriptionItems) { const modifiedSubscriptionItems = subscriptionItems.map((subscriptionItem) => { const priceId = subscriptionItem.price.id.toString(); const deleted = subscriptionItem.deleted; const quantity = subscriptionItem.quantity; return { ...subscriptionItem, price: priceId, deleted: deleted ?? false, quantity: quantity ?? null }; }); await this.postgresClient.upsertMany( modifiedSubscriptionItems, "subscription_items", subscriptionItemSchema ); } async markDeletedSubscriptionItems(subscriptionId, currentSubItemIds) { let prepared = (0, import_yesql2.pg)(` select id from "${this.config.schema}"."subscription_items" where subscription = :subscriptionId and deleted = false; `)({ subscriptionId }); const { rows } = await this.postgresClient.query(prepared.text, prepared.values); const deletedIds = rows.filter( ({ id }) => currentSubItemIds.includes(id) === false ); if (deletedIds.length > 0) { const ids = deletedIds.map(({ id }) => id); prepared = (0, import_yesql2.pg)(` update "${this.config.schema}"."subscription_items" set deleted = true where id=any(:ids::text[]); `)({ ids }); const { rowCount } = await await this.postgresClient.query(prepared.text, prepared.values); return { rowCount: rowCount || 0 }; } else { return { rowCount: 0 }; } } async upsertSubscriptionSchedules(subscriptionSchedules, backfillRelatedEntities) { if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) { const customerIds = getUniqueIds(subscriptionSchedules, "customer"); await this.backfillCustomers(customerIds); } const rows = await this.postgresClient.upsertMany( subscriptionSchedules, "subscription_schedules", subscriptionScheduleSchema ); return rows; } async upsertSubscriptions(subscriptions, backfillRelatedEntities) { if (backfillRelatedEntities ?? this.config.backfillRelatedEntities) { const customerIds = getUniqueIds(subscriptions, "customer"); await this.backfillCustomers(customerIds); } await this.expandEntity( subscriptions, "items", (id) => this.stripe.subscriptionItems.list({ subscription: id, limit: 100 }) ); const rows = await this.postgresClient.upsertMany( subscriptions, "subscriptions", subscriptionSchema ); const allSubscriptionItems = subscriptions.flatMap((subscription) => subscription.items.data); await this.upsertSubscriptionItems(allSubscriptionItems); const markSubscriptionItemsDeleted = []; for (const subscription of subscriptions) { const subscriptionItems = subscription.items.data; const subItemIds = subscriptionItems.map((x) => x.id); markSubscriptionItemsDeleted.push( this.markDeletedSubscriptionItems(subscription.id, subItemIds) ); } await Promise.all(markSubscriptionItemsDeleted); return rows; } async backfillSubscriptions(subscriptionIds) { const missingSubscriptionIds = await this.postgresClient.findMissingEntries( "subscriptions", subscriptionIds ); await this.fetchMissingEntities( missingSubscriptionIds, (id) => this.stripe.subscriptions.retrieve(id) ).then((subscriptions) => this.upsertSubscriptions(subscripti