This commit is contained in:
null 2026-05-16 10:17:24 -05:00
parent bfc1521835
commit 9174ec3290
4 changed files with 356 additions and 41 deletions

View File

@ -27,12 +27,18 @@ function getOrdinalSuffix(day) {
const CAT_NONE = 'none';
const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt'];
const SNOWBALL_KEYWORDS = ['credit', 'loan', 'debt'];
function isDebtCat(categories, catId) {
if (!catId || catId === CAT_NONE) return false;
const cat = categories.find(c => String(c.id) === catId);
return cat ? DEBT_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false;
}
function isSnowballCat(categories, catId) {
if (!catId || catId === CAT_NONE) return false;
const cat = categories.find(c => String(c.id) === catId);
return cat ? SNOWBALL_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false;
}
export default function BillModal({ bill, categories, onClose, onSave }) {
const isNew = !bill;
@ -66,7 +72,8 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
const [errors, setErrors] = useState({});
const isDebtCategory = isDebtCat(categories, categoryId);
const showOnSnowball = snowballInclude || (isDebtCategory && !snowballExempt);
const isSnowballCategory = isSnowballCat(categories, categoryId);
const showOnSnowball = snowballInclude || (isSnowballCategory && !snowballExempt);
const validateName = (val) => {
if (!val || val.trim() === '') return 'Name is required';
@ -144,10 +151,10 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
const handleSnowballVisibilityChange = (checked) => {
if (checked) {
setSnowballExempt(false);
setSnowballInclude(!isDebtCategory);
setSnowballInclude(!isSnowballCategory);
} else {
setSnowballInclude(false);
setSnowballExempt(isDebtCategory);
setSnowballExempt(isSnowballCategory);
}
};
@ -387,12 +394,12 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
className={cn('h-3.5 w-3.5 transition-transform duration-150', !showDebtSection && '-rotate-90')}
/>
Debt / Snowball Details
{isDebtCategory && (
{isSnowballCategory && (
<span className="ml-1 text-[9px] text-emerald-400 font-semibold tracking-normal normal-case">
· auto-detected
· snowball auto-detected
</span>
)}
{!showOnSnowball && isDebtCategory && (
{!showOnSnowball && isSnowballCategory && (
<span className="ml-1 text-[9px] text-amber-400 font-semibold tracking-normal normal-case">
· exempt
</span>
@ -473,7 +480,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
</span>
</label>
<p className="text-[10px] text-muted-foreground/70 pl-6">
Uncheck to exempt an auto-detected debt bill, or check to include a non-debt bill.
Uncheck to exempt an auto-detected snowball bill, or check to include this bill manually.
</p>
</div>

View File

@ -28,6 +28,11 @@ export const RELEASE_NOTES = {
title: 'Privacy and release notes',
desc: 'A public Privacy page is available from About, release notes can render images, and this update card now resets from the backend whenever the app version changes.',
},
{
icon: '❄️',
title: 'Ramsey Snowball mode',
desc: 'Debt Snowball now defaults to smallest-balance-first, keeps custom drag ordering behind a toggle, skips mortgages by default, and adds an inline Ramsey readiness checklist.',
},
{
icon: '🎛️',
title: 'Cleaner tracker and interface polish',

View File

@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, PenLine, EyeOff } from 'lucide-react';
import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, PenLine, EyeOff, CheckCircle2, Circle } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/Skeleton';
import { cn } from '@/lib/utils';
import BillModal from '@/components/BillModal';
@ -30,6 +31,19 @@ function ordinal(n) {
case 1: return `${d}st`; case 2: return `${d}nd`; case 3: return `${d}rd`; default: return `${d}th`;
}
}
function sortRamseyDebts(debts) {
return [...debts].sort((a, b) => {
if (a.current_balance == null && b.current_balance == null) return a.name.localeCompare(b.name);
if (a.current_balance == null) return 1;
if (b.current_balance == null) return -1;
const diff = Number(a.current_balance) - Number(b.current_balance);
return diff || a.name.localeCompare(b.name);
});
}
function isRamseyOrdered(debts) {
const sorted = sortRamseyDebts(debts);
return debts.every((debt, index) => debt.id === sorted[index]?.id);
}
// Client-side snowball simulation (mirrors server snowballService)
// Returns the full projection shape so the panel and attack card both update
@ -243,6 +257,71 @@ function ProjectionPanel({ projection, projectionLoading, billCount }) {
);
}
// Readiness strip
function ReadinessStrip({ items, readyCount, totalCount, allReady, onToggle, disabled }) {
return (
<div className={cn(
'surface-elevated rounded-xl px-4 py-3 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between',
allReady && 'border border-emerald-500/30',
)}>
<div className="flex items-center gap-3 shrink-0">
<div className={cn(
'flex h-9 w-9 items-center justify-center rounded-lg border',
allReady ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400' : 'border-border/60 bg-muted/30 text-muted-foreground',
)}>
{allReady ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
</div>
<div>
<p className="text-sm font-semibold">Snowball Readiness</p>
<p className={cn('text-xs', allReady ? 'text-emerald-400' : 'text-muted-foreground')}>
{allReady ? 'Ready to roll: attack the smallest balance first.' : `${readyCount} of ${totalCount} ready`}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{items.map(item => {
const Icon = item.ready ? CheckCircle2 : Circle;
const content = (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{item.label}</span>
</>
);
const className = cn(
'inline-flex h-8 max-w-full items-center gap-1.5 rounded-full border px-3 text-xs font-medium transition-colors',
item.ready
? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-400'
: 'border-border/60 bg-muted/30 text-muted-foreground',
item.manual && !disabled && 'hover:bg-muted/60 cursor-pointer',
item.manual && disabled && 'opacity-60 cursor-not-allowed',
);
if (item.manual) {
return (
<button
key={item.id}
type="button"
className={className}
onClick={() => onToggle(item.id, !item.ready)}
disabled={disabled}
title={item.hint}
>
{content}
</button>
);
}
return (
<span key={item.id} className={className} title={item.hint}>
{content}
</span>
);
})}
</div>
</div>
);
}
// Pointer-based drag-and-drop hook (works on touch + mouse)
function useSortable(items, setItems, setDirty) {
const [draggingIdx, setDraggingIdx] = useState(null);
@ -350,6 +429,9 @@ export default function SnowballPage() {
const [editBill, setEditBill] = useState(null);
const [extraPayment, setExtraPayment] = useState('');
const [ramseyMode, setRamseyMode] = useState(true);
const [readyCurrentOnBills, setReadyCurrentOnBills] = useState(false);
const [readyEmergencyFund, setReadyEmergencyFund] = useState(false);
const [savingSettings, setSavingSettings] = useState(false);
const extraPaymentRef = useRef('');
@ -379,6 +461,9 @@ export default function SnowballPage() {
setBills(billsArr);
setDirty(false);
const ep = settings.extra_payment > 0 ? String(settings.extra_payment) : '';
setRamseyMode(settings.ramsey_mode !== false);
setReadyCurrentOnBills(!!settings.ready_current_on_bills);
setReadyEmergencyFund(!!settings.ready_emergency_fund);
setExtraPayment(ep);
extraPaymentRef.current = ep;
} catch (err) {
@ -390,12 +475,7 @@ export default function SnowballPage() {
// auto-arrange
const handleAutoArrange = () => {
setBills(prev => [...prev].sort((a, b) => {
if (a.current_balance == null && b.current_balance == null) return 0;
if (a.current_balance == null) return 1;
if (b.current_balance == null) return -1;
return a.current_balance - b.current_balance;
}));
setBills(prev => sortRamseyDebts(prev));
setDirty(true);
toast.success('Arranged smallest-to-largest balance');
};
@ -431,6 +511,47 @@ export default function SnowballPage() {
finally { setSavingSettings(false); }
};
const handleRamseyModeChange = async (checked) => {
setSavingSettings(true);
try {
const result = await api.saveSnowballSettings({ ramsey_mode: checked });
const nextMode = result.ramsey_mode !== false;
setRamseyMode(nextMode);
setDirty(false);
if (nextMode) setBills(prev => sortRamseyDebts(prev));
else load();
loadProjection();
toast.success(nextMode ? 'Ramsey Mode enabled' : 'Custom order enabled');
} catch (err) {
toast.error(err.message || 'Failed to save Snowball mode');
} finally {
setSavingSettings(false);
}
};
const handleReadinessToggle = async (id, checked) => {
const payload = id === 'current_on_bills'
? { ready_current_on_bills: checked }
: { ready_emergency_fund: checked };
const previous = id === 'current_on_bills' ? readyCurrentOnBills : readyEmergencyFund;
if (id === 'current_on_bills') setReadyCurrentOnBills(checked);
else setReadyEmergencyFund(checked);
setSavingSettings(true);
try {
const result = await api.saveSnowballSettings(payload);
setReadyCurrentOnBills(!!result.ready_current_on_bills);
setReadyEmergencyFund(!!result.ready_emergency_fund);
} catch (err) {
if (id === 'current_on_bills') setReadyCurrentOnBills(previous);
else setReadyEmergencyFund(previous);
toast.error(err.message || 'Failed to save readiness');
} finally {
setSavingSettings(false);
}
};
// inline balance edit
const startEditBalance = (bill) =>
setEditingBalance({ billId: bill.id, value: bill.current_balance != null ? String(bill.current_balance) : '' });
@ -443,7 +564,10 @@ export default function SnowballPage() {
if (num === current?.current_balance) { setEditingBalance({ billId: null, value: '' }); return; }
try {
await api.updateBillBalance(billId, num);
setBills(prev => prev.map(b => b.id === billId ? { ...b, current_balance: num } : b));
setBills(prev => {
const next = prev.map(b => b.id === billId ? { ...b, current_balance: num } : b);
return ramseyMode ? sortRamseyDebts(next) : next;
});
setEditingBalance({ billId: null, value: '' });
loadProjection();
} catch (err) { toast.error(err.message || 'Failed to update balance'); }
@ -481,7 +605,50 @@ export default function SnowballPage() {
const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0);
const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0);
const unknownCount = bills.filter(b => b.current_balance == null).length;
const missingMinCount = bills.filter(b => b.current_balance > 0 && b.minimum_payment == null).length;
const extraAmt = parseFloat(extraPayment) || 0;
const attackBill = bills[0] ?? null;
const attackAmount = attackBill ? (attackBill.minimum_payment || 0) + extraAmt : 0;
const customOrderDrift = !ramseyMode && bills.length > 1 && !isRamseyOrdered(bills);
const mortgageIncluded = bills.some(b => /mortgage|housing/i.test(b.category_name || b.name || ''));
const readinessItems = [
{
id: 'current_on_bills',
label: 'Current on bills',
ready: readyCurrentOnBills,
manual: true,
hint: 'Confirm all bills are current before starting the snowball.',
},
{
id: 'emergency_fund',
label: '$1,000 emergency fund',
ready: readyEmergencyFund,
manual: true,
hint: 'Confirm the starter emergency fund is set aside.',
},
{
id: 'debts_entered',
label: 'Debts entered',
ready: bills.length > 0 && !mortgageIncluded,
hint: mortgageIncluded
? 'Mortgage or housing debt is included; Ramsey Baby Step 2 excludes the house.'
: 'Enter consumer debts and leave the mortgage out of this snowball.',
},
{
id: 'minimums_entered',
label: 'Minimums entered',
ready: bills.length > 0 && missingMinCount === 0,
hint: 'Every debt with a balance needs a minimum payment.',
},
{
id: 'extra_set',
label: 'Extra amount set',
ready: extraAmt > 0,
hint: 'Set the extra monthly budget you can throw at the current target.',
},
];
const readinessReadyCount = readinessItems.filter(item => item.ready).length;
const readinessAllReady = readinessReadyCount === readinessItems.length;
// loading skeleton
if (loading) {
@ -522,14 +689,29 @@ export default function SnowballPage() {
sub={unknownCount > 0 ? `+ ${unknownCount} unknown` : undefined} />
<StatCard label="Monthly Minimums" value={fmt(totalMinPayment)} />
<StatCard label="Extra / Month" value={extraAmt > 0 ? fmt(extraAmt) : '—'} sub="snowball accelerator" />
<StatCard label="Total Attack" value={fmt(totalMinPayment + extraAmt)}
sub="toward #1 target" highlight={extraAmt > 0} />
<StatCard label="Next Win" value={liveAttackPayoff || 'Add details'}
sub={attackBill ? `${attackBill.name} · attacking ${fmt(attackAmount)}/mo` : undefined}
highlight={!!liveAttackPayoff} />
</div>
)}
{/* Toolbar */}
{bills.length > 0 && (
<div className="flex flex-wrap items-end gap-3">
<div className="surface-elevated rounded-xl px-4 py-3 flex items-center gap-3 min-h-[58px]">
<Switch
id="ramsey-mode"
checked={ramseyMode}
onCheckedChange={handleRamseyModeChange}
disabled={savingSettings}
/>
<div>
<Label htmlFor="ramsey-mode" className="text-xs font-semibold cursor-pointer">Ramsey Mode</Label>
<p className="text-[10px] text-muted-foreground">
{ramseyMode ? 'Smallest balance first' : 'Custom drag order'}
</p>
</div>
</div>
<div className="space-y-1">
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground">
Extra monthly budget ($)
@ -544,10 +726,17 @@ export default function SnowballPage() {
/>
</div>
<div className="flex items-center gap-2 pb-0.5">
<Button type="button" variant="outline" size="sm" onClick={handleAutoArrange} className="gap-2">
<Zap className="h-3.5 w-3.5" /> Auto-arrange
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAutoArrange}
className="gap-2"
disabled={ramseyMode}
>
<Zap className="h-3.5 w-3.5" /> Restore Ramsey Order
</Button>
<Button type="button" size="sm" disabled={!dirty || saving} onClick={handleSaveOrder} className="gap-2">
<Button type="button" size="sm" disabled={ramseyMode || !dirty || saving} onClick={handleSaveOrder} className="gap-2">
<Save className="h-3.5 w-3.5" /> {saving ? 'Saving…' : 'Save Order'}
</Button>
{dirty && <span className="text-xs text-amber-400">Unsaved changes</span>}
@ -555,13 +744,41 @@ export default function SnowballPage() {
</div>
)}
{bills.length > 0 && (
<ReadinessStrip
items={readinessItems}
readyCount={readinessReadyCount}
totalCount={readinessItems.length}
allReady={readinessAllReady}
onToggle={handleReadinessToggle}
disabled={savingSettings}
/>
)}
{bills.length > 0 && (customOrderDrift || missingMinCount > 0) && (
<div className="surface-elevated rounded-xl border border-amber-500/25 bg-amber-500/10 px-4 py-3 space-y-2">
{customOrderDrift && (
<div className="flex items-start gap-2 text-xs text-amber-300">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
Custom order is active, so the payoff list no longer strictly follows smallest-balance-first.
</div>
)}
{missingMinCount > 0 && (
<div className="flex items-start gap-2 text-xs text-amber-300">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
{missingMinCount} debt{missingMinCount > 1 ? 's need' : ' needs'} a minimum payment before the projection can model the snowball cleanly.
</div>
)}
</div>
)}
{/* Empty state */}
{bills.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 py-20 text-center gap-3">
<TrendingDown className="h-10 w-10 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground">No debt bills found</p>
<p className="text-xs text-muted-foreground/70 max-w-sm">
Bills in Credit Cards, Loans, or Mortgage categories appear here automatically.
Bills in Credit Cards, Loans, or Debt categories appear here automatically.
You can also enable "Include in Snowball" when editing any bill.
</p>
</div>
@ -611,9 +828,14 @@ export default function SnowballPage() {
{/* Grip */}
<div
data-grip
onPointerDown={e => onPointerDown(e, index)}
className="flex items-center px-3 text-muted-foreground/20 hover:text-muted-foreground/60 cursor-grab active:cursor-grabbing transition-colors touch-none shrink-0"
aria-label="Drag to reorder"
onPointerDown={e => { if (!ramseyMode) onPointerDown(e, index); }}
className={cn(
'flex items-center px-3 transition-colors touch-none shrink-0',
ramseyMode
? 'text-muted-foreground/10 cursor-not-allowed'
: 'text-muted-foreground/20 hover:text-muted-foreground/60 cursor-grab active:cursor-grabbing',
)}
aria-label={ramseyMode ? 'Ramsey Mode controls order' : 'Drag to reorder'}
>
<GripVertical className="h-5 w-5" />
</div>
@ -682,6 +904,13 @@ export default function SnowballPage() {
</div>
)}
{bill.current_balance > 0 && bill.minimum_payment == null && (
<div className="inline-flex items-center gap-1 text-xs text-amber-400">
<AlertTriangle className="h-3 w-3" />
Needs minimum payment
</div>
)}
{isAttack && extraAmt > 0 && (
<div>
<span className="text-xs text-muted-foreground">Throwing </span>
@ -753,7 +982,9 @@ export default function SnowballPage() {
})}
<p className="text-xs text-muted-foreground/50 text-center pt-1">
Drag the grip handle to reorder · Click a balance to update it · Save Order to persist
{ramseyMode
? 'Ramsey Mode keeps debts sorted by smallest balance · Click a balance to update it'
: 'Drag the grip handle to reorder · Click a balance to update it · Save Order to persist'}
</p>
</div>

View File

@ -12,43 +12,91 @@ const DEBT_LIKE_CLAUSES = `(
AND (
LOWER(c.name) LIKE '%credit%'
OR LOWER(c.name) LIKE '%loan%'
OR LOWER(c.name) LIKE '%mortgage%'
OR LOWER(c.name) LIKE '%housing%'
OR LOWER(c.name) LIKE '%debt%'
)
)
)`;
const DEBT_QUERY = `
function isRamseyMode(userId) {
const db = getDb();
const row = db.prepare(`
SELECT value
FROM user_settings
WHERE user_id = ? AND key = 'snowball_ramsey_mode'
`).get(userId);
return row ? row.value !== 'false' && row.value !== '0' : true;
}
function getUserBoolSetting(userId, key, fallback = false) {
const db = getDb();
const row = db.prepare(`
SELECT value
FROM user_settings
WHERE user_id = ? AND key = ?
`).get(userId, key);
if (!row) return fallback;
return row.value === 'true' || row.value === '1';
}
function upsertUserSetting(db, userId, key, value) {
db.prepare(`
INSERT INTO user_settings (user_id, key, value, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id, key) DO UPDATE SET
value = excluded.value,
updated_at = datetime('now')
`).run(userId, key, String(value));
}
function getDebtQuery(ramseyMode) {
const orderBy = ramseyMode
? `
CASE WHEN b.current_balance IS NULL THEN 1 ELSE 0 END ASC,
b.current_balance ASC,
LOWER(b.name) ASC,
b.id ASC`
: `
CASE WHEN b.snowball_order IS NULL THEN 1 ELSE 0 END ASC,
b.snowball_order ASC,
CASE WHEN b.current_balance IS NULL THEN 1 ELSE 0 END ASC,
b.current_balance ASC`;
return `
SELECT b.*, c.name AS category_name
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.user_id = b.user_id
WHERE b.user_id = ?
AND b.active = 1
AND ${DEBT_LIKE_CLAUSES}
ORDER BY
CASE WHEN b.snowball_order IS NULL THEN 1 ELSE 0 END ASC,
b.snowball_order ASC,
CASE WHEN b.current_balance IS NULL THEN 1 ELSE 0 END ASC,
b.current_balance ASC
`;
ORDER BY${orderBy}
`;
}
function getDebtBills(userId) {
const db = getDb();
return db.prepare(getDebtQuery(isRamseyMode(userId))).all(userId);
}
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
router.get('/', (req, res) => {
const db = getDb();
res.json(db.prepare(DEBT_QUERY).all(req.user.id));
res.json(getDebtBills(req.user.id));
});
// GET /api/snowball/settings — extra monthly payment for this user
router.get('/settings', (req, res) => {
const db = getDb();
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
res.json({ extra_payment: user?.snowball_extra_payment ?? 0 });
res.json({
extra_payment: user?.snowball_extra_payment ?? 0,
ramsey_mode: isRamseyMode(req.user.id),
ready_current_on_bills: getUserBoolSetting(req.user.id, 'snowball_ready_current_on_bills'),
ready_emergency_fund: getUserBoolSetting(req.user.id, 'snowball_ready_emergency_fund'),
});
});
// PATCH /api/snowball/settings — save extra monthly payment
router.patch('/settings', (req, res) => {
const { extra_payment } = req.body;
const { extra_payment, ramsey_mode, ready_current_on_bills, ready_emergency_fund } = req.body;
let val = 0;
if (extra_payment !== undefined && extra_payment !== null && extra_payment !== '') {
@ -63,8 +111,32 @@ router.patch('/settings', (req, res) => {
}
const db = getDb();
db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(val, req.user.id);
res.json({ extra_payment: val });
const save = db.transaction(() => {
if (extra_payment !== undefined) {
db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(val, req.user.id);
}
if (ramsey_mode !== undefined) {
upsertUserSetting(db, req.user.id, 'snowball_ramsey_mode', ramsey_mode ? 'true' : 'false');
}
if (ready_current_on_bills !== undefined) {
upsertUserSetting(db, req.user.id, 'snowball_ready_current_on_bills', ready_current_on_bills ? 'true' : 'false');
}
if (ready_emergency_fund !== undefined) {
upsertUserSetting(db, req.user.id, 'snowball_ready_emergency_fund', ready_emergency_fund ? 'true' : 'false');
}
});
save();
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
res.json({
extra_payment: user?.snowball_extra_payment ?? 0,
ramsey_mode: isRamseyMode(req.user.id),
ready_current_on_bills: getUserBoolSetting(req.user.id, 'snowball_ready_current_on_bills'),
ready_emergency_fund: getUserBoolSetting(req.user.id, 'snowball_ready_emergency_fund'),
});
});
// GET /api/snowball/projection — snowball, avalanche, minimum-only projections
@ -72,7 +144,7 @@ router.patch('/settings', (req, res) => {
router.get('/projection', (req, res) => {
const db = getDb();
const bills = db.prepare(DEBT_QUERY).all(req.user.id);
const bills = getDebtBills(req.user.id);
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
const extra = user?.snowball_extra_payment ?? 0;