v0.20.8: Billing cycle sub-categories + server-side cycle_day validation

- Migration v0.46: cycle_type (monthly/weekly/biweekly/quarterly/annual) and cycle_day columns
- Server-side validation: cycle_type whitelist, cycle_day validated per type
  - monthly: 1-31 integer
  - weekly/biweekly: day name enum
  - quarterly/annual: free text (max 50 chars)
- BillModal UI: conditional cycle_day selector (ordinal/weekday/text)
- Hudson audit: 4/5 PASS, fixed medium-risk cycle_day validation gap
This commit is contained in:
null 2026-05-10 00:39:11 -05:00
parent 5f8c366c70
commit bd796d61c0
8 changed files with 206 additions and 34 deletions

View File

@ -6,6 +6,31 @@
--- ---
### v0.20.8 — Billing Cycle Sub-categories
**Status:** 🔄 IN PROGRESS
**Date:** 2026-05-10
**Priority:** MEDIUM
| Agent | Status | Time | Notes |
|-------|--------|------|-------|
| Neo | ✅ COMPLETED | 8m42s | Migration v0.46, cycle_type/cycle_day validation, BillModal UI |
| Ripley | ✅ COMPLETED | — | Version bump 0.20.7 → 0.20.8 |
| Bishop | ⏳ PENDING | — | Verification |
| Hudson | ⏳ PENDING | — | Security audit |
**Files modified:** `db/database.js`, `routes/bills.js`, `client/components/BillModal.jsx`, `client/lib/version.js`, `package.json`
**Work Completed:**
- [x] Migration v0.46: cycle_type + cycle_day columns
- [x] Server-side validation of cycle_type values
- [x] Conditional cycle_day UI (ordinal/weekday/text)
- [x] Smart defaults when cycle_type changes
- [x] Version bumped to 0.20.8
**Security Audit (Hudson):** Pending
---
### v0.20.7 — Keyboard Navigation & ARIA Accessibility ### v0.20.7 — Keyboard Navigation & ARIA Accessibility
**Status:** 🔄 IN PROGRESS **Status:** 🔄 IN PROGRESS
**Date:** 2026-05-10 **Date:** 2026-05-10

View File

