feat: added stripe sync engine

This commit is contained in:
John Yeo
2026-04-10 11:54:54 +01:00
parent d04605b4ac
commit 71d45c2033
23 changed files with 719 additions and 71 deletions

View File

@@ -0,0 +1,20 @@
{
"name": "@autumn/stripe-sync",
"version": "1.0.0",
"description": "Stripe-to-Postgres sync engine wrapper for Autumn",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts",
"default": "./src/index.ts"
}
},
"private": true,
"dependencies": {
"@supabase/stripe-sync-engine": "^0.48.5",
"stripe": "catalog:"
}
}

View File

@@ -0,0 +1,26 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { config } from "dotenv";
import { addAutumnColumns, runStripeSyncMigrations } from "../src/index.js";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "../../..");
config({ path: path.join(repoRoot, "server/.env"), override: true });
const databaseUrl = process.env.STRIPE_SYNC_DATABASE_URL;
if (!databaseUrl) {
console.error("STRIPE_SYNC_DATABASE_URL is required");
process.exit(1);
}
console.log("Running stripe sync migrations...");
await runStripeSyncMigrations({ databaseUrl });
console.log("Stripe sync migrations complete");
console.log(
"Adding Autumn columns (stripe_account_id, org_id) to synced tables...",
);
await addAutumnColumns({ databaseUrl });
console.log("Autumn columns added");

View File

@@ -0,0 +1,37 @@
import pg from "pg";
import { SYNCED_TABLES } from "./eventTypeToTable.js";
/**
* Adds `stripe_account_id` and `org_id` columns + indexes to all synced tables.
* Idempotent -- safe to run multiple times.
*/
export const addAutumnColumns = async ({
databaseUrl,
schema = "stripe",
}: {
databaseUrl: string;
schema?: string;
}): Promise<void> => {
const client = new pg.Client({ connectionString: databaseUrl });
await client.connect();
try {
for (const table of SYNCED_TABLES) {
await client.query(`
ALTER TABLE "${schema}"."${table}"
ADD COLUMN IF NOT EXISTS stripe_account_id TEXT,
ADD COLUMN IF NOT EXISTS org_id TEXT
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_${table}_stripe_account_id
ON "${schema}"."${table}" (stripe_account_id)
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_${table}_org_id
ON "${schema}"."${table}" (org_id)
`);
}
} finally {
await client.end();
}
};

View File

@@ -0,0 +1,61 @@
/**
* Maps a Stripe event type to the sync DB table that stores the object.
* Returns undefined for event types that don't map to a known table.
*/
export const eventTypeToTable = ({
eventType,
}: {
eventType: string;
}): string | undefined => {
if (eventType.startsWith("charge.dispute.")) return "disputes";
if (eventType.startsWith("charge.")) return "charges";
if (eventType.startsWith("checkout.session.")) return "checkout_sessions";
if (eventType.startsWith("customer.subscription.")) return "subscriptions";
if (eventType.startsWith("customer.tax_id.")) return "tax_ids";
if (eventType.startsWith("customer.")) return "customers";
if (eventType.startsWith("invoice.")) return "invoices";
if (eventType.startsWith("product.")) return "products";
if (eventType.startsWith("price.")) return "prices";
if (eventType.startsWith("plan.")) return "plans";
if (eventType.startsWith("setup_intent.")) return "setup_intents";
if (eventType.startsWith("subscription_schedule."))
return "subscription_schedules";
if (eventType.startsWith("payment_method.")) return "payment_methods";
if (eventType.startsWith("payment_intent.")) return "payment_intents";
if (eventType.startsWith("credit_note.")) return "credit_notes";
if (eventType.startsWith("radar.early_fraud_warning."))
return "early_fraud_warnings";
if (eventType.startsWith("refund.")) return "refunds";
if (eventType.startsWith("review.")) return "reviews";
if (eventType === "invoice_payment.paid") return "invoice_payments";
return undefined;
};
/** All tables in the stripe sync schema that store Stripe objects. */
export const SYNCED_TABLES = [
"charges",
"checkout_sessions",
"checkout_session_line_items",
"coupons",
"credit_notes",
"customers",
"disputes",
"early_fraud_warnings",
"events",
"invoices",
"invoice_payments",
"payment_intents",
"payment_methods",
"payouts",
"plans",
"prices",
"products",
"refunds",
"reviews",
"setup_intents",
"subscription_items",
"subscription_schedules",
"subscriptions",
"tax_ids",
] as const;

