stack/apps/backend/scripts/db-migrations.ts
Aman Ganapathy 1de8a17183
Payments bulldozer txn rework (#1315)
### Object of this PR
This PR is NOT a monolithic series of fixes for the payments suite + a
complete rework. Its aims were
a) introducing and robustly testing the bulldozer db system 
b) reworking the payments underlying architecture to use bulldozer for
correctness and scalability
c) Achieving parity with the old payments system excepting a few changes
like ensuring correctness of the ledger algo
There may still be some work to do with handling refunds, decoupling the
concepts of purchases from that of products, and some other things.

### Ledger Algorithm
This has been tuned and fixed. Item removals i.e negative item quantity
changes will apply to the soonest expiring item grant i.e positive item
quantity change. This is what is best for the user. Item grants can also
expire, and when they expire we obviate whatever is left of their
original capacity (meaning after all the removals that were applied to
it). Our ledger algo is applied via Bulldozer, so automatic
re-computation is handled when a new grant/ removal is inserted in the
middle of the existing ones.

### Things we got rid of 
* No more automatic support for default products. You can use $0 plan
provisions to accomplish the same effect but it's manual
* Negative item quantity changes (i.e item removals) no longer can have
expiries



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Enhanced payment processing pipeline with improved data consistency
and state management.
  * Advanced refund handling with comprehensive transaction tracking.
* Better tracking and management of customer item quantities and owned
products.
* Improved subscription lifecycle management including period-end
handling.

* **Bug Fixes**
  * Fixed payment data integrity verification.
  * Improved handling of edge cases in refund scenarios.

* **Chores**
  * Updated cSpell configuration with additional words.
  * Expanded developer documentation for linting workflows.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>
Co-authored-by: Aadesh Kheria <kheriaaadesh@gmail.com>
Co-authored-by: Mantra <87142457+mantrakp04@users.noreply.github.com>
2026-04-17 22:11:21 +00:00

248 lines
7.7 KiB
TypeScript