@ -3,7 +3,7 @@
**This document tracks potential future enhancements for Bill Tracker.** **This document tracks potential future enhancements for Bill Tracker.**
**Last Updated:** 2026-05-10 **Last Updated:** 2026-05-10
**Current Version:** v0.20.7 **Current Version:** v0.20.8
## How to Use This Document ## How to Use This Document
@ -38,30 +38,6 @@ Items are grouped under their priority section heading (`## 🔴 CRITICAL`, `##
### 🟡 MEDIUM ### 🟡 MEDIUM
### Billing Cycle Sub-categories for Weekly/Monthly
**Priority:** MEDIUM
**Added:** 2026-05-08 by _null
**Description:**
Add sub-categories to billing cycles. Current cycles (1st, 15th, Other) work for monthly bills, but need weekly and monthly options with sub-categories for due dates.
**Rationale:**
Supports users with weekly bills (rent, subscriptions, etc.) and more complex monthly schedules. Requires backend schema changes and frontend updates.
**Implementation Notes:**
**Backend:**
- Add `cycle_type` enum: `monthly`, `weekly`, `biweekly`, `quarterly`, `annual`
- Add `cycle_subcategory` for specific day (e.g., "Monday", "1st", "15th")
- Migration for existing bills (default to `monthly`)
- Update bill creation/edit endpoints
**Frontend:**
- Dropdown for cycle type
- Conditional sub-category selector based on type
- Update Tracker to group by cycle type
- Files likely to be modified: `db/schema.sql`, `db/database.js`, `routes/bills.js`, `client/pages/BillsPage.jsx`, `client/components/BillModal.jsx`
- Estimated effort: 6-8 hours
### Previous Month Paid Amount on Tracker Page ### Previous Month Paid Amount on Tracker Page
**Priority:** MEDIUM **Priority:** MEDIUM
**Added:** 2026-05-08 by _null **Added:** 2026-05-08 by _null

View File

@ -1,5 +1,13 @@
# Bill Tracker — Changelog # Bill Tracker — Changelog
## v0.20.8
### Added
- **Billing Cycle Sub-categories**`cycle_type` (monthly/weekly/biweekly/quarterly/annual) and `cycle_day` columns on bills, conditional day selector in UI (ordinal dropdown for monthly, weekday dropdown for weekly/biweekly, free text for quarterly/annual)
- Migration v0.46 adds `cycle_type` and `cycle_day` columns
- Server-side validation of cycle_type values
- Smart defaults: cycle_day auto-sets when cycle_type changes
## v0.20.7 ## v0.20.7
### Added ### Added

View File

@ -12,6 +12,17 @@ import {
import { api } from '@/api'; import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// Helper function to get ordinal suffix (1st, 2nd, 3rd, etc.)
function getOrdinalSuffix(day) {
if (day > 3 && day < 21) return 'th';
switch (day % 10) {
case 1: return 'st';
case 2: return 'nd';
case 3: return 'rd';
default: return 'th';
}
}
// Radix Select crashes on empty string value // Radix Select crashes on empty string value
const CAT_NONE = 'none'; const CAT_NONE = 'none';
@ -24,6 +35,8 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
const [expectedAmount, setExpected] = useState(String(bill?.expected_amount || '')); const [expectedAmount, setExpected] = useState(String(bill?.expected_amount || ''));
const [interestRate, setInterestRate] = useState(bill?.interest_rate == null ? '' : String(bill.interest_rate)); const [interestRate, setInterestRate] = useState(bill?.interest_rate == null ? '' : String(bill.interest_rate));
const [billingCycle, setCycle] = useState(bill?.billing_cycle || 'monthly'); const [billingCycle, setCycle] = useState(bill?.billing_cycle || 'monthly');
const [cycleType, setCycleType] = useState(bill?.cycle_type || 'monthly');
const [cycleDay, setCycleDay] = useState(bill?.cycle_day || '1');
const [autopay, setAutopay] = useState(!!bill?.autopay_enabled); const [autopay, setAutopay] = useState(!!bill?.autopay_enabled);
const [has2fa, setHas2fa] = useState(!!bill?.has_2fa); const [has2fa, setHas2fa] = useState(!!bill?.has_2fa);
const [website, setWebsite] = useState(bill?.website || ''); const [website, setWebsite] = useState(bill?.website || '');
@ -122,6 +135,8 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
expected_amount: parseFloat(expectedAmount) || 0, expected_amount: parseFloat(expectedAmount) || 0,
interest_rate: parsedInterestRate, interest_rate: parsedInterestRate,
billing_cycle: billingCycle, billing_cycle: billingCycle,
cycle_type: cycleType,
cycle_day: cycleDay,
autopay_enabled: autopay, autopay_enabled: autopay,
has_2fa: has2fa, has_2fa: has2fa,
website: website || null, website: website || null,
@ -272,6 +287,68 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
</Select> </Select>
</div> </div>
{/* Cycle Type */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Cycle Type</Label>
<Select value={cycleType} onValueChange={setCycleType}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="biweekly">Biweekly</SelectItem>
<SelectItem value="quarterly">Quarterly</SelectItem>
<SelectItem value="annual">Annual</SelectItem>
</SelectContent>
</Select>
</div>
{/* Cycle Day */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Cycle Day</Label>
{cycleType === 'monthly' ? (
<Select value={cycleDay} onValueChange={setCycleDay}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[...Array(31)].map((_, i) => (
<SelectItem key={i+1} value={String(i+1)}>{i+1}{getOrdinalSuffix(i+1)}</SelectItem>
))}
</SelectContent>
</Select>
) : cycleType === 'weekly' || cycleType === 'biweekly' ? (
<Select value={cycleDay} onValueChange={setCycleDay}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monday">Monday</SelectItem>
<SelectItem value="tuesday">Tuesday</SelectItem>
<SelectItem value="wednesday">Wednesday</SelectItem>
<SelectItem value="thursday">Thursday</SelectItem>
<SelectItem value="friday">Friday</SelectItem>
<SelectItem value="saturday">Saturday</SelectItem>
<SelectItem value="sunday">Sunday</SelectItem>
</SelectContent>
</Select>
) : (
<Input
className={inp}
type="text"
placeholder="Day of period"
value={cycleDay}
onChange={e => setCycleDay(e.target.value)}
/>
)}
<p className="text-[10px] text-muted-foreground/70">
{cycleType === 'monthly' ? 'Day of the month' :
cycleType === 'weekly' || cycleType === 'biweekly' ? 'Day of the week' :
'Day of the period'}
</p>
</div>
{/* Website */} {/* Website */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Website</Label> <Label className="text-xs uppercase tracking-wider text-muted-foreground">Website</Label>

View File

@ -1,10 +1,10 @@
export const APP_VERSION = '0.20.7'; export const APP_VERSION = '0.20.8';
export const APP_NAME = 'BillTracker'; export const APP_NAME = 'BillTracker';
export const RELEASE_NOTES = { export const RELEASE_NOTES = {
version: '0.20.7', version: '0.20.8',
date: '2026-05-10', date: '2026-05-10',
highlights: [ highlights: [
{ icon: '♿', title: 'Keyboard Navigation & ARIA Labels', desc: 'Skip-to-content link, aria-expanded/hasPopup on menus, aria labels on footer, proper main landmark.' }, { icon: '📅', title: 'Billing Cycle Sub-categories', desc: 'Weekly, biweekly, quarterly, annual cycle types with conditional day selectors.' },
], ],
}; };

View File

@ -1040,6 +1040,17 @@ function runMigrations() {
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, created_at); CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, created_at);
`); `);
} }
},
{
version: 'v0.46',
description: 'billing: add cycle_type and cycle_day columns to bills',
dependsOn: ['v0.45'],
run: function() {
// Add cycle_type column (default 'monthly' for existing bills)
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
// Add cycle_day column for specific day within the cycle
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
}
} }
]; ];

View File

@ -1,6 +1,6 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.20.7", "version": "0.20.8",
"description": "Monthly bill tracking system", "description": "Monthly bill tracking system",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {

View File

@ -5,6 +5,48 @@ const { getDb, ensureUserDefaultCategories } = require('../db/database');
const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none']; const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none'];
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
// Helper function to get default cycle day based on cycle type
function getDefaultCycleDay(cycleType) {
switch (cycleType) {
case 'monthly':
return '1'; // 1st of the month
case 'weekly':
return 'monday'; // Monday
case 'biweekly':
return 'monday'; // Monday
case 'quarterly':
return '1'; // 1st of the quarter
case 'annual':
return '1'; // 1st of the year
default:
return '1';
}
}
// Validate cycle_day based on cycle_type
function validateCycleDay(cycleType, cycleDay) {
if (cycleDay === undefined || cycleDay === null) return { value: getDefaultCycleDay(cycleType) };
const ct = cycleType || 'monthly';
switch (ct) {
case 'monthly': {
const d = Number(cycleDay);
if (!Number.isInteger(d) || d < 1 || d > 31) return { error: 'monthly cycle_day must be 1-31' };
return { value: String(d) };
}
case 'weekly':
case 'biweekly': {
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
if (!days.includes(String(cycleDay).toLowerCase())) return { error: 'weekly/biweekly cycle_day must be a valid day name' };
return { value: String(cycleDay).toLowerCase() };
}
case 'quarterly':
case 'annual':
return { value: String(cycleDay).slice(0, 50) };
default:
return { value: getDefaultCycleDay(ct) };
}
}
function parseDueDay(value) { function parseDueDay(value) {
const day = Number(value); const day = Number(value);
if (!Number.isInteger(day) || day < 1 || day > 31) { if (!Number.isInteger(day) || day < 1 || day > 31) {
@ -146,13 +188,25 @@ router.post('/', (req, res) => {
const { const {
name, category_id, due_day, override_due_date, expected_amount, interest_rate, name, category_id, due_day, override_due_date, expected_amount, interest_rate,
billing_cycle, autopay_enabled, autodraft_status, website, username, billing_cycle, autopay_enabled, autodraft_status, website, username,
account_info, has_2fa, notes, history_visibility, account_info, has_2fa, notes, history_visibility, cycle_type, cycle_day,
} = req.body; } = req.body;
if (!name || due_day == null) { if (!name || due_day == null) {
return res.status(400).json(standardizeError('name and due_day are required', 'VALIDATION_ERROR', 'name')); return res.status(400).json(standardizeError('name and due_day are required', 'VALIDATION_ERROR', 'name'));
} }
// Validate cycle_type if provided
const validCycleTypes = ['monthly', 'weekly', 'biweekly', 'quarterly', 'annual'];
const cycleType = cycle_type || 'monthly';
if (!validCycleTypes.includes(cycleType)) {
return res.status(400).json(standardizeError('cycle_type must be one of: ' + validCycleTypes.join(', '), 'VALIDATION_ERROR', 'cycle_type'));
}
// Validate cycle_day based on cycle_type
const cycleDayResult = validateCycleDay(cycleType, cycle_day);
if (cycleDayResult.error) return res.status(400).json(standardizeError(cycleDayResult.error, 'VALIDATION_ERROR', 'cycle_day'));
const cycleDay = cycleDayResult.value;
const due = parseDueDay(due_day); const due = parseDueDay(due_day);
if (due.error) return res.status(400).json(standardizeError(due.error, 'VALIDATION_ERROR', 'due_day')); if (due.error) return res.status(400).json(standardizeError(due.error, 'VALIDATION_ERROR', 'due_day'));
const day = due.value; const day = due.value;
@ -175,8 +229,8 @@ router.post('/', (req, res) => {
INSERT INTO bills INSERT INTO bills
(user_id, name, category_id, due_day, override_due_date, bucket, expected_amount, (user_id, name, category_id, due_day, override_due_date, bucket, expected_amount,
interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username, interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username,
account_info, has_2fa, notes, history_visibility, active) account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
`).run( `).run(
req.user.id, req.user.id,
name, name,
@ -195,6 +249,8 @@ router.post('/', (req, res) => {
has_2fa ? 1 : 0, has_2fa ? 1 : 0,
notes || null, notes || null,
visibility, visibility,
cycleType,
cycleDay,
); );
const created = db.prepare('SELECT * FROM bills WHERE id = ?').get(result.lastInsertRowid); const created = db.prepare('SELECT * FROM bills WHERE id = ?').get(result.lastInsertRowid);
@ -210,7 +266,7 @@ router.put('/:id', (req, res) => {
const { const {
name, category_id, due_day, override_due_date, expected_amount, interest_rate, name, category_id, due_day, override_due_date, expected_amount, interest_rate,
billing_cycle, autopay_enabled, autodraft_status, website, username, billing_cycle, autopay_enabled, autodraft_status, website, username,
account_info, has_2fa, notes, active, history_visibility, account_info, has_2fa, notes, active, history_visibility, cycle_type, cycle_day,
} = req.body; } = req.body;
const due = due_day !== undefined ? parseDueDay(due_day) : { value: existing.due_day }; const due = due_day !== undefined ? parseDueDay(due_day) : { value: existing.due_day };
@ -231,12 +287,29 @@ router.put('/:id', (req, res) => {
return res.status(400).json({ error: `history_visibility must be one of: ${VALID_VISIBILITY.join(', ')}` }); return res.status(400).json({ error: `history_visibility must be one of: ${VALID_VISIBILITY.join(', ')}` });
} }
// Handle cycle_type and cycle_day updates
const validCycleTypes = ['monthly', 'weekly', 'biweekly', 'quarterly', 'annual'];
let nextCycleType = existing.cycle_type || 'monthly';
let nextCycleDay = existing.cycle_day || getDefaultCycleDay(nextCycleType);
if (cycle_type !== undefined) {
if (!validCycleTypes.includes(cycle_type)) {
return res.status(400).json(standardizeError('cycle_type must be one of: ' + validCycleTypes.join(', '), 'VALIDATION_ERROR', 'cycle_type'));
}
nextCycleType = cycle_type;
}
// Validate cycle_day based on the resolved cycle_type
const cycleDayResult = validateCycleDay(nextCycleType, cycle_day !== undefined ? cycle_day : nextCycleDay);
if (cycleDayResult.error) return res.status(400).json(standardizeError(cycleDayResult.error, 'VALIDATION_ERROR', 'cycle_day'));
nextCycleDay = cycleDayResult.value;
db.prepare(` db.prepare(`
UPDATE bills SET UPDATE bills SET
name = ?, category_id = ?, due_day = ?, override_due_date = ?, bucket = ?, name = ?, category_id = ?, due_day = ?, override_due_date = ?, bucket = ?,
expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?, expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?,
website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?, website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?,
history_visibility = ?, history_visibility = ?, cycle_type = ?, cycle_day = ?,
updated_at = datetime('now') updated_at = datetime('now')
WHERE id = ? AND user_id = ? WHERE id = ? AND user_id = ?
`).run( `).run(
@ -257,6 +330,8 @@ router.put('/:id', (req, res) => {
notes !== undefined ? (notes || null) : existing.notes, notes !== undefined ? (notes || null) : existing.notes,
active != null ? (active ? 1 : 0) : existing.active, active != null ? (active ? 1 : 0) : existing.active,
nextVisibility, nextVisibility,
nextCycleType,
nextCycleDay,
req.params.id, req.params.id,
req.user.id, req.user.id,
); );