feat: added stripe sync engine
This commit is contained in:
20
packages/stripe-sync/package.json
Normal file
20
packages/stripe-sync/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
26
packages/stripe-sync/scripts/migrate.ts
Normal file
26
packages/stripe-sync/scripts/migrate.ts
Normal 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");
|
||||
37
packages/stripe-sync/src/addAccountIdColumn.ts
Normal file
37
packages/stripe-sync/src/addAccountIdColumn.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
61
packages/stripe-sync/src/eventTypeToTable.ts
Normal file
61
packages/stripe-sync/src/eventTypeToTable.ts
Normal 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;
|
||||
12
packages/stripe-sync/src/index.ts
Normal file
12
packages/stripe-sync/src/index.ts
Normal 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";
|
||||
99
packages/stripe-sync/src/initStripeSync.ts
Normal file
99
packages/stripe-sync/src/initStripeSync.ts
Normal 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;
|
||||
};
|
||||
15
packages/stripe-sync/src/runStripeSyncMigrations.ts
Normal file
15
packages/stripe-sync/src/runStripeSyncMigrations.ts
Normal 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 });
|
||||
};
|
||||
16
packages/stripe-sync/src/syncableResources.ts
Normal file
16
packages/stripe-sync/src/syncableResources.ts
Normal 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));
|
||||
};
|
||||
16
packages/stripe-sync/tsconfig.json
Normal file
16
packages/stripe-sync/tsconfig.json
Normal 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/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user