diff --git a/client/src/TODO-one-time-expenses.md b/client/src/TODO-one-time-expenses.md
new file mode 100644
index 0000000..1af5634
--- /dev/null
+++ b/client/src/TODO-one-time-expenses.md
@@ -0,0 +1,257 @@
+# TODO: One-Time Expenses UI for PaycheckView.jsx
+
+The server-side API for one-time expenses is complete. The following UI changes
+need to be applied to `client/src/pages/PaycheckView.jsx`.
+
+## API endpoints available
+
+- `POST /api/one-time-expenses` — body: `{ paycheck_id, name, amount }`
+- `DELETE /api/one-time-expenses/:id`
+- `PATCH /api/one-time-expenses/:id/paid` — body: `{ paid: true|false }`
+
+The `GET /api/paychecks` response already includes `one_time_expenses` array on
+each paycheck. After any mutation, re-fetch the month's paychecks to refresh
+state (use the existing `loadPaychecks(year, month)` function).
+
+---
+
+## 1. State to add inside `PaycheckColumn`
+
+The column needs a local form state for the inline "Add expense" form. Either
+lift it to `PaycheckView` and pass down, or keep it local to `PaycheckColumn`.
+The simplest approach is local state inside `PaycheckColumn`.
+
+Add these two state variables at the top of `PaycheckColumn`:
+
+```jsx
+const [newName, setNewName] = useState('');
+const [newAmount, setNewAmount] = useState('');
+```
+
+`PaycheckColumn` must also receive additional props:
+- `onOtePaidToggle(oteId, paid)` — calls PATCH and refreshes
+- `onOteDelete(oteId)` — calls DELETE (with confirm) and refreshes
+- `onOteAdd(paycheckId, name, amount)` — calls POST and refreshes
+
+---
+
+## 2. Replace the one-time expenses section in `PaycheckColumn`
+
+Locate the existing section (roughly lines 88–101 in the original file):
+
+```jsx
+
+
One-time expenses
+
+ {paycheck.one_time_expenses.length === 0 ? (
+
(none)
+ ) : (
+ paycheck.one_time_expenses.map((ote) => (
+
+ {ote.name}
+ {formatCurrency(ote.amount)}
+
+ ))
+ )}
+
+```
+
+Replace it with:
+
+```jsx
+
+
One-time expenses
+
+ {paycheck.one_time_expenses.length === 0 ? (
+
(none)
+ ) : (
+ paycheck.one_time_expenses.map((ote) => (
+
+ onOtePaidToggle(ote.id, !ote.paid)}
+ style={styles.checkbox}
+ />
+
+ {ote.name}
+
+ {formatCurrency(ote.amount)}
+ onOteDelete(ote.id)}
+ style={styles.deleteButton}
+ title="Remove expense"
+ >
+ ×
+
+
+ ))
+ )}
+
+ {/* Inline add form */}
+
+ setNewName(e.target.value)}
+ style={styles.oteAddInput}
+ />
+ setNewAmount(e.target.value)}
+ min="0"
+ step="0.01"
+ style={{ ...styles.oteAddInput, width: '80px' }}
+ />
+ {
+ if (!newName.trim() || !newAmount) return;
+ onOteAdd(paycheck.id, newName.trim(), parseFloat(newAmount));
+ setNewName('');
+ setNewAmount('');
+ }}
+ style={styles.oteAddButton}
+ >
+ Add
+
+
+
+```
+
+---
+
+## 3. New handlers to add in `PaycheckView` (the parent component)
+
+Add these three async handler functions inside `PaycheckView`, alongside the
+existing `handleBillPaidToggle`:
+
+```jsx
+async function handleOtePaidToggle(oteId, paid) {
+ try {
+ const res = await fetch(`/api/one-time-expenses/${oteId}/paid`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ paid }),
+ });
+ if (!res.ok) throw new Error(`Server error: ${res.status}`);
+ await loadPaychecks(year, month);
+ } catch (err) {
+ alert(`Failed to update expense: ${err.message}`);
+ }
+}
+
+async function handleOteDelete(oteId) {
+ if (!window.confirm('Remove this expense?')) return;
+ try {
+ const res = await fetch(`/api/one-time-expenses/${oteId}`, {
+ method: 'DELETE',
+ });
+ if (!res.ok) throw new Error(`Server error: ${res.status}`);
+ await loadPaychecks(year, month);
+ } catch (err) {
+ alert(`Failed to delete expense: ${err.message}`);
+ }
+}
+
+async function handleOteAdd(paycheckId, name, amount) {
+ try {
+ const res = await fetch('/api/one-time-expenses', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ paycheck_id: paycheckId, name, amount }),
+ });
+ if (!res.ok) throw new Error(`Server error: ${res.status}`);
+ await loadPaychecks(year, month);
+ } catch (err) {
+ alert(`Failed to add expense: ${err.message}`);
+ }
+}
+```
+
+---
+
+## 4. Update `PaycheckColumn` usage in the JSX grid
+
+In the `` block, update both `
` calls
+to pass the three new handler props:
+
+```jsx
+
+
+```
+
+---
+
+## 5. New style entries to add to the `styles` object
+
+Add these entries to the `styles` object at the bottom of the file:
+
+```js
+deleteButton: {
+ background: 'none',
+ border: 'none',
+ color: '#c0392b',
+ cursor: 'pointer',
+ fontSize: '1rem',
+ padding: '0 0.25rem',
+ lineHeight: 1,
+ flexShrink: 0,
+},
+oteAddForm: {
+ display: 'flex',
+ gap: '0.4rem',
+ marginTop: '0.5rem',
+ alignItems: 'center',
+},
+oteAddInput: {
+ padding: '0.2rem 0.4rem',
+ fontSize: '0.875rem',
+ border: '1px solid #ccc',
+ borderRadius: '3px',
+ flex: 1,
+},
+oteAddButton: {
+ padding: '0.2rem 0.6rem',
+ fontSize: '0.875rem',
+ cursor: 'pointer',
+ border: '1px solid #bbb',
+ borderRadius: '3px',
+ background: '#f0f0f0',
+ flexShrink: 0,
+},
+```
+
+---
+
+## 6. Remaining balance — already correct
+
+The existing `remaining` calculation in `PaycheckColumn` already deducts
+`one_time_expenses` amounts from `net`:
+
+```js
+const otesTotal = paycheck.one_time_expenses.reduce((sum, e) => sum + (parseFloat(e.amount) || 0), 0);
+const remaining = net - billsTotal - otesTotal;
+```
+
+No change needed there.
+
+---
+
+## Summary of files to modify
+
+- `client/src/pages/PaycheckView.jsx` — add state + handlers + updated JSX + styles
diff --git a/server/src/index.js b/server/src/index.js
index 7220617..287f469 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -6,6 +6,8 @@ const healthRouter = require('./routes/health');
const configRouter = require('./routes/config');
const billsRouter = require('./routes/bills');
const paychecksRouter = require('./routes/paychecks');
+const actualsRouter = require('./routes/actuals');
+const oneTimeExpensesRouter = require('./routes/one-time-expenses');
const db = require('./db');
const app = express();
@@ -19,6 +21,8 @@ app.use('/api', healthRouter);
app.use('/api', configRouter);
app.use('/api', billsRouter);
app.use('/api', paychecksRouter);
+app.use('/api', actualsRouter);
+app.use('/api', oneTimeExpensesRouter);
// Serve static client files in production
const clientDist = path.join(__dirname, '../../client/dist');
diff --git a/server/src/routes/one-time-expenses.js b/server/src/routes/one-time-expenses.js
new file mode 100644
index 0000000..62e5b59
--- /dev/null
+++ b/server/src/routes/one-time-expenses.js
@@ -0,0 +1,88 @@
+const express = require('express');
+const router = express.Router();
+const { pool } = require('../db');
+
+// POST /api/one-time-expenses — create a new one-time expense
+// Body: { paycheck_id, name, amount }
+router.post('/one-time-expenses', async (req, res) => {
+ const { paycheck_id, name, amount } = req.body;
+
+ if (!paycheck_id) {
+ return res.status(400).json({ message: 'paycheck_id is required' });
+ }
+ if (!name || typeof name !== 'string' || !name.trim()) {
+ return res.status(400).json({ message: 'name is required' });
+ }
+ if (amount === undefined || amount === null || isNaN(parseFloat(amount))) {
+ return res.status(400).json({ message: 'amount is required' });
+ }
+
+ try {
+ const result = await pool.query(
+ `INSERT INTO one_time_expenses (paycheck_id, name, amount)
+ VALUES ($1, $2, $3)
+ RETURNING id, paycheck_id, name, amount, paid, paid_at`,
+ [paycheck_id, name.trim(), parseFloat(amount)]
+ );
+ res.status(201).json(result.rows[0]);
+ } catch (err) {
+ console.error('POST /api/one-time-expenses error:', err);
+ res.status(500).json({ message: 'Failed to create one-time expense' });
+ }
+});
+
+// DELETE /api/one-time-expenses/:id — remove a one-time expense
+router.delete('/one-time-expenses/:id', async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ if (isNaN(id)) {
+ return res.status(400).json({ message: 'Invalid id' });
+ }
+
+ try {
+ const result = await pool.query(
+ 'DELETE FROM one_time_expenses WHERE id = $1 RETURNING id',
+ [id]
+ );
+ if (result.rows.length === 0) {
+ return res.status(404).json({ message: 'One-time expense not found' });
+ }
+ res.json({ id: result.rows[0].id });
+ } catch (err) {
+ console.error('DELETE /api/one-time-expenses/:id error:', err);
+ res.status(500).json({ message: 'Failed to delete one-time expense' });
+ }
+});
+
+// PATCH /api/one-time-expenses/:id/paid — toggle paid status
+// Body: { paid: true|false }
+router.patch('/one-time-expenses/:id/paid', async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ if (isNaN(id)) {
+ return res.status(400).json({ message: 'Invalid id' });
+ }
+
+ const { paid } = req.body;
+ if (typeof paid !== 'boolean') {
+ return res.status(400).json({ message: 'paid must be a boolean' });
+ }
+
+ try {
+ const result = await pool.query(
+ `UPDATE one_time_expenses
+ SET paid = $1,
+ paid_at = CASE WHEN $1 THEN NOW() ELSE NULL END
+ WHERE id = $2
+ RETURNING id, paycheck_id, name, amount, paid, paid_at`,
+ [paid, id]
+ );
+ if (result.rows.length === 0) {
+ return res.status(404).json({ message: 'One-time expense not found' });
+ }
+ res.json(result.rows[0]);
+ } catch (err) {
+ console.error('PATCH /api/one-time-expenses/:id/paid error:', err);
+ res.status(500).json({ message: 'Failed to update paid status' });
+ }
+});
+
+module.exports = router;