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');
+});