2026-05-03 19:51:57 -05:00
const Database = require ( 'better-sqlite3' ) ;
const path = require ( 'path' ) ;
const fs = require ( 'fs' ) ;
2026-05-10 09:45:39 -05:00
// 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 ;
}
2026-05-03 19:51:57 -05:00
const DB _PATH = process . env . DB _PATH || path . join ( _ _dirname , 'bills.db' ) ;
const SCHEMA _PATH = path . join ( _ _dirname , 'schema.sql' ) ;
2026-05-04 16:38:03 -05:00
const DEFAULT _CATEGORIES = [
'Housing' ,
'Utilities' ,
'Credit Cards' ,
2026-05-16 10:56:56 -05:00
'Food' ,
2026-05-04 16:38:03 -05:00
'Loans' ,
'Insurance' ,
2026-05-16 10:56:56 -05:00
'Beauty' ,
'Entertainment' ,
2026-05-04 16:38:03 -05:00
'Subscriptions' ,
2026-05-16 10:56:56 -05:00
'Pets' ,
2026-05-04 16:38:03 -05:00
'Phone & Internet' ,
'Transportation' ,
'Medical' ,
'Other' ,
] ;
2026-05-03 19:51:57 -05:00
2026-05-09 13:03:36 -05:00
// ── 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' ,
2026-05-30 14:33:55 -05:00
'notify_3d' , 'notify_1d' , 'notify_due' , 'notify_overdue' , 'notify_amount_change' ,
2026-05-09 13:03:36 -05:00
'display_name' , 'last_password_change_at' , 'auth_provider' , 'external_subject' ,
'email' , 'last_login_at' ,
// payments table columns
2026-05-16 20:26:09 -05:00
'deleted_at' , 'payment_source' , 'transaction_id' ,
2026-05-09 13:03:36 -05:00
// monthly_starting_amounts table columns
'other_amount' ,
// bills table columns
'history_visibility' , 'interest_rate' , 'user_id' ,
2026-05-14 02:11:54 -05:00
'current_balance' , 'minimum_payment' , 'snowball_order' , 'snowball_include' ,
2026-05-30 16:13:37 -05:00
'sort_order' , 'snowball_exempt' , 'is_subscription' , 'subscription_type' , 'reminder_days_before' ,
2026-05-30 14:33:55 -05:00
'subscription_source' , 'subscription_detected_at' , 'deleted_at' , 'drift_snoozed_until' ,
2026-05-30 20:04:50 -05:00
// categories table columns
'sort_order' ,
2026-05-09 20:19:46 -05:00
// sessions table columns
'created_at' ,
2026-05-29 01:51:42 -05:00
// financial_accounts table columns
'monitored' ,
2026-05-09 13:03:36 -05:00
] ) ;
// 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 ) ;
}
2026-05-29 01:51:42 -05:00
// ── 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' ] ,
2026-05-30 17:57:34 -05:00
[ 94 , 'Claude.ai' , 'AI' , 'software' , 'https://claude.ai/upgrade' , 'anthropic.com' ] ,
2026-05-29 01:51:42 -05:00
[ 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' ] ,
] ;
2026-05-29 18:34:50 -05:00
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 ` ) ;
}
}
2026-05-29 18:06:12 -05:00
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 ` ) ;
}
}
}
2026-05-29 01:51:42 -05:00
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 ` ) ;
}
}
2026-05-16 20:26:09 -05:00
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 ,
2026-05-30 21:20:51 -05:00
monitored INTEGER NOT NULL DEFAULT 1 ,
2026-05-16 20:26:09 -05:00
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 ) ;
}
2026-05-03 19:51:57 -05:00
fs . mkdirSync ( path . dirname ( DB _PATH ) , { recursive : true } ) ;
let db = null ;
let initializing = false ;
2026-06-03 22:38:33 -05:00
// Populated by runMigrations() so reconcileLegacyMigrations() can assert
// its own version list matches. Catches drift between the two arrays at startup
// on any legacy-DB upgrade path, rather than silently misconfiguring the schema.
let _runMigrationVersions = null ;
2026-05-03 19:51:57 -05:00
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 {
2026-05-10 09:45:39 -05:00
console . log ( 'Opening DB at:' , path . basename ( DB _PATH ) ) ;
2026-05-03 19:51:57 -05:00
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 ) ;
2026-05-09 15:17:40 -05:00
// 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' ) )
)
` );
2026-05-09 18:25:25 -05:00
// Check if this is a legacy database (tables exist but no migration tracking)
handleLegacyDatabase ( ) ;
2026-05-09 19:47:00 -05:00
// 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 ( `
2026-05-10 04:24:51 -05:00
UPDATE users SET password _hash = ? , first _login = 0 , must _change _password = 0
2026-05-09 19:47:00 -05:00
WHERE username = ? AND is _default _admin = 1
` ).run(newPasswordHash, initUser);
if ( result . changes > 0 ) {
2026-05-10 04:24:51 -05:00
console . log ( '[init] Reset password and flags for default admin user' ) ;
2026-05-09 19:47:00 -05:00
}
}
2026-05-03 19:51:57 -05:00
runMigrations ( ) ;
}
2026-05-09 15:17:40 -05:00
function hasMigrationBeenApplied ( version ) {
const stmt = db . prepare ( 'SELECT 1 FROM schema_migrations WHERE version = ?' ) ;
return ! ! stmt . get ( version ) ;
}
2026-05-09 18:25:25 -05:00
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' ) ;
2026-05-09 19:47:00 -05:00
} ,
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' ) ;
}
2026-05-09 18:25:25 -05:00
}
} ,
{
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 ;
2026-05-09 19:47:00 -05:00
} ,
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)' ) ;
2026-05-09 18:25:25 -05:00
}
} ,
{
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 ;
2026-05-09 19:47:00 -05:00
} ,
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' ) ;
2026-05-09 18:25:25 -05:00
}
} ,
{
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 ) ) ;
2026-05-09 19:47:00 -05:00
} ,
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 } ` ) ;
}
}
2026-05-09 18:25:25 -05:00
}
} ,
{
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' ) ;
2026-05-09 19:47:00 -05:00
} ,
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' ) ;
}
2026-05-09 18:25:25 -05:00
}
} ,
{
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' ) ;
2026-05-09 19:47:00 -05:00
} ,
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' ) ;
}
2026-05-09 18:25:25 -05:00
}
} ,
{
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 ;
2026-05-09 19:47:00 -05:00
} ,
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)' ) ;
2026-05-09 18:25:25 -05:00
}
} ,
{
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 ) ) ;
2026-05-09 19:47:00 -05:00
} ,
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)' ) ;
2026-05-09 18:25:25 -05:00
}
} ,
{
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 ;
2026-05-09 19:47:00 -05:00
} ,
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)' ) ;
2026-05-09 18:25:25 -05:00
}
} ,
{
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 ;
2026-05-09 19:47:00 -05:00
} ,
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)' ) ;
2026-05-09 18:25:25 -05:00
}
} ,
{
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' ) ;
2026-05-09 19:47:00 -05:00
} ,
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' ) ;
}
2026-05-09 18:25:25 -05:00
}
} ,
{
version : 'v0.38' ,
description : 'import_history: per-user audit log' ,
check : function ( ) {
// Already handled in v0.15
return true ;
2026-05-09 19:47:00 -05:00
} ,
run : function ( ) {
// This was already handled in v0.15, but keeping for completeness
2026-05-09 18:25:25 -05:00
}
} ,
{
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' ) ;
2026-05-09 19:47:00 -05:00
} ,
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)' ) ;
2026-05-09 18:25:25 -05:00
}
} ,
{
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' ) ;
2026-05-09 19:47:00 -05:00
} ,
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' ) ;
}
2026-05-09 18:25:25 -05:00
}
} ,
{
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 ;
2026-05-09 19:47:00 -05:00
} ,
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)' ) ;
2026-05-09 18:25:25 -05:00
}
2026-05-09 20:19:46 -05:00
} ,
{
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' ) ;
}
}
2026-05-14 01:17:05 -05:00
} ,
{
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' ) ;
}
2026-05-14 02:11:54 -05:00
} ,
{
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' ) ;
}
2026-05-14 02:51:29 -05:00
} ,
{
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' ) ;
}
2026-05-14 03:00:01 -05:00
} ,
{
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' ) ;
}
2026-05-14 21:00:07 -05:00
} ,
{
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' ) ;
}
2026-05-15 01:36:56 -05:00
} ,
{
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 ,
2026-05-15 22:45:38 -05:00
user _agent TEXT ,
browser TEXT ,
os TEXT ,
device _type TEXT ,
device _fingerprint TEXT
2026-05-15 01:36:56 -05:00
)
` );
console . log ( '[migration] user_login_history table created' ) ;
}
2026-05-16 10:34:32 -05:00
} ,
2026-05-28 23:28:53 -05:00
{
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' ) ;
}
} ,
2026-05-16 10:34:32 -05:00
{
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' ) ;
}
2026-05-16 15:38:28 -05:00
} ,
{
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' ) ;
}
2026-05-16 20:26:09 -05:00
} ,
{
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' ) ;
}
2026-05-16 21:36:04 -05:00
} ,
{
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' ) ;
}
2026-05-28 22:54:07 -05:00
} ,
{
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' ) ;
}
2026-05-29 01:06:20 -05:00
} ,
{
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' ) ;
}
}
2026-05-29 01:51:42 -05:00
} ,
{
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 ) ;
}
2026-05-29 02:51:30 -05:00
} ,
{
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 ) ;
` );
}
2026-05-29 03:38:48 -05:00
} ,
{
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 ) ;
` );
}
2026-05-09 18:25:25 -05:00
}
] ;
2026-05-14 01:17:05 -05:00
2026-05-09 18:25:25 -05:00
// 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
}
}
2026-06-03 22:38:33 -05:00
// Assert version sync with runMigrations() to catch drift between the two arrays.
// runMigrations() always runs first and populates _runMigrationVersions.
if ( _runMigrationVersions !== null ) {
const reconcileVersions = migrations . map ( m => m . version ) ;
const runSet = new Set ( _runMigrationVersions ) ;
const reconcileSet = new Set ( reconcileVersions ) ;
const onlyInRun = _runMigrationVersions . filter ( v => ! reconcileSet . has ( v ) ) ;
const onlyInReconcile = reconcileVersions . filter ( v => ! runSet . has ( v ) ) ;
if ( onlyInRun . length || onlyInReconcile . length ) {
const msg =
'[migration-sync] reconcileLegacyMigrations and runMigrations version lists are out of sync.' +
( onlyInRun . length ? ` Only in runMigrations: ${ onlyInRun . join ( ', ' ) } . ` : '' ) +
( onlyInReconcile . length ? ` Only in reconcile: ${ onlyInReconcile . join ( ', ' ) } . ` : '' ) +
' Add the missing version to both arrays.' ;
console . error ( msg ) ;
throw new Error ( msg ) ;
}
}
2026-05-09 18:25:25 -05:00
// 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
}
2026-05-09 19:47:00 -05:00
} else {
// Migration changes are NOT present - run the migration to apply them
try {
console . log ( ` [migration] Running legacy migration ${ migration . version } : ${ migration . description } ` ) ;
2026-05-09 22:34:50 -05:00
// Wrap legacy migration in transaction
db . exec ( 'BEGIN' ) ;
console . log ( ` [migration] Transaction BEGIN for legacy ${ migration . version } ` ) ;
2026-05-09 19:47:00 -05:00
migration . run ( ) ;
recordMigration ( migration . version , migration . description ) ;
2026-05-09 22:34:50 -05:00
db . exec ( 'COMMIT' ) ;
console . log ( ` [migration] Transaction COMMIT for legacy ${ migration . version } ` ) ;
2026-05-09 19:47:00 -05:00
} catch ( err ) {
2026-05-09 22:34:50 -05:00
db . exec ( 'ROLLBACK' ) ;
console . error ( ` [migration-error] Failed to apply legacy migration ${ migration . version } : ${ err . message } . Rolled back. ` ) ;
2026-05-09 19:47:00 -05:00
throw err ;
}
2026-05-09 18:25:25 -05:00
}
}
console . log ( '[migration] Legacy database reconciliation complete' ) ;
}
2026-05-09 15:17:40 -05:00
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 } ` ) ;
}
2026-05-09 23:24:51 -05:00
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 } ;
}
2026-05-03 19:51:57 -05:00
function runMigrations ( ) {
2026-05-10 09:45:39 -05:00
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 } ` ) ;
}
2026-05-09 23:24:51 -05:00
// Define all migrations with explicit version tracking and dependency chains
2026-05-09 15:17:40 -05:00
const migrations = [
{
version : 'v0.2' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ ] ,
2026-05-09 15:17:40 -05:00
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' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.2' ] ,
2026-05-09 15:17:40 -05:00
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' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.3' ] ,
2026-05-09 15:17:40 -05:00
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' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.4' ] ,
2026-05-09 15:17:40 -05:00
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' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.13' ] ,
2026-05-09 15:17:40 -05:00
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' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.14' ] ,
2026-05-09 15:17:40 -05:00
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' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.14.4' ] ,
2026-05-09 15:17:40 -05:00
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' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.15' ] ,
2026-05-09 15:17:40 -05:00
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' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.17' ] ,
2026-05-09 15:17:40 -05:00
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' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.18.1' ] ,
2026-05-09 15:17:40 -05:00
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' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.18.2' ] ,
2026-05-09 15:17:40 -05:00
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' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.18.3' ] ,
2026-05-09 15:17:40 -05:00
description : 'import_history: per-user audit log' ,
run : function ( ) {
// This was already handled in v0.15, but keeping for completeness
}
} ,
{
version : 'v0.40' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.38' ] ,
2026-05-09 15:17:40 -05:00
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' ) ;
}
2026-05-09 19:47:00 -05:00
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)
2026-05-09 15:17:40 -05:00
// 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 = ?
)
2026-05-09 19:47:00 -05:00
` ).run(firstAdmin.id);
db . prepare ( 'UPDATE categories SET user_id = ? WHERE user_id IS NULL' ) . run ( firstAdmin . id ) ;
2026-05-09 15:17:40 -05:00
}
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' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.40' ] ,
2026-05-09 15:17:40 -05:00
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' ) ;
}
}
2026-05-09 16:38:28 -05:00
} ,
{
version : 'v0.42' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.41' ] ,
2026-05-09 16:38:28 -05:00
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)' ) ;
}
2026-05-09 20:19:46 -05:00
} ,
{
version : 'v0.43' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.42' ] ,
2026-05-09 20:19:46 -05:00
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' ) ;
}
}
2026-05-09 22:44:38 -05:00
} ,
{
version : 'v0.44' ,
2026-05-09 23:24:51 -05:00
dependsOn : [ 'v0.43' ] ,
2026-05-09 22:44:38 -05:00
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' ) ;
}
2026-05-10 00:03:12 -05:00
} ,
{
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 ) ;
` );
}
2026-05-10 00:39:11 -05:00
} ,
{
version : 'v0.46' ,
description : 'billing: add cycle_type and cycle_day columns to bills' ,
dependsOn : [ 'v0.45' ] ,
run : function ( ) {
2026-05-14 01:17:05 -05:00
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' ) ;
2026-05-10 00:39:11 -05:00
}
2026-05-14 02:11:54 -05:00
} ,
{
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' ) ;
}
2026-05-14 03:00:01 -05:00
} ,
{
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' ) ;
}
2026-05-14 21:00:07 -05:00
} ,
{
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' ) ;
}
2026-05-15 01:36:56 -05:00
} ,
{
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' ) ;
}
2026-05-15 22:45:38 -05:00
} ,
{
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' ) ;
}
2026-05-16 10:34:32 -05:00
} ,
{
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' ) ;
}
2026-05-16 15:38:28 -05:00
} ,
{
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' ) ;
}
2026-05-16 20:26:09 -05:00
} ,
{
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' ) ;
}
2026-05-16 21:36:04 -05:00
} ,
{
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' ) ;
}
2026-05-28 22:54:07 -05:00
} ,
{
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' ) ;
}
2026-05-29 01:06:20 -05:00
} ,
{
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' ) ;
}
}
2026-05-29 01:51:42 -05:00
} ,
{
version : 'v0.65' ,
description : 'subscription_catalog: top-200 known subscription services' ,
dependsOn : [ 'v0.64' ] ,
run : function ( ) {
runSubscriptionCatalogMigration ( db ) ;
}
2026-05-29 02:51:30 -05:00
} ,
{
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 ) ;
` );
}
2026-05-29 03:38:48 -05:00
} ,
{
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 ) ;
` );
}
2026-05-29 18:06:12 -05:00
} ,
{
version : 'v0.68' ,
description : 'advisory_non_bill_filters: 5k advisory patterns + bill-like override terms' ,
dependsOn : [ 'v0.67' ] ,
run : function ( ) {
runAdvisoryFiltersMigration ( db ) ;
}
2026-05-29 18:34:50 -05:00
} ,
{
version : 'v0.69' ,
description : 'subscription_catalog v2: 90 new services + category fixes' ,
dependsOn : [ 'v0.68' ] ,
run : function ( ) {
runSubscriptionCatalogV2Migration ( db ) ;
}
2026-05-30 13:19:09 -05:00
} ,
{
version : 'v0.70' ,
description : 'monthly_bill_state: add snoozed_until for overdue command center' ,
dependsOn : [ 'v0.69' ] ,
run : function ( ) {
2026-05-30 16:13:37 -05:00
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' ) ;
2026-05-30 13:19:09 -05:00
}
2026-05-30 14:33:55 -05:00
} ,
{
version : 'v0.71' ,
description : 'bills: add drift_snoozed_until; users: add notify_amount_change' ,
dependsOn : [ 'v0.70' ] ,
run : function ( ) {
2026-05-30 16:13:37 -05:00
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)' ) ;
2026-05-30 14:33:55 -05:00
}
2026-05-30 17:27:15 -05:00
} ,
{
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)' ) ;
}
2026-05-30 17:57:34 -05:00
} ,
{
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();
}
2026-05-30 20:04:50 -05:00
} ,
{
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)' ) ;
}
2026-05-30 21:20:51 -05:00
} ,
{
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 ;
` );
}
2026-05-31 15:06:10 -05:00
} ,
{
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 ) ;
}
}
2026-05-31 15:52:50 -05:00
} ,
{
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 ) ;
}
}
2026-06-03 20:28:37 -05:00
} ,
{
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 ) ;
}
}
2026-06-03 21:43:54 -05:00
} ,
{
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' ) ;
}
2026-06-03 22:25:30 -05:00
} ,
{
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' ) ;
}
2026-06-03 23:29:30 -05:00
} ,
{
version : 'v0.82' ,
description : 'payments: normalise auto_match source to provider_sync' ,
dependsOn : [ 'v0.81' ] ,
run : function ( ) {
const result = db . prepare (
"UPDATE payments SET payment_source = 'provider_sync' WHERE payment_source = 'auto_match'"
) . run ( ) ;
console . log ( ` [v0.82] Normalised ${ result . changes } auto_match payment(s) to provider_sync ` ) ;
}
2026-05-09 15:17:40 -05:00
}
] ;
2026-05-14 01:17:05 -05:00
2026-05-03 19:51:57 -05:00
// ── users: notification columns ───────────────────────────────────────────
2026-05-09 15:17:40 -05:00
// This migration needs to run first since it's not versioned in the schema
2026-05-10 09:45:39 -05:00
console . log ( '[migration] Applying unversioned user notification columns' ) ;
const unversionedStartTime = Date . now ( ) ;
2026-05-09 22:34:50 -05:00
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 } ` ) ;
2026-05-09 13:03:36 -05:00
}
}
2026-05-09 22:34:50 -05:00
const defaultAdminName = process . env . INIT _ADMIN _USER || 'admin' ;
db . prepare ( `
UPDATE users
SET is _default _admin = 1
2026-05-04 23:34:24 -05:00
WHERE role = 'admin'
2026-05-09 22:34:50 -05:00
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' ) ;
2026-05-10 09:45:39 -05:00
// Log successful completion with timing
const elapsed = Date . now ( ) - unversionedStartTime ;
console . log ( ` [migration] Unversioned user notification columns completed in ${ elapsed } ms ` ) ;
2026-05-09 22:34:50 -05:00
} catch ( err ) {
db . exec ( 'ROLLBACK' ) ;
2026-05-10 09:45:39 -05:00
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 } ` ) ;
}
2026-05-09 22:34:50 -05:00
throw err ;
}
2026-05-09 15:17:40 -05:00
2026-06-03 22:38:33 -05:00
// Store version list so reconcileLegacyMigrations() can assert sync.
_runMigrationVersions = migrations . map ( m => m . version ) ;
2026-05-09 23:24:51 -05:00
// 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 )
) ;
2026-05-09 15:17:40 -05:00
// Process all versioned migrations
for ( const migration of migrations ) {
if ( ! hasMigrationBeenApplied ( migration . version ) ) {
2026-05-09 23:24:51 -05:00
// 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 ;
}
2026-05-09 15:17:40 -05:00
console . log ( ` [migration] Applying ${ migration . version } : ${ migration . description } ` ) ;
2026-05-09 23:24:51 -05:00
if ( migration . dependsOn && migration . dependsOn . length > 0 ) {
console . log ( ` [migration] ${ migration . version } depends on [ ${ migration . dependsOn . join ( ', ' ) } ] — satisfied ` ) ;
}
2026-05-10 09:45:39 -05:00
// Timing for migration execution
const migrationStartTime = Date . now ( ) ;
2026-05-09 15:17:40 -05:00
try {
2026-05-09 22:34:50 -05:00
// 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 } ` ) ;
2026-05-10 09:45:39 -05:00
// Log successful completion with timing
const elapsed = Date . now ( ) - migrationStartTime ;
console . log ( ` [migration] ${ migration . version } completed in ${ elapsed } ms ` ) ;
2026-05-09 23:24:51 -05:00
appliedVersions . add ( migration . version ) ;
2026-05-09 22:34:50 -05:00
} catch ( innerErr ) {
db . exec ( 'ROLLBACK' ) ;
2026-05-10 09:45:39 -05:00
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 } ` ) ;
}
2026-05-09 22:34:50 -05:00
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 } ` ) ;
2026-05-10 09:45:39 -05:00
// Log successful completion with timing
const elapsed = Date . now ( ) - migrationStartTime ;
console . log ( ` [migration] ${ migration . version } completed in ${ elapsed } ms ` ) ;
2026-05-09 23:24:51 -05:00
appliedVersions . add ( migration . version ) ;
2026-05-09 22:34:50 -05:00
}
2026-05-09 15:17:40 -05:00
} catch ( err ) {
2026-05-09 22:34:50 -05:00
db . exec ( 'ROLLBACK' ) ;
2026-05-10 09:45:39 -05:00
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 } ` ) ;
}
2026-05-09 15:17:40 -05:00
throw err ;
2026-05-09 13:03:36 -05:00
}
2026-05-09 15:17:40 -05:00
} else {
console . log ( ` [migration] Skipping already applied ${ migration . version } : ${ migration . description } ` ) ;
2026-05-09 13:03:36 -05:00
}
2026-05-03 19:51:57 -05:00
}
2026-05-09 15:17:40 -05:00
2026-05-10 09:45:39 -05:00
// Log total migration time
// Log completion of all migrations to audit log
try {
getLogAudit ( ) ( {
action : 'migration.complete' ,
entity _type : 'migration' ,
entity _id : null ,
details : {
2026-05-10 10:44:39 -05:00
total _time _ms : Date . now ( ) - startTime ,
2026-05-10 09:45:39 -05:00
message : 'All migrations completed successfully'
2026-05-10 10:44:39 -05:00
}
} ) ;
} catch ( auditErr ) {
2026-05-10 09:45:39 -05:00
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 ` ) ;
2026-05-09 16:38:28 -05:00
// All migrations are now versioned
2026-05-03 19:51:57 -05:00
}
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' ] ,
2026-05-14 01:17:05 -05:00
[ 'backup_schedule_retention_count' , '2' ] ,
2026-05-03 19:51:57 -05:00
[ '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 ) ;
}
2026-05-04 16:38:03 -05:00
// Category defaults are user-scoped. They are applied by
// ensureUserDefaultCategories(userId) when user-owned category/bill data is read.
2026-05-09 13:03:36 -05:00
// ── 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 } ` ) ;
}
2026-05-16 20:26:09 -05:00
seedManualDataSources ( db ) ;
2026-05-03 19:51:57 -05:00
}
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 ;
}
2026-05-10 10:44:39 -05:00
// 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'
]
2026-05-14 01:17:05 -05:00
} ,
'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'"
]
2026-05-14 02:11:54 -05:00
} ,
'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' ]
2026-05-14 03:00:01 -05:00
} ,
2026-05-16 20:26:09 -05:00
'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' ,
]
} ,
2026-05-16 21:36:04 -05:00
'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' ,
]
} ,
2026-05-28 22:54:07 -05:00
'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' ,
]
} ,
2026-05-30 16:13:37 -05:00
'v0.72' : {
description : 'bills: persistent tracker sort order' ,
sql : [
'DROP INDEX IF EXISTS idx_bills_user_sort' ,
'ALTER TABLE bills DROP COLUMN sort_order' ,
]
} ,
2026-05-30 20:04:50 -05:00
'v0.75' : {
description : 'categories: persistent sort order' ,
sql : [
'DROP INDEX IF EXISTS idx_categories_user_sort' ,
'ALTER TABLE categories DROP COLUMN sort_order' ,
]
} ,
2026-05-30 21:20:51 -05:00
'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 ` ,
]
} ,
2026-05-14 03:00:01 -05:00
'v0.51' : {
description : 'bills: snowball_exempt column' ,
sql : [ 'ALTER TABLE bills DROP COLUMN snowball_exempt' ]
2026-05-14 21:00:07 -05:00
} ,
'v0.52' : {
description : 'users: last_seen_version column' ,
sql : [ 'ALTER TABLE users DROP COLUMN last_seen_version' ]
2026-05-15 01:36:56 -05:00
} ,
'v0.53' : {
description : 'user_login_history table' ,
sql : [ 'DROP TABLE IF EXISTS user_login_history' ]
2026-05-15 22:45:38 -05:00
} ,
'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' ,
]
2026-05-16 10:34:32 -05:00
} ,
'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' ,
]
2026-05-16 15:38:28 -05:00
} ,
'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' ,
]
2026-05-10 10:44:39 -05:00
}
} ;
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 ;
}
}
2026-05-09 20:19:46 -05:00
/ * *
* 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 ;
}
2026-05-10 10:44:39 -05:00
module . exports = { getDb , getSetting , setSetting , closeDb , getDbPath , ensureUserDefaultCategories , cleanupExpiredSessions , rollbackMigration } ;