@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
JavaScript
"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