diff --git a/client/components/data/SeedDemoDataSection.tsx b/client/components/data/SeedDemoDataSection.tsx index 7d50c9b..655e950 100644 --- a/client/components/data/SeedDemoDataSection.tsx +++ b/client/components/data/SeedDemoDataSection.tsx @@ -17,7 +17,9 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }: { }) { const [loading, setLoading] = useState(false); const [seeded, setSeeded] = useState(false); - const [counts, setCounts] = useState<{ bills: number; categories: number }>({ bills: 0, categories: 0 }); + const [counts, setCounts] = useState<{ bills: number; categories: number; payments: number; transactions: number }>( + { bills: 0, categories: 0, payments: 0, transactions: 0 } + ); const [clearing, setClearing] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false); const [statusLoading, setStatusLoading] = useState(true); @@ -25,9 +27,14 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }: { useEffect(() => { api.seededStatus() .then(raw => { - const data = raw as { seeded?: boolean; seededBills?: number; seededCategories?: number }; + const data = raw as { seeded?: boolean; seededBills?: number; seededCategories?: number; seededPayments?: number; seededTransactions?: number }; setSeeded(!!data.seeded); - if (data.seeded) setCounts({ bills: data.seededBills || 0, categories: data.seededCategories || 0 }); + if (data.seeded) setCounts({ + bills: data.seededBills || 0, + categories: data.seededCategories || 0, + payments: data.seededPayments || 0, + transactions: data.seededTransactions || 0, + }); }) .catch(err => console.error('Failed to check seeded status:', err)) .finally(() => setStatusLoading(false)); @@ -36,11 +43,16 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }: { const handleSeed = async () => { setLoading(true); try { - const data = await api.seedDemoData() as { billsCreated?: number; categoriesCreated?: number } | null; + const data = await api.seedDemoData() as { billsCreated?: number; categoriesCreated?: number; paymentsCreated?: number; transactionsCreated?: number } | null; if (!data || typeof data !== 'object') throw new Error('Invalid response from server'); - setCounts({ bills: data.billsCreated || 0, categories: data.categoriesCreated || 0 }); + setCounts({ + bills: data.billsCreated || 0, + categories: data.categoriesCreated || 0, + payments: data.paymentsCreated || 0, + transactions: data.transactionsCreated || 0, + }); setSeeded(true); - toast.success(`Created ${data.billsCreated || 0} demo bills successfully.`); + toast.success(`Seeded ${data.billsCreated || 0} bills with ${data.paymentsCreated || 0} payments and ${data.transactionsCreated || 0} transactions.`); setTimeout(() => onSeeded?.(), 100); } catch (err) { console.error('Seed error:', err); @@ -53,11 +65,11 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }: { const handleClearDemoData = async () => { setClearing(true); try { - const data = await api.clearDemoData() as { billsDeleted?: number; categoriesDeleted?: number }; + const data = await api.clearDemoData() as { removed?: number }; setSeeded(false); - setCounts({ bills: 0, categories: 0 }); + setCounts({ bills: 0, categories: 0, payments: 0, transactions: 0 }); setShowClearConfirm(false); - toast.success(`Removed ${data.billsDeleted || 0} demo bills and ${data.categoriesDeleted || 0} demo categories.`); + toast.success(`Removed all demo data (${data.removed || 0} records). Your own data is untouched.`); onSeeded?.(); } catch (err) { toast.error(errMessage(err, 'Failed to clear demo data.')); @@ -79,6 +91,14 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }: {

Bills

{counts.bills}

+
+

Payments

+

{counts.payments}

+
+
+

Transactions

+

{counts.transactions}

+

Categories

{counts.categories}

@@ -87,8 +107,9 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }: { ) : (

- Create 20 realistic demo bills and 8 demo categories for testing purposes. - The data will be associated with your account. + Populate every page with a realistic dataset — bills, months of payment history, + bank transactions, budgets, a debt snowball, and more — so you can explore the whole app. + It's tied to your account and fully removable; your own data is never touched.

)} @@ -107,7 +128,9 @@ export default function SeedDemoDataSection({ onSeeded, cardProps = {} }: { Clear Demo Data - This will remove {counts.bills} demo bills and {counts.categories} demo categories from your account. This cannot be undone. + This removes all seeded demo data — {counts.bills} bills, {counts.payments} payments, + {' '}{counts.transactions} transactions and related records. Your own bills and categories + are kept. This cannot be undone. diff --git a/db/migrations/versionedMigrations.cts b/db/migrations/versionedMigrations.cts index 7372373..7e78725 100644 --- a/db/migrations/versionedMigrations.cts +++ b/db/migrations/versionedMigrations.cts @@ -1766,5 +1766,36 @@ module.exports = function buildVersionedMigrations(deps) { console.log('[v1.06] category_groups table + categories.group_id added'); } }, + { + version: 'v1.07', + description: 'demo data: is_seeded marker on user-scoped tables the demo seeder touches (for surgical clear-demo removal)', + dependsOn: ['v1.06'], + run: function() { + // Tables the demo seeder writes that are NOT bill-children (bill-children + // cascade-delete with the seeded bill, so they need no marker). Marking + // these lets clear-demo remove ONLY seeded rows without touching the + // user's real planning / bank / spending / snowball data. + const SEED_MARKER_TABLES = [ + 'category_groups', 'bill_templates', 'snowball_plans', + 'spending_category_rules', 'spending_budgets', + 'monthly_income', 'monthly_starting_amounts', + 'data_sources', 'financial_accounts', 'transactions', + 'calendar_tokens', 'declined_subscription_hints', + ]; + let added = 0; + for (const table of SEED_MARKER_TABLES) { + const exists = db.prepare( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?" + ).get(table); + if (!exists) continue; // defensive — all created by <= v1.06 + const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name); + if (!cols.includes('is_seeded')) { + db.exec(`ALTER TABLE ${table} ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0`); + added++; + } + } + console.log(`[v1.07] is_seeded marker ensured on ${added} demo table(s)`); + } + }, ]; }; diff --git a/routes/user.cts b/routes/user.cts index 7ab9406..ac3758b 100644 --- a/routes/user.cts +++ b/routes/user.cts @@ -7,80 +7,80 @@ const router = express.Router(); const { getDb } = require('../db/database.cts'); const { seedDemoData } = require('../scripts/seedDemoData.cts'); const { demoDataLimiter } = require('../middleware/rateLimiter.cts'); -const { eraseUserData } = require('../services/userDataService.cts'); +const { eraseUserData, clearSeededDemoData } = require('../services/userDataService.cts'); const { standardizeError } = require('../middleware/errorFormatter.cts'); -// GET /api/user/seeded-status — returns whether the current user has any seeded data +// GET /api/user/seeded-status — whether the current user has demo data, with counts router.get('/seeded-status', (req: Req, res: Res) => { try { const db = getDb(); const userId = req.user.id; - - // Check for seeded bills - const seededBillsResult = db.prepare('SELECT COUNT(*) as count FROM bills WHERE user_id = ? AND is_seeded = 1').get(userId); - const seededBillsCount = seededBillsResult.count; - - // Check for seeded categories - const seededCategoriesResult = db.prepare('SELECT COUNT(*) as count FROM categories WHERE user_id = ? AND is_seeded = 1').get(userId); - const seededCategoriesCount = seededCategoriesResult.count; - - const hasSeededData = seededBillsCount > 0 || seededCategoriesCount > 0; - + const one = (sql: string) => db.prepare(sql).get(userId).count as number; + + const seededBills = one('SELECT COUNT(*) as count FROM bills WHERE user_id = ? AND is_seeded = 1'); + const seededCategories = one('SELECT COUNT(*) as count FROM categories WHERE user_id = ? AND is_seeded = 1'); + // Bill-children (cascade with the seeded bill) counted via their bill. + const seededPayments = one('SELECT COUNT(*) as count FROM payments WHERE bill_id IN (SELECT id FROM bills WHERE user_id = ? AND is_seeded = 1)'); + const seededTransactions = one('SELECT COUNT(*) as count FROM transactions WHERE user_id = ? AND is_seeded = 1'); + res.json({ - seeded: hasSeededData, - seededBills: seededBillsCount, - seededCategories: seededCategoriesCount, + seeded: seededBills > 0 || seededCategories > 0, + seededBills, + seededCategories, + seededPayments, + seededTransactions, }); } catch (err) { - const status = err.status || 500; - res.status(status).json({ error: status === 500 ? 'Seeded status check failed' : err.message }); + res.status(err.status || 500).json(standardizeError(err.message || 'Seeded status check failed', err.code || 'SEEDED_STATUS_ERROR')); } }); -// POST /api/user/clear-demo-data — removes all seeded bills and categories for the requesting user +// POST /api/user/clear-demo-data — surgically remove ONLY seeded demo data for the +// requesting user (in one transaction, child → parent). Marker tables are removed +// by is_seeded; bill-children cascade with their seeded bill. A seeded category is +// removed only if no remaining (non-seeded) bill references it, so a user's own bill +// that reused a demo category keeps its category. Mirrors eraseUserData(). router.post('/clear-demo-data', demoDataLimiter, (req: Req, res: Res) => { try { - const db = getDb(); - const userId = req.user.id; - - // Delete seeded bills - const billsResult = db.prepare('DELETE FROM bills WHERE user_id = ? AND is_seeded = 1').run(userId); - const billsDeleted = billsResult.changes; - - // Delete seeded categories - const categoriesResult = db.prepare('DELETE FROM categories WHERE user_id = ? AND is_seeded = 1').run(userId); - const categoriesDeleted = categoriesResult.changes; - - // Audit logging: record the clear action to import_history - db.prepare( - `INSERT INTO import_history (user_id, imported_at, source_filename, file_type, rows_parsed, rows_created, rows_updated, rows_skipped, rows_ambiguous, rows_errored, options_json, summary_json) - VALUES (?, datetime('now'), ?, 'clear-demo', ?, 0, 0, 0, 0, 0, ?, ?)` - ).run(userId, 'clear-demo-data', billsDeleted + categoriesDeleted, JSON.stringify({ action: 'clear-demo-data', userId }), JSON.stringify({ bills_deleted: billsDeleted, categories_deleted: categoriesDeleted })); - + const result = clearSeededDemoData(getDb(), req.user.id); res.json({ success: true, - billsDeleted, - categoriesDeleted, + removed: result.removed, + billsDeleted: result.counts.bills || 0, + categoriesDeleted: result.counts.categories || 0, + counts: result.counts, }); } catch (err) { - const status = err.status || 500; - res.status(status).json({ error: status === 500 ? 'Clear demo data operation failed' : err.message }); + res.status(err.status || 500).json(standardizeError(err.message || 'Clear demo data operation failed', err.code || 'CLEAR_DEMO_ERROR')); } }); -// POST /api/user/seed-demo-data — seeds demo bills for the requesting user -router.post('/seed-demo-data', (req: Req, res: Res) => { +// POST /api/user/seed-demo-data — seed the full demo dataset for the requesting user +router.post('/seed-demo-data', demoDataLimiter, (req: Req, res: Res) => { try { + const db = getDb(); const result = seedDemoData(req.user.id); + db.prepare( + `INSERT INTO import_history (user_id, imported_at, source_filename, file_type, rows_parsed, rows_created, rows_updated, rows_skipped, rows_ambiguous, rows_errored, options_json, summary_json) + VALUES (?, datetime('now'), 'seed-demo-data', 'seed-demo', ?, ?, 0, 0, 0, 0, ?, ?)` + ).run( + req.user.id, + result.billsCreated + result.paymentsCreated + result.transactionsCreated, + result.billsCreated, + JSON.stringify({ action: 'seed-demo-data' }), + JSON.stringify(result.counts), + ); res.json({ success: true, - message: `Created ${result.billsCreated} demo bills and ${result.categoriesCreated} demo categories`, + message: `Created ${result.billsCreated} bills, ${result.paymentsCreated} payments and ${result.transactionsCreated} transactions`, billsCreated: result.billsCreated, categoriesCreated: result.categoriesCreated, + paymentsCreated: result.paymentsCreated, + transactionsCreated: result.transactionsCreated, + counts: result.counts, }); } catch (err) { - const status = err.status || 500; - res.status(status).json({ error: status === 500 ? 'Seed operation failed' : err.message }); + res.status(err.status || 500).json(standardizeError(err.message || 'Seed operation failed', err.code || 'SEED_ERROR')); } }); diff --git a/scripts/seedDemoData.cts b/scripts/seedDemoData.cts index 3fc1e91..ea2ad14 100644 --- a/scripts/seedDemoData.cts +++ b/scripts/seedDemoData.cts @@ -1,458 +1,419 @@ #!/usr/bin/env node /** - * Seed Demo Data Script - * Creates realistic bills across common categories for demo purposes. - * Idempotent: can be run multiple times safely. + * Seed Demo Data — a full "take it for a ride" dataset. + * + * Populates every user-facing surface (bills, categories + groups, payment + * history with drift + debt paydown, monthly overrides/skips/snoozes, planning + * income/starting amounts, spending budgets/rules/transactions, a demo bank + * source + accounts + matched/unmatched/subscription transactions, an active + * snowball plan, merchant rules, a calendar feed) so a new user can explore the + * whole app. + * + * Removal contract: everything created here is either marked `is_seeded = 1` on a + * user-scoped table, or is a bill-child that cascade-deletes with its seeded bill. + * `POST /user/clear-demo-data` removes exactly these rows and nothing else — see + * routes/user.cts. Keep that in sync when adding a new seeded surface. + * + * Idempotent: refuses to run when the account already has seeded bills. */ const path = require('path'); -// Use DB_PATH from env or default to db/bills.db -const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'db', 'bills.db'); +// DB_PATH is read at require-time by db/database — keep the default in sync. +process.env.DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'db', 'bills.db'); -// Import database helper const { getDb, ensureUserDefaultCategories } = require('../db/database.cts'); -// Money columns (expected_amount, current_balance, minimum_payment) are stored as -// integer cents since migration v1.03 — convert the demo dollars before insert. +// Money columns are integer cents (migration v1.03) — convert demo dollars. const { toCents } = require('../utils/money.mts'); -const CATEGORIES = [ - 'Utilities', - 'Housing', - 'Insurance', - 'Subscriptions', - 'Transportation', - 'Healthcare', - 'Credit Cards', - 'Loans', - 'Entertainment', +// ── Static demo content ───────────────────────────────────────────────────── + +const BILL_CATEGORIES = [ + 'Utilities', 'Housing', 'Insurance', 'Subscriptions', + 'Transportation', 'Healthcare', 'Credit Cards', 'Loans', 'Entertainment', ]; -// billing_cycle CHECK: 'monthly' | 'quarterly' | 'annually' | 'irregular' -// cycle_type CHECK: 'monthly' | 'weekly' | 'biweekly' | 'quarterly' | 'annual' +// Spending categories are distinct from bill categories and from the app's +// DEFAULT_CATEGORIES, so they are always seed-created (and thus removable). +const SPENDING_CATEGORIES = ['Groceries', 'Dining Out', 'Gas & Fuel', 'Shopping', 'Coffee']; + +const CATEGORY_GROUPS = [ + { name: 'Living Expenses', members: ['Groceries', 'Gas & Fuel'] }, + { name: 'Discretionary', members: ['Dining Out', 'Shopping', 'Coffee'] }, +]; + +// billing_cycle: 'monthly' | 'quarterly' | 'annually' | 'irregular' +// cycle_type: 'monthly' | 'weekly' | 'biweekly' | 'quarterly' | 'annual' const BILLS = [ - // ── Utilities ───────────────────────────────────────────────────────────── - { - name: 'Electric Company', - category: 'Utilities', - amount: 85, - dueDay: 15, - cycle: 'monthly', - cycleType: 'monthly', - autopay: true, - interestRate: 0, - }, - { - name: 'Gas Utility', - category: 'Utilities', - amount: 35, - dueDay: 12, - cycle: 'monthly', - cycleType: 'monthly', - autopay: true, - interestRate: 0, - }, - { - name: 'City Water Dept', - category: 'Utilities', - amount: 45, - dueDay: 20, - cycle: 'monthly', - cycleType: 'monthly', - autopay: true, - interestRate: 0, - }, - { - name: 'Internet Provider', - category: 'Utilities', - amount: 70, - dueDay: 18, - cycle: 'monthly', - cycleType: 'monthly', - autopay: true, - interestRate: 0, - }, - { - name: 'Cell Phone', - category: 'Utilities', - amount: 65, - dueDay: 25, - cycle: 'monthly', - cycleType: 'monthly', - autopay: true, - interestRate: 0, - currentBalance: 648, // remaining on 24-month 0% carrier installment plan - minPayment: 27, - snowballOrder: 0, // smallest balance — first in snowball - snowballInclude: 1, - }, - { - name: 'Trash Service', - category: 'Utilities', - amount: 25, - dueDay: 28, - cycle: 'monthly', - cycleType: 'monthly', - autopay: true, - interestRate: 0, - }, + // ── Utilities ── + { name: 'Electric Company', category: 'Utilities', amount: 85, dueDay: 15, cycle: 'monthly', cycleType: 'monthly', autopay: true, autopayVerified: true }, + { name: 'Gas Utility', category: 'Utilities', amount: 35, dueDay: 12, cycle: 'monthly', cycleType: 'monthly', autopay: true }, + { name: 'City Water Dept', category: 'Utilities', amount: 45, dueDay: 20, cycle: 'monthly', cycleType: 'monthly', autopay: true }, + { name: 'Internet Provider', category: 'Utilities', amount: 70, dueDay: 18, cycle: 'monthly', cycleType: 'monthly', autopay: true, autopayVerified: true }, + { name: 'Cell Phone', category: 'Utilities', amount: 65, dueDay: 25, cycle: 'monthly', cycleType: 'monthly', autopay: true, currentBalance: 648, minPayment: 27, interestRate: 0, snowballOrder: 0, snowballInclude: 1 }, + { name: 'Trash Service', category: 'Utilities', amount: 25, dueDay: 28, cycle: 'monthly', cycleType: 'monthly', autopay: false }, - // ── Housing ─────────────────────────────────────────────────────────────── - { - name: 'Mortgage', - category: 'Housing', - amount: 1450, // paying $250/mo above minimum → extra principal paydown - dueDay: 1, - cycle: 'monthly', - cycleType: 'monthly', - autopay: true, - interestRate: 3.25, - currentBalance: 185000, - minPayment: 1200, - snowballOrder: null, // not included in snowball — too large, handled separately - snowballInclude: 0, - snowballExempt: 1, - }, + // ── Housing ── + { name: 'Mortgage', category: 'Housing', amount: 1450, dueDay: 1, cycle: 'monthly', cycleType: 'monthly', autopay: true, autopayVerified: true, interestRate: 3.25, currentBalance: 185000, minPayment: 1200, snowballInclude: 0, snowballExempt: 1 }, - // ── Insurance ───────────────────────────────────────────────────────────── - { - name: 'Car Insurance', - category: 'Insurance', - amount: 120, - dueDay: 5, - cycle: 'quarterly', - cycleType: 'quarterly', - autopay: true, - interestRate: 0, - }, - { - name: 'Homeowners Insurance', - category: 'Insurance', - amount: 300, - dueDay: 10, - cycle: 'annually', - cycleType: 'annual', - autopay: false, - interestRate: 0, - }, - { - name: 'Health Insurance', - category: 'Healthcare', - amount: 200, - dueDay: 1, - cycle: 'quarterly', - cycleType: 'quarterly', - autopay: true, - interestRate: 0, - }, - { - name: 'Dental Insurance', - category: 'Healthcare', - amount: 40, - dueDay: 15, - cycle: 'quarterly', - cycleType: 'quarterly', - autopay: true, - interestRate: 0, - }, + // ── Insurance / Healthcare ── + { name: 'Car Insurance', category: 'Insurance', amount: 120, dueDay: 5, cycle: 'quarterly', cycleType: 'quarterly', autopay: true }, + { name: 'Homeowners Insurance', category: 'Insurance', amount: 300, dueDay: 10, cycle: 'annually', cycleType: 'annual', autopay: false }, + { name: 'Health Insurance', category: 'Healthcare', amount: 200, dueDay: 1, cycle: 'quarterly', cycleType: 'quarterly', autopay: true }, + { name: 'Dental Insurance', category: 'Healthcare', amount: 40, dueDay: 15, cycle: 'quarterly', cycleType: 'quarterly', autopay: true }, - // ── Credit Cards (Snowball — ordered smallest→largest balance) ───────────── - { - name: 'Discover It Card', - category: 'Credit Cards', - amount: 65, - dueDay: 26, - cycle: 'monthly', - cycleType: 'monthly', - autopay: true, - interestRate: 22.99, - currentBalance: 920, - minPayment: 35, - snowballOrder: 1, - snowballInclude: 1, - website: 'https://discover.com', - has2fa: true, - }, - { - name: 'Capital One Quicksilver', - category: 'Credit Cards', - amount: 95, - dueDay: 28, - cycle: 'monthly', - cycleType: 'monthly', - autopay: true, - interestRate: 24.49, - currentBalance: 1850, - minPayment: 55, - snowballOrder: 2, - snowballInclude: 1, - website: 'https://capitalone.com', - has2fa: true, - }, - { - name: 'Chase Freedom', - category: 'Credit Cards', - amount: 140, - dueDay: 12, - cycle: 'monthly', - cycleType: 'monthly', - autopay: true, - interestRate: 21.49, - currentBalance: 3200, - minPayment: 90, - snowballOrder: 3, - snowballInclude: 1, - website: 'https://chase.com', - has2fa: true, - }, + // ── Credit Cards (snowball, smallest→largest) ── + { name: 'Discover It Card', category: 'Credit Cards', amount: 65, dueDay: 26, cycle: 'monthly', cycleType: 'monthly', autopay: true, interestRate: 22.99, currentBalance: 920, minPayment: 35, snowballOrder: 1, snowballInclude: 1, website: 'https://discover.com', has2fa: true }, + { name: 'Capital One Quicksilver', category: 'Credit Cards', amount: 95, dueDay: 28, cycle: 'monthly', cycleType: 'monthly', autopay: true, interestRate: 24.49, currentBalance: 1850, minPayment: 55, snowballOrder: 2, snowballInclude: 1, website: 'https://capitalone.com', has2fa: true }, + { name: 'Chase Freedom', category: 'Credit Cards', amount: 140, dueDay: 12, cycle: 'monthly', cycleType: 'monthly', autopay: true, interestRate: 21.49, currentBalance: 3200, minPayment: 90, snowballOrder: 3, snowballInclude: 1, website: 'https://chase.com', has2fa: true }, - // ── Loans (Snowball — ordered by balance after credit cards) ────────────── - { - name: 'Car Payment', - category: 'Loans', - amount: 425, // paying $75/mo above contractual minimum to shorten payoff - dueDay: 22, - cycle: 'monthly', - cycleType: 'monthly', - autopay: true, - interestRate: 4.5, - currentBalance: 8400, - minPayment: 350, - snowballOrder: 4, - snowballInclude: 1, - }, - { - name: 'Student Loan', - category: 'Loans', - amount: 250, - dueDay: 15, - cycle: 'monthly', - cycleType: 'monthly', - autopay: true, - interestRate: 5.5, - currentBalance: 12500, - minPayment: 150, - snowballOrder: 5, - snowballInclude: 1, - }, + // ── Loans ── + { name: 'Car Payment', category: 'Loans', amount: 425, dueDay: 22, cycle: 'monthly', cycleType: 'monthly', autopay: true, interestRate: 4.5, currentBalance: 8400, minPayment: 350, snowballOrder: 4, snowballInclude: 1 }, + { name: 'Student Loan', category: 'Loans', amount: 250, dueDay: 15, cycle: 'monthly', cycleType: 'monthly', autopay: true, interestRate: 5.5, currentBalance: 12500, minPayment: 150, snowballOrder: 5, snowballInclude: 1 }, - // ── Subscriptions ───────────────────────────────────────────────────────── - { - name: 'Netflix', - category: 'Subscriptions', - amount: 15.99, - dueDay: 22, - cycle: 'monthly', - cycleType: 'monthly', - autopay: true, - interestRate: 0, - isSubscription: true, - subscriptionType: 'streaming', - website: 'https://netflix.com', - }, - { - name: 'Spotify', - category: 'Subscriptions', - amount: 9.99, - dueDay: 14, - cycle: 'monthly', - cycleType: 'monthly', - autopay: true, - interestRate: 0, - isSubscription: true, - subscriptionType: 'music', - website: 'https://spotify.com', - }, - { - name: 'Adobe Creative Cloud', - category: 'Subscriptions', - amount: 54.99, - dueDay: 8, - cycle: 'monthly', - cycleType: 'monthly', - autopay: true, - interestRate: 0, - isSubscription: true, - subscriptionType: 'software', - website: 'https://adobe.com', - has2fa: true, - }, - { - name: 'Amazon Prime', - category: 'Subscriptions', - amount: 139, - dueDay: 1, - cycle: 'annually', - cycleType: 'annual', - autopay: true, - interestRate: 0, - isSubscription: true, - subscriptionType: 'shopping', - website: 'https://amazon.com', - }, - { - name: 'Apple iCloud+', - category: 'Subscriptions', - amount: 2.99, - dueDay: 18, - cycle: 'monthly', - cycleType: 'monthly', - autopay: true, - interestRate: 0, - isSubscription: true, - subscriptionType: 'cloud', - website: 'https://icloud.com', - }, - { - name: 'Gym Membership', - category: 'Subscriptions', - amount: 45, - dueDay: 10, - cycle: 'monthly', - cycleType: 'monthly', - autopay: true, - interestRate: 0, - isSubscription: true, - subscriptionType: 'fitness', - }, + // ── Subscriptions ── + { name: 'Netflix', category: 'Subscriptions', amount: 15.99, dueDay: 22, cycle: 'monthly', cycleType: 'monthly', autopay: true, isSubscription: true, subscriptionType: 'streaming', website: 'https://netflix.com' }, + { name: 'Spotify', category: 'Subscriptions', amount: 9.99, dueDay: 14, cycle: 'monthly', cycleType: 'monthly', autopay: true, isSubscription: true, subscriptionType: 'music', website: 'https://spotify.com' }, + { name: 'Adobe Creative Cloud', category: 'Subscriptions', amount: 54.99, dueDay: 8, cycle: 'monthly', cycleType: 'monthly', autopay: true, isSubscription: true, subscriptionType: 'software', website: 'https://adobe.com', has2fa: true }, + { name: 'Amazon Prime', category: 'Subscriptions', amount: 139, dueDay: 1, cycle: 'annually', cycleType: 'annual', autopay: true, isSubscription: true, subscriptionType: 'shopping', website: 'https://amazon.com' }, + { name: 'Apple iCloud+', category: 'Subscriptions', amount: 2.99, dueDay: 18, cycle: 'monthly', cycleType: 'monthly', autopay: true, isSubscription: true, subscriptionType: 'cloud', website: 'https://icloud.com' }, + { name: 'Gym Membership', category: 'Subscriptions', amount: 45, dueDay: 10, cycle: 'monthly', cycleType: 'monthly', autopay: true, isSubscription: true, subscriptionType: 'fitness' }, - // ── Entertainment ───────────────────────────────────────────────────────── - { - name: 'Grocery Delivery', - category: 'Entertainment', - amount: 30, - dueDay: 3, - cycle: 'irregular', - cycleType: 'monthly', - autopay: false, - interestRate: 0, - }, + // ── Entertainment (a genuine recurring entertainment bill, monthly) ── + { name: 'AMC A-List', category: 'Entertainment', amount: 24.99, dueDay: 3, cycle: 'monthly', cycleType: 'monthly', autopay: true, isSubscription: true, subscriptionType: 'entertainment', website: 'https://amctheatres.com' }, ]; -/** - * Get or create a category by name for a user - */ -function getCategoryByName(db, userId, name) { - let category = db.prepare('SELECT id FROM categories WHERE user_id = ? AND LOWER(name) = LOWER(?)').get(userId, name); - if (!category) { - const result = db.prepare('INSERT INTO categories (user_id, name) VALUES (?, ?)').run(userId, name); - category = { id: result.lastInsertRowid }; +// Bills deliberately left OVERDUE this month (due date passed, no payment). +const OVERDUE_THIS_MONTH = new Set(['Trash Service', 'Dental Insurance']); +// Bills whose most-recent payments also appear as matched bank transactions. +const BANK_MATCHED = new Set(['Electric Company', 'Internet Provider', 'Netflix', 'Spotify', 'Car Payment']); +// One skipped month (bill leaves that month unpaid, flagged skipped not overdue). +const SKIP = { bill: 'Gym Membership', monthsAgo: 1 }; +// One amount override (actual differs from expected) and one note. +const OVERRIDE = { bill: 'Electric Company', monthsAgo: 1, actual: 132.40 }; +const NOTE = { bill: 'City Water Dept', text: 'Called about the leak — credit expected next cycle.' }; + +// Un-tracked subscription charges (in the catalog, no matching bill) → drive the +// Subscriptions "recommendations" surface. +const UNTRACKED_SUBS = [ + { payee: 'HULU', amount: 17.99, day: 6 }, + { payee: 'HBO MAX', amount: 15.99, day: 11 }, + { payee: 'YOUTUBE PREMIUM', amount: 13.99, day: 19 }, +]; + +// Everyday spending (not bills) → populate the Spending page + budgets/rules. +const SPENDING_MERCHANTS = [ + { payee: 'WHOLE FOODS MARKET', category: 'Groceries', amounts: [82.14, 46.03, 118.77] }, + { payee: 'TRADER JOE\'S', category: 'Groceries', amounts: [54.20, 61.88] }, + { payee: 'CHIPOTLE', category: 'Dining Out', amounts: [14.85, 22.40] }, + { payee: 'OLIVE GARDEN', category: 'Dining Out', amounts: [63.12] }, + { payee: 'SHELL OIL', category: 'Gas & Fuel', amounts: [48.90, 51.35] }, + { payee: 'AMAZON MARKETPLACE', category: 'Shopping', amounts: [39.99, 27.49, 112.00] }, + { payee: 'STARBUCKS', category: 'Coffee', amounts: [6.45, 5.75, 6.45] }, +]; +const SPENDING_RULES = [ + { merchant: 'WHOLE FOODS', category: 'Groceries' }, + { merchant: 'SHELL', category: 'Gas & Fuel' }, + { merchant: 'STARBUCKS', category: 'Coffee' }, + { merchant: 'AMAZON', category: 'Shopping' }, +]; +const SPENDING_BUDGETS = { Groceries: 500, 'Dining Out': 200, 'Gas & Fuel': 160, Shopping: 250, Coffee: 40 }; + +const HISTORY_MONTHS = 6; // months of payment history to generate (incl. current) + +// ── Date helpers ──────────────────────────────────────────────────────────── + +function ymd(d) { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} +function clampedDate(year, monthIdx0, day) { + const lastDay = new Date(year, monthIdx0 + 1, 0).getDate(); + return new Date(year, monthIdx0, Math.min(day, lastDay)); +} +// Month offsets (0 = current, negative = past) a bill occurs in, within the window. +function cycleOffsets(bill) { + switch (bill.cycle) { + case 'annually': return [-1]; + case 'quarterly': return [0, -3]; + case 'irregular': return [0, -1, -2]; + default: return Array.from({ length: HISTORY_MONTHS }, (_, i) => -(HISTORY_MONTHS - 1) + i); // -5..0 } - return category; +} +// Small deterministic-ish drift on variable bills so amounts differ from expected. +function driftedAmount(bill, offset) { + const variable = ['Utilities'].includes(bill.category) && !bill.currentBalance; + if (!variable) return bill.amount; + const pct = ((Math.abs((bill.name.length * 7 + offset * 13)) % 21) - 10) / 100; // ±10% + return Math.round(bill.amount * (1 + pct) * 100) / 100; } -/** - * Main seed function - * @param {number} [userId] - User ID to seed data for. If not provided, uses the first admin user. - */ +// ── Main ──────────────────────────────────────────────────────────────────── + function seedDemoData(userId = null) { const db = getDb(); + const now = new Date(); + const todayStr = ymd(now); - // Check if data already exists for this user (if userId provided) or globally - let existingCheck; - if (userId !== null) { - existingCheck = db.prepare('SELECT COUNT(*) AS count FROM bills WHERE user_id = ?').get(userId); - } else { - existingCheck = db.prepare('SELECT COUNT(*) AS count FROM bills').get(); + const targetUser = userId !== null + ? db.prepare('SELECT id FROM users WHERE id = ?').get(userId) + : db.prepare("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get(); + if (!targetUser) throw new Error('User not found. Please create a user first.'); + const uid = targetUser.id; + + const alreadySeeded = db.prepare('SELECT COUNT(*) AS c FROM bills WHERE user_id = ? AND is_seeded = 1').get(uid).c; + if (alreadySeeded > 0) { + const err = new Error('Demo data is already seeded for this account. Clear it first to re-seed.'); + err.status = 409; + err.code = 'ALREADY_SEEDED'; + throw err; } - if (existingCheck.count > 0) { - console.log(`⚠️ Found ${existingCheck.count} existing bills. Skipping seed to prevent duplicates.`); - console.log(' Run again with --force to overwrite.'); - return { billsCreated: 0, categoriesCreated: 0, message: 'Data already exists' }; - } + const counts = { + bills: 0, categories: 0, groups: 0, payments: 0, transactions: 0, + monthlyState: 0, planning: 0, budgets: 0, spendingRules: 0, + merchantRules: 0, snowballPlans: 0, calendarTokens: 0, + }; - // Get user (or admin if userId not provided) - let targetUser; - if (userId !== null) { - targetUser = db.prepare('SELECT id FROM users WHERE id = ?').get(userId); - } else { - targetUser = db.prepare('SELECT id FROM users WHERE role = ? ORDER BY id LIMIT 1', 'admin').get(); - } + db.transaction(() => { + ensureUserDefaultCategories(uid); - if (!targetUser) { - throw new Error('User not found. Please create a user first.'); - } + // ── Categories (bill + spending), flagging ONLY ones this seed creates ── + const catId = {}; + const getOrCreateCategory = (name, { spending = false } = {}) => { + const existing = db.prepare('SELECT id FROM categories WHERE user_id = ? AND name = ? COLLATE NOCASE').get(uid, name); + if (existing) { catId[name] = existing.id; return; } + const r = db.prepare('INSERT INTO categories (user_id, name, spending_enabled, is_seeded) VALUES (?, ?, ?, 1)') + .run(uid, name, spending ? 1 : 0); + catId[name] = r.lastInsertRowid; + counts.categories++; + }; + for (const name of BILL_CATEGORIES) getOrCreateCategory(name); + for (const name of SPENDING_CATEGORIES) getOrCreateCategory(name, { spending: true }); - const targetUserId = targetUser.id; - console.log(`📝 Seeding demo data for user: ${targetUserId}`); - - // Ensure default categories exist for this user - ensureUserDefaultCategories(targetUserId); - - // Create our demo categories if they don't exist - const categoriesMap = {}; - let categoriesCreated = 0; - - for (const categoryName of CATEGORIES) { - const before = db.prepare('SELECT id FROM categories WHERE user_id = ? AND LOWER(name) = LOWER(?)').get(targetUserId, categoryName); - const category = getCategoryByName(db, targetUserId, categoryName); - categoriesMap[categoryName] = category.id; - db.prepare('UPDATE categories SET is_seeded = 1 WHERE id = ?').run(category.id); - if (!before) categoriesCreated++; - } - - // Create bills - let billsCreated = 0; - const insertBill = db.prepare(` - INSERT INTO bills ( - user_id, name, category_id, due_day, - billing_cycle, cycle_type, - expected_amount, autopay_enabled, interest_rate, - current_balance, minimum_payment, - snowball_order, snowball_include, snowball_exempt, - is_subscription, subscription_type, - website, has_2fa, - active, is_seeded - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1) - `); - - for (const billData of BILLS) { - const category = categoriesMap[billData.category]; - - try { - insertBill.run( - targetUserId, - billData.name, - category, - billData.dueDay || Math.floor(Math.random() * 28) + 1, - billData.cycle || 'monthly', - billData.cycleType || 'monthly', - toCents(billData.amount), - billData.autopay !== undefined ? (billData.autopay ? 1 : 0) : 0, - billData.interestRate ?? 0, - billData.currentBalance != null ? toCents(billData.currentBalance) : null, - billData.minPayment != null ? toCents(billData.minPayment) : null, - billData.snowballOrder ?? null, - billData.snowballInclude ?? 0, - billData.snowballExempt ?? 0, - billData.isSubscription ? 1 : 0, - billData.subscriptionType ?? null, - billData.website ?? null, - billData.has2fa ? 1 : 0, - ); - billsCreated++; - } catch (err) { - console.error(`Failed to create bill "${billData.name}":`, err.message); + // ── Category groups (organise spending categories) ── + for (const [i, grp] of CATEGORY_GROUPS.entries()) { + const r = db.prepare('INSERT INTO category_groups (user_id, name, sort_order, is_seeded) VALUES (?, ?, ?, 1)') + .run(uid, grp.name, i); + counts.groups++; + for (const member of grp.members) { + if (catId[member]) db.prepare('UPDATE categories SET group_id = ? WHERE id = ?').run(r.lastInsertRowid, catId[member]); + } } - } - console.log(`✅ Created ${billsCreated} demo bills`); - console.log(`✅ Created ${categoriesCreated} demo categories`); + // ── Bills ── + const insertBill = db.prepare(` + INSERT INTO bills ( + user_id, name, category_id, due_day, billing_cycle, cycle_type, + expected_amount, autopay_enabled, autodraft_status, autopay_verified_at, interest_rate, + current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt, + is_subscription, subscription_type, website, has_2fa, active, is_seeded + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1) + `); + const billId = {}; + for (const b of BILLS) { + const verifiedAt = b.autopayVerified ? ymd(clampedDate(now.getFullYear(), now.getMonth(), Math.min(b.dueDay, 28))) : null; + const r = insertBill.run( + uid, b.name, catId[b.category] ?? null, b.dueDay, b.cycle, b.cycleType, + toCents(b.amount), b.autopay ? 1 : 0, b.autopayVerified ? 'confirmed' : 'none', verifiedAt, b.interestRate ?? 0, + b.currentBalance != null ? toCents(b.currentBalance) : null, + b.minPayment != null ? toCents(b.minPayment) : null, + b.snowballOrder ?? null, b.snowballInclude ?? 0, b.snowballExempt ?? 0, + b.isSubscription ? 1 : 0, b.subscriptionType ?? null, b.website ?? null, b.has2fa ? 1 : 0, + ); + billId[b.name] = r.lastInsertRowid; + counts.bills++; + } - return { billsCreated, categoriesCreated }; + // ── Demo bank source + account (for matched/unmatched transactions) ── + const source = db.prepare( + "INSERT INTO data_sources (user_id, type, provider, name, status, is_seeded) VALUES (?, 'manual', 'demo', 'Demo Bank', 'active', 1)" + ).run(uid); + const sourceId = source.lastInsertRowid; + const account = db.prepare(` + INSERT INTO financial_accounts + (user_id, data_source_id, provider_account_id, name, org_name, account_type, currency, balance, available_balance, monitored, is_seeded) + VALUES (?, ?, 'demo-checking', 'Everyday Checking', 'Demo Bank', 'checking', 'USD', ?, ?, 1, 1) + `).run(uid, sourceId, toCents(4210.55), toCents(4210.55)); + const accountId = account.lastInsertRowid; + + let txnSeq = 0; + const insertTxn = db.prepare(` + INSERT INTO transactions + (user_id, data_source_id, account_id, provider_transaction_id, source_type, transaction_type, + posted_date, transacted_at, amount, currency, description, payee, category, + matched_bill_id, match_status, spending_category_id, is_seeded) + VALUES (?, ?, ?, ?, 'manual', ?, ?, ?, ?, 'USD', ?, ?, ?, ?, ?, ?, 1) + `); + const addTxn = ({ type = 'debit', date, amountDollars, description, payee, category = null, matchedBillId = null, matchStatus = 'unmatched', spendingCategoryId = null }) => { + const cents = type === 'credit' ? toCents(Math.abs(amountDollars)) : -toCents(Math.abs(amountDollars)); + const r = insertTxn.run(uid, sourceId, accountId, `demo-${++txnSeq}`, type, date, date, cents, + description, payee, category, matchedBillId, matchStatus, spendingCategoryId); + counts.transactions++; + return r.lastInsertRowid; + }; + + // ── Payment history + matched bank transactions + monthly overrides ── + const insertPayment = db.prepare(` + INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source, transaction_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const b of BILLS) { + const bid = billId[b.name]; + for (const offset of cycleOffsets(b)) { + const y = now.getFullYear(), m = now.getMonth() + offset; + const payDate = clampedDate(y, m, b.dueDay); + if (payDate > now) continue; // future occurrence → naturally "upcoming", no payment + + // Deliberate skipped month → recorded as a monthly_bill_state skip, no payment. + if (b.name === SKIP.bill && offset === -SKIP.monthsAgo) { + db.prepare('INSERT INTO monthly_bill_state (bill_id, year, month, is_skipped) VALUES (?, ?, ?, 1)') + .run(bid, payDate.getFullYear(), payDate.getMonth() + 1); + counts.monthlyState++; + continue; + } + // Current month: leave the designated overdue bills unpaid (past due, no payment). + if (offset === 0 && OVERDUE_THIS_MONTH.has(b.name)) continue; + + let amount = driftedAmount(b, offset); + if (b.name === OVERRIDE.bill && offset === -OVERRIDE.monthsAgo) amount = OVERRIDE.actual; + + // Debt bills: split into interest + principal so the balance pays down. + let balanceDelta = null; + if (b.currentBalance) { + const interest = Math.round((toCents(b.currentBalance) * (b.interestRate ?? 0)) / 100 / 12); + balanceDelta = -Math.max(0, toCents(amount) - interest); + } + + // A subset of recent payments are auto-matched to a seeded bank transaction. + let txnId = null, source_ = 'manual'; + if (BANK_MATCHED.has(b.name) && offset >= -1) { + txnId = addTxn({ + date: ymd(payDate), amountDollars: amount, + description: `${b.name.toUpperCase()} AUTOPAY`, payee: b.name, + matchedBillId: bid, matchStatus: 'matched', + }); + source_ = 'transaction_match'; + } + + insertPayment.run(bid, toCents(amount), ymd(payDate), 'demo', null, balanceDelta, source_, txnId); + counts.payments++; + } + } + + // Amount-override state (actual recorded above; flag the month's actual_amount) + a note. + { + const od = clampedDate(now.getFullYear(), now.getMonth() - OVERRIDE.monthsAgo, 1); + db.prepare('INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount) VALUES (?, ?, ?, ?)') + .run(billId[OVERRIDE.bill], od.getFullYear(), od.getMonth() + 1, toCents(OVERRIDE.actual)); + counts.monthlyState++; + db.prepare('INSERT INTO monthly_bill_state (bill_id, year, month, notes) VALUES (?, ?, ?, ?)') + .run(billId[NOTE.bill], now.getFullYear(), now.getMonth() + 1, NOTE.text); + counts.monthlyState++; + // Snooze one overdue bill's alert (Overdue Command Center demo). + const snoozeUntil = ymd(new Date(now.getFullYear(), now.getMonth(), now.getDate() + 5)); + db.prepare('INSERT INTO monthly_bill_state (bill_id, year, month, snoozed_until) VALUES (?, ?, ?, ?)') + .run(billId['Trash Service'], now.getFullYear(), now.getMonth() + 1, snoozeUntil); + counts.monthlyState++; + } + + // ── Unmatched bank transactions that look like bills → Matches suggestions ── + for (const name of ['Gas Utility', 'City Water Dept']) { + const b = BILLS.find(x => x.name === name); + const d = clampedDate(now.getFullYear(), now.getMonth(), b.dueDay); + if (d <= now) addTxn({ date: ymd(d), amountDollars: b.amount, description: `${name.toUpperCase()} PMT`, payee: name }); + } + // ── Un-tracked subscription charges → Subscriptions recommendations ── + for (const s of UNTRACKED_SUBS) { + const d = clampedDate(now.getFullYear(), now.getMonth(), s.day); + if (d <= now) addTxn({ date: ymd(d), amountDollars: s.amount, description: `${s.payee} SUBSCRIPTION`, payee: s.payee }); + } + // ── Everyday spending transactions (categorised) → Spending page ── + for (const merchant of SPENDING_MERCHANTS) { + const scid = catId[merchant.category] ?? null; + merchant.amounts.forEach((amt, i) => { + const d = clampedDate(now.getFullYear(), now.getMonth() - (i % 2), 2 + (i * 5) % 26); + if (d <= now) addTxn({ date: ymd(d), amountDollars: amt, description: merchant.payee, payee: merchant.payee, spendingCategoryId: scid }); + }); + } + + // ── Planning: monthly income + starting amounts (recent months) ── + for (let off = -3; off <= 0; off++) { + const d = new Date(now.getFullYear(), now.getMonth() + off, 1); + const y = d.getFullYear(), mo = d.getMonth() + 1; + const hasIncome = db.prepare('SELECT 1 FROM monthly_income WHERE user_id = ? AND year = ? AND month = ?').get(uid, y, mo); + if (!hasIncome) { + db.prepare('INSERT INTO monthly_income (user_id, year, month, label, amount, is_seeded) VALUES (?, ?, ?, ?, ?, 1)') + .run(uid, y, mo, 'Paycheck', toCents(5200)); + counts.planning++; + } + const hasStart = db.prepare('SELECT 1 FROM monthly_starting_amounts WHERE user_id = ? AND year = ? AND month = ?').get(uid, y, mo); + if (!hasStart) { + db.prepare('INSERT INTO monthly_starting_amounts (user_id, year, month, first_amount, fifteenth_amount, is_seeded) VALUES (?, ?, ?, ?, ?, 1)') + .run(uid, y, mo, toCents(3200), toCents(2800)); + counts.planning++; + } + } + + // ── Spending budgets + auto-categorise rules ── + for (const [cat, amount] of Object.entries(SPENDING_BUDGETS)) { + if (!catId[cat]) continue; + db.prepare('INSERT INTO spending_budgets (user_id, category_id, year, month, amount, is_seeded) VALUES (?, ?, ?, ?, ?, 1)') + .run(uid, catId[cat], now.getFullYear(), now.getMonth() + 1, toCents(amount)); + counts.budgets++; + } + for (const rule of SPENDING_RULES) { + if (!catId[rule.category]) continue; + db.prepare('INSERT INTO spending_category_rules (user_id, category_id, merchant, is_seeded) VALUES (?, ?, ?, 1)') + .run(uid, catId[rule.category], rule.merchant); + counts.spendingRules++; + } + + // ── Snowball: an active plan + the user's extra-payment setting ── + const debtBills = BILLS.filter(b => b.snowballInclude).sort((a, b) => (a.snowballOrder ?? 99) - (b.snowballOrder ?? 99)); + const snapshot = JSON.stringify({ + method: 'snowball', + extra_payment_cents: toCents(200), + order: debtBills.map(b => ({ bill_id: billId[b.name], name: b.name, balance_cents: toCents(b.currentBalance) })), + }); + db.prepare(`INSERT INTO snowball_plans (user_id, name, method, status, extra_payment, plan_snapshot, is_seeded) + VALUES (?, 'My Debt Snowball', 'snowball', 'active', ?, ?, 1)`).run(uid, toCents(200), snapshot); + counts.snowballPlans++; + // Only set the extra-payment if the user hasn't chosen one (don't clobber real data). + db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ? AND COALESCE(snowball_extra_payment, 0) = 0') + .run(toCents(200), uid); + + // ── Merchant → bill auto-match rules (cascade with their bill) ── + for (const name of ['Netflix', 'Spotify']) { + db.prepare('INSERT INTO bill_merchant_rules (user_id, bill_id, merchant) VALUES (?, ?, ?)') + .run(uid, billId[name], name.toUpperCase()); + counts.merchantRules++; + } + + // ── Calendar feed subscription token ── + const crypto = require('crypto'); + db.prepare('INSERT INTO calendar_tokens (user_id, token, label, active, is_seeded) VALUES (?, ?, ?, 1, 1)') + .run(uid, crypto.randomBytes(24).toString('hex'), 'Demo Calendar Feed'); + counts.calendarTokens++; + })(); + + return { + billsCreated: counts.bills, + categoriesCreated: counts.categories, + paymentsCreated: counts.payments, + transactionsCreated: counts.transactions, + counts, + }; } -// Run seed if called directly +// CLI entry if (require.main === module) { try { const result = seedDemoData(); - console.log('\nSeed complete:', result); + console.log('\n✅ Seed complete:', JSON.stringify(result.counts, null, 2)); process.exit(0); } catch (err) { console.error('Seed failed:', err.message); - process.exit(1); + process.exit(err.code === 'ALREADY_SEEDED' ? 0 : 1); } } diff --git a/services/userDataService.cts b/services/userDataService.cts index 9354fde..ededb8c 100644 --- a/services/userDataService.cts +++ b/services/userDataService.cts @@ -56,4 +56,54 @@ function eraseUserData(db: Db, userId: number): { erased: number; counts: Record return { erased, counts }; } -module.exports = { eraseUserData }; +// User-scoped tables the demo seeder marks with is_seeded = 1 (not bill-children, +// which cascade with their seeded bill). Keep in sync with scripts/seedDemoData.cts +// and the v1.07 migration. +const DEMO_MARKER_TABLES = [ + 'transactions', 'financial_accounts', 'data_sources', + 'category_groups', 'bill_templates', 'snowball_plans', 'spending_category_rules', + 'spending_budgets', 'monthly_income', 'monthly_starting_amounts', + 'calendar_tokens', 'declined_subscription_hints', +]; + +/** + * Surgically remove ONLY the demo data seeded by scripts/seedDemoData.cts for one + * user — never their real data. Runs in one transaction, child → parent so it holds + * with foreign_keys ON. Marker tables go by is_seeded; bill-children cascade with the + * seeded bill. A seeded category is removed only if no surviving (non-seeded) bill + * still references it, so a user's own bill that reused a demo category keeps it. + * Mirrors eraseUserData(); audits to import_history. + */ +function clearSeededDemoData(db: Db, userId: number): { removed: number; counts: Record } { + const counts: Record = {}; + const del = (label: string, sql: string) => { counts[label] = db.prepare(sql).run(userId).changes; }; + + const run = db.transaction(() => { + // Bank chain first (transactions cascade their match rejections), then the rest + // of the independent marker tables. + for (const t of DEMO_MARKER_TABLES) del(t, `DELETE FROM ${t} WHERE user_id = ? AND is_seeded = 1`); + + // Bill-children counted explicitly (they also cascade with the bill below). + del('payments', 'DELETE FROM payments WHERE bill_id IN (SELECT id FROM bills WHERE user_id = ? AND is_seeded = 1)'); + del('bills', 'DELETE FROM bills WHERE user_id = ? AND is_seeded = 1'); + // Seeded categories only if no surviving bill still references them. + counts.categories = db.prepare( + `DELETE FROM categories WHERE user_id = ? AND is_seeded = 1 + AND id NOT IN (SELECT category_id FROM bills WHERE user_id = ? AND category_id IS NOT NULL)` + ).run(userId, userId).changes; + // Reset the demo snowball extra-payment. + db.prepare('UPDATE users SET snowball_extra_payment = 0 WHERE id = ?').run(userId); + }); + run(); + + const removed = Object.values(counts).reduce((sum, n) => sum + n, 0); + db.prepare(` + INSERT INTO import_history (user_id, imported_at, source_filename, file_type, + rows_parsed, rows_created, rows_updated, rows_skipped, rows_ambiguous, rows_errored, options_json, summary_json) + VALUES (?, ?, 'clear-demo-data', 'clear-demo', ?, 0, 0, 0, 0, 0, ?, ?) + `).run(userId, new Date().toISOString(), removed, JSON.stringify({ action: 'clear-demo-data' }), JSON.stringify(counts)); + + return { removed, counts }; +} + +module.exports = { eraseUserData, clearSeededDemoData }; diff --git a/tests/seedDemoData.test.js b/tests/seedDemoData.test.js new file mode 100644 index 0000000..eda532f --- /dev/null +++ b/tests/seedDemoData.test.js @@ -0,0 +1,129 @@ +'use strict'; + +// Demo seed + surgical removal. seedDemoData() must populate every user-facing +// surface, and clearSeededDemoData() must remove EXACTLY the seeded rows — never a +// user's own bills/categories/planning, and leave no orphans. This is the contract +// the "Seed / Clear Demo Data" buttons depend on. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const os = require('node:os'); +const path = require('node:path'); +const fs = require('node:fs'); + +const dbPath = path.join(os.tmpdir(), `bill-tracker-seed-${process.pid}.sqlite`); +process.env.DB_PATH = dbPath; + +const { getDb, closeDb } = require('../db/database.cts'); +const { seedDemoData } = require('../scripts/seedDemoData.cts'); +const { clearSeededDemoData, eraseUserData } = require('../services/userDataService.cts'); + +const mkUser = (db, name) => + db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES (?, 'hash', 'user', 1)").run(name).lastInsertRowid; +const num = (db, sql, ...p) => db.prepare(sql).get(...p).n; + +let db, userA, userB, userC; +test.before(() => { + db = getDb(); + db.pragma('foreign_keys = ON'); + userA = mkUser(db, 'seed-a'); + userB = mkUser(db, 'seed-b'); + userC = mkUser(db, 'seed-c'); + seedDemoData(userA); + seedDemoData(userB); + seedDemoData(userC); +}); +test.after(() => { + closeDb(); + for (const s of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + s); } catch {} } +}); + +test('seed populates every user-facing surface', () => { + assert.equal(num(db, 'SELECT COUNT(*) n FROM bills WHERE user_id=? AND is_seeded=1', userA), 23); + // 8 of 9 bill categories reuse the user's defaults (not flagged); Healthcare + 5 spending are created. + assert.equal(num(db, 'SELECT COUNT(*) n FROM categories WHERE user_id=? AND is_seeded=1', userA), 6); + assert.equal(num(db, 'SELECT COUNT(*) n FROM categories WHERE user_id=? AND is_seeded=1 AND spending_enabled=1', userA), 5); + assert.ok(num(db, 'SELECT COUNT(*) n FROM payments p JOIN bills b ON b.id=p.bill_id WHERE b.user_id=?', userA) > 40, 'payment history'); + assert.ok(num(db, 'SELECT COUNT(*) n FROM transactions WHERE user_id=? AND is_seeded=1', userA) > 10, 'bank transactions'); + assert.ok(num(db, "SELECT COUNT(*) n FROM transactions WHERE user_id=? AND match_status='matched'", userA) >= 3, 'matched txns'); + assert.ok(num(db, "SELECT COUNT(*) n FROM transactions WHERE user_id=? AND match_status='unmatched'", userA) >= 5, 'unmatched txns → suggestions/recommendations'); + assert.ok(num(db, 'SELECT COUNT(*) n FROM transactions WHERE user_id=? AND spending_category_id IS NOT NULL', userA) > 5, 'categorised spending'); + assert.ok(num(db, 'SELECT COUNT(*) n FROM monthly_bill_state m JOIN bills b ON b.id=m.bill_id WHERE b.user_id=?', userA) >= 4, 'skip/override/note/snooze'); + assert.equal(num(db, 'SELECT COUNT(*) n FROM category_groups WHERE user_id=? AND is_seeded=1', userA), 2); + assert.equal(num(db, 'SELECT COUNT(*) n FROM snowball_plans WHERE user_id=? AND is_seeded=1', userA), 1); + assert.equal(num(db, 'SELECT COUNT(*) n FROM spending_budgets WHERE user_id=? AND is_seeded=1', userA), 5); + assert.ok(num(db, 'SELECT COUNT(*) n FROM spending_category_rules WHERE user_id=? AND is_seeded=1', userA) >= 3); + assert.ok(num(db, 'SELECT COUNT(*) n FROM monthly_income WHERE user_id=? AND is_seeded=1', userA) >= 1); + assert.ok(num(db, 'SELECT COUNT(*) n FROM monthly_starting_amounts WHERE user_id=? AND is_seeded=1', userA) >= 1); + assert.ok(num(db, 'SELECT COUNT(*) n FROM bill_merchant_rules r JOIN bills b ON b.id=r.bill_id WHERE b.user_id=?', userA) >= 1); + assert.equal(num(db, 'SELECT COUNT(*) n FROM data_sources WHERE user_id=? AND is_seeded=1', userA), 1); + assert.equal(num(db, 'SELECT COUNT(*) n FROM financial_accounts WHERE user_id=? AND is_seeded=1', userA), 1); + assert.equal(num(db, 'SELECT COUNT(*) n FROM calendar_tokens WHERE user_id=? AND is_seeded=1', userA), 1); + assert.ok(db.prepare('SELECT snowball_extra_payment e FROM users WHERE id=?').get(userA).e > 0, 'snowball extra-payment set'); + + // Matched payments link a real seeded transaction; money is integer cents. + assert.ok(num(db, "SELECT COUNT(*) n FROM payments WHERE payment_source='transaction_match' AND transaction_id IN (SELECT id FROM transactions)") >= 3); + assert.equal(num(db, 'SELECT COUNT(*) n FROM payments WHERE amount <> CAST(amount AS INT)'), 0, 'cents-clean'); +}); + +test('seed refuses to double-seed (idempotency guard)', () => { + assert.throws(() => seedDemoData(userA), (e) => e.code === 'ALREADY_SEEDED' && e.status === 409); +}); + +test('clear removes ONLY seeded data — user bills/categories & no orphans', () => { + // A user's OWN bill in a seed-created category (Healthcare) + one in a default category. + const healthcareId = db.prepare("SELECT id FROM categories WHERE user_id=? AND name='Healthcare'").get(userA).id; + assert.equal(db.prepare('SELECT is_seeded s FROM categories WHERE id=?').get(healthcareId).s, 1, 'Healthcare is a seeded category'); + const utilitiesId = db.prepare("SELECT id FROM categories WHERE user_id=? AND name='Utilities'").get(userA).id; + const ownBill1 = db.prepare("INSERT INTO bills (user_id, name, category_id, due_day, expected_amount, active, is_seeded) VALUES (?, 'My Doctor', ?, 9, 5000, 1, 0)").run(userA, healthcareId).lastInsertRowid; + const ownBill2 = db.prepare("INSERT INTO bills (user_id, name, category_id, due_day, expected_amount, active, is_seeded) VALUES (?, 'My Power', ?, 9, 6000, 1, 0)").run(userA, utilitiesId).lastInsertRowid; + + const result = clearSeededDemoData(db, userA); + assert.ok(result.removed > 0); + + // Every seeded surface gone. + assert.equal(num(db, 'SELECT COUNT(*) n FROM bills WHERE user_id=? AND is_seeded=1', userA), 0); + assert.equal(num(db, 'SELECT COUNT(*) n FROM transactions WHERE user_id=? AND is_seeded=1', userA), 0); + assert.equal(num(db, 'SELECT COUNT(*) n FROM data_sources WHERE user_id=? AND is_seeded=1', userA), 0); + assert.equal(num(db, 'SELECT COUNT(*) n FROM financial_accounts WHERE user_id=? AND is_seeded=1', userA), 0); + assert.equal(num(db, 'SELECT COUNT(*) n FROM snowball_plans WHERE user_id=? AND is_seeded=1', userA), 0); + assert.equal(num(db, 'SELECT COUNT(*) n FROM spending_budgets WHERE user_id=? AND is_seeded=1', userA), 0); + assert.equal(num(db, 'SELECT COUNT(*) n FROM monthly_income WHERE user_id=? AND is_seeded=1', userA), 0); + assert.equal(num(db, 'SELECT COUNT(*) n FROM category_groups WHERE user_id=? AND is_seeded=1', userA), 0); + + // No orphans: payments/transactions/state for deleted bills are gone. + assert.equal(num(db, 'SELECT COUNT(*) n FROM payments p LEFT JOIN bills b ON b.id=p.bill_id WHERE b.id IS NULL'), 0, 'no orphan payments'); + assert.equal(num(db, 'SELECT COUNT(*) n FROM monthly_bill_state m LEFT JOIN bills b ON b.id=m.bill_id WHERE b.id IS NULL'), 0, 'no orphan monthly_bill_state'); + + // User's own data preserved. + assert.ok(db.prepare('SELECT 1 FROM bills WHERE id=?').get(ownBill1), 'user bill 1 survives'); + assert.ok(db.prepare('SELECT 1 FROM bills WHERE id=?').get(ownBill2), 'user bill 2 survives'); + assert.equal(db.prepare('SELECT category_id c FROM bills WHERE id=?').get(ownBill1).c, healthcareId, 'still categorised (not SET NULL)'); + // Healthcare seeded category SURVIVES because a user bill still references it. + assert.ok(db.prepare('SELECT 1 FROM categories WHERE id=?').get(healthcareId), 'referenced seeded category kept'); + // A seeded spending category with no user bill IS removed. + assert.equal(num(db, "SELECT COUNT(*) n FROM categories WHERE user_id=? AND name='Groceries'", userA), 0, 'unreferenced seeded category removed'); + // Default categories untouched. + assert.ok(db.prepare('SELECT 1 FROM categories WHERE id=?').get(utilitiesId), 'default category kept'); + // Snowball extra-payment reset. + assert.equal(db.prepare('SELECT snowball_extra_payment e FROM users WHERE id=?').get(userA).e, 0); +}); + +test('clear leaves other users\' seeded data untouched', () => { + assert.equal(num(db, 'SELECT COUNT(*) n FROM bills WHERE user_id=? AND is_seeded=1', userB), 23, 'user B intact after clearing A'); + assert.ok(num(db, 'SELECT COUNT(*) n FROM transactions WHERE user_id=? AND is_seeded=1', userB) > 0); +}); + +test('clear is audited to import_history', () => { + const row = db.prepare("SELECT file_type, source_filename FROM import_history WHERE user_id=? AND file_type='clear-demo' ORDER BY id DESC LIMIT 1").get(userA); + assert.equal(row.file_type, 'clear-demo'); + assert.equal(row.source_filename, 'clear-demo-data'); +}); + +test('eraseUserData still wipes seeded demo data wholesale', () => { + assert.ok(num(db, 'SELECT COUNT(*) n FROM bills WHERE user_id=?', userC) > 0); + eraseUserData(db, userC); + assert.equal(num(db, 'SELECT COUNT(*) n FROM bills WHERE user_id=?', userC), 0); + assert.equal(num(db, 'SELECT COUNT(*) n FROM transactions WHERE user_id=?', userC), 0); + assert.equal(num(db, 'SELECT COUNT(*) n FROM snowball_plans WHERE user_id=?', userC), 0); + assert.equal(num(db, 'SELECT COUNT(*) n FROM payments p LEFT JOIN bills b ON b.id=p.bill_id WHERE b.id IS NULL'), 0, 'no orphans after erase'); +});