import { applyMigrations } from "@/auto-migrations";
import { MIGRATION_FILES_DIR, getMigrationFiles } from "@/auto-migrations/utils";
import { Prisma } from "@/generated/prisma/client";
import { getClickhouseAdminClient } from "@/lib/clickhouse";
import { globalPrismaClient, globalPrismaSchema, sqlQuoteIdent } from "@/prisma-client";
import { spawnSync } from "child_process";
import fs from "fs";
import path from "path";
import * as readline from "readline";
import { seed } from "../prisma/seed";
import { runBulldozerPaymentsInit } from "./bulldozer-payments-init";
import { runClickhouseMigrations } from "./clickhouse-migrations";
const getClickhouseClient = () => getClickhouseAdminClient();
const dropSchema = async () => {
await globalPrismaClient.$executeRaw(Prisma.sql`DROP SCHEMA ${sqlQuoteIdent(globalPrismaSchema)} CASCADE`);
await globalPrismaClient.$executeRaw(Prisma.sql`CREATE SCHEMA ${sqlQuoteIdent(globalPrismaSchema)}`);
await globalPrismaClient.$executeRaw(Prisma.sql`GRANT ALL ON SCHEMA ${sqlQuoteIdent(globalPrismaSchema)} TO postgres`);
await globalPrismaClient.$executeRaw(Prisma.sql`GRANT ALL ON SCHEMA ${sqlQuoteIdent(globalPrismaSchema)} TO public`);
const clickhouseClient = getClickhouseClient();
await clickhouseClient.command({ query: "DROP DATABASE IF EXISTS analytics_internal" });
await clickhouseClient.command({ query: "CREATE DATABASE IF NOT EXISTS analytics_internal" });
};
const askQuestion = (question: string) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise<string>((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
};
const promptDropDb = async () => {
const answer = (await askQuestion(
'Are you sure you want to drop everything in the database? This action cannot be undone. (y/N): ',
)).trim();
if (answer.toLowerCase() !== 'y') {
console.log('Operation cancelled');
process.exit(0);
}
};
const formatMigrationName = (input: string) =>
input
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
const promptMigrationName = async () => {
while (true) {
const rawName = (await askQuestion('Enter a migration name: ')).trim();
const formattedName = formatMigrationName(rawName);
if (!formattedName) {
console.log('Migration name cannot be empty. Please try again.');
continue;
}
if (formattedName !== rawName) {
console.log(`Using sanitized migration name: ${formattedName}`);
}
return formattedName;
}
};
const timestampPrefix = () => new Date().toISOString().replace(/\D/g, '').slice(0, 14);
const generateMigrationFile = async () => {
const migrationName = await promptMigrationName();
const folderName = `${timestampPrefix()}_${migrationName}`;
const migrationDir = path.join(MIGRATION_FILES_DIR, folderName);
const migrationSqlPath = path.join(migrationDir, 'migration.sql');
console.log(`Generating migration ${folderName}...`);
const diffResult = spawnSync(
'pnpm',
[
'-s',
'prisma',
'migrate',
'diff',
'--from-config-datasource',
'--to-schema',
'prisma/schema.prisma',
'--script',
],
{
cwd: process.cwd(),
encoding: 'utf8',
},
);
if (diffResult.error || diffResult.status !== 0) {
console.error(diffResult.stdout);
console.error(diffResult.stderr);
throw diffResult.error ?? new Error(`Failed to generate migration (exit code ${diffResult.status})`);
}
const sql = diffResult.stdout;
if (!sql.trim()) {
console.log('No schema changes detected. Migration file was not created.');
} else {
fs.mkdirSync(migrationDir, { recursive: true });
fs.writeFileSync(migrationSqlPath, sql, 'utf8');
console.log(`Migration written to ${path.relative(process.cwd(), migrationSqlPath)}`);
console.log('Applying migration...');
await migrate([{ migrationName: folderName, sql }]);
}
};
const promptContinueMigration = async (migrationName: string) => {
const answer = (await askQuestion(
`\n🔄 Ready to apply migration: ${migrationName}\nPress Enter to continue or 'q' to quit: `,
)).trim();
if (answer.toLowerCase() === 'q') {
console.log('Migration cancelled by user');
process.exit(0);
}
};
const migrate = async (selectedMigrationFiles?: { migrationName: string, sql: string }[], options?: { interactive?: boolean }) => {
const startTime = performance.now();
const migrationFiles = selectedMigrationFiles ?? getMigrationFiles(MIGRATION_FILES_DIR);
const totalMigrations = migrationFiles.length;
const result = await applyMigrations({
prismaClient: globalPrismaClient,
migrationFiles,
logging: true,
schema: globalPrismaSchema,
onBeforeMigration: options?.interactive ? promptContinueMigration : undefined,
});
const endTime = performance.now();
const duration = ((endTime - startTime) / 1000).toFixed(2);
// Print summary
console.log('\n' + '='.repeat(60));
console.log('📊 MIGRATION SUMMARY');
console.log('='.repeat(60));
console.log(`✅ Migrations completed successfully`);
console.log(`⏱️ Duration: ${duration} seconds`);
console.log(`📁 Total migrations in folder: ${totalMigrations}`);
console.log(`🆕 Newly applied migrations: ${result.newlyAppliedMigrationNames.length}`);
console.log(`✓ Already applied migrations: ${totalMigrations - result.newlyAppliedMigrationNames.length}`);
if (result.newlyAppliedMigrationNames.length > 0) {
console.log('\n📝 Newly applied migrations:');
result.newlyAppliedMigrationNames.forEach((name, index) => {
console.log(` ${index + 1}. ${name}`);
});
} else {
console.log('\n✨ Database is already up to date!');
}
console.log('='.repeat(60) + '\n');
await runClickhouseMigrations();
return result;
};
const showHelp = () => {
console.log(`Database Migration Script
Usage: pnpm db-migrations <command> [options]
Commands:
reset Drop all data and recreate the database, then apply migrations and seed
generate-migration-file Generate a new migration file using Prisma, then reset and migrate
seed [Advanced] Run database seeding only
init Apply migrations and seed the database
migrate Apply migrations
help Show this help message
Options:
--interactive Prompt before each new migration (not on conditional repeats)
`);
};
const main = async () => {
const args = process.argv.slice(2);
const command = args[0];
const interactive = args.includes('--interactive');
switch (command) {
case 'reset': {
await promptDropDb();
await dropSchema();
await migrate(undefined, { interactive });
await seed();
break;
}
case 'generate-migration-file': {
await promptDropDb();
await dropSchema();
await migrate(undefined, { interactive });
await generateMigrationFile();
await seed();
break;
}
case 'seed': {
await seed();
await runBulldozerPaymentsInit(globalPrismaClient);
break;
}
case 'init': {
await migrate(undefined, { interactive });
await seed();
await runBulldozerPaymentsInit(globalPrismaClient);
break;
}
case 'migrate': {
await migrate(undefined, { interactive });
await runBulldozerPaymentsInit(globalPrismaClient);
break;
}
case 'help': {
showHelp();
break;
}
default: {
console.error('Unknown command.');
showHelp();
process.exit(1);
}
}
};
// eslint-disable-next-line no-restricted-syntax
main().catch((error) => {
console.error(error);
process.exit(1);
});