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; 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 } } // 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'); } } ]; // ── 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; } // 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.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 };