import { pool } from './pool'; const migrations: { name: string; sql: string }[] = [ { name: '001_create_tables', sql: ` CREATE TABLE IF NOT EXISTS accounts ( id BIGSERIAL PRIMARY KEY, bank TEXT NOT NULL, account_number TEXT NOT NULL, currency TEXT NOT NULL, alias TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_bank_number ON accounts(bank, account_number); CREATE TABLE IF NOT EXISTS categories ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL, is_active BOOLEAN NOT NULL DEFAULT TRUE ); ALTER TABLE categories DROP CONSTRAINT IF EXISTS chk_categories_type; ALTER TABLE categories ADD CONSTRAINT chk_categories_type CHECK (type IN ('expense', 'income', 'transfer')); CREATE TABLE IF NOT EXISTS transactions ( id BIGSERIAL PRIMARY KEY, account_id BIGINT NOT NULL REFERENCES accounts(id), operation_at TIMESTAMPTZ NOT NULL, amount_signed BIGINT NOT NULL, commission BIGINT NOT NULL, description TEXT NOT NULL, direction TEXT NOT NULL, fingerprint TEXT NOT NULL, category_id BIGINT REFERENCES categories(id), is_category_confirmed BOOLEAN NOT NULL DEFAULT FALSE, comment TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE UNIQUE INDEX IF NOT EXISTS ux_transactions_account_fingerprint ON transactions(account_id, fingerprint); ALTER TABLE transactions DROP CONSTRAINT IF EXISTS chk_transactions_direction; ALTER TABLE transactions ADD CONSTRAINT chk_transactions_direction CHECK (direction IN ('income', 'expense', 'transfer')); CREATE TABLE IF NOT EXISTS category_rules ( id BIGSERIAL PRIMARY KEY, pattern TEXT NOT NULL, match_type TEXT NOT NULL, category_id BIGINT NOT NULL REFERENCES categories(id), priority INT NOT NULL DEFAULT 0, requires_confirmation BOOLEAN NOT NULL DEFAULT FALSE, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_activity_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), is_active BOOLEAN NOT NULL DEFAULT TRUE ); `, }, { name: '002_seed_categories', sql: ` INSERT INTO categories (name, type) VALUES ('Продукты', 'expense'), ('Авто', 'expense'), ('Здоровье', 'expense'), ('Арчи', 'expense'), ('ЖКХ', 'expense'), ('Дом', 'expense'), ('Проезд', 'expense'), ('Одежда', 'expense'), ('Химия', 'expense'), ('Косметика', 'expense'), ('Инвестиции', 'transfer'), ('Развлечения', 'expense'), ('Общепит', 'expense'), ('Штрафы', 'expense'), ('Налоги', 'expense'), ('Подписки', 'expense'), ('Перевод', 'transfer'), ('Наличные', 'expense'), ('Подарки', 'expense'), ('Спорт', 'expense'), ('Отпуск', 'expense'), ('Техника', 'expense'), ('Поступления', 'income') ON CONFLICT DO NOTHING; `, }, { name: '003_seed_category_rules', sql: ` INSERT INTO category_rules (pattern, match_type, category_id, priority, requires_confirmation) SELECT pattern, match_type, category_id, priority, requires_confirmation FROM (VALUES ('PYATEROCHK', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false), ('ПЯТЕРОЧКА', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false), ('AUCHAN', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false), ('DOSTAVKA IZ PYATEROCHK', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 25, false), ('МЕТРО', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false), ('PEREKRESTOK', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false), ('MAGNIT', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false), ('LENTA', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false), ('SPORTMASTER', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' AND type = 'expense' LIMIT 1), 20, false), ('DECATHLON', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' AND type = 'expense' LIMIT 1), 20, false), ('PAYPARKING', 'contains', (SELECT id FROM categories WHERE name = 'Авто' AND type = 'expense' LIMIT 1), 20, false), ('PARKING', 'contains', (SELECT id FROM categories WHERE name = 'Авто' AND type = 'expense' LIMIT 1), 15, false), ('AZS', 'contains', (SELECT id FROM categories WHERE name = 'Авто' AND type = 'expense' LIMIT 1), 20, false), ('ЯНДЕКС.ЕДА', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' AND type = 'expense' LIMIT 1), 25, false), ('MCDONALD', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' AND type = 'expense' LIMIT 1), 20, false), ('KFC', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' AND type = 'expense' LIMIT 1), 20, false), ('STARBUCKS', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' AND type = 'expense' LIMIT 1), 20, false), ('tapper.ru', 'contains', (SELECT id FROM categories WHERE name = 'Развлечения' AND type = 'expense' LIMIT 1), 20, false), ('АПТЕКА', 'contains', (SELECT id FROM categories WHERE name = 'Здоровье' AND type = 'expense' LIMIT 1), 20, false), ('РИГЛА', 'contains', (SELECT id FROM categories WHERE name = 'Здоровье' AND type = 'expense' LIMIT 1), 20, false), ('OZON', 'contains', (SELECT id FROM categories WHERE name = 'Дом' AND type = 'expense' LIMIT 1), 15, false), ('WILDBERRIES', 'contains', (SELECT id FROM categories WHERE name = 'Дом' AND type = 'expense' LIMIT 1), 15, false), ('ЯНДЕКС ПЛЮС', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' AND type = 'expense' LIMIT 1), 20, false), ('SPOTIFY', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' AND type = 'expense' LIMIT 1), 20, false), ('НЕТФЛИКС', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' AND type = 'expense' LIMIT 1), 20, false) ) AS v(pattern, match_type, category_id, priority, requires_confirmation) WHERE category_id IS NOT NULL AND EXISTS (SELECT 1 FROM categories LIMIT 1) AND NOT EXISTS (SELECT 1 FROM category_rules LIMIT 1); `, }, { name: '004_seed_category_rules_extended', sql: ` INSERT INTO category_rules (pattern, match_type, category_id, priority, requires_confirmation) SELECT pattern, match_type, category_id, priority, requires_confirmation FROM (VALUES ('STOLOVAYA', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false), ('KOFEJNYA', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false), ('JEFFREY S COFFEESHOP', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false), ('TA TORRRO GRIL', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false), ('OUSHEN FRENDS', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false), ('mkad', 'contains', (SELECT id FROM categories WHERE name = 'Авто' LIMIT 1), 20, false), ('RNAZK', 'contains', (SELECT id FROM categories WHERE name = 'Авто' LIMIT 1), 20, false), ('IP SHEVELEV', 'contains', (SELECT id FROM categories WHERE name = 'Авто' LIMIT 1), 20, false), ('PAVELETSKAYA', 'contains', (SELECT id FROM categories WHERE name = 'Проезд' LIMIT 1), 25, false), ('CPPK-', 'contains', (SELECT id FROM categories WHERE name = 'Проезд' LIMIT 1), 25, false), ('MOS.TRANSPORT', 'contains', (SELECT id FROM categories WHERE name = 'Проезд' LIMIT 1), 25, false), ('Lab4uru', 'contains', (SELECT id FROM categories WHERE name = 'Здоровье' LIMIT 1), 25, false), ('APTEKA', 'contains', (SELECT id FROM categories WHERE name = 'Здоровье' LIMIT 1), 20, false), ('IP SHARAFETDINOV', 'contains', (SELECT id FROM categories WHERE name = 'Арчи' LIMIT 1), 25, false), ('ZOOMAGAZIN CHETYRE LAP', 'contains', (SELECT id FROM categories WHERE name = 'Арчи' LIMIT 1), 25, false), ('VKUSVILL', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' LIMIT 1), 20, false), ('GLOBUS', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' LIMIT 1), 20, false), ('VERNYJ', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' LIMIT 1), 20, false), ('GOLD APPLE', 'contains', (SELECT id FROM categories WHERE name = 'Косметика' LIMIT 1), 25, false), ('SPIRITFIT', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' LIMIT 1), 25, false), ('insanity', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' LIMIT 1), 25, false), ('anta-sport', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' LIMIT 1), 25, false), ('VANYAVPN', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' LIMIT 1), 25, false), ('ГРАНЛАЙН', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' LIMIT 1), 20, false), ('Мобильная связь', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' LIMIT 1), 25, false), ('OSTROVOK', 'contains', (SELECT id FROM categories WHERE name = 'Отпуск' LIMIT 1), 25, false), ('sutochno', 'contains', (SELECT id FROM categories WHERE name = 'Отпуск' LIMIT 1), 25, false), ('УФК', 'contains', (SELECT id FROM categories WHERE name = 'Штрафы' LIMIT 1), 30, false), ('ГИБДД', 'contains', (SELECT id FROM categories WHERE name = 'Штрафы' LIMIT 1), 30, false), ('Поступление заработной платы', 'contains', (SELECT id FROM categories WHERE name = 'Поступления' LIMIT 1), 30, false), ('avito', 'contains', (SELECT id FROM categories WHERE name = 'Поступления' LIMIT 1), 25, false), ('Init payout', 'contains', (SELECT id FROM categories WHERE name = 'Поступления' LIMIT 1), 25, false) ) AS v(pattern, match_type, category_id, priority, requires_confirmation) WHERE category_id IS NOT NULL AND EXISTS (SELECT 1 FROM categories LIMIT 1); `, }, ]; export async function runMigrations(): Promise { await pool.query(` CREATE TABLE IF NOT EXISTS _migrations ( name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); for (const m of migrations) { const { rows } = await pool.query( 'SELECT 1 FROM _migrations WHERE name = $1', [m.name], ); if (rows.length > 0) continue; const client = await pool.connect(); try { await client.query('BEGIN'); await client.query(m.sql); await client.query('INSERT INTO _migrations (name) VALUES ($1)', [m.name]); await client.query('COMMIT'); console.log(`Migration applied: ${m.name}`); } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } } } if (require.main === module) { runMigrations() .then(() => { console.log('All migrations applied'); process.exit(0); }) .catch((err) => { console.error('Migration failed:', err); process.exit(1); }); }