import { useEffect, useState } from 'react'; import { ChevronDown, Copy, Link2, Link2Off, Pencil, Plus, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { api } from '@/api'; import { cn, fmt, fmtDate, todayStr } from '@/lib/utils'; 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'; } } function defaultCycleDayFor(type) { return type === 'weekly' || type === 'biweekly' ? 'monday' : '1'; } // Radix Select crashes on empty string value const CAT_NONE = 'none'; const PAYMENT_METHODS = [ ['manual', 'Manual'], ['bank', 'Bank Transfer'], ['card', 'Card'], ['autopay', 'Autopay'], ['check', 'Check'], ['cash', 'Cash'], ]; const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt']; const SNOWBALL_KEYWORDS = ['credit', 'loan', 'debt']; const SUBSCRIPTION_TYPES = [ ['streaming', 'Streaming'], ['software', 'Software'], ['cloud', 'Cloud'], ['music', 'Music'], ['news', 'News'], ['fitness', 'Fitness'], ['gaming', 'Gaming'], ['utilities', 'Utilities'], ['insurance', 'Insurance'], ['other', 'Other'], ]; function fmtTransactionAmount(amount, currency = 'USD') { const cents = Number(amount || 0); const value = Math.abs(cents) / 100; const sign = cents < 0 ? '-' : '+'; return `${sign}${new Intl.NumberFormat(undefined, { style: 'currency', currency: currency || 'USD', }).format(value)}`; } function transactionDate(tx) { return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || null; } function transactionTitle(tx) { return tx?.payee || tx?.description || tx?.memo || 'Transaction'; } function isTransactionLinkedPayment(payment) { return payment?.payment_source === 'transaction_match' || payment?.transaction_id != null; } function paymentSourceLabel(source) { const labels = { manual: 'Manual', file_import: 'File import', provider_sync: 'Sync', transaction_match: 'Transaction', }; return labels[source] || source || 'Manual'; } function paymentSourceTone(source) { const tones = { manual: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', file_import: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400', provider_sync: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400', transaction_match: 'border-primary/25 bg-primary/10 text-primary', }; return tones[source] || tones.manual; } 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, initialBill, categories, onClose, onSave, onDuplicate }) { const isNew = !bill; const sourceBill = bill || initialBill || null; const [name, setName] = useState(sourceBill?.name || ''); const [categoryId, setCategoryId] = useState(sourceBill?.category_id ? String(sourceBill.category_id) : CAT_NONE); const [dueDay, setDueDay] = useState(String(sourceBill?.due_day || '')); const [expectedAmount, setExpected] = useState(String(sourceBill?.expected_amount || '')); const [interestRate, setInterestRate] = useState(sourceBill?.interest_rate == null ? '' : String(sourceBill.interest_rate)); const [billingCycle, setCycle] = useState(sourceBill?.billing_cycle || 'monthly'); const [cycleType, setCycleType] = useState(sourceBill?.cycle_type || 'monthly'); const [cycleDay, setCycleDay] = useState(sourceBill?.cycle_day || '1'); const [autopay, setAutopay] = useState(!!sourceBill?.autopay_enabled); const [autodraftStatus, setAutodraftStatus] = useState(sourceBill?.autodraft_status || (sourceBill?.autopay_enabled ? 'assumed_paid' : 'none')); const [autoMarkPaid, setAutoMarkPaid] = useState(!!sourceBill?.auto_mark_paid); const [isSubscription, setIsSubscription] = useState(!!sourceBill?.is_subscription); const [subscriptionType, setSubscriptionType] = useState(sourceBill?.subscription_type || 'other'); const [reminderDaysBefore, setReminderDaysBefore] = useState(String(sourceBill?.reminder_days_before ?? 3)); const [has2fa, setHas2fa] = useState(!!sourceBill?.has_2fa); const [website, setWebsite] = useState(sourceBill?.website || ''); const [username, setUsername] = useState(sourceBill?.username || ''); const [accountInfo, setAccountInfo] = useState(sourceBill?.account_info || ''); const [notes, setNotes] = useState(sourceBill?.notes || ''); const [currentBalance, setCurrentBalance] = useState(sourceBill?.current_balance == null ? '' : String(sourceBill.current_balance)); const [minimumPayment, setMinimumPayment] = useState(sourceBill?.minimum_payment == null ? '' : String(sourceBill.minimum_payment)); const [snowballInclude, setSnowballInclude] = useState(!!sourceBill?.snowball_include); const [snowballExempt, setSnowballExempt] = useState(!!sourceBill?.snowball_exempt); const [showDebtSection, setShowDebtSection] = useState( () => isDebtCat(categories, sourceBill?.category_id ? String(sourceBill.category_id) : CAT_NONE) || !!sourceBill?.snowball_include || !!sourceBill?.snowball_exempt || sourceBill?.current_balance != null || sourceBill?.minimum_payment != null ); const [saveTemplate, setSaveTemplate] = useState(false); const [templateName, setTemplateName] = useState(''); const [busy, setBusy] = useState(false); const [errors, setErrors] = useState({}); const [payments, setPayments] = useState([]); const [paymentsLoading, setPaymentsLoading] = useState(false); const [linkedTransactions, setLinkedTransactions] = useState([]); const [linkedTransactionsLoading, setLinkedTransactionsLoading] = useState(false); const [transactionBusyId, setTransactionBusyId] = useState(null); const [paymentBusy, setPaymentBusy] = useState(false); const [paymentFormOpen, setPaymentFormOpen] = useState(false); const [editingPayment, setEditingPayment] = useState(null); const [deletePaymentTarget, setDeletePaymentTarget] = useState(null); const [paymentAmount, setPaymentAmount] = useState(''); const [paymentDate, setPaymentDate] = useState(todayStr()); const [paymentMethod, setPaymentMethod] = useState('manual'); const [paymentNotes, setPaymentNotes] = useState(''); const isDebtCategory = isDebtCat(categories, categoryId); const isSnowballCategory = isSnowballCat(categories, categoryId); const showOnSnowball = snowballInclude || (isSnowballCategory && !snowballExempt); const canAutoMarkPaid = autopay && autodraftStatus === 'assumed_paid'; async function loadPayments() { if (isNew || !bill?.id) return; setPaymentsLoading(true); try { const data = await api.billPayments(bill.id, 1, 100); setPayments(data.payments || []); } catch (err) { toast.error(err.message || 'Failed to load payment history.'); } finally { setPaymentsLoading(false); } } async function loadLinkedTransactions() { if (isNew || !bill?.id) return; setLinkedTransactionsLoading(true); try { const data = await api.billTransactions(bill.id); setLinkedTransactions(data.transactions || []); } catch (err) { toast.error(err.message || 'Failed to load linked transactions.'); } finally { setLinkedTransactionsLoading(false); } } useEffect(() => { loadPayments(); loadLinkedTransactions(); }, [bill?.id]); const validateName = (val) => { if (!val || val.trim() === '') return 'Name is required'; if (val.trim().length < 2) return 'Name must be at least 2 characters'; return ''; }; const validateDueDay = (val) => { if (!val || val.trim() === '') return 'Due day is required'; const num = parseInt(val, 10); if (isNaN(num) || num < 1 || num > 31) return 'Due day must be between 1 and 31'; return ''; }; const validateExpectedAmount = (val) => { if (val === '' || val === null) return ''; const num = parseFloat(val); if (isNaN(num) || num < 0) return 'Amount must be a positive number'; return ''; }; const validateInterestRate = (val) => { if (val === '' || val === null) return ''; const num = parseFloat(val); if (isNaN(num)) return 'Invalid number'; if (num < 0 || num > 100) return 'Interest rate must be between 0 and 100'; return ''; }; const validateCurrentBalance = (val) => { if (val === '' || val === null) return ''; const num = parseFloat(val); if (isNaN(num) || num < 0) return 'Balance must be a non-negative number'; return ''; }; const validateMinimumPayment = (val) => { if (val === '' || val === null) return ''; const num = parseFloat(val); if (isNaN(num) || num < 0) return 'Min payment must be a non-negative number'; return ''; }; const validateForm = () => { const newErrors = { name: validateName(name), dueDay: validateDueDay(dueDay), expectedAmount: validateExpectedAmount(expectedAmount), interestRate: validateInterestRate(interestRate), currentBalance: validateCurrentBalance(currentBalance), minimumPayment: validateMinimumPayment(minimumPayment), }; setErrors(newErrors); return Object.values(newErrors).every(err => err === ''); }; const handleBlur = (field, validator) => { setErrors(prev => ({ ...prev, [field]: validator( field === 'name' ? name : field === 'dueDay' ? dueDay : field === 'expectedAmount' ? expectedAmount : interestRate )})); }; const handleCategoryChange = (val) => { setCategoryId(val); if (isDebtCat(categories, val)) { setShowDebtSection(true); } else { setSnowballExempt(false); } }; const handleSnowballVisibilityChange = (checked) => { if (checked) { setSnowballExempt(false); setSnowballInclude(!isSnowballCategory); } else { setSnowballInclude(false); setSnowballExempt(isSnowballCategory); } }; const handleAutopayChange = (checked) => { setAutopay(checked); if (checked) { setAutodraftStatus(prev => (prev && prev !== 'none' ? prev : 'assumed_paid')); } else { setAutodraftStatus('none'); setAutoMarkPaid(false); } }; const handleCycleTypeChange = (value) => { setCycleType(value); setCycleDay(defaultCycleDayFor(value)); }; function resetPaymentForm() { setPaymentAmount(''); setPaymentDate(todayStr()); setPaymentMethod('manual'); setPaymentNotes(''); setEditingPayment(null); } function startAddPayment() { resetPaymentForm(); setPaymentFormOpen(true); } function startEditPayment(payment) { setEditingPayment(payment); setPaymentAmount(String(payment.amount ?? '')); setPaymentDate(payment.paid_date || todayStr()); setPaymentMethod(payment.method || 'manual'); setPaymentNotes(payment.notes || ''); setPaymentFormOpen(true); } async function handlePaymentSubmit(e) { e.preventDefault(); const parsedAmount = parseFloat(paymentAmount); if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { toast.error('Enter a positive payment amount.'); return; } if (!paymentDate) { toast.error('Choose a paid date.'); return; } setPaymentBusy(true); try { const payload = { amount: parsedAmount, paid_date: paymentDate, method: paymentMethod, notes: paymentNotes || null, payment_source: 'manual', }; if (editingPayment) { await api.updatePayment(editingPayment.id, payload); toast.success('Payment updated'); } else { await api.createPayment({ ...payload, bill_id: bill.id }); toast.success('Payment added'); } resetPaymentForm(); setPaymentFormOpen(false); await loadPayments(); onSave?.(); } catch (err) { toast.error(err.message || 'Payment could not be saved.'); } finally { setPaymentBusy(false); } } async function handleDeletePayment() { if (!deletePaymentTarget) return; const payment = deletePaymentTarget; setPaymentBusy(true); try { await api.deletePayment(payment.id); setDeletePaymentTarget(null); toast.success('Payment moved to recovery.', { action: { label: 'Undo', onClick: async () => { try { await api.restorePayment(payment.id); toast.success('Payment restored'); await loadPayments(); onSave?.(); } catch (err) { toast.error(err.message || 'Failed to restore payment.'); } }, }, }); if (editingPayment?.id === payment.id) { resetPaymentForm(); setPaymentFormOpen(false); } await loadPayments(); onSave?.(); } catch (err) { toast.error(err.message || 'Payment could not be removed.'); } finally { setPaymentBusy(false); } } async function handleUnmatchTransaction(transaction) { if (!transaction?.id) return; setTransactionBusyId(transaction.id); try { await api.unmatchTransaction(transaction.id); toast.success('Transaction unmatched'); await Promise.all([loadPayments(), loadLinkedTransactions()]); onSave?.(); } catch (err) { toast.error(err.message || 'Transaction could not be unmatched.'); } finally { setTransactionBusyId(null); } } async function handleSubmit(e) { e.preventDefault(); if (!validateForm()) { toast.error('Please fix the form errors before saving.'); return; } const parsedDueDay = Number(dueDay); if (!Number.isInteger(parsedDueDay) || parsedDueDay < 1 || parsedDueDay > 31) { toast.error('Due day must be a whole number from 1 to 31.'); return; } const trimmedInterestRate = interestRate.trim(); const parsedInterestRate = trimmedInterestRate === '' ? null : Number(trimmedInterestRate); if (parsedInterestRate !== null && (!Number.isFinite(parsedInterestRate) || parsedInterestRate < 0 || parsedInterestRate > 100)) { toast.error('Interest rate must be blank or a number from 0 to 100.'); return; } const data = { source_bill_id: sourceBill?.source_bill_id, name: name.trim(), category_id: categoryId === CAT_NONE ? null : parseInt(categoryId, 10), due_day: parsedDueDay, override_due_date: sourceBill?.override_due_date, expected_amount: parseFloat(expectedAmount) || 0, interest_rate: parsedInterestRate, billing_cycle: billingCycle, cycle_type: cycleType, cycle_day: cycleDay, autopay_enabled: autopay, autodraft_status: autopay ? autodraftStatus : 'none', auto_mark_paid: canAutoMarkPaid && autoMarkPaid, is_subscription: isSubscription, subscription_type: isSubscription ? subscriptionType : null, reminder_days_before: isSubscription ? parseInt(reminderDaysBefore || '3', 10) : 3, subscription_source: sourceBill?.subscription_source || 'manual', subscription_detected_at: sourceBill?.subscription_detected_at, has_2fa: has2fa, website: website || null, username: username || null, account_info: accountInfo || null, notes: notes || null, history_visibility: sourceBill?.history_visibility, current_balance: currentBalance === '' ? null : parseFloat(currentBalance), minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment), snowball_order: sourceBill?.snowball_order, snowball_include: snowballInclude, snowball_exempt: snowballExempt, }; setBusy(true); try { if (isNew) { if (data.source_bill_id) { await api.duplicateBill(data.source_bill_id, data); } else { await api.createBill(data); } toast.success('Bill added'); } else { await api.updateBill(bill.id, data); toast.success('Bill updated'); } if (saveTemplate) { const safeTemplateName = templateName.trim() || data.name; await api.saveBillTemplate({ name: safeTemplateName, data }); toast.success('Template saved'); } onSave(); onClose(); } catch (err) { toast.error(err.message); } finally { setBusy(false); } } const inp = 'bg-background/50 border-border/60 h-9 text-sm w-full'; return ( { if (!v) onClose(); }}> {isNew ? 'Add Bill' : 'Edit Bill'}
{/* Name */}
{ setName(e.target.value); setTimeout(() => setErrors(prev => ({ ...prev, name: validateName(e.target.value) })), 300); }} onBlur={() => handleBlur('name', validateName)} required /> {errors.name && ( {errors.name} )}
{/* Category */}
{/* Due Day */}
{ setDueDay(e.target.value); setTimeout(() => setErrors(prev => ({ ...prev, dueDay: validateDueDay(e.target.value) })), 300); }} onBlur={() => handleBlur('dueDay', validateDueDay)} /> {errors.dueDay && ( {errors.dueDay} )}

