3669 lines
170 KiB
JavaScript
3669 lines
170 KiB
JavaScript
const Database = require('better-sqlite3');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
|
|
// Lazy import for auditService — cannot require at top level due to circular dependency
|
|
// (auditService -> database.js -> auditService). Use getLogAudit() instead of logAudit directly.
|
|
let _logAudit = null;
|
|
function getLogAudit() {
|
|
if (!_logAudit) {
|
|
try { _logAudit = require('../services/auditService').logAudit; } catch { _logAudit = () => {}; }
|
|
}
|
|
return _logAudit;
|
|
}
|
|
|
|
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'bills.db');
|
|
const SCHEMA_PATH = path.join(__dirname, 'schema.sql');
|
|
const DEFAULT_CATEGORIES = [
|
|
'Housing',
|
|
'Utilities',
|
|
'Credit Cards',
|
|
'Food',
|
|
'Loans',
|
|
'Insurance',
|
|
'Beauty',
|
|
'Entertainment',
|
|
'Subscriptions',
|
|
'Pets',
|
|
'Phone & Internet',
|
|
'Transportation',
|
|
'Medical',
|
|
'Other',
|
|
];
|
|
|
|
// ── SQL Whitelist Mappings ────────────────────────────────────────────────────
|
|
// Security FIX (2026-05-08): Whitelist all allowed column names to prevent SQL injection
|
|
// in migrations that use dynamic ALTER TABLE statements.
|
|
|
|
const COLUMN_WHITELIST = new Set([
|
|
// users table columns
|
|
'active', 'is_default_admin', 'notification_email', 'notifications_enabled',
|
|
'notify_3d', 'notify_1d', 'notify_due', 'notify_overdue', 'notify_amount_change',
|
|
'display_name', 'last_password_change_at', 'auth_provider', 'external_subject',
|
|
'email', 'last_login_at',
|
|
// payments table columns
|
|
'deleted_at', 'payment_source', 'transaction_id',
|
|
// monthly_starting_amounts table columns
|
|
'other_amount',
|
|
// bills table columns
|
|
'history_visibility', 'interest_rate', 'user_id',
|
|
'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include',
|
|
'sort_order', 'snowball_exempt', 'is_subscription', 'subscription_type', 'reminder_days_before',
|
|
'subscription_source', 'subscription_detected_at', 'deleted_at', 'drift_snoozed_until',
|
|
// categories table columns
|
|
'sort_order',
|
|
// sessions table columns
|
|
'created_at',
|
|
// financial_accounts table columns
|
|
'monitored',
|
|
]);
|
|
|
|
// Security validation function for column names
|
|
function isValidColumnName(col) {
|
|
if (!col || typeof col !== 'string') return false;
|
|
// Must be in whitelist AND match valid SQL identifier pattern
|
|
return COLUMN_WHITELIST.has(col) && /^[a-z0-9_]+$/i.test(col);
|
|
}
|
|
|
|
// Security validation function for SQL definition fragments
|
|
function isValidSqlDefinition(def) {
|
|
if (!def || typeof def !== 'string') return false;
|
|
// Allow standard column definitions but reject any user input
|
|
// This is safe because all definitions are hardcoded here
|
|
return /^[\w\s\(\)\',!@#$%^&*+=\[\]<>\-.]+$/i.test(def);
|
|
}
|
|
|
|
// ── Subscription catalog seed ─────────────────────────────────────────────────
|
|
// [rank, name, category, subscription_type, website, domain]
|
|
const SUBSCRIPTION_CATALOG_ROWS = [
|
|
[1,'Netflix','Video Streaming','streaming','https://www.netflix.com/','netflix.com'],
|
|
[2,'Amazon Prime Video','Video Streaming','streaming','https://www.primevideo.com/','primevideo.com'],
|
|
[3,'Hulu','Video Streaming','streaming','https://www.hulu.com/','hulu.com'],
|
|
[4,'Disney+','Video Streaming','streaming','https://www.disneyplus.com/','disneyplus.com'],
|
|
[5,'Max','Video Streaming','streaming','https://www.max.com/','max.com'],
|
|
[6,'Peacock','Video Streaming','streaming','https://www.peacocktv.com/','peacocktv.com'],
|
|
[7,'Paramount+','Video Streaming','streaming','https://www.paramountplus.com/','paramountplus.com'],
|
|
[8,'Apple TV+','Video Streaming','streaming','https://tv.apple.com/','tv.apple.com'],
|
|
[9,'YouTube Premium','Video Streaming','streaming','https://www.youtube.com/premium','youtube.com'],
|
|
[10,'ESPN+','Sports Streaming','streaming','https://plus.espn.com/','plus.espn.com'],
|
|
[11,'YouTube TV','Live TV Streaming','streaming','https://tv.youtube.com/','tv.youtube.com'],
|
|
[12,'Sling TV','Live TV Streaming','streaming','https://www.sling.com/','sling.com'],
|
|
[13,'Fubo','Live TV Streaming','streaming','https://www.fubo.tv/','fubo.tv'],
|
|
[14,'DirecTV Stream','Live TV Streaming','streaming','https://streamtv.directv.com/','streamtv.directv.com'],
|
|
[15,'Philo','Live TV Streaming','streaming','https://www.philo.com/','philo.com'],
|
|
[16,'Starz','Video Streaming','streaming','https://www.starz.com/','starz.com'],
|
|
[17,'MGM+','Video Streaming','streaming','https://www.mgmplus.com/','mgmplus.com'],
|
|
[18,'AMC+','Video Streaming','streaming','https://www.amcplus.com/','amcplus.com'],
|
|
[19,'BET+','Video Streaming','streaming','https://www.bet.plus/','bet.plus'],
|
|
[20,'Crunchyroll','Video Streaming','streaming','https://www.crunchyroll.com/','crunchyroll.com'],
|
|
[21,'HIDIVE','Video Streaming','streaming','https://www.hidive.com/','hidive.com'],
|
|
[22,'Shudder','Video Streaming','streaming','https://www.shudder.com/','shudder.com'],
|
|
[23,'Acorn TV','Video Streaming','streaming','https://acorn.tv/','acorn.tv'],
|
|
[24,'BritBox','Video Streaming','streaming','https://www.britbox.com/','britbox.com'],
|
|
[25,'The Criterion Channel','Video Streaming','streaming','https://www.criterionchannel.com/','criterionchannel.com'],
|
|
[26,'MUBI','Video Streaming','streaming','https://mubi.com/','mubi.com'],
|
|
[27,'Discovery+','Video Streaming','streaming','https://www.discoveryplus.com/','discoveryplus.com'],
|
|
[28,'Hallmark+','Video Streaming','streaming','https://www.hallmarkplus.com/','hallmarkplus.com'],
|
|
[29,'PBS Passport','Video Streaming','streaming','https://www.pbs.org/passport/','pbs.org'],
|
|
[30,'MagellanTV','Video Streaming','streaming','https://www.magellantv.com/','magellantv.com'],
|
|
[31,'Curiosity Stream','Video Streaming','streaming','https://curiositystream.com/','curiositystream.com'],
|
|
[32,'Nebula','Video Streaming','streaming','https://nebula.tv/','nebula.tv'],
|
|
[33,'WOW Presents Plus','Video Streaming','streaming','https://www.wowpresentsplus.com/','wowpresentsplus.com'],
|
|
[34,'ViX Premium','Video Streaming','streaming','https://vix.com/','vix.com'],
|
|
[35,'FloSports','Sports Streaming','streaming','https://www.flosports.tv/','flosports.tv'],
|
|
[36,'DAZN','Sports Streaming','streaming','https://www.dazn.com/','dazn.com'],
|
|
[37,'MLB.TV','Sports Streaming','streaming','https://www.mlb.com/live-stream-games/subscribe','mlb.com'],
|
|
[38,'NBA League Pass','Sports Streaming','streaming','https://www.nba.com/watch/league-pass-stream','nba.com'],
|
|
[39,'NHL Power Play on ESPN+','Sports Streaming','streaming','https://www.espn.com/espnplus/catalog/nhl','espn.com'],
|
|
[40,'NFL+','Sports Streaming','streaming','https://www.nfl.com/plus/','nfl.com'],
|
|
[41,'Spotify Premium','Music & Audio','music','https://www.spotify.com/premium/','spotify.com'],
|
|
[42,'Apple Music','Music & Audio','music','https://www.apple.com/apple-music/','apple.com'],
|
|
[43,'Amazon Music Unlimited','Music & Audio','music','https://music.amazon.com/','music.amazon.com'],
|
|
[44,'Pandora Premium','Music & Audio','music','https://www.pandora.com/upgrade','pandora.com'],
|
|
[45,'SiriusXM','Music & Audio','music','https://www.siriusxm.com/','siriusxm.com'],
|
|
[46,'TIDAL','Music & Audio','music','https://tidal.com/','tidal.com'],
|
|
[47,'Qobuz','Music & Audio','music','https://www.qobuz.com/us-en/music/streaming/offers','qobuz.com'],
|
|
[48,'SoundCloud Go+','Music & Audio','music','https://soundcloud.com/go','soundcloud.com'],
|
|
[49,'Deezer','Music & Audio','music','https://www.deezer.com/us/offers','deezer.com'],
|
|
[50,'iHeartRadio Plus','Music & Audio','music','https://www.iheart.com/plus/','iheart.com'],
|
|
[51,'Audible','Audiobooks','education','https://www.audible.com/','audible.com'],
|
|
[52,'Spotify Audiobooks','Audiobooks','education','https://www.spotify.com/us/audiobooks/','spotify.com'],
|
|
[53,'Everand','Audiobooks & Ebooks','education','https://www.everand.com/','everand.com'],
|
|
[54,'Scribd','Documents & Ebooks','education','https://www.scribd.com/','scribd.com'],
|
|
[55,'Kindle Unlimited','Ebooks','education','https://www.amazon.com/kindle-dbs/hz/subscribe/ku','amazon.com'],
|
|
[56,'Kobo Plus','Ebooks & Audiobooks','education','https://www.kobo.com/us/en/plus','kobo.com'],
|
|
[57,'Libro.fm','Audiobooks','education','https://libro.fm/','libro.fm'],
|
|
[58,'Blinkist','Books & Learning','education','https://www.blinkist.com/','blinkist.com'],
|
|
[59,'Pocket Casts Plus','Podcasts','music','https://pocketcasts.com/plus/','pocketcasts.com'],
|
|
[60,'Wondery+','Podcasts','music','https://wondery.com/plus/','wondery.com'],
|
|
[61,'The New York Times','News & Magazines','news','https://www.nytimes.com/subscription','nytimes.com'],
|
|
[62,'The Wall Street Journal','News & Magazines','news','https://www.wsj.com/news/subscribe','wsj.com'],
|
|
[63,'The Washington Post','News & Magazines','news','https://subscribe.washingtonpost.com/','subscribe.washingtonpost.com'],
|
|
[64,'The Atlantic','News & Magazines','news','https://www.theatlantic.com/subscribe/','theatlantic.com'],
|
|
[65,'The New Yorker','News & Magazines','news','https://www.newyorker.com/subscribe','newyorker.com'],
|
|
[66,'Bloomberg.com','News & Magazines','news','https://www.bloomberg.com/subscriptions','bloomberg.com'],
|
|
[67,'Financial Times','News & Magazines','news','https://www.ft.com/products','ft.com'],
|
|
[68,'The Economist','News & Magazines','news','https://www.economist.com/subscribe','economist.com'],
|
|
[69,'TIME','News & Magazines','news','https://time.com/subscribe/','time.com'],
|
|
[70,'WIRED','News & Magazines','news','https://www.wired.com/subscribe/','wired.com'],
|
|
[71,'Consumer Reports','News & Magazines','news','https://www.consumerreports.org/join/','consumerreports.org'],
|
|
[72,'Politico Pro','News & Magazines','news','https://www.politicopro.com/','politicopro.com'],
|
|
[73,'The Athletic','Sports Media','streaming','https://theathletic.com/','theathletic.com'],
|
|
[74,'Substack','Creator Media','news','https://substack.com/','substack.com'],
|
|
[75,'Medium','Creator Media','news','https://medium.com/membership','medium.com'],
|
|
[76,'Patreon','Creator Media','news','https://www.patreon.com/','patreon.com'],
|
|
[77,'Apple News+','News & Magazines','news','https://www.apple.com/apple-news/','apple.com'],
|
|
[78,'Readly','News & Magazines','news','https://us.readly.com/','us.readly.com'],
|
|
[79,'PressReader','News & Magazines','news','https://www.pressreader.com/','pressreader.com'],
|
|
[80,'The Information','News & Magazines','news','https://www.theinformation.com/subscribe','theinformation.com'],
|
|
[81,'Microsoft 365','Software & Productivity','software','https://www.microsoft.com/microsoft-365','microsoft.com'],
|
|
[82,'Google One','Cloud & Storage','cloud','https://one.google.com/','one.google.com'],
|
|
[83,'iCloud+','Cloud & Storage','cloud','https://www.apple.com/icloud/','apple.com'],
|
|
[84,'Dropbox','Cloud & Storage','cloud','https://www.dropbox.com/plans','dropbox.com'],
|
|
[85,'Box','Cloud & Storage','cloud','https://www.box.com/pricing','box.com'],
|
|
[86,'Adobe Creative Cloud','Software & Design','software','https://www.adobe.com/creativecloud.html','adobe.com'],
|
|
[87,'Canva Pro','Software & Design','software','https://www.canva.com/pro/','canva.com'],
|
|
[88,'Figma','Software & Design','software','https://www.figma.com/pricing/','figma.com'],
|
|
[89,'Notion','Software & Productivity','software','https://www.notion.so/pricing','notion.so'],
|
|
[90,'Evernote','Software & Productivity','software','https://evernote.com/compare-plans','evernote.com'],
|
|
[91,'Todoist','Software & Productivity','software','https://todoist.com/pricing','todoist.com'],
|
|
[92,'Grammarly','Writing & AI','software','https://www.grammarly.com/plans','grammarly.com'],
|
|
[93,'ChatGPT','AI','software','https://chatgpt.com/pricing','chatgpt.com'],
|
|
[94,'Claude.ai','AI','software','https://claude.ai/upgrade','anthropic.com'],
|
|
[95,'Perplexity','AI','software','https://www.perplexity.ai/pro','perplexity.ai'],
|
|
[96,'Gemini Advanced','AI','software','https://one.google.com/about/google-ai-plans/','one.google.com'],
|
|
[97,'GitHub Copilot','Developer Tools','software','https://github.com/features/copilot/plans','github.com'],
|
|
[98,'Cursor','Developer Tools','software','https://www.cursor.com/pricing','cursor.com'],
|
|
[99,'Replit','Developer Tools','software','https://replit.com/pricing','replit.com'],
|
|
[100,'Setapp','Software & Productivity','software','https://setapp.com/','setapp.com'],
|
|
[101,'1Password','Security','security','https://1password.com/pricing','1password.com'],
|
|
[102,'Dashlane','Security','security','https://www.dashlane.com/pricing','dashlane.com'],
|
|
[103,'NordVPN','Security','security','https://nordvpn.com/pricing/','nordvpn.com'],
|
|
[104,'ExpressVPN','Security','security','https://www.expressvpn.com/','expressvpn.com'],
|
|
[105,'Surfshark','Security','security','https://surfshark.com/pricing','surfshark.com'],
|
|
[106,'Norton 360','Security','security','https://us.norton.com/products','us.norton.com'],
|
|
[107,'McAfee+','Security','security','https://www.mcafee.com/en-us/consumer-support/pricing.html','mcafee.com'],
|
|
[108,'QuickBooks Online','Finance Software','software','https://quickbooks.intuit.com/pricing/','quickbooks.intuit.com'],
|
|
[109,'TurboTax Live','Finance Software','software','https://turbotax.intuit.com/personal-taxes/online/live/','turbotax.intuit.com'],
|
|
[110,'YNAB','Finance Software','software','https://www.ynab.com/pricing','ynab.com'],
|
|
[111,'Rocket Money Premium','Finance Software','software','https://www.rocketmoney.com/premium','rocketmoney.com'],
|
|
[112,'Copilot Money','Finance Software','software','https://copilot.money/','copilot.money'],
|
|
[113,'Calendly','Software & Productivity','software','https://calendly.com/pricing','calendly.com'],
|
|
[114,'Zoom Workplace','Software & Productivity','software','https://www.zoom.com/en/pricing/','zoom.com'],
|
|
[115,'Slack','Software & Productivity','software','https://slack.com/pricing','slack.com'],
|
|
[116,'Xbox Game Pass','Gaming','gaming','https://www.xbox.com/en-US/xbox-game-pass','xbox.com'],
|
|
[117,'PlayStation Plus','Gaming','gaming','https://www.playstation.com/en-us/ps-plus/','playstation.com'],
|
|
[118,'Nintendo Switch Online','Gaming','gaming','https://www.nintendo.com/us/switch/online/','nintendo.com'],
|
|
[119,'Apple Arcade','Gaming','gaming','https://www.apple.com/apple-arcade/','apple.com'],
|
|
[120,'EA Play','Gaming','gaming','https://www.ea.com/ea-play','ea.com'],
|
|
[121,'Ubisoft+','Gaming','gaming','https://www.ubisoft.com/en-us/ubisoft-plus','ubisoft.com'],
|
|
[122,'NVIDIA GeForce NOW','Gaming','gaming','https://www.nvidia.com/en-us/geforce-now/memberships/','nvidia.com'],
|
|
[123,'Roblox Premium','Gaming','gaming','https://www.roblox.com/premium/membership','roblox.com'],
|
|
[124,'Fortnite Crew','Gaming','gaming','https://www.fortnite.com/fortnite-crew-subscription','fortnite.com'],
|
|
[125,'Minecraft Realms','Gaming','gaming','https://www.minecraft.net/realms','minecraft.net'],
|
|
[126,'Twitch Turbo','Creator & Social','news','https://www.twitch.tv/turbo','twitch.tv'],
|
|
[127,'Discord Nitro','Creator & Social','news','https://discord.com/nitro','discord.com'],
|
|
[128,'X Premium','Creator & Social','news','https://help.x.com/en/using-x/x-premium','help.x.com'],
|
|
[129,'Snapchat+','Creator & Social','news','https://www.snapchat.com/plus','snapchat.com'],
|
|
[130,'TikTok Live Subscription','Creator & Social','news','https://www.tiktok.com/live/creators/en-US/subscription/','tiktok.com'],
|
|
[131,'Meta Verified','Creator & Social','news','https://about.meta.com/technologies/meta-verified/','about.meta.com'],
|
|
[132,'LinkedIn Premium','Career & Social','news','https://premium.linkedin.com/','premium.linkedin.com'],
|
|
[133,'Tinder Gold','Dating','other','https://tinder.com/feature/plus','tinder.com'],
|
|
[134,'Bumble Premium','Dating','other','https://bumble.com/en/the-buzz/bumble-premium','bumble.com'],
|
|
[135,'Hinge+','Dating','other','https://hinge.co/hinge-plus','hinge.co'],
|
|
[136,'Amazon Prime','Shopping & Delivery','shopping','https://www.amazon.com/amazonprime','amazon.com'],
|
|
[137,'Walmart+','Shopping & Delivery','shopping','https://www.walmart.com/plus','walmart.com'],
|
|
[138,'Target Circle 360','Shopping & Delivery','shopping','https://www.target.com/circle/target-circle-360','target.com'],
|
|
[139,'Costco','Warehouse Clubs','shopping','https://www.costco.com/join-costco.html','costco.com'],
|
|
[140,'Sam\'s Club','Warehouse Clubs','shopping','https://www.samsclub.com/join','samsclub.com'],
|
|
[141,'BJ\'s Wholesale Club','Warehouse Clubs','shopping','https://www.bjs.com/membership','bjs.com'],
|
|
[142,'Instacart+','Grocery & Delivery','food','https://www.instacart.com/instacart-plus','instacart.com'],
|
|
[143,'DoorDash DashPass','Food Delivery','food','https://www.doordash.com/dashpass/','doordash.com'],
|
|
[144,'Uber One','Food & Rides','food','https://www.uber.com/us/en/u/uber-one/','uber.com'],
|
|
[145,'Grubhub+','Food Delivery','food','https://www.grubhub.com/plus','grubhub.com'],
|
|
[146,'Shipt','Grocery & Delivery','food','https://www.shipt.com/membership/','shipt.com'],
|
|
[147,'Kroger Boost','Grocery & Delivery','food','https://www.kroger.com/pr/boost','kroger.com'],
|
|
[148,'Thrive Market','Grocery & Delivery','food','https://thrivemarket.com/','thrivemarket.com'],
|
|
[149,'Misfits Market','Grocery & Delivery','food','https://www.misfitsmarket.com/','misfitsmarket.com'],
|
|
[150,'Imperfect Foods','Grocery & Delivery','food','https://www.imperfectfoods.com/','imperfectfoods.com'],
|
|
[151,'Chewy Autoship','Pet Retail','shopping','https://www.chewy.com/app/content/autoship','chewy.com'],
|
|
[152,'Petco Vital Care Premier','Pet Retail','shopping','https://www.petco.com/shop/en/petcostore/c/vitalcare','petco.com'],
|
|
[153,'PetSmart Treats Rewards VIPP','Pet Retail','shopping','https://www.petsmart.com/treats-rewards-vipp.html','petsmart.com'],
|
|
[154,'GameStop Pro','Retail Memberships','shopping','https://www.gamestop.com/pro/','gamestop.com'],
|
|
[155,'Barnes & Noble Membership','Retail Memberships','shopping','https://www.barnesandnoble.com/membership','barnesandnoble.com'],
|
|
[156,'HelloFresh','Food & Meal Kits','food','https://www.hellofresh.com/','hellofresh.com'],
|
|
[157,'Blue Apron','Food & Meal Kits','food','https://www.blueapron.com/','blueapron.com'],
|
|
[158,'Home Chef','Food & Meal Kits','food','https://www.homechef.com/','homechef.com'],
|
|
[159,'Marley Spoon','Food & Meal Kits','food','https://marleyspoon.com/','marleyspoon.com'],
|
|
[160,'Dinnerly','Food & Meal Kits','food','https://dinnerly.com/','dinnerly.com'],
|
|
[161,'EveryPlate','Food & Meal Kits','food','https://www.everyplate.com/','everyplate.com'],
|
|
[162,'Green Chef','Food & Meal Kits','food','https://www.greenchef.com/','greenchef.com'],
|
|
[163,'Purple Carrot','Food & Meal Kits','food','https://www.purplecarrot.com/','purplecarrot.com'],
|
|
[164,'Sunbasket','Food & Meal Kits','food','https://sunbasket.com/','sunbasket.com'],
|
|
[165,'Factor','Prepared Meals','food','https://www.factor75.com/','factor75.com'],
|
|
[166,'CookUnity','Prepared Meals','food','https://www.cookunity.com/','cookunity.com'],
|
|
[167,'Fresh N Lean','Prepared Meals','food','https://www.freshnlean.com/','freshnlean.com'],
|
|
[168,'Hungryroot','Food & Meal Kits','food','https://www.hungryroot.com/','hungryroot.com'],
|
|
[169,'Daily Harvest','Prepared Meals','food','https://www.daily-harvest.com/','daily-harvest.com'],
|
|
[170,'Tovala','Prepared Meals','food','https://www.tovala.com/','tovala.com'],
|
|
[171,'MistoBox','Coffee & Tea','food','https://mistobox.com/','mistobox.com'],
|
|
[172,'Trade Coffee','Coffee & Tea','food','https://www.drinktrade.com/','drinktrade.com'],
|
|
[173,'Atlas Coffee Club','Coffee & Tea','food','https://atlascoffeeclub.com/','atlascoffeeclub.com'],
|
|
[174,'Bean Box','Coffee & Tea','food','https://beanbox.com/','beanbox.com'],
|
|
[175,'Universal Yums','Snacks','food','https://www.universalyums.com/','universalyums.com'],
|
|
[176,'Peloton App','Fitness & Wellness','fitness','https://www.onepeloton.com/app','onepeloton.com'],
|
|
[177,'ClassPass','Fitness & Wellness','fitness','https://classpass.com/','classpass.com'],
|
|
[178,'Apple Fitness+','Fitness & Wellness','fitness','https://www.apple.com/apple-fitness-plus/','apple.com'],
|
|
[179,'Strava','Fitness & Wellness','fitness','https://www.strava.com/subscribe','strava.com'],
|
|
[180,'Fitbit Premium','Fitness & Wellness','fitness','https://www.fitbit.com/global/us/products/services/premium','fitbit.com'],
|
|
[181,'MyFitnessPal Premium','Fitness & Wellness','fitness','https://www.myfitnesspal.com/premium','myfitnesspal.com'],
|
|
[182,'Noom','Fitness & Wellness','fitness','https://www.noom.com/','noom.com'],
|
|
[183,'WW','Fitness & Wellness','fitness','https://www.weightwatchers.com/us/plans','weightwatchers.com'],
|
|
[184,'Headspace','Meditation & Wellness','fitness','https://www.headspace.com/','headspace.com'],
|
|
[185,'Calm','Meditation & Wellness','fitness','https://www.calm.com/premium','calm.com'],
|
|
[186,'Sleep Cycle Premium','Sleep & Wellness','fitness','https://www.sleepcycle.com/premium/','sleepcycle.com'],
|
|
[187,'Oura Membership','Fitness & Wellness','fitness','https://ouraring.com/membership','ouraring.com'],
|
|
[188,'Whoop','Fitness & Wellness','fitness','https://www.whoop.com/us/en/membership/','whoop.com'],
|
|
[189,'Aaptiv','Fitness & Wellness','fitness','https://aaptiv.com/','aaptiv.com'],
|
|
[190,'Fitbod','Fitness & Wellness','fitness','https://fitbod.me/pricing/','fitbod.me'],
|
|
[191,'Alo Moves','Fitness & Wellness','fitness','https://www.alomoves.com/','alomoves.com'],
|
|
[192,'Obe Fitness','Fitness & Wellness','fitness','https://obefitness.com/','obefitness.com'],
|
|
[193,'Centr','Fitness & Wellness','fitness','https://centr.com/','centr.com'],
|
|
[194,'Future','Fitness & Wellness','fitness','https://www.future.co/','future.co'],
|
|
[195,'Tonal Membership','Fitness & Wellness','fitness','https://www.tonal.com/membership/','tonal.com'],
|
|
[196,'Duolingo Super','Education','education','https://www.duolingo.com/super','duolingo.com'],
|
|
[197,'MasterClass','Education','education','https://www.masterclass.com/','masterclass.com'],
|
|
[198,'Coursera Plus','Education','education','https://www.coursera.org/courseraplus','coursera.org'],
|
|
[199,'Skillshare','Education','education','https://www.skillshare.com/','skillshare.com'],
|
|
[200,'Book of the Month','Books & Subscription Boxes','education','https://www.bookofthemonth.com/','bookofthemonth.com'],
|
|
];
|
|
|
|
const SUBSCRIPTION_CATALOG_V2_ROWS = [
|
|
// ── AI ──────────────────────────────────────────────────────────────────────
|
|
[201,'Suno','AI Music','music','https://suno.com/','suno.com'],
|
|
[202,'Midjourney','AI Image Generation','software','https://midjourney.com/','midjourney.com'],
|
|
[203,'Grok','AI','software','https://grok.com/','grok.com'],
|
|
[204,'ElevenLabs','AI Voice','software','https://elevenlabs.io/','elevenlabs.io'],
|
|
[205,'Character.ai Plus','AI','software','https://character.ai/','character.ai'],
|
|
[206,'Runway','AI Video','software','https://runwayml.com/','runwayml.com'],
|
|
[207,'Windsurf','Developer Tools','software','https://windsurf.com/','windsurf.com'],
|
|
[208,'Leonardo.ai','AI Image Generation','software','https://leonardo.ai/','leonardo.ai'],
|
|
// ── Home Security / Smart Home ───────────────────────────────────────────────
|
|
[209,'Ring Protect','Home Security','other','https://ring.com/protect-plans','ring.com'],
|
|
[210,'Nest Aware','Home Security','other','https://store.google.com/us/category/connected_home','nest.com'],
|
|
[211,'SimpliSafe','Home Security','other','https://simplisafe.com/','simplisafe.com'],
|
|
[212,'ADT','Home Security','other','https://www.adt.com/','adt.com'],
|
|
[213,'Arlo Secure','Home Security','other','https://www.arlo.com/','arlo.com'],
|
|
[214,'Wyze Cam Plus','Home Security','other','https://www.wyze.com/','wyze.com'],
|
|
[215,'Abode','Home Security','other','https://goabode.com/','goabode.com'],
|
|
// ── Financial Data & Aggregation ────────────────────────────────────────────
|
|
[216,'SimpleFIN Bridge','Financial Data','software','https://simplefin.org/','simplefin.org'],
|
|
[217,'Tiller Money','Financial Data','software','https://www.tillerhq.com/','tillerhq.com'],
|
|
[218,'Monarch Money','Finance Software','software','https://www.monarchmoney.com/','monarchmoney.com'],
|
|
[219,'Empower','Finance Software','software','https://www.empower.com/','empower.com'],
|
|
// ── Cloud Backup ─────────────────────────────────────────────────────────────
|
|
[220,'Backblaze','Cloud Backup','cloud','https://www.backblaze.com/','backblaze.com'],
|
|
[221,'Carbonite','Cloud Backup','cloud','https://www.carbonite.com/','carbonite.com'],
|
|
[222,'iDrive','Cloud Backup','cloud','https://www.idrive.com/','idrive.com'],
|
|
// ── Email ────────────────────────────────────────────────────────────────────
|
|
[223,'Proton Mail','Email & Privacy','software','https://proton.me/mail','proton.me'],
|
|
[224,'Fastmail','Email','software','https://www.fastmail.com/','fastmail.com'],
|
|
[225,'Superhuman','Email','software','https://superhuman.com/','superhuman.com'],
|
|
[226,'Hey','Email','software','https://www.hey.com/','hey.com'],
|
|
// ── Security / Privacy ───────────────────────────────────────────────────────
|
|
[227,'Bitwarden','Security','security','https://bitwarden.com/','bitwarden.com'],
|
|
[228,'Keeper','Security','security','https://www.keepersecurity.com/','keepersecurity.com'],
|
|
[229,'LastPass','Security','security','https://www.lastpass.com/','lastpass.com'],
|
|
[230,'Proton VPN','Security','security','https://protonvpn.com/','protonvpn.com'],
|
|
[231,'Mullvad VPN','Security','security','https://mullvad.net/','mullvad.net'],
|
|
[232,'Private Internet Access','Security','security','https://www.privateinternetaccess.com/','privateinternetaccess.com'],
|
|
[233,'Proton Pass','Security','security','https://proton.me/pass','proton.me'],
|
|
[234,'SimpleLogin','Email Privacy','software','https://simplelogin.io/','simplelogin.io'],
|
|
// ── Identity Protection ──────────────────────────────────────────────────────
|
|
[235,'LifeLock','Identity Protection','security','https://www.lifelock.com/','lifelock.com'],
|
|
[236,'Aura','Identity Protection','security','https://www.aura.com/','aura.com'],
|
|
[237,'IdentityForce','Identity Protection','security','https://www.identityforce.com/','identityforce.com'],
|
|
// ── Comics ───────────────────────────────────────────────────────────────────
|
|
[238,'Marvel Unlimited','Comics','streaming','https://www.marvel.com/unlimited','marvel.com'],
|
|
[239,'DC Universe Infinite','Comics','streaming','https://www.dcuniverseinfinite.com/','dcuniverseinfinite.com'],
|
|
// ── Genealogy ────────────────────────────────────────────────────────────────
|
|
[240,'Ancestry','Genealogy','other','https://www.ancestry.com/','ancestry.com'],
|
|
[241,'MyHeritage','Genealogy','other','https://www.myheritage.com/','myheritage.com'],
|
|
[242,'Findmypast','Genealogy','other','https://www.findmypast.com/','findmypast.com'],
|
|
// ── Telehealth ───────────────────────────────────────────────────────────────
|
|
[243,'BetterHelp','Telehealth','other','https://www.betterhelp.com/','betterhelp.com'],
|
|
[244,'Talkspace','Telehealth','other','https://www.talkspace.com/','talkspace.com'],
|
|
[245,'Teladoc','Telehealth','other','https://www.teladoc.com/','teladoc.com'],
|
|
[246,'Hims & Hers','Telehealth','other','https://www.forhims.com/','forhims.com'],
|
|
[247,'Cerebral','Telehealth','other','https://cerebral.com/','cerebral.com'],
|
|
// ── Website Builder / Hosting / Domains ─────────────────────────────────────
|
|
[248,'Squarespace','Website Builder','software','https://www.squarespace.com/','squarespace.com'],
|
|
[249,'Wix','Website Builder','software','https://www.wix.com/','wix.com'],
|
|
[250,'Webflow','Website Builder','software','https://webflow.com/','webflow.com'],
|
|
[251,'GoDaddy','Domain & Hosting','software','https://www.godaddy.com/','godaddy.com'],
|
|
[252,'Namecheap','Domain & Hosting','software','https://www.namecheap.com/','namecheap.com'],
|
|
[253,'Netlify','Developer Tools','software','https://www.netlify.com/','netlify.com'],
|
|
[254,'Vercel','Developer Tools','software','https://vercel.com/','vercel.com'],
|
|
// ── Learning ─────────────────────────────────────────────────────────────────
|
|
[255,'LinkedIn Learning','Education','education','https://www.linkedin.com/learning/','linkedin.com'],
|
|
[256,'Pluralsight','Education','education','https://www.pluralsight.com/','pluralsight.com'],
|
|
[257,"O'Reilly",'Education','education','https://www.oreilly.com/','oreilly.com'],
|
|
[258,'CBT Nuggets','Education','education','https://www.cbtnuggets.com/','cbtnuggets.com'],
|
|
[259,'Udacity','Education','education','https://www.udacity.com/','udacity.com'],
|
|
[260,'Frontend Masters','Education','education','https://frontendmasters.com/','frontendmasters.com'],
|
|
// ── Project Management ───────────────────────────────────────────────────────
|
|
[261,'Monday.com','Software & Productivity','software','https://monday.com/','monday.com'],
|
|
[262,'Asana','Software & Productivity','software','https://asana.com/','asana.com'],
|
|
[263,'ClickUp','Software & Productivity','software','https://clickup.com/','clickup.com'],
|
|
[264,'Linear','Developer Tools','software','https://linear.app/','linear.app'],
|
|
[265,'Basecamp','Software & Productivity','software','https://basecamp.com/','basecamp.com'],
|
|
[266,'Jira','Developer Tools','software','https://www.atlassian.com/software/jira','atlassian.com'],
|
|
[267,'Miro','Software & Productivity','software','https://miro.com/','miro.com'],
|
|
[268,'Airtable','Software & Productivity','software','https://airtable.com/','airtable.com'],
|
|
// ── Email Marketing ──────────────────────────────────────────────────────────
|
|
[269,'Mailchimp','Email Marketing','software','https://mailchimp.com/','mailchimp.com'],
|
|
[270,'ConvertKit','Email Marketing','software','https://convertkit.com/','convertkit.com'],
|
|
[271,'Beehiiv','Newsletter Platform','software','https://www.beehiiv.com/','beehiiv.com'],
|
|
[272,'Constant Contact','Email Marketing','software','https://www.constantcontact.com/','constantcontact.com'],
|
|
[273,'ActiveCampaign','Email Marketing','software','https://www.activecampaign.com/','activecampaign.com'],
|
|
// ── Cloud Computing ──────────────────────────────────────────────────────────
|
|
[274,'DigitalOcean','Cloud Computing','cloud','https://www.digitalocean.com/','digitalocean.com'],
|
|
[275,'Linode','Cloud Computing','cloud','https://www.linode.com/','linode.com'],
|
|
[276,'Render','Cloud Computing','cloud','https://render.com/','render.com'],
|
|
[277,'Vultr','Cloud Computing','cloud','https://www.vultr.com/','vultr.com'],
|
|
[278,'Hetzner','Cloud Computing','cloud','https://www.hetzner.com/','hetzner.com'],
|
|
[279,'Cloudflare','Network & Security','cloud','https://www.cloudflare.com/','cloudflare.com'],
|
|
// ── Media Server ─────────────────────────────────────────────────────────────
|
|
[280,'Plex Pass','Media Server','streaming','https://www.plex.tv/plex-pass/','plex.tv'],
|
|
[281,'Emby Premiere','Media Server','streaming','https://emby.media/premiere/','emby.media'],
|
|
// ── Network / Homelab ────────────────────────────────────────────────────────
|
|
[282,'NextDNS','Network & Security','software','https://nextdns.io/','nextdns.io'],
|
|
[283,'Tailscale','Network & Security','software','https://tailscale.com/','tailscale.com'],
|
|
// ── Productivity ─────────────────────────────────────────────────────────────
|
|
[284,'Obsidian Sync','Software & Productivity','software','https://obsidian.md/','obsidian.md'],
|
|
[285,'Readwise','Software & Productivity','software','https://readwise.io/','readwise.io'],
|
|
[286,'Loom','Software & Productivity','software','https://www.loom.com/','loom.com'],
|
|
[287,'Raycast Pro','Software & Productivity','software','https://www.raycast.com/','raycast.com'],
|
|
// ── Developer Tools ──────────────────────────────────────────────────────────
|
|
[288,'JetBrains','Developer Tools','software','https://www.jetbrains.com/','jetbrains.com'],
|
|
[289,'Tabnine','Developer Tools','software','https://www.tabnine.com/','tabnine.com'],
|
|
// ── Communication ────────────────────────────────────────────────────────────
|
|
[290,'Telegram Premium','Messaging','software','https://telegram.org/','telegram.org'],
|
|
];
|
|
|
|
function runSubscriptionCatalogV2Migration(database) {
|
|
// Category fixes for existing rows
|
|
database.prepare(`
|
|
UPDATE subscription_catalog SET subscription_type = 'software'
|
|
WHERE name = 'Discord Nitro' AND subscription_type = 'news'
|
|
`).run();
|
|
database.prepare(`
|
|
UPDATE subscription_catalog SET subscription_type = 'streaming'
|
|
WHERE name = 'Twitch Turbo' AND subscription_type = 'news'
|
|
`).run();
|
|
database.prepare(`
|
|
UPDATE subscription_catalog SET subscription_type = 'software'
|
|
WHERE name = 'X Premium' AND subscription_type = 'news'
|
|
`).run();
|
|
|
|
// New entries — skip any name already in the catalog
|
|
const existing = new Set(
|
|
database.prepare('SELECT name FROM subscription_catalog').all().map(r => r.name)
|
|
);
|
|
const toInsert = SUBSCRIPTION_CATALOG_V2_ROWS.filter(r => !existing.has(r[1]));
|
|
if (toInsert.length > 0) {
|
|
const insert = database.prepare(
|
|
'INSERT INTO subscription_catalog (rank, name, category, subscription_type, website, domain) VALUES (?,?,?,?,?,?)'
|
|
);
|
|
const insertMany = database.transaction((rows) => {
|
|
for (const row of rows) insert.run(...row);
|
|
});
|
|
insertMany(toInsert);
|
|
console.log(`[migration] subscription_catalog v2: added ${toInsert.length} new entries`);
|
|
}
|
|
}
|
|
|
|
function runAdvisoryFiltersMigration(database) {
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS advisory_non_bill_filters (
|
|
id TEXT PRIMARY KEY,
|
|
pattern TEXT NOT NULL,
|
|
confidence TEXT NOT NULL CHECK(confidence IN ('high', 'medium')),
|
|
category TEXT NOT NULL,
|
|
rationale TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_advisory_filters_confidence
|
|
ON advisory_non_bill_filters(confidence);
|
|
|
|
CREATE TABLE IF NOT EXISTS advisory_bill_like_overrides (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
term TEXT NOT NULL UNIQUE
|
|
);
|
|
`);
|
|
|
|
const filterCount = database.prepare('SELECT COUNT(*) as n FROM advisory_non_bill_filters').get();
|
|
if (filterCount.n === 0) {
|
|
const jsonPath = path.join(__dirname, '..', 'docs', 'advisory_non_bill_transaction_filters_us_ms_5000.json');
|
|
const raw = fs.readFileSync(jsonPath, 'utf8');
|
|
const data = JSON.parse(raw);
|
|
|
|
const insertFilter = database.prepare(
|
|
'INSERT INTO advisory_non_bill_filters (id, pattern, confidence, category, rationale) VALUES (?,?,?,?,?)'
|
|
);
|
|
const insertFilters = database.transaction((rows) => {
|
|
for (const row of rows) {
|
|
insertFilter.run(row.id, row.pattern, row.confidence, row.category, row.rationale || null);
|
|
}
|
|
});
|
|
insertFilters(data.patterns || []);
|
|
console.log(`[migration] advisory_non_bill_filters: seeded ${(data.patterns || []).length} rows`);
|
|
|
|
const overrideTerms = data.bill_like_override_terms || [];
|
|
if (overrideTerms.length > 0) {
|
|
const insertOverride = database.prepare(
|
|
'INSERT OR IGNORE INTO advisory_bill_like_overrides (term) VALUES (?)'
|
|
);
|
|
const insertOverrides = database.transaction((terms) => {
|
|
for (const term of terms) insertOverride.run(term);
|
|
});
|
|
insertOverrides(overrideTerms);
|
|
console.log(`[migration] advisory_bill_like_overrides: seeded ${overrideTerms.length} rows`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function runSubscriptionCatalogMigration(database) {
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS subscription_catalog (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
rank INTEGER NOT NULL,
|
|
name TEXT NOT NULL,
|
|
category TEXT NOT NULL,
|
|
subscription_type TEXT NOT NULL,
|
|
website TEXT,
|
|
domain TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_subscription_catalog_rank ON subscription_catalog(rank);
|
|
CREATE INDEX IF NOT EXISTS idx_subscription_catalog_type ON subscription_catalog(subscription_type);
|
|
`);
|
|
|
|
const existing = database.prepare('SELECT COUNT(*) as n FROM subscription_catalog').get();
|
|
if (existing.n === 0) {
|
|
const insert = database.prepare(
|
|
'INSERT INTO subscription_catalog (rank, name, category, subscription_type, website, domain) VALUES (?,?,?,?,?,?)'
|
|
);
|
|
const insertMany = database.transaction((rows) => {
|
|
for (const row of rows) insert.run(...row);
|
|
});
|
|
insertMany(SUBSCRIPTION_CATALOG_ROWS);
|
|
console.log(`[migration] subscription_catalog: seeded ${SUBSCRIPTION_CATALOG_ROWS.length} rows`);
|
|
}
|
|
}
|
|
|
|
function seedManualDataSources(database = db) {
|
|
if (!database) return;
|
|
const hasDataSources = database.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='data_sources'").get();
|
|
const hasUsers = database.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'").get();
|
|
if (!hasDataSources || !hasUsers) return;
|
|
|
|
database.exec(`
|
|
INSERT INTO data_sources (user_id, type, provider, name, status)
|
|
SELECT u.id, 'manual', 'manual', 'Manual Entry', 'active'
|
|
FROM users u
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM data_sources ds
|
|
WHERE ds.user_id = u.id
|
|
AND ds.type = 'manual'
|
|
AND ds.provider = 'manual'
|
|
)
|
|
`);
|
|
}
|
|
|
|
function ensureTransactionFoundationSchema(database = db) {
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS data_sources (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
type TEXT NOT NULL,
|
|
provider TEXT,
|
|
name TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
config_json TEXT,
|
|
encrypted_secret TEXT,
|
|
last_sync_at TEXT,
|
|
last_error TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS financial_accounts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
data_source_id INTEGER,
|
|
provider_account_id TEXT,
|
|
name TEXT NOT NULL,
|
|
org_name TEXT,
|
|
account_type TEXT,
|
|
currency TEXT,
|
|
balance INTEGER,
|
|
available_balance INTEGER,
|
|
monitored INTEGER NOT NULL DEFAULT 1,
|
|
raw_data TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
FOREIGN KEY (data_source_id) REFERENCES data_sources(id) ON DELETE SET NULL,
|
|
UNIQUE(data_source_id, provider_account_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS transactions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
data_source_id INTEGER,
|
|
account_id INTEGER,
|
|
provider_transaction_id TEXT,
|
|
source_type TEXT NOT NULL,
|
|
transaction_type TEXT,
|
|
posted_date TEXT,
|
|
transacted_at TEXT,
|
|
amount INTEGER NOT NULL,
|
|
currency TEXT,
|
|
description TEXT,
|
|
payee TEXT,
|
|
memo TEXT,
|
|
category TEXT,
|
|
raw_data TEXT,
|
|
matched_bill_id INTEGER,
|
|
match_status TEXT NOT NULL DEFAULT 'unmatched',
|
|
ignored INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
FOREIGN KEY (data_source_id) REFERENCES data_sources(id) ON DELETE SET NULL,
|
|
FOREIGN KEY (account_id) REFERENCES financial_accounts(id) ON DELETE SET NULL,
|
|
FOREIGN KEY (matched_bill_id) REFERENCES bills(id) ON DELETE SET NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_data_sources_user_type ON data_sources(user_id, type, status);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_data_sources_user_manual
|
|
ON data_sources(user_id, type, provider)
|
|
WHERE type = 'manual' AND provider = 'manual';
|
|
CREATE INDEX IF NOT EXISTS idx_financial_accounts_user_source ON financial_accounts(user_id, data_source_id);
|
|
CREATE INDEX IF NOT EXISTS idx_transactions_user_date ON transactions(user_id, posted_date, transacted_at);
|
|
CREATE INDEX IF NOT EXISTS idx_transactions_user_match ON transactions(user_id, match_status, ignored);
|
|
CREATE INDEX IF NOT EXISTS idx_transactions_account ON transactions(account_id);
|
|
CREATE INDEX IF NOT EXISTS idx_transactions_matched_bill ON transactions(matched_bill_id);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_provider_dedupe
|
|
ON transactions (data_source_id, provider_transaction_id)
|
|
WHERE provider_transaction_id IS NOT NULL;
|
|
`);
|
|
|
|
seedManualDataSources(database);
|
|
}
|
|
|
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
|
|
let db = null;
|
|
let initializing = false;
|
|
|
|
// Populated by runMigrations() so reconcileLegacyMigrations() can assert
|
|
// its own version list matches. Catches drift between the two arrays at startup
|
|
// on any legacy-DB upgrade path, rather than silently misconfiguring the schema.
|
|
let _runMigrationVersions = null;
|
|
|
|
function assertWritableDbPath() {
|
|
const dir = path.dirname(DB_PATH);
|
|
const probe = path.join(dir, `.write-test-${process.pid}-${Date.now()}`);
|
|
|
|
try {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(probe, 'ok');
|
|
fs.unlinkSync(probe);
|
|
|
|
if (fs.existsSync(DB_PATH)) {
|
|
fs.accessSync(DB_PATH, fs.constants.R_OK | fs.constants.W_OK);
|
|
}
|
|
} catch (err) {
|
|
const message = [
|
|
`Database path is not writable: ${DB_PATH}`,
|
|
`Ensure the DB directory is writable by the app user. In Docker, rebuild the image and recreate the container so the entrypoint can chown /data.`,
|
|
`Original error: ${err.message}`,
|
|
].join('\n');
|
|
const wrapped = new Error(message);
|
|
wrapped.code = err.code;
|
|
throw wrapped;
|
|
} finally {
|
|
try {
|
|
if (fs.existsSync(probe)) fs.unlinkSync(probe);
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
function sleep(ms) {
|
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
}
|
|
|
|
function getDb() {
|
|
// already ready
|
|
if (db) return db;
|
|
|
|
// wait if another init is happening
|
|
while (initializing) {
|
|
sleep(50);
|
|
}
|
|
|
|
// check again after wait
|
|
if (db) return db;
|
|
|
|
initializing = true;
|
|
|
|
try {
|
|
console.log('Opening DB at:', path.basename(DB_PATH));
|
|
assertWritableDbPath();
|
|
|
|
db = new Database(DB_PATH, {
|
|
timeout: 5000
|
|
});
|
|
|
|
db.pragma('busy_timeout = 5000');
|
|
|
|
try {
|
|
db.pragma('journal_mode = WAL');
|
|
} catch (e) {
|
|
console.warn('WAL failed:', e.message);
|
|
}
|
|
|
|
db.pragma('foreign_keys = ON');
|
|
|
|
initSchema();
|
|
seedDefaults();
|
|
|
|
console.log('DB initialized successfully');
|
|
|
|
return db;
|
|
} catch (err) {
|
|
console.error('DB init failed:', err);
|
|
throw err;
|
|
} finally {
|
|
initializing = false;
|
|
}
|
|
}
|
|
|
|
function initSchema() {
|
|
const schema = fs.readFileSync(SCHEMA_PATH, 'utf8');
|
|
db.exec(schema);
|
|
|
|
// Create schema_migrations table for tracking applied migrations
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
version TEXT NOT NULL UNIQUE,
|
|
description TEXT NOT NULL,
|
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)
|
|
`);
|
|
|
|
// Check if this is a legacy database (tables exist but no migration tracking)
|
|
handleLegacyDatabase();
|
|
|
|
// After legacy reconciliation and user seeding, reset the default admin password
|
|
// when INIT_ADMIN_PASS is set. This ensures legacy DBs can be accessed after migration.
|
|
// The must_change_password flag forces the admin to pick a new password on first login.
|
|
if (process.env.INIT_ADMIN_PASS) {
|
|
const initUser = process.env.INIT_ADMIN_USER || 'admin';
|
|
const initPass = process.env.INIT_ADMIN_PASS;
|
|
const bcrypt = require('bcryptjs');
|
|
const newPasswordHash = bcrypt.hashSync(initPass, 12);
|
|
|
|
// Reset password for the default admin user if INIT_ADMIN_PASS is set
|
|
const result = db.prepare(`
|
|
UPDATE users SET password_hash = ?, first_login = 0, must_change_password = 0
|
|
WHERE username = ? AND is_default_admin = 1
|
|
`).run(newPasswordHash, initUser);
|
|
|
|
if (result.changes > 0) {
|
|
console.log('[init] Reset password and flags for default admin user');
|
|
}
|
|
}
|
|
|
|
runMigrations();
|
|
}
|
|
|
|
function hasMigrationBeenApplied(version) {
|
|
const stmt = db.prepare('SELECT 1 FROM schema_migrations WHERE version = ?');
|
|
return !!stmt.get(version);
|
|
}
|
|
|
|
function handleLegacyDatabase() {
|
|
// Check if schema_migrations table exists but is empty
|
|
// This indicates a legacy database that predates migration tracking
|
|
const migrationCount = db.prepare('SELECT COUNT(*) as count FROM schema_migrations').get().count;
|
|
|
|
if (migrationCount === 0) {
|
|
// This might be a legacy database. Check if core tables exist.
|
|
const tableCheck = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('users', 'bills', 'payments', 'categories', 'settings')").all();
|
|
|
|
// If we have core tables but no migrations tracked, this is likely a legacy DB
|
|
if (tableCheck.length >= 3) { // At least some core tables exist
|
|
console.log('[migration] Detected legacy database, reconciling schema migrations...');
|
|
|
|
// For each migration, check if its changes are already present and mark as applied if so
|
|
reconcileLegacyMigrations();
|
|
}
|
|
}
|
|
}
|
|
|
|
function reconcileLegacyMigrations() {
|
|
// Define all migrations with explicit version tracking
|
|
const migrations = [
|
|
{
|
|
version: 'v0.2',
|
|
description: 'payments: soft-delete column',
|
|
check: function() {
|
|
const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
|
return paymentCols.includes('deleted_at');
|
|
},
|
|
run: function() {
|
|
const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
|
if (!paymentCols.includes('deleted_at')) {
|
|
db.exec('ALTER TABLE payments ADD COLUMN deleted_at TEXT');
|
|
// Index for fast filtering of live payments
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_deleted ON payments(deleted_at)');
|
|
console.log('[migration] payments.deleted_at column added');
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.3',
|
|
description: 'payments: compound index for tracker query',
|
|
check: function() {
|
|
// Check if the index exists
|
|
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_payments_bill_date_del'").all();
|
|
return indexes.length > 0;
|
|
},
|
|
run: function() {
|
|
// Supports: WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_bill_date_del ON payments(bill_id, paid_date, deleted_at)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.4',
|
|
description: 'monthly_bill_state: per-bill per-month overrides',
|
|
check: function() {
|
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_bill_state'").all();
|
|
return tables.length > 0;
|
|
},
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS monthly_bill_state (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
|
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
|
|
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
|
|
actual_amount REAL,
|
|
notes TEXT,
|
|
is_skipped INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
updated_at TEXT DEFAULT (datetime('now')),
|
|
UNIQUE(bill_id, year, month)
|
|
)
|
|
`);
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup ON monthly_bill_state(bill_id, year, month)');
|
|
console.log('[migration] monthly_bill_state table ensured');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.13',
|
|
description: 'users: profile columns',
|
|
check: function() {
|
|
const userColsNow = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
const profileCols = ['display_name', 'last_password_change_at'];
|
|
return profileCols.every(col => userColsNow.includes(col));
|
|
},
|
|
run: function() {
|
|
const userColsNow = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
const profileCols = [
|
|
['display_name', 'TEXT'],
|
|
['last_password_change_at','TEXT'],
|
|
];
|
|
for (const [col, def] of profileCols) {
|
|
if (!userColsNow.includes(col)) {
|
|
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
|
|
if (!isValidColumnName(col) || !isValidSqlDefinition(def)) {
|
|
throw new Error(`Invalid migration: column '${col}' not in whitelist`);
|
|
}
|
|
db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.14',
|
|
description: 'bills: history visibility mode',
|
|
check: function() {
|
|
const billColsHist = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
return billColsHist.includes('history_visibility');
|
|
},
|
|
run: function() {
|
|
const billColsHist = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!billColsHist.includes('history_visibility')) {
|
|
db.exec("ALTER TABLE bills ADD COLUMN history_visibility TEXT NOT NULL DEFAULT 'default'");
|
|
console.log('[migration] bills.history_visibility column added');
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.14.4',
|
|
description: 'bills: optional credit-card APR / interest rate',
|
|
check: function() {
|
|
const billColsInterest = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
return billColsInterest.includes('interest_rate');
|
|
},
|
|
run: function() {
|
|
const billColsInterest = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!billColsInterest.includes('interest_rate')) {
|
|
db.exec('ALTER TABLE bills ADD COLUMN interest_rate REAL');
|
|
console.log('[migration] bills.interest_rate column added');
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.15',
|
|
description: 'import_sessions and import_history tables',
|
|
check: function() {
|
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('import_sessions', 'import_history')").all();
|
|
return tables.length >= 2;
|
|
},
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS import_sessions (
|
|
id TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
preview_json TEXT NOT NULL
|
|
)
|
|
`);
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_import_sessions_user ON import_sessions(user_id)');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_import_sessions_expires ON import_sessions(expires_at)');
|
|
|
|
// ── import_history: per-user audit log (v0.38) ────────────────────────────
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS import_history (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
imported_at TEXT NOT NULL,
|
|
source_filename TEXT,
|
|
file_type TEXT DEFAULT 'xlsx',
|
|
sheet_name TEXT,
|
|
rows_parsed INTEGER DEFAULT 0,
|
|
rows_created INTEGER DEFAULT 0,
|
|
rows_updated INTEGER DEFAULT 0,
|
|
rows_skipped INTEGER DEFAULT 0,
|
|
rows_ambiguous INTEGER DEFAULT 0,
|
|
rows_errored INTEGER DEFAULT 0,
|
|
options_json TEXT,
|
|
summary_json TEXT
|
|
)
|
|
`);
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_user ON import_history(user_id)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.17',
|
|
description: 'users: external identity / OIDC columns',
|
|
check: function() {
|
|
const userColsOidc = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
const oidcUserCols = ['auth_provider', 'external_subject', 'email', 'last_login_at'];
|
|
return oidcUserCols.every(col => userColsOidc.includes(col));
|
|
},
|
|
run: function() {
|
|
const userColsOidc = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
const oidcUserCols = [
|
|
['auth_provider', "TEXT NOT NULL DEFAULT 'local'"],
|
|
['external_subject', 'TEXT'],
|
|
['email', 'TEXT'],
|
|
['last_login_at', 'TEXT'],
|
|
];
|
|
for (const [col, def] of oidcUserCols) {
|
|
if (!userColsOidc.includes(col)) {
|
|
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
|
|
if (!isValidColumnName(col) || !isValidSqlDefinition(def)) {
|
|
throw new Error(`Invalid migration: column '${col}' not in whitelist`);
|
|
}
|
|
db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
|
|
}
|
|
}
|
|
|
|
// ── oidc_states: short-lived PKCE + nonce state for OIDC login (v0.17) ───
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS oidc_states (
|
|
id TEXT PRIMARY KEY,
|
|
nonce TEXT NOT NULL,
|
|
code_verifier TEXT NOT NULL,
|
|
redirect_to TEXT,
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL
|
|
)
|
|
`);
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_oidc_states_expires ON oidc_states(expires_at)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.18.1',
|
|
description: 'monthly_income: per-user monthly income for Summary planning',
|
|
check: function() {
|
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_income'").all();
|
|
return tables.length > 0;
|
|
},
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS monthly_income (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
|
|
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
|
|
label TEXT NOT NULL DEFAULT 'Salary',
|
|
amount REAL NOT NULL DEFAULT 0,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
updated_at TEXT DEFAULT (datetime('now')),
|
|
UNIQUE(user_id, year, month)
|
|
)
|
|
`);
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.18.2',
|
|
description: 'monthly_starting_amounts: per-user monthly starting amounts for 1st and 15th',
|
|
check: function() {
|
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_starting_amounts'").all();
|
|
return tables.length > 0;
|
|
},
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS monthly_starting_amounts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
|
|
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
|
|
first_amount REAL NOT NULL DEFAULT 0 CHECK(first_amount >= 0),
|
|
fifteenth_amount REAL NOT NULL DEFAULT 0 CHECK(fifteenth_amount >= 0),
|
|
other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0),
|
|
notes TEXT,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
updated_at TEXT DEFAULT (datetime('now')),
|
|
UNIQUE(user_id, year, month)
|
|
)
|
|
`);
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user_month ON monthly_starting_amounts(user_id, year, month)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.18.3',
|
|
description: 'monthly_starting_amounts: add other_amount column',
|
|
check: function() {
|
|
const startingCols = db.prepare('PRAGMA table_info(monthly_starting_amounts)').all().map(c => c.name);
|
|
return startingCols.includes('other_amount');
|
|
},
|
|
run: function() {
|
|
const startingCols = db.prepare('PRAGMA table_info(monthly_starting_amounts)').all().map(c => c.name);
|
|
if (!startingCols.includes('other_amount')) {
|
|
// Security FIX (2026-05-08): Validate column name to prevent SQL injection
|
|
if (!isValidColumnName('other_amount')) {
|
|
throw new Error('Invalid migration: column other_amount not in whitelist');
|
|
}
|
|
db.exec('ALTER TABLE monthly_starting_amounts ADD COLUMN other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0)');
|
|
console.log('[migration] monthly_starting_amounts.other_amount column added');
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.38',
|
|
description: 'import_history: per-user audit log',
|
|
check: function() {
|
|
// Already handled in v0.15
|
|
return true;
|
|
},
|
|
run: function() {
|
|
// This was already handled in v0.15, but keeping for completeness
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.40',
|
|
description: 'ownership: user-scoped bills/categories',
|
|
check: function() {
|
|
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
const categoryCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
|
return billCols.includes('user_id') && categoryCols.includes('user_id');
|
|
},
|
|
run: function() {
|
|
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!billCols.includes('user_id')) {
|
|
db.exec('ALTER TABLE bills ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE');
|
|
}
|
|
const categoryCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
|
if (!categoryCols.includes('user_id')) {
|
|
db.exec('ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE');
|
|
}
|
|
const categorySql = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='categories'").get()?.sql || '';
|
|
if (/name\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i.test(categorySql)) {
|
|
db.exec('PRAGMA foreign_keys = OFF');
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS categories_v040 (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
)
|
|
`);
|
|
db.exec('INSERT INTO categories_v040 (id, user_id, name, created_at, updated_at) SELECT id, user_id, name, created_at, updated_at FROM categories');
|
|
db.exec('DROP TABLE categories');
|
|
db.exec('ALTER TABLE categories_v040 RENAME TO categories');
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
}
|
|
|
|
const firstAdmin = db.prepare("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get();
|
|
if (firstAdmin) {
|
|
db.prepare('UPDATE bills SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.id);
|
|
// Drop any NULL-owner categories whose name already exists for this admin (case-insensitive)
|
|
// to prevent a UNIQUE(user_id, name) violation when we assign them below.
|
|
db.prepare(`
|
|
DELETE FROM categories
|
|
WHERE user_id IS NULL
|
|
AND LOWER(name) IN (
|
|
SELECT LOWER(name) FROM categories WHERE user_id = ?
|
|
)
|
|
`).run(firstAdmin.id);
|
|
db.prepare('UPDATE categories SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.id);
|
|
}
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_active ON bills(user_id, active)');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_categories_user_name ON categories(user_id, name)');
|
|
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_categories_user_name_unique ON categories(user_id, name COLLATE NOCASE)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.41',
|
|
description: 'bills and categories: is_seeded flag for demo data cleanup',
|
|
check: function() {
|
|
const billColsSeeded = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
const categoryColsSeeded = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
|
return billColsSeeded.includes('is_seeded') && categoryColsSeeded.includes('is_seeded');
|
|
},
|
|
run: function() {
|
|
// ── bills: is_seeded flag for demo data cleanup (v0.41) ───────────────────
|
|
const billColsSeeded = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!billColsSeeded.includes('is_seeded')) {
|
|
db.exec('ALTER TABLE bills ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0');
|
|
console.log('[migration] bills.is_seeded column added');
|
|
}
|
|
|
|
// ── categories: is_seeded flag for demo data cleanup (v0.41) ──────────────
|
|
const categoryColsSeeded = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
|
if (!categoryColsSeeded.includes('is_seeded')) {
|
|
db.exec('ALTER TABLE categories ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0');
|
|
console.log('[migration] categories.is_seeded column added');
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.42',
|
|
description: 'bill_history_ranges: per-bill date ranges for history visibility',
|
|
check: function() {
|
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_history_ranges'").all();
|
|
return tables.length > 0;
|
|
},
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS bill_history_ranges (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
|
start_year INTEGER NOT NULL,
|
|
start_month INTEGER NOT NULL,
|
|
end_year INTEGER,
|
|
end_month INTEGER,
|
|
label TEXT,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
)
|
|
`);
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_bill_history_ranges_bill ON bill_history_ranges(bill_id)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.43',
|
|
description: 'sessions: add created_at column',
|
|
check: function() {
|
|
const sessionCols = db.prepare('PRAGMA table_info(sessions)').all().map(c => c.name);
|
|
return sessionCols.includes('created_at');
|
|
},
|
|
run: function() {
|
|
const sessionCols = db.prepare('PRAGMA table_info(sessions)').all().map(c => c.name);
|
|
if (!sessionCols.includes('created_at')) {
|
|
// Security FIX (2026-05-09): Validate column name to prevent SQL injection
|
|
if (!isValidColumnName('created_at')) {
|
|
throw new Error('Invalid migration: column created_at not in whitelist');
|
|
}
|
|
db.exec("ALTER TABLE sessions ADD COLUMN created_at TEXT DEFAULT (datetime('now'))");
|
|
console.log('[migration] sessions.created_at column added');
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.44',
|
|
description: 'performance: add missing indexes for frequently queried columns',
|
|
check: function() {
|
|
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_bills_user_name'").get();
|
|
},
|
|
run: function() {
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_name ON bills(user_id, name)');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_method ON payments(method)');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user ON monthly_starting_amounts(user_id)');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_imported_at ON import_history(imported_at)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.45',
|
|
description: 'audit: add audit_log table for security event tracking',
|
|
check: function() {
|
|
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='audit_log'").get();
|
|
},
|
|
run: function() {
|
|
db.exec(`CREATE TABLE IF NOT EXISTS audit_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER,
|
|
action TEXT NOT NULL,
|
|
entity_type TEXT,
|
|
entity_id INTEGER,
|
|
details_json TEXT,
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
created_at TEXT DEFAULT (datetime('now'))
|
|
)`);
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id, created_at)');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, created_at)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.46',
|
|
description: 'billing: add cycle_type and cycle_day columns to bills',
|
|
check: function() {
|
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
return cols.includes('cycle_type') && cols.includes('cycle_day');
|
|
},
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!cols.includes('cycle_type')) {
|
|
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
|
|
}
|
|
if (!cols.includes('cycle_day')) {
|
|
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.47',
|
|
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
|
|
check: function() {
|
|
const row = db.prepare("SELECT value FROM settings WHERE key = 'backup_schedule_retention_count'").get();
|
|
return !row || row.value !== '14';
|
|
},
|
|
run: function() {
|
|
db.prepare("UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'").run();
|
|
console.log('[migration] backup_schedule_retention_count updated from 14 to 2');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.48',
|
|
description: 'bills: debt snowball fields (current_balance, minimum_payment, snowball_order, snowball_include)',
|
|
check: function() {
|
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
return ['current_balance', 'minimum_payment', 'snowball_order', 'snowball_include'].every(c => cols.includes(c));
|
|
},
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!cols.includes('current_balance')) db.exec('ALTER TABLE bills ADD COLUMN current_balance REAL');
|
|
if (!cols.includes('minimum_payment')) db.exec('ALTER TABLE bills ADD COLUMN minimum_payment REAL');
|
|
if (!cols.includes('snowball_order')) db.exec('ALTER TABLE bills ADD COLUMN snowball_order INTEGER');
|
|
if (!cols.includes('snowball_include')) db.exec('ALTER TABLE bills ADD COLUMN snowball_include INTEGER NOT NULL DEFAULT 0');
|
|
console.log('[migration] bills: debt snowball columns added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.49',
|
|
description: 'users: snowball_extra_payment column',
|
|
check: function() {
|
|
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
return cols.includes('snowball_extra_payment');
|
|
},
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
if (!cols.includes('snowball_extra_payment')) {
|
|
db.exec('ALTER TABLE users ADD COLUMN snowball_extra_payment REAL NOT NULL DEFAULT 0');
|
|
}
|
|
console.log('[migration] users: snowball_extra_payment column added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.50',
|
|
description: 'payments: balance_delta column for debt payoff tracking',
|
|
check: function() {
|
|
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
|
return cols.includes('balance_delta');
|
|
},
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
|
if (!cols.includes('balance_delta')) {
|
|
db.exec('ALTER TABLE payments ADD COLUMN balance_delta REAL');
|
|
}
|
|
console.log('[migration] payments: balance_delta column added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.51',
|
|
description: 'bills: snowball_exempt column for hiding debt-like bills',
|
|
check: function() {
|
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
return cols.includes('snowball_exempt');
|
|
},
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!cols.includes('snowball_exempt')) {
|
|
db.exec('ALTER TABLE bills ADD COLUMN snowball_exempt INTEGER NOT NULL DEFAULT 0');
|
|
}
|
|
console.log('[migration] bills: snowball_exempt column added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.52',
|
|
description: 'users: last_seen_version for release-notes notifications',
|
|
check: function() {
|
|
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
return cols.includes('last_seen_version');
|
|
},
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
if (!cols.includes('last_seen_version')) {
|
|
db.exec('ALTER TABLE users ADD COLUMN last_seen_version TEXT');
|
|
}
|
|
console.log('[migration] users: last_seen_version column added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.53',
|
|
description: 'user_login_history: track last 3 logins per user',
|
|
check: function() {
|
|
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_login_history'").get();
|
|
},
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS user_login_history (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
logged_in_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
browser TEXT,
|
|
os TEXT,
|
|
device_type TEXT,
|
|
device_fingerprint TEXT
|
|
)
|
|
`);
|
|
console.log('[migration] user_login_history table created');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.54',
|
|
description: 'user_settings: per-user display and billing preferences',
|
|
check: function() {
|
|
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_settings'").get();
|
|
},
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS user_settings (
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
key TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
updated_at TEXT DEFAULT (datetime('now')),
|
|
PRIMARY KEY (user_id, key)
|
|
)
|
|
`);
|
|
const userSettingKeys = ['currency', 'date_format', 'grace_period_days', 'notify_days_before'];
|
|
const users = db.prepare('SELECT id FROM users').all();
|
|
const getCurrent = db.prepare('SELECT value FROM settings WHERE key = ?');
|
|
const insert = db.prepare('INSERT OR IGNORE INTO user_settings (user_id, key, value) VALUES (?, ?, ?)');
|
|
for (const user of users) {
|
|
for (const key of userSettingKeys) {
|
|
const row = getCurrent.get(key);
|
|
if (row) insert.run(user.id, key, row.value);
|
|
}
|
|
}
|
|
console.log('[migration] user_settings table ensured');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.55',
|
|
description: 'user_login_history: parsed device metadata and fingerprint',
|
|
check: function() {
|
|
const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name);
|
|
return ['browser', 'os', 'device_type', 'device_fingerprint'].every(c => cols.includes(c));
|
|
},
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name);
|
|
for (const col of ['browser', 'os', 'device_type', 'device_fingerprint']) {
|
|
if (!cols.includes(col)) db.exec(`ALTER TABLE user_login_history ADD COLUMN ${col} TEXT`);
|
|
}
|
|
console.log('[migration] user_login_history device metadata columns ensured');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.56',
|
|
description: 'bills/categories: soft-delete columns',
|
|
check: function() {
|
|
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
const catCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
|
return billCols.includes('deleted_at') && catCols.includes('deleted_at');
|
|
},
|
|
run: function() {
|
|
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
const catCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
|
if (!billCols.includes('deleted_at')) {
|
|
db.exec('ALTER TABLE bills ADD COLUMN deleted_at TEXT');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_deleted ON bills(user_id, deleted_at)');
|
|
}
|
|
if (!catCols.includes('deleted_at')) {
|
|
db.exec('ALTER TABLE categories ADD COLUMN deleted_at TEXT');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(user_id, deleted_at)');
|
|
}
|
|
console.log('[migration] bills/categories deleted_at columns added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.57',
|
|
description: 'autopay: suggestions and auto-mark paid',
|
|
check: function() {
|
|
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
const hasDismissals = !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='autopay_suggestion_dismissals'").get();
|
|
return billCols.includes('auto_mark_paid') && hasDismissals;
|
|
},
|
|
run: function() {
|
|
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!billCols.includes('auto_mark_paid')) {
|
|
db.exec('ALTER TABLE bills ADD COLUMN auto_mark_paid INTEGER NOT NULL DEFAULT 0');
|
|
}
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS autopay_suggestion_dismissals (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
|
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
|
|
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
|
|
dismissed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(user_id, bill_id, year, month)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_autopay_suggestion_dismissals_user_month
|
|
ON autopay_suggestion_dismissals(user_id, year, month);
|
|
`);
|
|
console.log('[migration] autopay auto_mark_paid and suggestion dismissals ensured');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.58',
|
|
description: 'bills: saved bill templates',
|
|
check: function() {
|
|
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_templates'").get();
|
|
},
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS bill_templates (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
data TEXT NOT NULL,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
updated_at TEXT DEFAULT (datetime('now')),
|
|
UNIQUE(user_id, name)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_bill_templates_user_name
|
|
ON bill_templates(user_id, name);
|
|
`);
|
|
console.log('[migration] bill_templates table ensured');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.59',
|
|
description: 'payments: source metadata for future transaction matching',
|
|
check: function() {
|
|
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
|
return cols.includes('payment_source') && cols.includes('transaction_id');
|
|
},
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
|
if (!cols.includes('payment_source')) {
|
|
db.exec("ALTER TABLE payments ADD COLUMN payment_source TEXT NOT NULL DEFAULT 'manual'");
|
|
}
|
|
if (!cols.includes('transaction_id')) {
|
|
db.exec('ALTER TABLE payments ADD COLUMN transaction_id INTEGER');
|
|
}
|
|
console.log('[migration] payments: source metadata columns added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.60',
|
|
description: 'transactions: shared transaction foundation tables',
|
|
check: function() {
|
|
const tables = db.prepare(`
|
|
SELECT name
|
|
FROM sqlite_master
|
|
WHERE type = 'table'
|
|
AND name IN ('data_sources', 'financial_accounts', 'transactions')
|
|
`).all();
|
|
return tables.length === 3;
|
|
},
|
|
run: function() {
|
|
ensureTransactionFoundationSchema(db);
|
|
console.log('[migration] transaction foundation tables ensured');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.61',
|
|
description: 'payments: one active payment per linked transaction',
|
|
check: function() {
|
|
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_payments_transaction_active'").get();
|
|
},
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_transaction_active
|
|
ON payments(transaction_id)
|
|
WHERE transaction_id IS NOT NULL AND deleted_at IS NULL
|
|
`);
|
|
console.log('[migration] payments: transaction active unique index ensured');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.62',
|
|
description: 'matches: rejected transaction match suggestions',
|
|
check: function() {
|
|
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='match_suggestion_rejections'").get();
|
|
},
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS match_suggestion_rejections (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
|
|
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
|
rejected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(user_id, transaction_id, bill_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_match_suggestion_rejections_user
|
|
ON match_suggestion_rejections(user_id, transaction_id, bill_id);
|
|
`);
|
|
console.log('[migration] match suggestion rejections table ensured');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.63',
|
|
description: 'bills: subscription metadata fields',
|
|
check: function() {
|
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
return ['is_subscription', 'subscription_type', 'reminder_days_before', 'subscription_source', 'subscription_detected_at']
|
|
.every(col => cols.includes(col));
|
|
},
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!cols.includes('is_subscription')) db.exec('ALTER TABLE bills ADD COLUMN is_subscription INTEGER NOT NULL DEFAULT 0');
|
|
if (!cols.includes('subscription_type')) db.exec('ALTER TABLE bills ADD COLUMN subscription_type TEXT');
|
|
if (!cols.includes('reminder_days_before')) db.exec('ALTER TABLE bills ADD COLUMN reminder_days_before INTEGER NOT NULL DEFAULT 3');
|
|
if (!cols.includes('subscription_source')) db.exec("ALTER TABLE bills ADD COLUMN subscription_source TEXT NOT NULL DEFAULT 'manual'");
|
|
if (!cols.includes('subscription_detected_at')) db.exec('ALTER TABLE bills ADD COLUMN subscription_detected_at TEXT');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_subscription ON bills(user_id, is_subscription, active)');
|
|
console.log('[migration] bills: subscription metadata columns added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.64',
|
|
description: 'financial_accounts: monitored flag for bill matching',
|
|
check: function() {
|
|
const cols = db.prepare('PRAGMA table_info(financial_accounts)').all().map(c => c.name);
|
|
return cols.includes('monitored');
|
|
},
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(financial_accounts)').all().map(c => c.name);
|
|
if (!cols.includes('monitored')) {
|
|
db.exec('ALTER TABLE financial_accounts ADD COLUMN monitored INTEGER NOT NULL DEFAULT 1');
|
|
console.log('[migration] financial_accounts: monitored column added');
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.65',
|
|
description: 'subscription_catalog: top-200 known subscription services',
|
|
check: function() {
|
|
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='subscription_catalog'").get();
|
|
},
|
|
run: function() {
|
|
runSubscriptionCatalogMigration(db);
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.66',
|
|
description: 'declined_subscription_hints: per-user dismissed recommendation store',
|
|
check: function() {
|
|
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='declined_subscription_hints'").get();
|
|
},
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS declined_subscription_hints (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
decline_key TEXT NOT NULL,
|
|
declined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(user_id, decline_key)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_declined_hints_user
|
|
ON declined_subscription_hints(user_id);
|
|
`);
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.67',
|
|
description: 'bill_merchant_rules: persistent merchant→bill auto-match rules',
|
|
check: function() {
|
|
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_merchant_rules'").get();
|
|
},
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS bill_merchant_rules (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
|
merchant TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(user_id, bill_id, merchant)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_bill_merchant_rules_user
|
|
ON bill_merchant_rules(user_id);
|
|
`);
|
|
}
|
|
}
|
|
];
|
|
|
|
// Check for legacy notification columns
|
|
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
const newUserCols = [
|
|
'active', 'is_default_admin', 'notification_email', 'notifications_enabled',
|
|
'notify_3d', 'notify_1d', 'notify_due', 'notify_overdue'
|
|
];
|
|
const hasNotificationColumns = newUserCols.every(col => userCols.includes(col));
|
|
|
|
// If notification columns exist, mark that migration as applied
|
|
if (hasNotificationColumns) {
|
|
try {
|
|
recordMigration('legacy-notification-columns', 'users: notification columns');
|
|
console.log('[migration] Recorded legacy notification columns migration');
|
|
} catch (e) {
|
|
// Ignore if already recorded
|
|
}
|
|
}
|
|
|
|
// Assert version sync with runMigrations() to catch drift between the two arrays.
|
|
// runMigrations() always runs first and populates _runMigrationVersions.
|
|
if (_runMigrationVersions !== null) {
|
|
const reconcileVersions = migrations.map(m => m.version);
|
|
const runSet = new Set(_runMigrationVersions);
|
|
const reconcileSet = new Set(reconcileVersions);
|
|
const onlyInRun = _runMigrationVersions.filter(v => !reconcileSet.has(v));
|
|
const onlyInReconcile = reconcileVersions.filter(v => !runSet.has(v));
|
|
if (onlyInRun.length || onlyInReconcile.length) {
|
|
const msg =
|
|
'[migration-sync] reconcileLegacyMigrations and runMigrations version lists are out of sync.' +
|
|
(onlyInRun.length ? ` Only in runMigrations: ${onlyInRun.join(', ')}.` : '') +
|
|
(onlyInReconcile.length ? ` Only in reconcile: ${onlyInReconcile.join(', ')}.` : '') +
|
|
' Add the missing version to both arrays.';
|
|
console.error(msg);
|
|
throw new Error(msg);
|
|
}
|
|
}
|
|
|
|
// Process all versioned migrations
|
|
for (const migration of migrations) {
|
|
if (migration.check()) {
|
|
try {
|
|
recordMigration(migration.version, migration.description);
|
|
console.log(`[migration] Recorded legacy migration ${migration.version}: ${migration.description}`);
|
|
} catch (e) {
|
|
// Ignore if already recorded
|
|
}
|
|
} else {
|
|
// Migration changes are NOT present - run the migration to apply them
|
|
try {
|
|
console.log(`[migration] Running legacy migration ${migration.version}: ${migration.description}`);
|
|
// Wrap legacy migration in transaction
|
|
db.exec('BEGIN');
|
|
console.log(`[migration] Transaction BEGIN for legacy ${migration.version}`);
|
|
migration.run();
|
|
recordMigration(migration.version, migration.description);
|
|
db.exec('COMMIT');
|
|
console.log(`[migration] Transaction COMMIT for legacy ${migration.version}`);
|
|
} catch (err) {
|
|
db.exec('ROLLBACK');
|
|
console.error(`[migration-error] Failed to apply legacy migration ${migration.version}: ${err.message}. Rolled back.`);
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log('[migration] Legacy database reconciliation complete');
|
|
}
|
|
|
|
function recordMigration(version, description) {
|
|
const stmt = db.prepare('INSERT INTO schema_migrations (version, description) VALUES (?, ?)');
|
|
stmt.run(version, description);
|
|
console.log(`[migration] Applied ${version}: ${description}`);
|
|
}
|
|
|
|
function validateMigrationDependencies(migration, appliedVersions) {
|
|
// Validate that all dependencies for a migration have been applied
|
|
const deps = migration.dependsOn || [];
|
|
const missing = deps.filter(dep => !appliedVersions.has(dep));
|
|
if (missing.length === 0) {
|
|
return { valid: true };
|
|
}
|
|
return { valid: false, missing };
|
|
}
|
|
|
|
function runMigrations() {
|
|
console.log('[migration] Starting database migrations');
|
|
const startTime = Date.now();
|
|
|
|
|
|
// Log start of migrations to audit log
|
|
try {
|
|
getLogAudit()({
|
|
action: 'migration.start',
|
|
entity_type: 'migration',
|
|
entity_id: null,
|
|
details: { message: 'Starting database migrations' }
|
|
});
|
|
} catch (auditErr) {
|
|
console.error(`[audit-error] Failed to log migration start to audit log: ${auditErr.message}`);
|
|
}
|
|
// Define all migrations with explicit version tracking and dependency chains
|
|
const migrations = [
|
|
{
|
|
version: 'v0.2',
|
|
dependsOn: [],
|
|
description: 'payments: soft-delete column',
|
|
run: function() {
|
|
const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
|
if (!paymentCols.includes('deleted_at')) {
|
|
db.exec('ALTER TABLE payments ADD COLUMN deleted_at TEXT');
|
|
// Index for fast filtering of live payments
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_deleted ON payments(deleted_at)');
|
|
console.log('[migration] payments.deleted_at column added');
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.3',
|
|
dependsOn: ['v0.2'],
|
|
description: 'payments: compound index for tracker query',
|
|
run: function() {
|
|
// Supports: WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_bill_date_del ON payments(bill_id, paid_date, deleted_at)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.4',
|
|
dependsOn: ['v0.3'],
|
|
description: 'monthly_bill_state: per-bill per-month overrides',
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS monthly_bill_state (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
|
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
|
|
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
|
|
actual_amount REAL,
|
|
notes TEXT,
|
|
is_skipped INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
updated_at TEXT DEFAULT (datetime('now')),
|
|
UNIQUE(bill_id, year, month)
|
|
)
|
|
`);
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup ON monthly_bill_state(bill_id, year, month)');
|
|
console.log('[migration] monthly_bill_state table ensured');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.13',
|
|
dependsOn: ['v0.4'],
|
|
description: 'users: profile columns',
|
|
run: function() {
|
|
const userColsNow = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
const profileCols = [
|
|
['display_name', 'TEXT'],
|
|
['last_password_change_at','TEXT'],
|
|
];
|
|
for (const [col, def] of profileCols) {
|
|
if (!userColsNow.includes(col)) {
|
|
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
|
|
if (!isValidColumnName(col) || !isValidSqlDefinition(def)) {
|
|
throw new Error(`Invalid migration: column '${col}' not in whitelist`);
|
|
}
|
|
db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.14',
|
|
dependsOn: ['v0.13'],
|
|
description: 'bills: history visibility mode',
|
|
run: function() {
|
|
const billColsHist = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!billColsHist.includes('history_visibility')) {
|
|
db.exec("ALTER TABLE bills ADD COLUMN history_visibility TEXT NOT NULL DEFAULT 'default'");
|
|
console.log('[migration] bills.history_visibility column added');
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.14.4',
|
|
dependsOn: ['v0.14'],
|
|
description: 'bills: optional credit-card APR / interest rate',
|
|
run: function() {
|
|
const billColsInterest = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!billColsInterest.includes('interest_rate')) {
|
|
db.exec('ALTER TABLE bills ADD COLUMN interest_rate REAL');
|
|
console.log('[migration] bills.interest_rate column added');
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.15',
|
|
dependsOn: ['v0.14.4'],
|
|
description: 'import_sessions and import_history tables',
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS import_sessions (
|
|
id TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
preview_json TEXT NOT NULL
|
|
)
|
|
`);
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_import_sessions_user ON import_sessions(user_id)');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_import_sessions_expires ON import_sessions(expires_at)');
|
|
|
|
// ── import_history: per-user audit log (v0.38) ────────────────────────────
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS import_history (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
imported_at TEXT NOT NULL,
|
|
source_filename TEXT,
|
|
file_type TEXT DEFAULT 'xlsx',
|
|
sheet_name TEXT,
|
|
rows_parsed INTEGER DEFAULT 0,
|
|
rows_created INTEGER DEFAULT 0,
|
|
rows_updated INTEGER DEFAULT 0,
|
|
rows_skipped INTEGER DEFAULT 0,
|
|
rows_ambiguous INTEGER DEFAULT 0,
|
|
rows_errored INTEGER DEFAULT 0,
|
|
options_json TEXT,
|
|
summary_json TEXT
|
|
)
|
|
`);
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_user ON import_history(user_id)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.17',
|
|
dependsOn: ['v0.15'],
|
|
description: 'users: external identity / OIDC columns',
|
|
run: function() {
|
|
const userColsOidc = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
const oidcUserCols = [
|
|
['auth_provider', "TEXT NOT NULL DEFAULT 'local'"],
|
|
['external_subject', 'TEXT'],
|
|
['email', 'TEXT'],
|
|
['last_login_at', 'TEXT'],
|
|
];
|
|
for (const [col, def] of oidcUserCols) {
|
|
if (!userColsOidc.includes(col)) {
|
|
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
|
|
if (!isValidColumnName(col) || !isValidSqlDefinition(def)) {
|
|
throw new Error(`Invalid migration: column '${col}' not in whitelist`);
|
|
}
|
|
db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
|
|
}
|
|
}
|
|
|
|
// ── oidc_states: short-lived PKCE + nonce state for OIDC login (v0.17) ───
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS oidc_states (
|
|
id TEXT PRIMARY KEY,
|
|
nonce TEXT NOT NULL,
|
|
code_verifier TEXT NOT NULL,
|
|
redirect_to TEXT,
|
|
created_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL
|
|
)
|
|
`);
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_oidc_states_expires ON oidc_states(expires_at)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.18.1',
|
|
dependsOn: ['v0.17'],
|
|
description: 'monthly_income: per-user monthly income for Summary planning',
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS monthly_income (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
|
|
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
|
|
label TEXT NOT NULL DEFAULT 'Salary',
|
|
amount REAL NOT NULL DEFAULT 0,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
updated_at TEXT DEFAULT (datetime('now')),
|
|
UNIQUE(user_id, year, month)
|
|
)
|
|
`);
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.18.2',
|
|
dependsOn: ['v0.18.1'],
|
|
description: 'monthly_starting_amounts: per-user monthly starting amounts for 1st and 15th',
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS monthly_starting_amounts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
|
|
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
|
|
first_amount REAL NOT NULL DEFAULT 0 CHECK(first_amount >= 0),
|
|
fifteenth_amount REAL NOT NULL DEFAULT 0 CHECK(fifteenth_amount >= 0),
|
|
other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0),
|
|
notes TEXT,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
updated_at TEXT DEFAULT (datetime('now')),
|
|
UNIQUE(user_id, year, month)
|
|
)
|
|
`);
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user_month ON monthly_starting_amounts(user_id, year, month)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.18.3',
|
|
dependsOn: ['v0.18.2'],
|
|
description: 'monthly_starting_amounts: add other_amount column',
|
|
run: function() {
|
|
const startingCols = db.prepare('PRAGMA table_info(monthly_starting_amounts)').all().map(c => c.name);
|
|
if (!startingCols.includes('other_amount')) {
|
|
// Security FIX (2026-05-08): Validate column name to prevent SQL injection
|
|
if (!isValidColumnName('other_amount')) {
|
|
throw new Error('Invalid migration: column other_amount not in whitelist');
|
|
}
|
|
db.exec('ALTER TABLE monthly_starting_amounts ADD COLUMN other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0)');
|
|
console.log('[migration] monthly_starting_amounts.other_amount column added');
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.38',
|
|
dependsOn: ['v0.18.3'],
|
|
description: 'import_history: per-user audit log',
|
|
run: function() {
|
|
// This was already handled in v0.15, but keeping for completeness
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.40',
|
|
dependsOn: ['v0.38'],
|
|
description: 'ownership: user-scoped bills/categories',
|
|
run: function() {
|
|
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!billCols.includes('user_id')) {
|
|
db.exec('ALTER TABLE bills ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE');
|
|
}
|
|
const categoryCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
|
if (!categoryCols.includes('user_id')) {
|
|
db.exec('ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE');
|
|
}
|
|
const categorySql = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='categories'").get()?.sql || '';
|
|
if (/name\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i.test(categorySql)) {
|
|
db.exec('PRAGMA foreign_keys = OFF');
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS categories_v040 (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
)
|
|
`);
|
|
db.exec('INSERT INTO categories_v040 (id, user_id, name, created_at, updated_at) SELECT id, user_id, name, created_at, updated_at FROM categories');
|
|
db.exec('DROP TABLE categories');
|
|
db.exec('ALTER TABLE categories_v040 RENAME TO categories');
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
}
|
|
|
|
const firstAdmin = db.prepare("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get();
|
|
if (firstAdmin) {
|
|
db.prepare('UPDATE bills SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.id);
|
|
// Drop any NULL-owner categories whose name already exists for this admin (case-insensitive)
|
|
// to prevent a UNIQUE(user_id, name) violation when we assign them below.
|
|
db.prepare(`
|
|
DELETE FROM categories
|
|
WHERE user_id IS NULL
|
|
AND LOWER(name) IN (
|
|
SELECT LOWER(name) FROM categories WHERE user_id = ?
|
|
)
|
|
`).run(firstAdmin.id);
|
|
db.prepare('UPDATE categories SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.id);
|
|
}
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_active ON bills(user_id, active)');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_categories_user_name ON categories(user_id, name)');
|
|
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_categories_user_name_unique ON categories(user_id, name COLLATE NOCASE)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.41',
|
|
dependsOn: ['v0.40'],
|
|
description: 'bills and categories: is_seeded flag for demo data cleanup',
|
|
run: function() {
|
|
// ── bills: is_seeded flag for demo data cleanup (v0.41) ───────────────────
|
|
const billColsSeeded = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!billColsSeeded.includes('is_seeded')) {
|
|
db.exec('ALTER TABLE bills ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0');
|
|
console.log('[migration] bills.is_seeded column added');
|
|
}
|
|
|
|
// ── categories: is_seeded flag for demo data cleanup (v0.41) ──────────────
|
|
const categoryColsSeeded = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
|
if (!categoryColsSeeded.includes('is_seeded')) {
|
|
db.exec('ALTER TABLE categories ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0');
|
|
console.log('[migration] categories.is_seeded column added');
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.42',
|
|
dependsOn: ['v0.41'],
|
|
description: 'bill_history_ranges: per-bill date ranges for history visibility',
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS bill_history_ranges (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
|
start_year INTEGER NOT NULL,
|
|
start_month INTEGER NOT NULL,
|
|
end_year INTEGER,
|
|
end_month INTEGER,
|
|
label TEXT,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
)
|
|
`);
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_bill_history_ranges_bill ON bill_history_ranges(bill_id)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.43',
|
|
dependsOn: ['v0.42'],
|
|
description: 'sessions: add created_at column',
|
|
run: function() {
|
|
const sessionCols = db.prepare('PRAGMA table_info(sessions)').all().map(c => c.name);
|
|
if (!sessionCols.includes('created_at')) {
|
|
// Security FIX (2026-05-09): Validate column name to prevent SQL injection
|
|
if (!isValidColumnName('created_at')) {
|
|
throw new Error('Invalid migration: column created_at not in whitelist');
|
|
}
|
|
db.exec("ALTER TABLE sessions ADD COLUMN created_at TEXT DEFAULT (datetime('now'))");
|
|
console.log('[migration] sessions.created_at column added');
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.44',
|
|
dependsOn: ['v0.43'],
|
|
description: 'performance: add missing indexes for frequently queried columns',
|
|
run: function() {
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_name ON bills(user_id, name)');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_method ON payments(method)');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user ON monthly_starting_amounts(user_id)');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_imported_at ON import_history(imported_at)');
|
|
console.log('[migration] Added indexes for frequently queried columns');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.45',
|
|
dependsOn: ['v0.44'],
|
|
description: 'audit: add audit_log table for security event tracking',
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER,
|
|
action TEXT NOT NULL,
|
|
entity_type TEXT,
|
|
entity_id INTEGER,
|
|
details_json TEXT,
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
created_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, created_at);
|
|
`);
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.46',
|
|
description: 'billing: add cycle_type and cycle_day columns to bills',
|
|
dependsOn: ['v0.45'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!cols.includes('cycle_type')) {
|
|
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
|
|
}
|
|
if (!cols.includes('cycle_day')) {
|
|
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.47',
|
|
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
|
|
dependsOn: ['v0.46'],
|
|
run: function() {
|
|
db.prepare("UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'").run();
|
|
console.log('[migration] backup_schedule_retention_count updated from 14 to 2');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.48',
|
|
description: 'bills: debt snowball fields (current_balance, minimum_payment, snowball_order, snowball_include)',
|
|
dependsOn: ['v0.47'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!cols.includes('current_balance')) db.exec('ALTER TABLE bills ADD COLUMN current_balance REAL');
|
|
if (!cols.includes('minimum_payment')) db.exec('ALTER TABLE bills ADD COLUMN minimum_payment REAL');
|
|
if (!cols.includes('snowball_order')) db.exec('ALTER TABLE bills ADD COLUMN snowball_order INTEGER');
|
|
if (!cols.includes('snowball_include')) db.exec('ALTER TABLE bills ADD COLUMN snowball_include INTEGER NOT NULL DEFAULT 0');
|
|
console.log('[migration] bills: debt snowball columns added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.49',
|
|
description: 'users: snowball_extra_payment column',
|
|
dependsOn: ['v0.48'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
if (!cols.includes('snowball_extra_payment')) {
|
|
db.exec('ALTER TABLE users ADD COLUMN snowball_extra_payment REAL NOT NULL DEFAULT 0');
|
|
}
|
|
console.log('[migration] users: snowball_extra_payment column added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.50',
|
|
description: 'payments: balance_delta column for debt payoff tracking',
|
|
dependsOn: ['v0.49'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
|
if (!cols.includes('balance_delta')) {
|
|
db.exec('ALTER TABLE payments ADD COLUMN balance_delta REAL');
|
|
}
|
|
console.log('[migration] payments: balance_delta column added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.51',
|
|
description: 'bills: snowball_exempt column for hiding debt-like bills',
|
|
dependsOn: ['v0.50'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!cols.includes('snowball_exempt')) {
|
|
db.exec('ALTER TABLE bills ADD COLUMN snowball_exempt INTEGER NOT NULL DEFAULT 0');
|
|
}
|
|
console.log('[migration] bills: snowball_exempt column added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.52',
|
|
description: 'users: last_seen_version for release-notes notifications',
|
|
dependsOn: ['v0.51'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
if (!cols.includes('last_seen_version')) {
|
|
db.exec('ALTER TABLE users ADD COLUMN last_seen_version TEXT');
|
|
}
|
|
console.log('[migration] users: last_seen_version column added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.53',
|
|
description: 'user_login_history: track last 3 logins per user',
|
|
dependsOn: ['v0.52'],
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS user_login_history (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
logged_in_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
ip_address TEXT,
|
|
user_agent TEXT
|
|
)
|
|
`);
|
|
console.log('[migration] user_login_history table created');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.54',
|
|
description: 'user_settings: per-user display and billing preferences',
|
|
dependsOn: ['v0.53'],
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS user_settings (
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
key TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
updated_at TEXT DEFAULT (datetime('now')),
|
|
PRIMARY KEY (user_id, key)
|
|
)
|
|
`);
|
|
|
|
const userSettingKeys = ['currency', 'date_format', 'grace_period_days', 'notify_days_before'];
|
|
const users = db.prepare('SELECT id FROM users').all();
|
|
const getCurrent = db.prepare('SELECT value FROM settings WHERE key = ?');
|
|
const insert = db.prepare(`
|
|
INSERT OR IGNORE INTO user_settings (user_id, key, value)
|
|
VALUES (?, ?, ?)
|
|
`);
|
|
|
|
for (const user of users) {
|
|
for (const key of userSettingKeys) {
|
|
const row = getCurrent.get(key);
|
|
if (row) insert.run(user.id, key, row.value);
|
|
}
|
|
}
|
|
console.log('[migration] user_settings table created and seeded from current global defaults');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.55',
|
|
description: 'user_login_history: parsed device metadata and fingerprint',
|
|
dependsOn: ['v0.54'],
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS user_login_history (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
logged_in_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
browser TEXT,
|
|
os TEXT,
|
|
device_type TEXT,
|
|
device_fingerprint TEXT
|
|
)
|
|
`);
|
|
|
|
const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name);
|
|
const newCols = [
|
|
['browser', 'TEXT'],
|
|
['os', 'TEXT'],
|
|
['device_type', 'TEXT'],
|
|
['device_fingerprint', 'TEXT'],
|
|
];
|
|
for (const [col, def] of newCols) {
|
|
if (!cols.includes(col)) {
|
|
db.exec(`ALTER TABLE user_login_history ADD COLUMN ${col} ${def}`);
|
|
}
|
|
}
|
|
console.log('[migration] user_login_history device metadata columns ensured');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.56',
|
|
description: 'bills/categories: soft-delete columns',
|
|
dependsOn: ['v0.55'],
|
|
run: function() {
|
|
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
const catCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
|
if (!billCols.includes('deleted_at')) {
|
|
db.exec('ALTER TABLE bills ADD COLUMN deleted_at TEXT');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_deleted ON bills(user_id, deleted_at)');
|
|
}
|
|
if (!catCols.includes('deleted_at')) {
|
|
db.exec('ALTER TABLE categories ADD COLUMN deleted_at TEXT');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(user_id, deleted_at)');
|
|
}
|
|
console.log('[migration] bills/categories deleted_at columns added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.57',
|
|
description: 'autopay: suggestions and auto-mark paid',
|
|
dependsOn: ['v0.56'],
|
|
run: function() {
|
|
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!billCols.includes('auto_mark_paid')) {
|
|
db.exec('ALTER TABLE bills ADD COLUMN auto_mark_paid INTEGER NOT NULL DEFAULT 0');
|
|
}
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS autopay_suggestion_dismissals (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
|
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
|
|
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
|
|
dismissed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(user_id, bill_id, year, month)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_autopay_suggestion_dismissals_user_month
|
|
ON autopay_suggestion_dismissals(user_id, year, month);
|
|
`);
|
|
console.log('[migration] autopay auto_mark_paid and suggestion dismissals ensured');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.58',
|
|
description: 'bills: saved bill templates',
|
|
dependsOn: ['v0.57'],
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS bill_templates (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
data TEXT NOT NULL,
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|
updated_at TEXT DEFAULT (datetime('now')),
|
|
UNIQUE(user_id, name)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_bill_templates_user_name
|
|
ON bill_templates(user_id, name);
|
|
`);
|
|
console.log('[migration] bill_templates table ensured');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.59',
|
|
description: 'payments: source metadata for future transaction matching',
|
|
dependsOn: ['v0.58'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
|
if (!cols.includes('payment_source')) {
|
|
db.exec("ALTER TABLE payments ADD COLUMN payment_source TEXT NOT NULL DEFAULT 'manual'");
|
|
}
|
|
if (!cols.includes('transaction_id')) {
|
|
db.exec('ALTER TABLE payments ADD COLUMN transaction_id INTEGER');
|
|
}
|
|
console.log('[migration] payments: source metadata columns added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.60',
|
|
description: 'transactions: shared transaction foundation tables',
|
|
dependsOn: ['v0.59'],
|
|
run: function() {
|
|
ensureTransactionFoundationSchema(db);
|
|
console.log('[migration] transaction foundation tables ensured');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.61',
|
|
description: 'payments: one active payment per linked transaction',
|
|
dependsOn: ['v0.60'],
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_transaction_active
|
|
ON payments(transaction_id)
|
|
WHERE transaction_id IS NOT NULL AND deleted_at IS NULL
|
|
`);
|
|
console.log('[migration] payments: transaction active unique index ensured');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.62',
|
|
description: 'matches: rejected transaction match suggestions',
|
|
dependsOn: ['v0.61'],
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS match_suggestion_rejections (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
|
|
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
|
rejected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(user_id, transaction_id, bill_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_match_suggestion_rejections_user
|
|
ON match_suggestion_rejections(user_id, transaction_id, bill_id);
|
|
`);
|
|
console.log('[migration] match suggestion rejections table ensured');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.63',
|
|
description: 'bills: subscription metadata fields',
|
|
dependsOn: ['v0.62'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!cols.includes('is_subscription')) db.exec('ALTER TABLE bills ADD COLUMN is_subscription INTEGER NOT NULL DEFAULT 0');
|
|
if (!cols.includes('subscription_type')) db.exec('ALTER TABLE bills ADD COLUMN subscription_type TEXT');
|
|
if (!cols.includes('reminder_days_before')) db.exec('ALTER TABLE bills ADD COLUMN reminder_days_before INTEGER NOT NULL DEFAULT 3');
|
|
if (!cols.includes('subscription_source')) db.exec("ALTER TABLE bills ADD COLUMN subscription_source TEXT NOT NULL DEFAULT 'manual'");
|
|
if (!cols.includes('subscription_detected_at')) db.exec('ALTER TABLE bills ADD COLUMN subscription_detected_at TEXT');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_subscription ON bills(user_id, is_subscription, active)');
|
|
console.log('[migration] bills: subscription metadata columns added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.64',
|
|
description: 'financial_accounts: monitored flag for bill matching',
|
|
dependsOn: ['v0.63'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(financial_accounts)').all().map(c => c.name);
|
|
if (!cols.includes('monitored')) {
|
|
db.exec('ALTER TABLE financial_accounts ADD COLUMN monitored INTEGER NOT NULL DEFAULT 1');
|
|
console.log('[migration] financial_accounts: monitored column added');
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.65',
|
|
description: 'subscription_catalog: top-200 known subscription services',
|
|
dependsOn: ['v0.64'],
|
|
run: function() {
|
|
runSubscriptionCatalogMigration(db);
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.66',
|
|
description: 'declined_subscription_hints: per-user dismissed recommendation store',
|
|
dependsOn: ['v0.65'],
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS declined_subscription_hints (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
decline_key TEXT NOT NULL,
|
|
declined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(user_id, decline_key)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_declined_hints_user
|
|
ON declined_subscription_hints(user_id);
|
|
`);
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.67',
|
|
description: 'bill_merchant_rules: persistent merchant→bill auto-match rules',
|
|
dependsOn: ['v0.66'],
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS bill_merchant_rules (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
|
|
merchant TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(user_id, bill_id, merchant)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_bill_merchant_rules_user
|
|
ON bill_merchant_rules(user_id);
|
|
`);
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.68',
|
|
description: 'advisory_non_bill_filters: 5k advisory patterns + bill-like override terms',
|
|
dependsOn: ['v0.67'],
|
|
run: function() {
|
|
runAdvisoryFiltersMigration(db);
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.69',
|
|
description: 'subscription_catalog v2: 90 new services + category fixes',
|
|
dependsOn: ['v0.68'],
|
|
run: function() {
|
|
runSubscriptionCatalogV2Migration(db);
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.70',
|
|
description: 'monthly_bill_state: add snoozed_until for overdue command center',
|
|
dependsOn: ['v0.69'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(monthly_bill_state)').all().map(c => c.name);
|
|
if (!cols.includes('snoozed_until')) db.exec('ALTER TABLE monthly_bill_state ADD COLUMN snoozed_until TEXT');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.71',
|
|
description: 'bills: add drift_snoozed_until; users: add notify_amount_change',
|
|
dependsOn: ['v0.70'],
|
|
run: function() {
|
|
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
if (!billCols.includes('drift_snoozed_until')) db.exec('ALTER TABLE bills ADD COLUMN drift_snoozed_until TEXT');
|
|
if (!userCols.includes('notify_amount_change')) db.exec('ALTER TABLE users ADD COLUMN notify_amount_change INTEGER NOT NULL DEFAULT 1');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.72',
|
|
description: 'bills: persistent tracker sort order',
|
|
dependsOn: ['v0.71'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!cols.includes('sort_order')) db.exec('ALTER TABLE bills ADD COLUMN sort_order INTEGER');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_sort ON bills(user_id, active, sort_order, due_day)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.73',
|
|
description: 'add snowball_plans table for plan lifecycle + history',
|
|
dependsOn: ['v0.72'],
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS snowball_plans (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL DEFAULT 'Snowball Plan',
|
|
method TEXT NOT NULL DEFAULT 'snowball',
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
paused_at TEXT,
|
|
completed_at TEXT,
|
|
extra_payment REAL NOT NULL DEFAULT 0,
|
|
plan_snapshot TEXT NOT NULL,
|
|
notes TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)
|
|
`);
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_snowball_plans_user ON snowball_plans(user_id, status, created_at)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.74',
|
|
description: 'subscription_catalog: Claude.ai Anthropic matching',
|
|
dependsOn: ['v0.73'],
|
|
run: function() {
|
|
db.prepare(`
|
|
UPDATE subscription_catalog
|
|
SET name = 'Claude.ai',
|
|
category = 'AI',
|
|
subscription_type = 'software',
|
|
website = 'https://claude.ai/upgrade',
|
|
domain = 'anthropic.com'
|
|
WHERE name IN ('Claude', 'Claude.ai')
|
|
OR domain IN ('claude.ai', 'anthropic.com')
|
|
`).run();
|
|
|
|
db.prepare(`
|
|
INSERT INTO subscription_catalog (rank, name, category, subscription_type, website, domain)
|
|
SELECT 94, 'Claude.ai', 'AI', 'software', 'https://claude.ai/upgrade', 'anthropic.com'
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM subscription_catalog
|
|
WHERE name = 'Claude.ai' OR domain IN ('claude.ai', 'anthropic.com')
|
|
)
|
|
`).run();
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.75',
|
|
description: 'categories: persistent sort order',
|
|
dependsOn: ['v0.74'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
|
if (!cols.includes('sort_order')) db.exec('ALTER TABLE categories ADD COLUMN sort_order INTEGER');
|
|
db.exec('CREATE INDEX IF NOT EXISTS idx_categories_user_sort ON categories(user_id, sort_order, name)');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.76',
|
|
description: 'bills: canonical billing schedule cleanup',
|
|
dependsOn: ['v0.75'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!cols.includes('cycle_type') || !cols.includes('cycle_day') || !cols.includes('billing_cycle')) return;
|
|
|
|
db.exec(`
|
|
UPDATE bills
|
|
SET cycle_type = CASE
|
|
WHEN LOWER(COALESCE(cycle_type, '')) IN ('', 'monthly')
|
|
AND LOWER(COALESCE(billing_cycle, '')) = 'quarterly'
|
|
THEN 'quarterly'
|
|
WHEN LOWER(COALESCE(cycle_type, '')) IN ('', 'monthly')
|
|
AND LOWER(COALESCE(billing_cycle, '')) IN ('annually', 'annual')
|
|
THEN 'annual'
|
|
WHEN LOWER(COALESCE(cycle_type, '')) IN ('monthly', 'weekly', 'biweekly', 'quarterly', 'annual')
|
|
THEN LOWER(cycle_type)
|
|
ELSE 'monthly'
|
|
END;
|
|
|
|
UPDATE bills
|
|
SET cycle_day = CASE
|
|
WHEN cycle_type IN ('weekly', 'biweekly')
|
|
AND LOWER(COALESCE(cycle_day, '')) IN ('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday')
|
|
THEN LOWER(cycle_day)
|
|
WHEN cycle_type IN ('weekly', 'biweekly')
|
|
THEN 'monday'
|
|
WHEN cycle_type IN ('quarterly', 'annual')
|
|
AND CAST(cycle_day AS INTEGER) BETWEEN 1 AND 12
|
|
THEN CAST(CAST(cycle_day AS INTEGER) AS TEXT)
|
|
WHEN cycle_type IN ('quarterly', 'annual')
|
|
THEN '1'
|
|
WHEN cycle_type = 'monthly'
|
|
AND CAST(cycle_day AS INTEGER) BETWEEN 1 AND 31
|
|
THEN CAST(CAST(cycle_day AS INTEGER) AS TEXT)
|
|
ELSE CAST(CASE WHEN due_day BETWEEN 1 AND 31 THEN due_day ELSE 1 END AS TEXT)
|
|
END;
|
|
|
|
UPDATE bills
|
|
SET billing_cycle = CASE
|
|
WHEN cycle_type = 'quarterly' THEN 'quarterly'
|
|
WHEN cycle_type = 'annual' THEN 'annually'
|
|
WHEN cycle_type IN ('weekly', 'biweekly') THEN 'irregular'
|
|
ELSE 'monthly'
|
|
END;
|
|
`);
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.77',
|
|
description: 'encrypt SMTP password at rest',
|
|
dependsOn: ['v0.76'],
|
|
run: function() {
|
|
try {
|
|
const { decryptSecret, encryptSecret } = require('../services/encryptionService');
|
|
const row = db.prepare("SELECT value FROM settings WHERE key = 'notify_smtp_password'").get();
|
|
if (row?.value) {
|
|
try {
|
|
decryptSecret(row.value); // already encrypted — skip
|
|
} catch {
|
|
// plaintext — encrypt it
|
|
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'notify_smtp_password'")
|
|
.run(encryptSecret(row.value));
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('[v0.77] SMTP password encryption migration failed:', err.message);
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.78',
|
|
description: 're-encrypt secrets from SHA-256 to HKDF key derivation',
|
|
dependsOn: ['v0.77'],
|
|
run: function() {
|
|
try {
|
|
const { decryptSecret, encryptSecret } = require('../services/encryptionService');
|
|
|
|
// Re-encrypt SimpleFIN tokens in data_sources
|
|
const sources = db.prepare(
|
|
"SELECT id, encrypted_secret FROM data_sources WHERE encrypted_secret IS NOT NULL AND encrypted_secret NOT LIKE 'v2:%'"
|
|
).all();
|
|
const updateSource = db.prepare('UPDATE data_sources SET encrypted_secret = ? WHERE id = ?');
|
|
for (const row of sources) {
|
|
try {
|
|
updateSource.run(encryptSecret(decryptSecret(row.encrypted_secret)), row.id);
|
|
} catch (err) {
|
|
console.warn(`[v0.78] Could not re-encrypt data_source id=${row.id}:`, err.message);
|
|
}
|
|
}
|
|
|
|
// Re-encrypt SMTP password
|
|
const smtp = db.prepare("SELECT value FROM settings WHERE key = 'notify_smtp_password'").get();
|
|
if (smtp?.value && !smtp.value.startsWith('v2:')) {
|
|
try {
|
|
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'notify_smtp_password'")
|
|
.run(encryptSecret(decryptSecret(smtp.value)));
|
|
} catch (err) {
|
|
console.warn('[v0.78] Could not re-encrypt SMTP password:', err.message);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('[v0.78] HKDF re-encryption migration failed:', err.message);
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.79',
|
|
description: 'encrypt OIDC client secret at rest',
|
|
dependsOn: ['v0.78'],
|
|
run: function() {
|
|
try {
|
|
const { decryptSecret, encryptSecret } = require('../services/encryptionService');
|
|
const row = db.prepare("SELECT value FROM settings WHERE key = 'oidc_client_secret'").get();
|
|
if (row?.value) {
|
|
try {
|
|
decryptSecret(row.value); // already encrypted — skip
|
|
} catch {
|
|
// plaintext — encrypt it
|
|
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'oidc_client_secret'")
|
|
.run(encryptSecret(row.value));
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('[v0.79] OIDC client secret encryption migration failed:', err.message);
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.80',
|
|
description: 'users: push notification columns (ntfy / Gotify / Discord / Telegram)',
|
|
dependsOn: ['v0.79'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
const add = (col, def) => {
|
|
if (!cols.includes(col)) db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
|
|
};
|
|
add('notify_push_enabled', 'INTEGER NOT NULL DEFAULT 0');
|
|
add('push_channel', 'TEXT');
|
|
add('push_url', 'TEXT');
|
|
add('push_token', 'TEXT');
|
|
add('push_chat_id', 'TEXT');
|
|
console.log('[v0.80] push notification columns added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.81',
|
|
description: 'bill_merchant_rules: composite index on (user_id, bill_id) for faster EXISTS lookups',
|
|
dependsOn: ['v0.80'],
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_bill_merchant_rules_user_bill
|
|
ON bill_merchant_rules(user_id, bill_id)
|
|
`);
|
|
console.log('[v0.81] bill_merchant_rules composite index added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.82',
|
|
description: 'payments: normalise auto_match source to provider_sync',
|
|
dependsOn: ['v0.81'],
|
|
run: function() {
|
|
const result = db.prepare(
|
|
"UPDATE payments SET payment_source = 'provider_sync' WHERE payment_source = 'auto_match'"
|
|
).run();
|
|
console.log(`[v0.82] Normalised ${result.changes} auto_match payment(s) to provider_sync`);
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.83',
|
|
description: 'bill_merchant_rules: auto_attribute_late flag for bills that always post after month end',
|
|
dependsOn: ['v0.82'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(bill_merchant_rules)').all().map(c => c.name);
|
|
if (!cols.includes('auto_attribute_late')) {
|
|
db.exec('ALTER TABLE bill_merchant_rules ADD COLUMN auto_attribute_late INTEGER NOT NULL DEFAULT 0');
|
|
console.log('[v0.83] bill_merchant_rules.auto_attribute_late added');
|
|
}
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.84',
|
|
description: 'user_login_history: encrypt ip/useragent at rest + add location + keep 10 records',
|
|
dependsOn: ['v0.83'],
|
|
run: function() {
|
|
const { encryptSecret, decryptSecret } = require('../services/encryptionService');
|
|
const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name);
|
|
|
|
// Add location columns
|
|
const newCols = ['location_city', 'location_country', 'location_region', 'location_isp'];
|
|
for (const col of newCols) {
|
|
if (!cols.includes(col)) db.exec(`ALTER TABLE user_login_history ADD COLUMN ${col} TEXT`);
|
|
}
|
|
|
|
// Encrypt existing plaintext ip_address and user_agent rows
|
|
const rows = db.prepare('SELECT id, ip_address, user_agent FROM user_login_history').all();
|
|
const updIp = db.prepare("UPDATE user_login_history SET ip_address=? WHERE id=?");
|
|
const updUa = db.prepare("UPDATE user_login_history SET user_agent=? WHERE id=?");
|
|
for (const row of rows) {
|
|
if (row.ip_address && !row.ip_address.startsWith('v2:')) {
|
|
try { updIp.run(encryptSecret(row.ip_address), row.id); } catch {}
|
|
}
|
|
if (row.user_agent && !row.user_agent.startsWith('v2:')) {
|
|
try { updUa.run(encryptSecret(row.user_agent), row.id); } catch {}
|
|
}
|
|
}
|
|
console.log(`[v0.84] login history: location columns added, ${rows.length} rows encrypted`);
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.85',
|
|
description: 'user_login_history: failed attempt tracking + session fingerprint for current-session detection',
|
|
dependsOn: ['v0.84'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name);
|
|
if (!cols.includes('success')) {
|
|
db.exec('ALTER TABLE user_login_history ADD COLUMN success INTEGER NOT NULL DEFAULT 1');
|
|
}
|
|
if (!cols.includes('session_fingerprint')) {
|
|
db.exec('ALTER TABLE user_login_history ADD COLUMN session_fingerprint TEXT');
|
|
}
|
|
console.log('[v0.85] user_login_history: success + session_fingerprint columns added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.86',
|
|
description: 'users: TOTP/authenticator 2FA columns + totp_challenges table',
|
|
dependsOn: ['v0.85'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
if (!cols.includes('totp_enabled'))
|
|
db.exec('ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0');
|
|
if (!cols.includes('totp_secret'))
|
|
db.exec('ALTER TABLE users ADD COLUMN totp_secret TEXT');
|
|
if (!cols.includes('totp_recovery_codes'))
|
|
db.exec('ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT');
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS totp_challenges (
|
|
id TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
expires_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)
|
|
`);
|
|
console.log('[v0.86] users: TOTP columns + totp_challenges table');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.87',
|
|
description: 'spending: category assignment on transactions + rules + budgets + default categories',
|
|
dependsOn: ['v0.86'],
|
|
run: function() {
|
|
// spending_category_id on transactions
|
|
const txCols = db.prepare('PRAGMA table_info(transactions)').all().map(c => c.name);
|
|
if (!txCols.includes('spending_category_id'))
|
|
db.exec('ALTER TABLE transactions ADD COLUMN spending_category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL');
|
|
|
|
// spending category rules (merchant → category)
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS spending_category_rules (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
|
|
merchant TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(user_id, merchant)
|
|
)
|
|
`);
|
|
|
|
// monthly spending budgets
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS spending_budgets (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
|
|
year INTEGER NOT NULL,
|
|
month INTEGER NOT NULL,
|
|
amount REAL NOT NULL DEFAULT 0,
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(user_id, category_id, year, month)
|
|
)
|
|
`);
|
|
|
|
// Seed default spending categories for each user that has none yet
|
|
const DEFAULTS = ['Groceries','Dining','Fuel & Transport','Shopping','Entertainment','Health','Travel','Other'];
|
|
const users = db.prepare("SELECT id FROM users WHERE role='user' AND active=1").all();
|
|
const insert = db.prepare("INSERT OR IGNORE INTO categories (user_id, name, sort_order, is_seeded) VALUES (?, ?, ?, 1)");
|
|
for (const user of users) {
|
|
const existing = db.prepare("SELECT COUNT(*) AS n FROM categories WHERE user_id=? AND deleted_at IS NULL").get(user.id);
|
|
if ((existing?.n ?? 0) === 0) {
|
|
DEFAULTS.forEach((name, i) => insert.run(user.id, name, 100 + i));
|
|
}
|
|
}
|
|
|
|
console.log('[v0.87] spending: transactions.spending_category_id, spending_category_rules, spending_budgets');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.88',
|
|
description: 'categories: spending_enabled flag to separate bill vs spending categories',
|
|
dependsOn: ['v0.87'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
|
if (!cols.includes('spending_enabled'))
|
|
db.exec('ALTER TABLE categories ADD COLUMN spending_enabled INTEGER NOT NULL DEFAULT 0');
|
|
|
|
// Mark the v0.87-seeded defaults as spending-enabled
|
|
const SPENDING_DEFAULTS = ['Groceries','Dining','Fuel & Transport','Shopping','Entertainment','Health','Travel','Other'];
|
|
const placeholder = SPENDING_DEFAULTS.map(() => '?').join(',');
|
|
db.exec(`UPDATE categories SET spending_enabled=1 WHERE is_seeded=1 AND name IN (${SPENDING_DEFAULTS.map(n => `'${n.replace("'", "''")}'`).join(',')})`);
|
|
|
|
// Mark any category already linked to a spending rule as spending-enabled
|
|
try {
|
|
db.exec(`
|
|
UPDATE categories SET spending_enabled=1
|
|
WHERE id IN (SELECT DISTINCT category_id FROM spending_category_rules)
|
|
`);
|
|
} catch { /* spending_category_rules may not exist on legacy paths */ }
|
|
|
|
console.log('[v0.88] categories.spending_enabled added, seeded defaults marked');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.89',
|
|
description: 'categories: seed spending defaults for users who had existing categories before v0.87',
|
|
dependsOn: ['v0.88'],
|
|
run: function() {
|
|
const SPENDING_DEFAULTS = [
|
|
'Groceries','Dining','Fuel & Transport','Shopping',
|
|
'Entertainment','Health','Travel','Other'
|
|
];
|
|
const users = db.prepare("SELECT id FROM users WHERE role='user' AND active=1").all();
|
|
const insert = db.prepare(`
|
|
INSERT OR IGNORE INTO categories (user_id, name, sort_order, is_seeded, spending_enabled)
|
|
VALUES (?, ?, ?, 1, 1)
|
|
`);
|
|
let seeded = 0;
|
|
for (const user of users) {
|
|
const hasSpending = db.prepare('SELECT 1 FROM categories WHERE user_id=? AND spending_enabled=1 AND deleted_at IS NULL LIMIT 1').get(user.id);
|
|
if (!hasSpending) {
|
|
SPENDING_DEFAULTS.forEach((name, i) => { insert.run(user.id, name, 200 + i); seeded++; });
|
|
}
|
|
}
|
|
console.log(`[v0.89] spending defaults seeded for users missing them (${seeded} categories inserted)`);
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.90',
|
|
description: 're-normalize merchant rules after & fix; ensure rejection expiry column',
|
|
dependsOn: ['v0.89'],
|
|
run: function() {
|
|
const { normalizeMerchant } = require('../services/subscriptionService');
|
|
|
|
// Re-normalize bill_merchant_rules stored under old normalization ("at t" → "att")
|
|
let billFixed = 0;
|
|
try {
|
|
const rules = db.prepare('SELECT id, merchant FROM bill_merchant_rules').all();
|
|
const updBill = db.prepare('UPDATE bill_merchant_rules SET merchant=? WHERE id=?');
|
|
for (const r of rules) {
|
|
try {
|
|
const fixed = normalizeMerchant(r.merchant);
|
|
if (fixed && fixed !== r.merchant) { updBill.run(fixed, r.id); billFixed++; }
|
|
} catch { /* skip invalid entries */ }
|
|
}
|
|
} catch (err) {
|
|
console.warn('[v0.90] bill_merchant_rules re-normalize skipped:', err.message);
|
|
}
|
|
|
|
// Re-normalize spending_category_rules
|
|
try {
|
|
const srules = db.prepare('SELECT id, merchant FROM spending_category_rules').all();
|
|
const updSpend = db.prepare('UPDATE spending_category_rules SET merchant=? WHERE id=?');
|
|
let spendFixed = 0;
|
|
for (const r of srules) {
|
|
const fixed = normalizeMerchant(r.merchant);
|
|
if (fixed !== r.merchant) { updSpend.run(fixed, r.id); spendFixed++; }
|
|
}
|
|
if (spendFixed) console.log(`[v0.90] spending_category_rules: ${spendFixed} re-normalized`);
|
|
} catch { /* spending_category_rules may not exist on legacy DBs */ }
|
|
|
|
// Ensure match_suggestion_rejections has created_at for expiry queries
|
|
const rejCols = db.prepare('PRAGMA table_info(match_suggestion_rejections)').all().map(c => c.name);
|
|
if (!rejCols.includes('created_at')) {
|
|
// Static default — existing rejections get a past date so they expire immediately on next cleanup
|
|
db.exec("ALTER TABLE match_suggestion_rejections ADD COLUMN created_at TEXT NOT NULL DEFAULT '2000-01-01'");
|
|
}
|
|
|
|
console.log(`[v0.90] merchant rules re-normalized (${billFixed} bill rules updated), rejection expiry column ensured`);
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.91',
|
|
description: 'performance: composite indexes on user_id+deleted_at for categories, bills, payments',
|
|
dependsOn: ['v0.90'],
|
|
run: function() {
|
|
db.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_categories_user_deleted ON categories(user_id, deleted_at);
|
|
CREATE INDEX IF NOT EXISTS idx_bills_user_deleted ON bills(user_id, deleted_at);
|
|
CREATE INDEX IF NOT EXISTS idx_bills_user_active ON bills(user_id, active, deleted_at);
|
|
CREATE INDEX IF NOT EXISTS idx_payments_bill_deleted ON payments(bill_id, deleted_at);
|
|
`);
|
|
console.log('[v0.91] composite indexes created on categories, bills, payments');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.92',
|
|
description: 'auth: WebAuthn/FIDO2 security key support — webauthn_credentials + webauthn_challenges tables',
|
|
dependsOn: ['v0.91'],
|
|
run: function() {
|
|
const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
if (!cols.includes('webauthn_enabled'))
|
|
db.exec('ALTER TABLE users ADD COLUMN webauthn_enabled INTEGER NOT NULL DEFAULT 0');
|
|
if (!cols.includes('webauthn_user_id'))
|
|
db.exec('ALTER TABLE users ADD COLUMN webauthn_user_id TEXT');
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
credential_id TEXT NOT NULL UNIQUE,
|
|
public_key TEXT NOT NULL,
|
|
sign_count INTEGER NOT NULL DEFAULT 0,
|
|
transports TEXT,
|
|
backup_eligible INTEGER NOT NULL DEFAULT 0,
|
|
backup_state INTEGER NOT NULL DEFAULT 0,
|
|
credential_name TEXT NOT NULL DEFAULT 'Security Key',
|
|
aaguid TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_webauthn_creds_user ON webauthn_credentials(user_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
|
id TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
challenge_type TEXT NOT NULL CHECK(challenge_type IN ('registration','authentication','login')),
|
|
challenge TEXT NOT NULL DEFAULT '',
|
|
expires_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user ON webauthn_challenges(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at);
|
|
`);
|
|
console.log('[v0.92] WebAuthn tables + users columns added');
|
|
}
|
|
},
|
|
{
|
|
version: 'v0.93',
|
|
description: 'bills: interest_accrued_month; payments: interest_delta; transactions: stable provider key + dedupe index',
|
|
dependsOn: ['v0.92'],
|
|
run: function() {
|
|
// 1. Track the calendar month when interest was last applied to a debt bill
|
|
// so computeBalanceDelta can skip interest if it was already charged this month.
|
|
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
if (!billCols.includes('interest_accrued_month')) {
|
|
db.exec('ALTER TABLE bills ADD COLUMN interest_accrued_month TEXT');
|
|
console.log('[v0.93] bills.interest_accrued_month column added');
|
|
}
|
|
|
|
// 2. Track the interest component of each payment separately so delete/restore
|
|
// can handle it without double-charging interest.
|
|
const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
|
|
if (!paymentCols.includes('interest_delta')) {
|
|
db.exec('ALTER TABLE payments ADD COLUMN interest_delta REAL');
|
|
console.log('[v0.93] payments.interest_delta column added');
|
|
}
|
|
|
|
// 3. Strip the data_source_id from existing provider_transaction_id keys so
|
|
// they survive disconnect/reconnect. Old: "simplefin:{dsId}:{acctId}:{txId}"
|
|
// New: "simplefin:{acctId}:{txId}".
|
|
// Only rows where the segment after "simplefin:" is a numeric id are migrated.
|
|
db.exec(`
|
|
UPDATE transactions
|
|
SET provider_transaction_id =
|
|
'simplefin:' || SUBSTR(
|
|
provider_transaction_id,
|
|
INSTR(SUBSTR(provider_transaction_id, 11), ':') + 11
|
|
)
|
|
WHERE provider_transaction_id LIKE 'simplefin:%'
|
|
AND CAST(
|
|
SUBSTR(provider_transaction_id, 11,
|
|
INSTR(SUBSTR(provider_transaction_id, 11), ':') - 1)
|
|
AS INTEGER) > 0
|
|
`);
|
|
console.log('[v0.93] transactions: stripped data_source_id from provider_transaction_id');
|
|
|
|
// 4. Dedup: after the key change, users who disconnected and reconnected now
|
|
// have duplicate (user_id, provider_transaction_id) pairs. Keep the best row
|
|
// (prefer linked rows; break ties by most-recent created_at).
|
|
db.exec(`
|
|
DELETE FROM transactions
|
|
WHERE id IN (
|
|
SELECT id FROM (
|
|
SELECT id,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY user_id, provider_transaction_id
|
|
ORDER BY (data_source_id IS NULL) ASC, created_at DESC
|
|
) AS rn
|
|
FROM transactions
|
|
WHERE provider_transaction_id IS NOT NULL
|
|
)
|
|
WHERE rn > 1
|
|
)
|
|
`);
|
|
console.log('[v0.93] transactions: removed duplicate provider keys from disconnect/reconnect');
|
|
|
|
// 5. Replace the old dedupe index (data_source_id, provider_transaction_id)
|
|
// with a user-scoped one (user_id, provider_transaction_id) so reconnect
|
|
// with a new data_source_id still deduplicates correctly.
|
|
db.exec(`
|
|
DROP INDEX IF EXISTS idx_transactions_provider_dedupe;
|
|
CREATE UNIQUE INDEX idx_transactions_provider_dedupe
|
|
ON transactions (user_id, provider_transaction_id)
|
|
WHERE provider_transaction_id IS NOT NULL;
|
|
`);
|
|
console.log('[v0.93] transactions: dedupe index changed to (user_id, provider_transaction_id)');
|
|
}
|
|
}
|
|
];
|
|
|
|
// ── users: notification columns ───────────────────────────────────────────
|
|
// This migration needs to run first since it's not versioned in the schema
|
|
console.log('[migration] Applying unversioned user notification columns');
|
|
const unversionedStartTime = Date.now();
|
|
|
|
try {
|
|
db.exec('BEGIN');
|
|
console.log('[migration] Transaction BEGIN for unversioned user notification columns');
|
|
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
|
const newUserCols = [
|
|
['active', 'INTEGER NOT NULL DEFAULT 1'],
|
|
['is_default_admin', 'INTEGER NOT NULL DEFAULT 0'],
|
|
['notification_email', 'TEXT'],
|
|
['notifications_enabled', 'INTEGER NOT NULL DEFAULT 0'],
|
|
['notify_3d', 'INTEGER NOT NULL DEFAULT 1'],
|
|
['notify_1d', 'INTEGER NOT NULL DEFAULT 1'],
|
|
['notify_due', 'INTEGER NOT NULL DEFAULT 1'],
|
|
['notify_overdue', 'INTEGER NOT NULL DEFAULT 1'],
|
|
];
|
|
for (const [col, def] of newUserCols) {
|
|
if (!userCols.includes(col)) {
|
|
// Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
|
|
if (!isValidColumnName(col) || !isValidSqlDefinition(def)) {
|
|
throw new Error(`Invalid migration: column '${col}' not in whitelist`);
|
|
}
|
|
db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
|
|
}
|
|
}
|
|
const defaultAdminName = process.env.INIT_ADMIN_USER || 'admin';
|
|
db.prepare(`
|
|
UPDATE users
|
|
SET is_default_admin = 1
|
|
WHERE role = 'admin'
|
|
AND username = ?
|
|
AND NOT EXISTS (SELECT 1 FROM users WHERE is_default_admin = 1)
|
|
`).run(defaultAdminName);
|
|
db.exec(`
|
|
UPDATE users
|
|
SET is_default_admin = 1
|
|
WHERE id = (
|
|
SELECT id FROM users
|
|
WHERE role = 'admin'
|
|
ORDER BY id
|
|
LIMIT 1
|
|
)
|
|
AND NOT EXISTS (SELECT 1 FROM users WHERE is_default_admin = 1)
|
|
`);
|
|
db.exec('COMMIT');
|
|
console.log('[migration] Transaction COMMIT for unversioned user notification columns');
|
|
|
|
// Log successful completion with timing
|
|
const elapsed = Date.now() - unversionedStartTime;
|
|
console.log(`[migration] Unversioned user notification columns completed in ${elapsed}ms`);
|
|
} catch (err) {
|
|
db.exec('ROLLBACK');
|
|
const elapsed = Date.now() - unversionedStartTime;
|
|
console.error(`[migration-error] Failed to apply unversioned user notification columns after ${elapsed}ms: ${err.message}. Rolled back.`);
|
|
|
|
// Log migration failure to audit log (only safe after initSchema completes)
|
|
try {
|
|
getLogAudit()({
|
|
action: 'migration.failure',
|
|
entity_type: 'migration',
|
|
entity_id: null,
|
|
details: {
|
|
version: 'unversioned-user-notification-columns',
|
|
description: 'users: notification columns',
|
|
error: err.message,
|
|
elapsed_ms: elapsed
|
|
}
|
|
});
|
|
} catch (auditErr) {
|
|
console.error(`[audit-error] Failed to log migration failure to audit log: ${auditErr.message}`);
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
|
|
// Store version list so reconcileLegacyMigrations() can assert sync.
|
|
_runMigrationVersions = migrations.map(m => m.version);
|
|
|
|
// Build set of already-applied versions for dependency checking
|
|
const appliedVersions = new Set(
|
|
db.prepare('SELECT version FROM schema_migrations').all().map(r => r.version)
|
|
);
|
|
|
|
// Process all versioned migrations
|
|
for (const migration of migrations) {
|
|
if (!hasMigrationBeenApplied(migration.version)) {
|
|
// Validate dependencies before applying
|
|
const depCheck = validateMigrationDependencies(migration, appliedVersions);
|
|
if (!depCheck.valid) {
|
|
console.error(`[migration-error] ${migration.version} depends on [${depCheck.missing.join(', ')}] which have not been applied. Skipping.`);
|
|
continue;
|
|
}
|
|
|
|
console.log(`[migration] Applying ${migration.version}: ${migration.description}`);
|
|
if (migration.dependsOn && migration.dependsOn.length > 0) {
|
|
console.log(`[migration] ${migration.version} depends on [${migration.dependsOn.join(', ')}] — satisfied`);
|
|
}
|
|
|
|
// Timing for migration execution
|
|
const migrationStartTime = Date.now();
|
|
|
|
try {
|
|
// Special handling for v0.40 migration which uses PRAGMA statements
|
|
if (migration.version === 'v0.40') {
|
|
// PRAGMA foreign_keys cannot run inside a transaction, so we
|
|
// disable FK checks before BEGIN and re-enable in a finally block
|
|
// to guarantee FK is always restored even on failure.
|
|
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
|
const categoryCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
|
const needsForeignKeyOff = !billCols.includes('user_id') || !categoryCols.includes('user_id');
|
|
|
|
if (needsForeignKeyOff) {
|
|
db.exec('PRAGMA foreign_keys = OFF');
|
|
}
|
|
try {
|
|
db.exec('BEGIN');
|
|
console.log(`[migration] Transaction BEGIN for ${migration.version}`);
|
|
migration.run();
|
|
recordMigration(migration.version, migration.description);
|
|
db.exec('COMMIT');
|
|
console.log(`[migration] Transaction COMMIT for ${migration.version}`);
|
|
|
|
// Log successful completion with timing
|
|
const elapsed = Date.now() - migrationStartTime;
|
|
console.log(`[migration] ${migration.version} completed in ${elapsed}ms`);
|
|
appliedVersions.add(migration.version);
|
|
} catch (innerErr) {
|
|
db.exec('ROLLBACK');
|
|
const elapsed = Date.now() - migrationStartTime;
|
|
console.error(`[migration-error] ${migration.version} failed after ${elapsed}ms: ${innerErr.message}. Rolled back.`);
|
|
|
|
// Log migration failure to audit log (only safe after initSchema completes)
|
|
try {
|
|
getLogAudit()({
|
|
action: 'migration.failure',
|
|
entity_type: 'migration',
|
|
entity_id: null,
|
|
details: {
|
|
version: migration.version,
|
|
description: migration.description,
|
|
error: innerErr.message,
|
|
elapsed_ms: elapsed
|
|
}
|
|
});
|
|
} catch (auditErr) {
|
|
console.error(`[audit-error] Failed to log migration failure to audit log: ${auditErr.message}`);
|
|
}
|
|
|
|
throw innerErr;
|
|
} finally {
|
|
// Always restore FK checks — even on failure path
|
|
if (needsForeignKeyOff) {
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
}
|
|
}
|
|
} else {
|
|
// Standard transaction wrapping for other migrations
|
|
db.exec('BEGIN');
|
|
console.log(`[migration] Transaction BEGIN for ${migration.version}`);
|
|
migration.run();
|
|
recordMigration(migration.version, migration.description);
|
|
db.exec('COMMIT');
|
|
console.log(`[migration] Transaction COMMIT for ${migration.version}`);
|
|
|
|
// Log successful completion with timing
|
|
const elapsed = Date.now() - migrationStartTime;
|
|
console.log(`[migration] ${migration.version} completed in ${elapsed}ms`);
|
|
appliedVersions.add(migration.version);
|
|
}
|
|
} catch (err) {
|
|
db.exec('ROLLBACK');
|
|
const elapsed = Date.now() - migrationStartTime;
|
|
console.error(`[migration-error] Failed to apply ${migration.version} after ${elapsed}ms: ${err.message}. Rolled back.`);
|
|
|
|
// Log migration failure to audit log (only safe after initSchema completes)
|
|
try {
|
|
getLogAudit()({
|
|
action: 'migration.failure',
|
|
entity_type: 'migration',
|
|
entity_id: null,
|
|
details: {
|
|
version: migration.version,
|
|
description: migration.description,
|
|
error: err.message,
|
|
elapsed_ms: elapsed
|
|
}
|
|
});
|
|
} catch (auditErr) {
|
|
console.error(`[audit-error] Failed to log migration failure to audit log: ${auditErr.message}`);
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
} else {
|
|
console.log(`[migration] Skipping already applied ${migration.version}: ${migration.description}`);
|
|
}
|
|
}
|
|
|
|
// Log total migration time
|
|
|
|
// Log completion of all migrations to audit log
|
|
try {
|
|
getLogAudit()({
|
|
action: 'migration.complete',
|
|
entity_type: 'migration',
|
|
entity_id: null,
|
|
details: {
|
|
total_time_ms: Date.now() - startTime,
|
|
message: 'All migrations completed successfully'
|
|
}
|
|
});
|
|
} catch (auditErr) {
|
|
console.error(`[audit-error] Failed to log migration completion to audit log: ${auditErr.message}`);
|
|
}
|
|
const totalTime = Date.now() - startTime;
|
|
console.log(`[migration] All migrations completed in ${totalTime}ms`);
|
|
|
|
// All migrations are now versioned
|
|
}
|
|
|
|
function seedDefaults() {
|
|
const defaults = [
|
|
['currency', 'USD'],
|
|
['date_format', 'MM/DD/YYYY'],
|
|
['grace_period_days', '5'],
|
|
['notify_days_before', '3'],
|
|
['backup_enabled', 'false'],
|
|
['backup_frequency_days', '1'],
|
|
['backup_keep_count', '14'],
|
|
['backup_path', process.env.BACKUP_PATH || path.join(__dirname, '..', 'backups')],
|
|
['backup_schedule_enabled', 'false'],
|
|
['backup_schedule_frequency', 'daily'],
|
|
['backup_schedule_time', '02:00'],
|
|
['backup_schedule_retention_count', '2'],
|
|
['backup_schedule_last_run_at', ''],
|
|
['backup_schedule_last_error', ''],
|
|
['auth_mode', 'multi'],
|
|
['default_user_id', ''],
|
|
['notify_smtp_enabled', 'false'],
|
|
['notify_sender_name', 'Bill Tracker'],
|
|
['notify_sender_address', ''],
|
|
['notify_smtp_host', ''],
|
|
['notify_smtp_port', '587'],
|
|
['notify_smtp_encryption', 'starttls'],
|
|
['notify_smtp_self_signed', 'false'],
|
|
['notify_smtp_username', ''],
|
|
['notify_smtp_password', ''],
|
|
['notify_allow_user_config', 'false'],
|
|
['notify_global_recipient', ''],
|
|
// Cleanup worker settings (v0.15)
|
|
['cleanup_import_sessions_enabled', 'true'],
|
|
['cleanup_temp_exports_enabled', 'true'],
|
|
['cleanup_temp_export_max_age_hours', '2'],
|
|
['cleanup_backup_partials_enabled', 'true'],
|
|
['cleanup_import_history_enabled', 'false'],
|
|
['cleanup_import_history_max_age_days', '365'],
|
|
['cleanup_last_run_at', ''],
|
|
['cleanup_last_result', ''],
|
|
// Auth method settings (v0.18)
|
|
['local_login_enabled', 'true'],
|
|
['oidc_login_enabled', 'false'],
|
|
['oidc_provider_name', 'authentik'],
|
|
['oidc_issuer_url', ''],
|
|
['oidc_client_id', ''],
|
|
['oidc_client_secret', ''],
|
|
['oidc_token_auth_method', 'client_secret_basic'],
|
|
['oidc_redirect_uri', ''],
|
|
['oidc_scopes', 'openid email profile groups'],
|
|
['oidc_auto_provision', 'true'],
|
|
['oidc_admin_group', ''],
|
|
['oidc_default_role', 'user'],
|
|
];
|
|
|
|
const insert = db.prepare(
|
|
'INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'
|
|
);
|
|
|
|
for (const [key, value] of defaults) {
|
|
insert.run(key, value);
|
|
}
|
|
|
|
// Category defaults are user-scoped. They are applied by
|
|
// ensureUserDefaultCategories(userId) when user-owned category/bill data is read.
|
|
|
|
// ── Create initial admin user if none exists ─────────────────────────────
|
|
const userCount = db.prepare('SELECT COUNT(*) as cnt FROM users').get().cnt;
|
|
if (userCount === 0) {
|
|
const initUser = process.env.INIT_ADMIN_USER || 'admin';
|
|
const initPass = process.env.INIT_ADMIN_PASS || 'admin123';
|
|
// Use bcryptjs sync for database init (safe, runs once at startup)
|
|
const bcrypt = require('bcryptjs');
|
|
const password_hash = bcrypt.hashSync(initPass, 12);
|
|
db.prepare(`
|
|
INSERT INTO users (username, password_hash, role, is_default_admin, active, email, created_at, updated_at)
|
|
VALUES (?, ?, 'admin', 1, 1, ?, datetime('now'), datetime('now'))
|
|
`).run(initUser, password_hash, initUser + '@local');
|
|
console.log(`[seed] Created initial admin user: ${initUser}`);
|
|
}
|
|
|
|
seedManualDataSources(db);
|
|
}
|
|
|
|
function ensureUserDefaultCategories(userId) {
|
|
const db = getDb();
|
|
const insert = db.prepare('INSERT INTO categories (user_id, name) VALUES (?, ?)');
|
|
for (const name of DEFAULT_CATEGORIES) {
|
|
const existing = db.prepare('SELECT id FROM categories WHERE user_id = ? AND name = ? COLLATE NOCASE')
|
|
.get(userId, name);
|
|
if (!existing) insert.run(userId, name);
|
|
}
|
|
}
|
|
|
|
function getSetting(key) {
|
|
const db = getDb();
|
|
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
|
return row ? row.value : null;
|
|
}
|
|
|
|
function setSetting(key, value) {
|
|
const db = getDb();
|
|
db.prepare(
|
|
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))"
|
|
).run(key, String(value));
|
|
}
|
|
|
|
function closeDb() {
|
|
if (!db) return;
|
|
db.close();
|
|
db = null;
|
|
}
|
|
|
|
function getDbPath() {
|
|
return DB_PATH;
|
|
}
|
|
|
|
// Rollback SQL definitions
|
|
const ROLLBACK_SQL_MAP = {
|
|
'v0.93': {
|
|
description: 'bills: interest_accrued_month; payments: interest_delta; transactions: stable provider key',
|
|
sql: [
|
|
'ALTER TABLE bills DROP COLUMN IF EXISTS interest_accrued_month',
|
|
'ALTER TABLE payments DROP COLUMN IF EXISTS interest_delta',
|
|
// Restore the old (data_source_id, provider_transaction_id) dedupe index.
|
|
// The key format change and deleted duplicates cannot be reversed.
|
|
'DROP INDEX IF EXISTS idx_transactions_provider_dedupe',
|
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_provider_dedupe
|
|
ON transactions (data_source_id, provider_transaction_id)
|
|
WHERE provider_transaction_id IS NOT NULL`,
|
|
]
|
|
},
|
|
'v0.44': {
|
|
description: 'performance: add missing indexes for frequently queried columns',
|
|
sql: [
|
|
'DROP INDEX IF EXISTS idx_bills_user_name',
|
|
'DROP INDEX IF EXISTS idx_payments_method',
|
|
'DROP INDEX IF EXISTS idx_monthly_starting_amounts_user',
|
|
'DROP INDEX IF EXISTS idx_import_history_imported_at'
|
|
]
|
|
},
|
|
'v0.45': {
|
|
description: 'audit: add audit_log table for security event tracking',
|
|
sql: [
|
|
'DROP INDEX IF EXISTS idx_audit_log_user',
|
|
'DROP INDEX IF EXISTS idx_audit_log_action',
|
|
'DROP TABLE IF EXISTS audit_log'
|
|
]
|
|
},
|
|
'v0.46': {
|
|
description: 'billing: add cycle_type and cycle_day columns to bills',
|
|
sql: [
|
|
'ALTER TABLE bills DROP COLUMN cycle_day',
|
|
'ALTER TABLE bills DROP COLUMN cycle_type'
|
|
]
|
|
},
|
|
'v0.47': {
|
|
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
|
|
sql: [
|
|
"UPDATE settings SET value = '14' WHERE key = 'backup_schedule_retention_count' AND value = '2'"
|
|
]
|
|
},
|
|
'v0.48': {
|
|
description: 'bills: debt snowball fields',
|
|
sql: [
|
|
'ALTER TABLE bills DROP COLUMN snowball_include',
|
|
'ALTER TABLE bills DROP COLUMN snowball_order',
|
|
'ALTER TABLE bills DROP COLUMN minimum_payment',
|
|
'ALTER TABLE bills DROP COLUMN current_balance',
|
|
]
|
|
},
|
|
'v0.49': {
|
|
description: 'users: snowball extra payment field',
|
|
sql: ['ALTER TABLE users DROP COLUMN snowball_extra_payment']
|
|
},
|
|
'v0.50': {
|
|
description: 'payments: balance_delta column',
|
|
sql: ['ALTER TABLE payments DROP COLUMN balance_delta']
|
|
},
|
|
'v0.59': {
|
|
description: 'payments: source metadata columns',
|
|
sql: [
|
|
'ALTER TABLE payments DROP COLUMN transaction_id',
|
|
'ALTER TABLE payments DROP COLUMN payment_source',
|
|
]
|
|
},
|
|
'v0.60': {
|
|
description: 'transactions: shared transaction foundation tables',
|
|
sql: [
|
|
'DROP INDEX IF EXISTS idx_transactions_provider_dedupe',
|
|
'DROP INDEX IF EXISTS idx_transactions_matched_bill',
|
|
'DROP INDEX IF EXISTS idx_transactions_account',
|
|
'DROP INDEX IF EXISTS idx_transactions_user_match',
|
|
'DROP INDEX IF EXISTS idx_transactions_user_date',
|
|
'DROP INDEX IF EXISTS idx_financial_accounts_user_source',
|
|
'DROP INDEX IF EXISTS idx_data_sources_user_manual',
|
|
'DROP INDEX IF EXISTS idx_data_sources_user_type',
|
|
'DROP TABLE IF EXISTS transactions',
|
|
'DROP TABLE IF EXISTS financial_accounts',
|
|
'DROP TABLE IF EXISTS data_sources',
|
|
]
|
|
},
|
|
'v0.61': {
|
|
description: 'payments: one active payment per linked transaction',
|
|
sql: ['DROP INDEX IF EXISTS idx_payments_transaction_active']
|
|
},
|
|
'v0.62': {
|
|
description: 'matches: rejected transaction match suggestions',
|
|
sql: [
|
|
'DROP INDEX IF EXISTS idx_match_suggestion_rejections_user',
|
|
'DROP TABLE IF EXISTS match_suggestion_rejections',
|
|
]
|
|
},
|
|
'v0.63': {
|
|
description: 'bills: subscription metadata fields',
|
|
sql: [
|
|
'DROP INDEX IF EXISTS idx_bills_user_subscription',
|
|
'ALTER TABLE bills DROP COLUMN subscription_detected_at',
|
|
'ALTER TABLE bills DROP COLUMN subscription_source',
|
|
'ALTER TABLE bills DROP COLUMN reminder_days_before',
|
|
'ALTER TABLE bills DROP COLUMN subscription_type',
|
|
'ALTER TABLE bills DROP COLUMN is_subscription',
|
|
]
|
|
},
|
|
'v0.72': {
|
|
description: 'bills: persistent tracker sort order',
|
|
sql: [
|
|
'DROP INDEX IF EXISTS idx_bills_user_sort',
|
|
'ALTER TABLE bills DROP COLUMN sort_order',
|
|
]
|
|
},
|
|
'v0.75': {
|
|
description: 'categories: persistent sort order',
|
|
sql: [
|
|
'DROP INDEX IF EXISTS idx_categories_user_sort',
|
|
'ALTER TABLE categories DROP COLUMN sort_order',
|
|
]
|
|
},
|
|
'v0.76': {
|
|
description: 'bills: canonical billing schedule cleanup',
|
|
sql: [
|
|
`UPDATE bills
|
|
SET billing_cycle = CASE
|
|
WHEN cycle_type = 'quarterly' THEN 'quarterly'
|
|
WHEN cycle_type = 'annual' THEN 'annually'
|
|
WHEN cycle_type IN ('weekly', 'biweekly') THEN 'irregular'
|
|
ELSE 'monthly'
|
|
END`,
|
|
]
|
|
},
|
|
'v0.51': {
|
|
description: 'bills: snowball_exempt column',
|
|
sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt']
|
|
},
|
|
'v0.52': {
|
|
description: 'users: last_seen_version column',
|
|
sql: ['ALTER TABLE users DROP COLUMN last_seen_version']
|
|
},
|
|
'v0.53': {
|
|
description: 'user_login_history table',
|
|
sql: ['DROP TABLE IF EXISTS user_login_history']
|
|
},
|
|
'v0.54': {
|
|
description: 'user_settings table',
|
|
sql: ['DROP TABLE IF EXISTS user_settings']
|
|
},
|
|
'v0.55': {
|
|
description: 'user_login_history device metadata columns',
|
|
sql: [
|
|
'ALTER TABLE user_login_history DROP COLUMN device_fingerprint',
|
|
'ALTER TABLE user_login_history DROP COLUMN device_type',
|
|
'ALTER TABLE user_login_history DROP COLUMN os',
|
|
'ALTER TABLE user_login_history DROP COLUMN browser',
|
|
]
|
|
},
|
|
'v0.56': {
|
|
description: 'bills/categories soft-delete columns',
|
|
sql: [
|
|
'DROP INDEX IF EXISTS idx_categories_deleted',
|
|
'DROP INDEX IF EXISTS idx_bills_deleted',
|
|
'ALTER TABLE categories DROP COLUMN deleted_at',
|
|
'ALTER TABLE bills DROP COLUMN deleted_at',
|
|
]
|
|
},
|
|
'v0.57': {
|
|
description: 'autopay suggestions and auto-mark paid',
|
|
sql: [
|
|
'DROP INDEX IF EXISTS idx_autopay_suggestion_dismissals_user_month',
|
|
'DROP TABLE IF EXISTS autopay_suggestion_dismissals',
|
|
'ALTER TABLE bills DROP COLUMN auto_mark_paid',
|
|
]
|
|
},
|
|
'v0.58': {
|
|
description: 'saved bill templates',
|
|
sql: [
|
|
'DROP INDEX IF EXISTS idx_bill_templates_user_name',
|
|
'DROP TABLE IF EXISTS bill_templates',
|
|
]
|
|
}
|
|
};
|
|
|
|
function rollbackMigration(version) {
|
|
if (!db) throw new Error('Database not initialized');
|
|
|
|
// Check the migration was actually applied
|
|
const applied = db.prepare('SELECT 1 FROM schema_migrations WHERE version = ?').get(version);
|
|
if (!applied) {
|
|
const err = new Error(`Migration ${version} has not been applied — cannot rollback`);
|
|
err.code = 'NOT_APPLIED';
|
|
throw err;
|
|
}
|
|
|
|
const rollback = ROLLBACK_SQL_MAP[version];
|
|
if (!rollback) {
|
|
const err = new Error(`Migration ${version} does not support rollback`);
|
|
err.code = 'ROLLBACK_NOT_SUPPORTED';
|
|
throw err;
|
|
}
|
|
|
|
console.log(`[rollback] Rolling back ${version}: ${rollback.description}`);
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
db.exec('BEGIN');
|
|
console.log(`[rollback] Transaction BEGIN for ${version}`);
|
|
|
|
for (const stmt of rollback.sql) {
|
|
console.log(`[rollback] Executing: ${stmt}`);
|
|
db.exec(stmt);
|
|
}
|
|
|
|
// Remove migration record
|
|
db.prepare('DELETE FROM schema_migrations WHERE version = ?').run(version);
|
|
console.log(`[rollback] Removed ${version} from schema_migrations`);
|
|
|
|
db.exec('COMMIT');
|
|
console.log(`[rollback] Transaction COMMIT for ${version}`);
|
|
|
|
const elapsed = Date.now() - startTime;
|
|
console.log(`[rollback] ${version} rolled back in ${elapsed}ms`);
|
|
|
|
// Audit log
|
|
try {
|
|
getLogAudit()({
|
|
action: 'migration.rollback',
|
|
entity_type: 'migration',
|
|
entity_id: null,
|
|
details: { version, description: rollback.description, elapsed_ms: elapsed }
|
|
});
|
|
} catch (auditErr) {
|
|
console.error(`[audit-error] Failed to log rollback to audit log: ${auditErr.message}`);
|
|
}
|
|
|
|
return { success: true, version, description: rollback.description, elapsed_ms: elapsed };
|
|
} catch (err) {
|
|
db.exec('ROLLBACK');
|
|
const elapsed = Date.now() - startTime;
|
|
console.error(`[rollback-error] ${version} failed after ${elapsed}ms: ${err.message}`);
|
|
|
|
// Audit log
|
|
try {
|
|
getLogAudit()({
|
|
action: 'migration.rollback.failure',
|
|
entity_type: 'migration',
|
|
entity_id: null,
|
|
details: { version, description: rollback.description, error: err.message, elapsed_ms: elapsed }
|
|
});
|
|
} catch (auditErr) {
|
|
console.error(`[audit-error] Failed to log rollback failure to audit log: ${auditErr.message}`);
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup expired sessions from the database
|
|
* @returns {Object} Result object with changes count
|
|
*/
|
|
function cleanupExpiredSessions() {
|
|
const result = db.prepare("DELETE FROM sessions WHERE expires_at < datetime('now')").run();
|
|
console.log(`[cleanup] Purged ${result.changes} expired sessions`);
|
|
return result;
|
|
}
|
|
|
|
module.exports = { getDb, getSetting, setSetting, closeDb, getDbPath, ensureUserDefaultCategories, cleanupExpiredSessions, rollbackMigration };
|