@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
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, {
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