Enter the day of the month this bill is due.

{/* Expected Amount */}
{ setExpected(e.target.value); setTimeout(() => setErrors(prev => ({ ...prev, expectedAmount: validateExpectedAmount(e.target.value) })), 300); }} onBlur={() => handleBlur('expectedAmount', validateExpectedAmount)} /> {errors.expectedAmount && ( {errors.expectedAmount} )}
{/* Billing Cycle */}
{/* Cycle Type */}
{/* Cycle Day */}
{cycleType === 'monthly' ? ( ) : cycleType === 'weekly' || cycleType === 'biweekly' ? ( ) : ( )}

{cycleType === 'monthly' ? 'Day of the month' : cycleType === 'weekly' || cycleType === 'biweekly' ? 'Day of the week' : cycleType === 'quarterly' ? 'First month of the quarterly cycle' : 'Month due each year'}

{/* Subscription Details */}
{isSubscription && (
setReminderDaysBefore(e.target.value)} />

0-30 days before renewal.

)}
{/* Debt / Snowball Details — collapsible */}
{showDebtSection && (
{/* Interest Rate */}
{ setInterestRate(e.target.value); setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300); }} onBlur={() => handleBlur('interestRate', validateInterestRate)} /> {errors.interestRate && ( {errors.interestRate} )}

Enter 29.99 for 29.99%.

{/* Current Balance */}
{ setCurrentBalance(e.target.value); setTimeout(() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(e.target.value) })), 300); }} onBlur={() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(currentBalance) }))} /> {errors.currentBalance && ( {errors.currentBalance} )}

Outstanding debt balance.

{/* Minimum Payment */}
{ setMinimumPayment(e.target.value); setTimeout(() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(e.target.value) })), 300); }} onBlur={() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(minimumPayment) }))} /> {errors.minimumPayment && ( {errors.minimumPayment} )}

Required minimum monthly payment.

{/* Include in Snowball */}

Uncheck to exempt an auto-detected snowball bill, or check to include this bill manually.

)}
{/* Website */}
setWebsite(e.target.value)} />
{/* Username */}
setUsername(e.target.value)} />
{/* Account Info */}
setAccountInfo(e.target.value)} />
{/* Checkboxes */}
{/* Notes */}