-
{data.income?.label || 'Salary'}
- {Number(summary.income_total || 0) === 0 && (
-
Add income to calculate savings.
- )}
+
+
+
1st
+
{fmt(starting.first_amount)}
+
+
+
15th
+
{fmt(starting.fifteenth_amount)}
+
+
+
Other
+
{fmt(starting.other_amount)}
+
+
+
+
+
Total starting
+
{fmt(starting.combined_amount)}
+
+
+
Paid
+
{fmt(starting.paid_total)}
+
+
+
Total remaining
+
+ {fmt(starting.combined_remaining)}
+
+
+
-
{fmt(summary.income_total)}
- {editingIncome && (
-
+ {data.previous_month && (
+
+ Previous month remaining: {fmt(data.previous_month.combined_remaining)}
+
+ )}
+
+ {editingStarting && (
+
- Label
- setIncomeLabel(event.target.value)} placeholder="Salary" />
-
-
- Amount
+ 1st
setIncomeAmount(event.target.value)}
+ value={startingFirst}
+ onChange={event => setStartingFirst(event.target.value)}
/>
-
+
+ 15th
+ setStartingFifteenth(event.target.value)}
+ />
+
+
+ Other
+ setStartingOther(event.target.value)}
+ />
+
+
{saving ? : }
Save
@@ -369,7 +420,7 @@ export default function SummaryPage() {
Total amount per type
- Income, planned expenses, and {Number(summary.result || 0) >= 0 ? 'savings' : 'shortfall'} for {monthLabel(data.year, data.month)}.
+ Starting balance, planned expenses, and {Number(summary.result || 0) >= 0 ? 'remaining' : 'shortfall'} for {monthLabel(data.year, data.month)}.
diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx
index 7c7fb4a..7634155 100644
--- a/client/pages/TrackerPage.jsx
+++ b/client/pages/TrackerPage.jsx
@@ -60,8 +60,8 @@ const STATUS_META = {
// ── Summary cards ──────────────────────────────────────────────────────────
const CARD_DEFS = {
- expected: {
- label: 'Total Expected',
+ starting: {
+ label: 'Starting',
icon: TrendingUp,
bar: 'from-slate-400 to-slate-300',
glow: '',
@@ -96,7 +96,7 @@ const CARD_DEFS = {
},
};
-function SummaryCard({ type, value }) {
+function SummaryCard({ type, value, onEdit, hint }) {
const def = CARD_DEFS[type];
const isActive = def.activateWhen(value || 0);
const Icon = def.icon;
@@ -118,6 +118,16 @@ function SummaryCard({ type, value }) {
{def.label}
+ {type === 'starting' && onEdit && (
+
+
+
+ )}
{fmt(value)}
+ {hint &&
{hint}
}
);
}
@@ -397,6 +408,186 @@ function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) {
);
}
+function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState('');
+ const [firstAmount, setFirstAmount] = useState('0');
+ const [fifteenthAmount, setFifteenthAmount] = useState('0');
+ const [otherAmount, setOtherAmount] = useState('0');
+ const [preview, setPreview] = useState(null);
+
+ const monthName = `${MONTHS[month - 1]} ${year}`;
+ const localFirst = Number(firstAmount) || 0;
+ const localFifteenth = Number(fifteenthAmount) || 0;
+ const localOther = Number(otherAmount) || 0;
+ const totalStarting = localFirst + localFifteenth + localOther;
+ const paidSoFar = Number(preview?.paid_total || 0);
+ const firstRemaining = localFirst - Number(preview?.paid_from_first || 0);
+ const fifteenthRemaining = localFifteenth - Number(preview?.paid_from_fifteenth || 0);
+ const totalRemaining = totalStarting - paidSoFar;
+
+ useEffect(() => {
+ let alive = true;
+ async function loadStartingAmounts() {
+ if (!open) return;
+ setLoading(true);
+ setError('');
+ try {
+ const result = await api.getMonthlyStartingAmounts(year, month);
+ if (!alive) return;
+ setPreview(result);
+ setFirstAmount(String(result.first_amount ?? 0));
+ setFifteenthAmount(String(result.fifteenth_amount ?? 0));
+ setOtherAmount(String(result.other_amount ?? 0));
+ } catch (err) {
+ if (!alive) return;
+ setError(err.message || 'Monthly starting amounts could not be loaded.');
+ } finally {
+ if (alive) setLoading(false);
+ }
+ }
+ loadStartingAmounts();
+ return () => { alive = false; };
+ }, [open, year, month]);
+
+ async function handleSave(e) {
+ e.preventDefault();
+ const first = Number(firstAmount);
+ const fifteenth = Number(fifteenthAmount);
+ const other = Number(otherAmount);
+ if (![first, fifteenth, other].every(value => Number.isFinite(value) && value >= 0)) {
+ setError('Starting amounts must be non-negative numbers.');
+ return;
+ }
+
+ setSaving(true);
+ setError('');
+ try {
+ await api.updateMonthlyStartingAmounts({
+ year,
+ month,
+ first_amount: first,
+ fifteenth_amount: fifteenth,
+ other_amount: other,
+ });
+ toast.success('Monthly starting amounts saved.');
+ onSave();
+ } catch (err) {
+ setError(err.message || 'Monthly starting amounts could not be saved.');
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
{ if (!value) onClose(); }}>
+
+
+ Monthly Starting Amounts
+ {monthName}
+
+
+
+
+
+
+ Cancel
+
+
+ {saving ? 'Saving...' : 'Save'}
+
+
+
+
+ );
+}
+
// ── Payment modal ──────────────────────────────────────────────────────────
function PaymentModal({ payment, onClose, onSave }) {
const [amount, setAmount] = useState(String(payment.amount));
@@ -983,6 +1174,8 @@ export default function TrackerPage() {
const [data, setData] = useState(null);
// Edit Bill modal: { bill, categories } when open, null when closed
const [editBillData, setEditBillData] = useState(null);
+ // Edit Starting Amounts modal: true when open, false when closed
+ const [editStartingOpen, setEditStartingOpen] = useState(false);
const load = useCallback(async () => {
try {
@@ -1072,7 +1265,12 @@ export default function TrackerPage() {
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
-
+ setEditStartingOpen(true)}
+ />
@@ -1105,6 +1303,15 @@ export default function TrackerPage() {
/>
)}
+ {/* Edit Starting Amounts modal */}
+ setEditStartingOpen(false)}
+ year={year}
+ month={month}
+ onSave={() => { setEditStartingOpen(false); load(); }}
+ />
+
);
}
diff --git a/db/database.js b/db/database.js
index 0afdccc..55483f8 100644
--- a/db/database.js
+++ b/db/database.js
@@ -168,7 +168,30 @@ function runMigrations() {
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)');
- // ── import_sessions: temporary preview state (v0.38) ─────────────────────
+ // -- monthly_starting_amounts: per-user monthly starting amounts for 1st and 15th (v0.18.2)
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS monthly_starting_amounts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
+ month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
+ first_amount REAL NOT NULL DEFAULT 0 CHECK(first_amount >= 0),
+ fifteenth_amount REAL NOT NULL DEFAULT 0 CHECK(fifteenth_amount >= 0),
+ other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0),
+ notes TEXT,
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now')),
+ UNIQUE(user_id, year, month)
+ )
+ `);
+ db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user_month ON monthly_starting_amounts(user_id, year, month)');
+
+ // ── monthly_starting_amounts: add other_amount column (v0.18.3) ─────────────
+ const startingCols = db.prepare('PRAGMA table_info(monthly_starting_amounts)').all().map(c => c.name);
+ if (!startingCols.includes('other_amount')) {
+ db.exec('ALTER TABLE monthly_starting_amounts ADD COLUMN other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0)');
+ console.log('[migration] monthly_starting_amounts.other_amount column added');
+ }
db.exec(`
CREATE TABLE IF NOT EXISTS import_sessions (
id TEXT PRIMARY KEY,
diff --git a/package-lock.json b/package-lock.json
index 0c899c1..b0dea5d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "bill-tracker",
- "version": "0.18.1",
+ "version": "0.18.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bill-tracker",
- "version": "0.18.1",
+ "version": "0.18.3",
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.2",
diff --git a/package.json b/package.json
index 6f5e40e..95c1148 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bill-tracker",
- "version": "0.18.1",
+ "version": "0.18.3",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {
diff --git a/routes/about.js b/routes/about.js
new file mode 100644
index 0000000..5d9846c
--- /dev/null
+++ b/routes/about.js
@@ -0,0 +1,24 @@
+const express = require('express');
+const router = express.Router();
+
+let pkg;
+try { pkg = require('../package.json'); } catch { pkg = { version: '0.1.0' }; }
+
+router.get('/', (req, res) => {
+ res.json({
+ name: 'BillTracker',
+ version: pkg.version,
+ description: 'A self-hosted app for tracking recurring bills, monthly payments, due dates, categories, and personal bill history.',
+ stack: {
+ backend: 'Node.js / Express',
+ frontend: 'React',
+ database: 'SQLite',
+ },
+ ai_assisted: true,
+ links: {
+ release_notes: '/release-notes',
+ },
+ });
+});
+
+module.exports = router;
diff --git a/routes/categories.js b/routes/categories.js
index c753227..50fd67b 100644
--- a/routes/categories.js
+++ b/routes/categories.js
@@ -106,8 +106,25 @@ router.delete('/:id', (req, res) => {
const db = getDb();
const cat = db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!cat) return res.status(404).json({ error: 'Category not found' });
- db.prepare('DELETE FROM categories WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);
- res.json({ success: true });
+
+ const deleteCategory = db.transaction(() => {
+ const bills = db.prepare(`
+ UPDATE bills
+ SET category_id = NULL, updated_at = datetime('now')
+ WHERE category_id = ? AND user_id = ?
+ `).run(req.params.id, req.user.id);
+
+ const deleted = db.prepare('DELETE FROM categories WHERE id = ? AND user_id = ?')
+ .run(req.params.id, req.user.id);
+
+ return {
+ deleted: deleted.changes,
+ uncategorized_bills: bills.changes,
+ };
+ });
+
+ const result = deleteCategory();
+ res.json({ success: true, ...result });
});
module.exports = router;
diff --git a/routes/export.js b/routes/export.js
index ff2eec4..337100f 100644
--- a/routes/export.js
+++ b/routes/export.js
@@ -109,6 +109,12 @@ function getUserExportData(userId) {
WHERE b.user_id = ?
ORDER BY m.year, m.month, m.bill_id
`).all(userId);
+ const monthlyStartingAmounts = db.prepare(`
+ SELECT id, year, month, first_amount, fifteenth_amount, other_amount, notes, created_at, updated_at
+ FROM monthly_starting_amounts
+ WHERE user_id = ?
+ ORDER BY year, month
+ `).all(userId);
const notes = [
...bills.filter(b => b.notes).map(b => ({ type: 'bill', bill_id: b.id, notes: b.notes })),
...payments.filter(p => p.notes).map(p => ({ type: 'payment', payment_id: p.id, bill_id: p.bill_id, notes: p.notes })),
@@ -117,16 +123,17 @@ function getUserExportData(userId) {
const metadata = {
exported_at: new Date().toISOString(),
export_type: 'user_data',
- includes: ['Bills', 'Payments', 'Categories', 'Monthly bill state', 'Notes', 'Export metadata'],
+ includes: ['Bills', 'Payments', 'Categories', 'Monthly bill state', 'Monthly starting amounts', 'Notes', 'Export metadata'],
counts: {
bills: bills.length,
payments: payments.length,
categories: categories.length,
monthly_bill_state: monthlyState.length,
+ monthly_starting_amounts: monthlyStartingAmounts.length,
notes: notes.length,
},
};
- return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, notes };
+ return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, monthly_starting_amounts: monthlyStartingAmounts, notes };
}
router.get('/user-excel', (req, res) => {
@@ -137,6 +144,7 @@ router.get('/user-excel', (req, res) => {
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.payments), 'Payments');
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.categories), 'Categories');
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.monthly_bill_state), 'Monthly State');
+ xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.monthly_starting_amounts), 'Monthly Starting Amounts');
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.notes), 'Notes');
const buffer = xlsx.write(wb, { type: 'buffer', bookType: 'xlsx' });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
@@ -155,6 +163,7 @@ router.get('/user-db', (req, res) => {
CREATE TABLE bills (id INTEGER PRIMARY KEY, name TEXT, category_id INTEGER, due_day INTEGER, override_due_date TEXT, bucket TEXT, expected_amount REAL, interest_rate REAL, billing_cycle TEXT, autopay_enabled INTEGER, autodraft_status TEXT, website TEXT, username TEXT, account_info TEXT, has_2fa INTEGER, active INTEGER, notes TEXT, created_at TEXT, updated_at TEXT);
CREATE TABLE payments (id INTEGER PRIMARY KEY, bill_id INTEGER, amount REAL, paid_date TEXT, method TEXT, notes TEXT, created_at TEXT, updated_at TEXT);
CREATE TABLE monthly_bill_state (id INTEGER PRIMARY KEY, bill_id INTEGER, year INTEGER, month INTEGER, actual_amount REAL, notes TEXT, is_skipped INTEGER, created_at TEXT, updated_at TEXT);
+ CREATE TABLE monthly_starting_amounts (id INTEGER PRIMARY KEY, year INTEGER, month INTEGER, first_amount REAL, fifteenth_amount REAL, other_amount REAL, notes TEXT, created_at TEXT, updated_at TEXT);
CREATE TABLE notes (type TEXT, bill_id INTEGER, payment_id INTEGER, monthly_state_id INTEGER, year INTEGER, month INTEGER, notes TEXT);
`);
const meta = out.prepare('INSERT INTO export_metadata (key, value) VALUES (?, ?)');
@@ -170,6 +179,7 @@ router.get('/user-db', (req, res) => {
insertRows('bills', data.bills);
insertRows('payments', data.payments);
insertRows('monthly_bill_state', data.monthly_bill_state);
+ insertRows('monthly_starting_amounts', data.monthly_starting_amounts);
insertRows('notes', data.notes.map(n => ({
type: n.type,
bill_id: n.bill_id ?? null,
diff --git a/routes/monthly-starting-amounts.js b/routes/monthly-starting-amounts.js
new file mode 100644
index 0000000..d00a4f3
--- /dev/null
+++ b/routes/monthly-starting-amounts.js
@@ -0,0 +1,146 @@
+const express = require('express');
+const router = express.Router();
+const { getDb } = require('../db/database');
+const { getCycleRange } = require('../services/statusService');
+
+function parseYearMonth(source) {
+ const now = new Date();
+ const year = parseInt(source.year || now.getFullYear(), 10);
+ const month = parseInt(source.month || now.getMonth() + 1, 10);
+
+ if (Number.isNaN(year) || year < 2000 || year > 2100) {
+ return { error: 'year must be a 4-digit integer between 2000 and 2100' };
+ }
+ if (Number.isNaN(month) || month < 1 || month > 12) {
+ return { error: 'month must be an integer between 1 and 12' };
+ }
+
+ return { year, month };
+}
+
+function money(value) {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : 0;
+}
+
+function getStartingAmounts(db, userId, year, month) {
+ const row = db.prepare(`
+ SELECT first_amount, fifteenth_amount, other_amount
+ FROM monthly_starting_amounts
+ WHERE user_id = ? AND year = ? AND month = ?
+ `).get(userId, year, month);
+
+ return {
+ first_amount: money(row?.first_amount || 0),
+ fifteenth_amount: money(row?.fifteenth_amount || 0),
+ other_amount: money(row?.other_amount || 0),
+ };
+}
+
+function calculatePaidDeductions(db, userId, year, month) {
+ const { start, end } = getCycleRange(year, month);
+
+ // Paid from first bucket: bills with due_day 1-14
+ const firstPaid = db.prepare(`
+ SELECT COALESCE(SUM(p.amount), 0) AS paid
+ FROM payments p
+ JOIN bills b ON b.id = p.bill_id
+ WHERE b.user_id = ?
+ AND p.paid_date BETWEEN ? AND ?
+ AND p.deleted_at IS NULL
+ AND b.due_day BETWEEN 1 AND 14
+ `).get(userId, start, end);
+
+ // Paid from fifteenth bucket: bills with due_day 15-31
+ const fifteenthPaid = db.prepare(`
+ SELECT COALESCE(SUM(p.amount), 0) AS paid
+ FROM payments p
+ JOIN bills b ON b.id = p.bill_id
+ WHERE b.user_id = ?
+ AND p.paid_date BETWEEN ? AND ?
+ AND p.deleted_at IS NULL
+ AND b.due_day BETWEEN 15 AND 31
+ `).get(userId, start, end);
+
+ const totalPaid = db.prepare(`
+ SELECT COALESCE(SUM(p.amount), 0) AS paid
+ FROM payments p
+ JOIN bills b ON b.id = p.bill_id
+ WHERE b.user_id = ?
+ AND p.paid_date BETWEEN ? AND ?
+ AND p.deleted_at IS NULL
+ `).get(userId, start, end);
+
+ return {
+ paid_from_first: money(firstPaid.paid),
+ paid_from_fifteenth: money(fifteenthPaid.paid),
+ paid_total: money(totalPaid.paid),
+ };
+}
+
+function buildStartingAmountsResponse(db, userId, year, month) {
+ const amounts = getStartingAmounts(db, userId, year, month);
+ const paid = calculatePaidDeductions(db, userId, year, month);
+
+ const combined_amount = amounts.first_amount + amounts.fifteenth_amount + amounts.other_amount;
+ const paid_total = paid.paid_total;
+
+ return {
+ year,
+ month,
+ first_amount: amounts.first_amount,
+ fifteenth_amount: amounts.fifteenth_amount,
+ other_amount: amounts.other_amount,
+ combined_amount,
+ paid_from_first: paid.paid_from_first,
+ paid_from_fifteenth: paid.paid_from_fifteenth,
+ paid_total,
+ first_remaining: amounts.first_amount - paid.paid_from_first,
+ fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
+ other_remaining: amounts.other_amount,
+ combined_remaining: combined_amount - paid_total,
+ };
+}
+
+router.get('/', (req, res) => {
+ const parsed = parseYearMonth(req.query);
+ if (parsed.error) return res.status(400).json({ error: parsed.error });
+
+ const db = getDb();
+ res.json(buildStartingAmountsResponse(db, req.user.id, parsed.year, parsed.month));
+});
+
+router.put('/', (req, res) => {
+ const parsed = parseYearMonth(req.body || {});
+ if (parsed.error) return res.status(400).json({ error: parsed.error });
+
+ const firstAmount = Number(req.body?.first_amount);
+ if (!Number.isFinite(firstAmount) || firstAmount < 0 || firstAmount > 1000000000) {
+ return res.status(400).json({ error: 'first_amount must be a number between 0 and 1000000000' });
+ }
+
+ const fifteenthAmount = Number(req.body?.fifteenth_amount);
+ if (!Number.isFinite(fifteenthAmount) || fifteenthAmount < 0 || fifteenthAmount > 1000000000) {
+ return res.status(400).json({ error: 'fifteenth_amount must be a number between 0 and 1000000000' });
+ }
+
+ const otherAmount = Number(req.body?.other_amount);
+ if (!Number.isFinite(otherAmount) || otherAmount < 0 || otherAmount > 1000000000) {
+ return res.status(400).json({ error: 'other_amount must be a number between 0 and 1000000000' });
+ }
+
+ const db = getDb();
+ db.prepare(`
+ INSERT INTO monthly_starting_amounts (user_id, year, month, first_amount, fifteenth_amount, other_amount, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
+ ON CONFLICT(user_id, year, month) DO UPDATE SET
+ first_amount = excluded.first_amount,
+ fifteenth_amount = excluded.fifteenth_amount,
+ other_amount = excluded.other_amount,
+ updated_at = datetime('now')
+ `).run(req.user.id, parsed.year, parsed.month, firstAmount, fifteenthAmount, otherAmount);
+
+ res.json(buildStartingAmountsResponse(db, req.user.id, parsed.year, parsed.month));
+});
+
+module.exports = router;
diff --git a/routes/summary.js b/routes/summary.js
index 30a9a7e..1a2d316 100644
--- a/routes/summary.js
+++ b/routes/summary.js
@@ -25,6 +25,85 @@ function money(value) {
return Number.isFinite(n) ? n : 0;
}
+function getStartingAmounts(db, userId, year, month) {
+ const row = db.prepare(`
+ SELECT first_amount, fifteenth_amount, other_amount
+ FROM monthly_starting_amounts
+ WHERE user_id = ? AND year = ? AND month = ?
+ `).get(userId, year, month);
+
+ return {
+ first_amount: money(row?.first_amount || 0),
+ fifteenth_amount: money(row?.fifteenth_amount || 0),
+ other_amount: money(row?.other_amount || 0),
+ };
+}
+
+function calculatePaidDeductions(db, userId, year, month) {
+ const { start, end } = getCycleRange(year, month);
+
+ // Paid from first bucket: bills with due_day 1-14
+ const firstPaid = db.prepare(`
+ SELECT COALESCE(SUM(p.amount), 0) AS paid
+ FROM payments p
+ JOIN bills b ON b.id = p.bill_id
+ WHERE b.user_id = ?
+ AND p.paid_date BETWEEN ? AND ?
+ AND p.deleted_at IS NULL
+ AND b.due_day BETWEEN 1 AND 14
+ `).get(userId, start, end);
+
+ // Paid from fifteenth bucket: bills with due_day 15-31
+ const fifteenthPaid = db.prepare(`
+ SELECT COALESCE(SUM(p.amount), 0) AS paid
+ FROM payments p
+ JOIN bills b ON b.id = p.bill_id
+ WHERE b.user_id = ?
+ AND p.paid_date BETWEEN ? AND ?
+ AND p.deleted_at IS NULL
+ AND b.due_day BETWEEN 15 AND 31
+ `).get(userId, start, end);
+
+ const totalPaid = db.prepare(`
+ SELECT COALESCE(SUM(p.amount), 0) AS paid
+ FROM payments p
+ JOIN bills b ON b.id = p.bill_id
+ WHERE b.user_id = ?
+ AND p.paid_date BETWEEN ? AND ?
+ AND p.deleted_at IS NULL
+ `).get(userId, start, end);
+
+ return {
+ paid_from_first: money(firstPaid.paid),
+ paid_from_fifteenth: money(fifteenthPaid.paid),
+ paid_total: money(totalPaid.paid),
+ };
+}
+
+function buildStartingAmountsSummary(db, userId, year, month) {
+ const amounts = getStartingAmounts(db, userId, year, month);
+ const paid = calculatePaidDeductions(db, userId, year, month);
+
+ const combined_amount = amounts.first_amount + amounts.fifteenth_amount + amounts.other_amount;
+ const paid_total = paid.paid_total;
+
+ return {
+ year,
+ month,
+ first_amount: amounts.first_amount,
+ fifteenth_amount: amounts.fifteenth_amount,
+ other_amount: amounts.other_amount,
+ combined_amount,
+ paid_from_first: paid.paid_from_first,
+ paid_from_fifteenth: paid.paid_from_fifteenth,
+ paid_total,
+ first_remaining: amounts.first_amount - paid.paid_from_first,
+ fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
+ other_remaining: amounts.other_amount,
+ combined_remaining: combined_amount - paid_total,
+ };
+}
+
function getIncome(db, userId, year, month) {
const row = db.prepare(`
SELECT id, label, amount
@@ -109,26 +188,57 @@ function buildSummary(db, userId, year, month) {
const expenseTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.display_amount), 0);
const paidTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.paid_amount), 0);
const paidExpenseCount = countedExpenses.filter(expense => expense.is_paid).length;
- const result = incomeTotal - expenseTotal;
+ const starting_amounts = buildStartingAmountsSummary(db, userId, year, month);
+ const planBaseTotal = money(starting_amounts.combined_amount);
+ const result = planBaseTotal - expenseTotal;
+
+ // Previous month context
+ let previous_month = null;
+ if (month > 1) {
+ const prevMonth = month - 1;
+ const prevYear = year;
+ const prevStarting = buildStartingAmountsSummary(db, userId, prevYear, prevMonth);
+ if (prevStarting.combined_amount > 0) {
+ previous_month = {
+ year: prevYear,
+ month: prevMonth,
+ combined_remaining: prevStarting.combined_remaining,
+ };
+ }
+ } else if (year > 2000) {
+ const prevMonth = 12;
+ const prevYear = year - 1;
+ const prevStarting = buildStartingAmountsSummary(db, userId, prevYear, prevMonth);
+ if (prevStarting.combined_amount > 0) {
+ previous_month = {
+ year: prevYear,
+ month: prevMonth,
+ combined_remaining: prevStarting.combined_remaining,
+ };
+ }
+ }
return {
year,
month,
income,
expenses,
+ starting_amounts,
+ previous_month,
summary: {
income_total: incomeTotal,
+ starting_total: planBaseTotal,
expense_total: expenseTotal,
paid_expense_count: paidExpenseCount,
expense_count: countedExpenses.length,
- paid_total: paidTotal,
+ paid_total: starting_amounts.paid_total,
remaining_expense_total: Math.max(0, expenseTotal - paidTotal),
result,
},
chart: [
- { type: 'Income', amount: incomeTotal },
+ { type: 'Starting', amount: planBaseTotal },
{ type: 'Expenses', amount: expenseTotal },
- { type: 'Savings', amount: result },
+ { type: 'Remaining', amount: result },
],
generated_at: new Date().toISOString(),
};
diff --git a/routes/tracker.js b/routes/tracker.js
index c3b1250..f46e5ff 100644
--- a/routes/tracker.js
+++ b/routes/tracker.js
@@ -51,20 +51,32 @@ router.get('/', (req, res) => {
return row;
});
- const totalExpected = rows.reduce((s, r) => s + r.expected_amount, 0);
- const totalPaid = rows.reduce((s, r) => s + r.total_paid, 0);
const totalOverdue = rows
.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed'))
.reduce((s, r) => s + r.balance, 0);
const activeRows = rows.filter(r => !r.is_skipped);
+ // Get starting amounts for this month
+ const startingAmounts = db.prepare(`
+ SELECT COALESCE(first_amount, 0) + COALESCE(fifteenth_amount, 0) + COALESCE(other_amount, 0) AS combined_amount
+ FROM monthly_starting_amounts
+ WHERE user_id = ? AND year = ? AND month = ?
+ `).get(req.user.id, year, month);
+
+ const totalStarting = startingAmounts?.combined_amount || 0;
+ const hasStartingAmounts = !!startingAmounts;
+ const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0);
+ const activeTotalExpected = activeRows.reduce((s, r) => s + r.expected_amount, 0);
+
res.json({
year, month, today: todayStr,
summary: {
- total_expected: activeRows.reduce((s, r) => s + r.expected_amount, 0),
- total_paid: activeRows.reduce((s, r) => s + r.total_paid, 0),
- remaining: Math.max(0, activeRows.reduce((s, r) => s + r.expected_amount, 0) - activeRows.reduce((s, r) => s + r.total_paid, 0)),
+ total_expected: activeTotalExpected,
+ total_starting: totalStarting,
+ has_starting_amounts: hasStartingAmounts,
+ total_paid: activeTotalPaid,
+ remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : Math.max(0, activeTotalExpected - activeTotalPaid),
overdue: totalOverdue,
count_paid: activeRows.filter(r => r.status === 'paid').length,
count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length,
diff --git a/server.js b/server.js
index 0919f8b..7829371 100644
--- a/server.js
+++ b/server.js
@@ -48,9 +48,11 @@ app.use('/api/categories', requireAuth, requireUser, require('./routes/catego
app.use('/api/settings', requireAuth, requireUser, require('./routes/settings'));
app.use('/api/calendar', requireAuth, requireUser, require('./routes/calendar'));
app.use('/api/summary', requireAuth, requireUser, require('./routes/summary'));
+app.use('/api/monthly-starting-amounts', requireAuth, requireUser, require('./routes/monthly-starting-amounts'));
app.use('/api/analytics', requireAuth, requireUser, require('./routes/analytics'));
app.use('/api/notifications', requireAuth, require('./routes/notifications'));
-app.use('/api/status', requireAuth, require('./routes/status'));
+app.use('/api/status', requireAuth, requireAdmin, require('./routes/status'));
+app.use('/api/about', require('./routes/about')); // public
app.use('/api/version', require('./routes/version')); // public
// Profile — password-change rate limit applied inside the route file
diff --git a/services/userDbImportService.js b/services/userDbImportService.js
index b2cc490..116028f 100644
--- a/services/userDbImportService.js
+++ b/services/userDbImportService.js
@@ -187,6 +187,23 @@ function sanitizeMonthlyState(row, validBillIds) {
};
}
+function sanitizeMonthlyStartingAmounts(row) {
+ const year = toInt(row.year);
+ const month = toInt(row.month);
+ if (year < 2000 || year > 2100 || month < 1 || month > 12) return null;
+ return {
+ old_id: toInt(row.id),
+ year,
+ month,
+ first_amount: Math.max(0, toNumber(row.first_amount, 0) ?? 0),
+ fifteenth_amount: Math.max(0, toNumber(row.fifteenth_amount, 0) ?? 0),
+ other_amount: Math.max(0, toNumber(row.other_amount, 0) ?? 0),
+ notes: cleanText(row.notes, 2000),
+ created_at: cleanText(row.created_at, 32),
+ updated_at: cleanText(row.updated_at, 32),
+ };
+}
+
function readExportData(src) {
const names = tableNames(src);
const missing = REQUIRED_TABLES.filter(t => !names.has(t));
@@ -210,12 +227,16 @@ function readExportData(src) {
.map(row => sanitizePayment(row, validBillIds)).filter(Boolean);
const monthlyState = selectKnown(src, 'monthly_bill_state', ['id', 'bill_id', 'year', 'month', 'actual_amount', 'notes', 'is_skipped', 'created_at', 'updated_at'])
.map(row => sanitizeMonthlyState(row, validBillIds)).filter(Boolean);
+ const monthlyStartingAmounts = names.has('monthly_starting_amounts')
+ ? selectKnown(src, 'monthly_starting_amounts', ['id', 'year', 'month', 'first_amount', 'fifteenth_amount', 'other_amount', 'notes', 'created_at', 'updated_at'])
+ .map(sanitizeMonthlyStartingAmounts).filter(Boolean)
+ : [];
const notes = names.has('notes')
? selectKnown(src, 'notes', ['type', 'bill_id', 'payment_id', 'monthly_state_id', 'year', 'month', 'notes'])
.map(n => ({ ...n, notes: cleanText(n.notes, 2000) })).filter(n => n.notes)
: [];
- return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, notes };
+ return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, monthly_starting_amounts: monthlyStartingAmounts, notes };
}
function existingLookups(db, userId) {
@@ -271,6 +292,13 @@ function buildPreview(userId, data, originalFilename) {
action: billPlanByOldId.has(m.bill_id) ? 'create_or_skip_duplicate' : 'conflict',
reason: billPlanByOldId.has(m.bill_id) ? 'Will import if monthly state is missing' : 'Referenced bill is not present in export',
}));
+ const monthlyStartingAmountsPlan = data.monthly_starting_amounts.map(m => ({
+ old_id: m.old_id,
+ year: m.year,
+ month: m.month,
+ action: 'create_or_skip_duplicate',
+ reason: 'Will import if monthly starting amounts are missing',
+ }));
const summary = {
categories: {
@@ -297,6 +325,12 @@ function buildPreview(userId, data, originalFilename) {
skip: 0,
conflict: monthlyPlan.filter(x => x.action === 'conflict').length,
},
+ monthly_starting_amounts: {
+ total: data.monthly_starting_amounts.length,
+ create: data.monthly_starting_amounts.length,
+ skip: 0,
+ conflict: 0,
+ },
notes: {
total: data.notes.length,
create: 0,
@@ -319,6 +353,7 @@ function buildPreview(userId, data, originalFilename) {
categories: data.categories.length,
payments: data.payments.length,
monthly_bill_state: data.monthly_bill_state.length,
+ monthly_starting_amounts: data.monthly_starting_amounts.length,
notes: data.notes.length,
},
summary,
@@ -327,6 +362,7 @@ function buildPreview(userId, data, originalFilename) {
bills: billPlan.slice(0, 100),
payments: paymentPlan.slice(0, 100),
monthly_bill_state: monthlyPlan.slice(0, 100),
+ monthly_starting_amounts: monthlyStartingAmountsPlan.slice(0, 100),
},
warnings,
};
@@ -470,6 +506,24 @@ function importMonthlyState(db, targetBillId, row, summary, details) {
details.monthly_bill_state.created++;
}
+function importMonthlyStartingAmounts(db, userId, row, summary, details) {
+ const duplicate = db.prepare(`
+ SELECT id FROM monthly_starting_amounts WHERE user_id = ? AND year = ? AND month = ?
+ `).get(userId, row.year, row.month);
+ if (duplicate) {
+ summary.rows_skipped++;
+ summary.rows_conflicted++;
+ details.monthly_starting_amounts.skipped++;
+ return;
+ }
+ db.prepare(`
+ INSERT INTO monthly_starting_amounts (user_id, year, month, first_amount, fifteenth_amount, other_amount, notes)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `).run(userId, row.year, row.month, row.first_amount, row.fifteenth_amount, row.other_amount, row.notes);
+ summary.rows_created++;
+ details.monthly_starting_amounts.created++;
+}
+
async function applyUserDbImport(userId, importSessionId, options = {}) {
if (options.overwrite) {
throw importError(400, 'Overwrite is not supported for user SQLite imports. Existing data is skipped.', 'USER_DB_IMPORT_OVERWRITE_UNSUPPORTED');
@@ -491,6 +545,7 @@ async function applyUserDbImport(userId, importSessionId, options = {}) {
bills: { created: 0, skipped: 0, errored: 0 },
payments: { created: 0, skipped: 0, errored: 0 },
monthly_bill_state: { created: 0, skipped: 0, errored: 0 },
+ monthly_starting_amounts: { created: 0, skipped: 0, errored: 0 },
notes: { created: 0, skipped: data.notes?.length || 0, errored: 0 },
};
summary.rows_skipped += details.notes.skipped;
@@ -529,6 +584,10 @@ async function applyUserDbImport(userId, importSessionId, options = {}) {
importMonthlyState(db, targetBillId, row, summary, details);
}
+ for (const row of data.monthly_starting_amounts) {
+ importMonthlyStartingAmounts(db, userId, row, summary, details);
+ }
+
db.prepare(`
INSERT INTO import_history
(user_id, imported_at, source_filename, file_type, sheet_name, rows_parsed,
@@ -541,7 +600,7 @@ async function applyUserDbImport(userId, importSessionId, options = {}) {
session.source_filename || null,
'sqlite',
'User SQLite export',
- data.categories.length + data.bills.length + data.payments.length + data.monthly_bill_state.length + (data.notes?.length || 0),
+ data.categories.length + data.bills.length + data.payments.length + data.monthly_bill_state.length + data.monthly_starting_amounts.length + (data.notes?.length || 0),
summary.rows_created,
summary.rows_updated,
summary.rows_skipped,