View File

@@ -0,0 +1,12 @@
export { addAutumnColumns } from "./addAccountIdColumn.js";
export { eventTypeToTable, SYNCED_TABLES } from "./eventTypeToTable.js";
export {
closeStripeSyncEngine,
getStripeSyncEngine,
processStripeSyncEvent,
} from "./initStripeSync.js";
export { runStripeSyncMigrations } from "./runStripeSyncMigrations.js";
export {
isSyncableEvent,
SYNCABLE_EVENT_PREFIXES,
} from "./syncableResources.js";

View File

@@ -0,0 +1,99 @@
import { StripeSync } from "@supabase/stripe-sync-engine";
import type Stripe from "stripe";
import { eventTypeToTable } from "./eventTypeToTable.js";
const SCHEMA = "stripe";
let instance: StripeSync | null = null;
let initAttempted = false;
/**
* Lazily creates a singleton StripeSync instance.
* Returns null if STRIPE_SYNC_DATABASE_URL is not configured or init fails.
*/
export const getStripeSyncEngine = (): StripeSync | null => {
if (instance) return instance;
if (initAttempted) return null;
initAttempted = true;
const databaseUrl = process.env.STRIPE_SYNC_DATABASE_URL;
const stripeSecretKey =
process.env.STRIPE_LIVE_SECRET_KEY || process.env.STRIPE_SANDBOX_SECRET_KEY;
if (!databaseUrl || !stripeSecretKey) return null;
try {
instance = new StripeSync({
poolConfig: {
connectionString: databaseUrl,
max: 5,
keepAlive: true,
connectionTimeoutMillis: 5_000,
idleTimeoutMillis: 30_000,
},
stripeSecretKey,
stripeWebhookSecret: "unused-processEvent-only",
schema: SCHEMA,
});
} catch {
instance = null;
}
return instance;
};
/**
* Upserts the Stripe event into the sync DB, then stamps the row
* with the originating Stripe account ID and org ID for multi-tenancy.
* Fully fail-open: any error is swallowed and returns silently.
*/
export const processStripeSyncEvent = async ({
event,
stripeAccountId,
orgId,
}: {
event: Stripe.Event;
stripeAccountId?: string;
orgId?: string;
}): Promise<void> => {
const engine = getStripeSyncEngine();
if (!engine) return;
try {
await engine.processEvent(event);
} catch {
return;
}
const table = eventTypeToTable({ eventType: event.type });
if (!table) return;
const objectId = (event.data.object as { id?: string }).id;
if (!objectId) return;
const accountId = stripeAccountId ?? event.account ?? null;
if (!accountId && !orgId) return;
try {
await engine.postgresClient.pool.query(
`UPDATE "${SCHEMA}"."${table}" SET stripe_account_id = COALESCE($1, stripe_account_id), org_id = COALESCE($2, org_id) WHERE id = $3`,
[accountId, orgId, objectId],
);
} catch {
// Fail-open: metadata stamp is best-effort
}
};
/** Gracefully close the sync engine's PG pool (call on server shutdown). */
export const closeStripeSyncEngine = async (): Promise<void> => {
if (!instance) return;
try {
await instance.close();
} catch {
// Best-effort cleanup
}
instance = null;
initAttempted = false;
};

View File

@@ -0,0 +1,15 @@
import { runMigrations } from "@supabase/stripe-sync-engine";
/**
* Run stripe-sync-engine migrations against the sync DB.
* Idempotent -- safe to run multiple times.
*/
export const runStripeSyncMigrations = async ({
databaseUrl,
schema = "stripe",
}: {
databaseUrl: string;
schema?: string;
}): Promise<void> => {
await runMigrations({ databaseUrl, schema });
};

View File

@@ -0,0 +1,16 @@
export const SYNCABLE_EVENT_PREFIXES = [
"customer.subscription.",
"payment_intent.",
"invoice.",
"customer.",
"product.",
"price.",
] as const;
export const isSyncableEvent = ({
eventType,
}: {
eventType: string;
}): boolean => {
return SYNCABLE_EVENT_PREFIXES.some((prefix) => eventType.startsWith(prefix));
};

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"strict": true,
"composite": true,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"module": "Preserve",
"moduleResolution": "bundler",
"target": "ES2020",
"outDir": "./dist",
"rootDir": "."
},
"include": ["src/**/*"]
}