import { useActionState, useEffect, useState } from 'react'; import { Copy, Layers, Link2, Link2Off, Loader2, RefreshCw } from 'lucide-react'; import { formatCentsUSD, validateNonNegativeMoney } from '@/lib/money'; 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, DialogDescription, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { Checkbox } from '@/components/ui/checkbox'; 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'; import BillMerchantRules from '@/components/BillMerchantRules'; import DebtDetailsSection from '@/components/bill-modal/DebtDetailsSection'; import AutopayTrustIndicator from '@/components/bill-modal/AutopayTrustIndicator'; import PaymentHistoryList from '@/components/bill-modal/PaymentHistoryList'; import PaymentFormFields from '@/components/bill-modal/PaymentFormFields'; import { BILLING_SCHEDULE_OPTIONS, billingCycleForSchedule, defaultCycleDayForSchedule, scheduleValue, } from '@/lib/billingSchedule'; 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 const CAT_NONE = 'none'; const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt']; const SNOWBALL_KEYWORDS = ['credit', 'loan', 'debt', 'mortgage', 'housing']; 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') { return formatCentsUSD(amount, { signed: true, currency }); } 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 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; } function normalizePayee(s) { return (s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); } function isSimilarPayee(a, b) { const na = normalizePayee(a); const nb = normalizePayee(b); const minLen = Math.min(na.length, nb.length); if (minLen < 3) return false; return na.startsWith(nb) || nb.startsWith(na); } 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 initialCycleType = scheduleValue(sourceBill || {}); const [cycleType, setCycleType] = useState(initialCycleType); const [cycleDay, setCycleDay] = useState(sourceBill?.cycle_day || defaultCycleDayForSchedule(initialCycleType)); 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 [syncingPayments, setSyncingPayments] = useState(false); // Track whether rules exist locally so the Sync button appears immediately // after the first rule is added without waiting for sourceBill to refetch. const [localHasRules, setLocalHasRules] = useState(!!sourceBill?.has_merchant_rule); 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 [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 [localVerifiedAt, setLocalVerifiedAt] = useState( bill?.autopay_verified_at ? new Date(bill.autopay_verified_at) : null ); // Controls the outer Dialog's open state so it closes via its own animation // rather than being abruptly unmounted, which can leave Radix cleanup in a broken state. const [dialogOpen, setDialogOpen] = useState(true); // Deactivate dialog state const [deactivateOpen, setDeactivateOpen] = useState(false); const [deactivateReason, setDeactivateReason] = useState(''); // Unmatch dialog state const [unmatchTarget, setUnmatchTarget] = useState(null); const [unmatchConfirmOpen, setUnmatchConfirmOpen] = useState(false); const [bulkUnmatch, setBulkUnmatch] = useState(null); const [bulkBusy, setBulkBusy] = useState(false); 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 ''; }; // Money fields share one non-negative validator (blank allowed, 0 allowed). const validateExpectedAmount = (val) => validateNonNegativeMoney(val, 'Amount'); const validateCurrentBalance = (val) => validateNonNegativeMoney(val, 'Balance'); const validateMinimumPayment = (val) => validateNonNegativeMoney(val, 'Min payment'); 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 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 === ''); }; // Value passed explicitly so this never falls through to the wrong field's // state (the old positional guessing defaulted every unmapped field to // interestRate). const handleBlur = (field, value, validator) => { setErrors(prev => ({ ...prev, [field]: validator(value) })); }; 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); } }; async function handleVerifyAutopay() { if (!bill?.id) return; try { const res = await api.verifyAutopay(bill.id); setLocalVerifiedAt(new Date(res.autopay_verified_at)); toast.success('Autopay marked as verified.'); } catch (err) { toast.error(err.message || 'Failed to verify autopay.'); } } 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(defaultCycleDayForSchedule(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); } } function openUnmatch(transaction) { setUnmatchTarget(transaction); setBulkUnmatch(null); setUnmatchConfirmOpen(false); } function closeUnmatch() { setUnmatchTarget(null); setBulkUnmatch(null); setUnmatchConfirmOpen(false); } async function handleSingleUnmatch() { if (!unmatchTarget?.id) return; setTransactionBusyId(unmatchTarget.id); try { await api.unmatchTransaction(unmatchTarget.id); toast.success('Transaction unmatched'); closeUnmatch(); await Promise.all([loadPayments(), loadLinkedTransactions()]); onSave?.(); } catch (err) { toast.error(err.message || 'Transaction could not be unmatched.'); } finally { setTransactionBusyId(null); } } async function handleOpenBulkUnmatch() { if (!unmatchTarget || !bill?.id) return; const targetPayee = transactionTitle(unmatchTarget); const similar = linkedTransactions.filter(tx => isSimilarPayee(transactionTitle(tx), targetPayee) ); if (!similar.find(tx => tx.id === unmatchTarget.id)) { similar.unshift(unmatchTarget); } let rules = []; try { const ruleData = await api.billMerchantRules(bill.id); rules = (ruleData || []).filter(r => isSimilarPayee(r.merchant, targetPayee)); } catch { // ignore — rules are optional } setBulkUnmatch({ similar, rules, checkedIds: new Set(similar.map(tx => tx.id)), removeRuleId: null, }); } async function handleBulkConfirm() { if (!bulkUnmatch) return; const { similar, checkedIds, removeRuleId } = bulkUnmatch; const matches = similar .filter(tx => checkedIds.has(tx.id) && tx.linked_payment) .map(tx => ({ transaction_id: tx.id, payment_id: tx.linked_payment.id, payment_source: tx.linked_payment.payment_source, })); if (matches.length === 0) { closeUnmatch(); return; } setBulkBusy(true); try { await api.unmatchTransactionBulk(matches); if (removeRuleId) { try { await api.deleteMerchantRule(bill.id, removeRuleId); } catch { toast.error('Transactions unmatched, but could not remove merchant rule.'); } } toast.success(`${matches.length} transaction${matches.length !== 1 ? 's' : ''} unmatched`); closeUnmatch(); await Promise.all([loadPayments(), loadLinkedTransactions()]); onSave?.(); } catch (err) { toast.error(err.message || 'Could not unmatch transactions.'); } finally { setBulkBusy(false); } } const [, submitAction, isPending] = useActionState(async () => { if (!validateForm()) { toast.error('Please fix the form errors before saving.'); return; } // validateForm() already enforced due-day (1–31) and interest-rate (0–100) // ranges and blocked the save with field errors, so these are just parses. const parsedDueDay = Number(dueDay); const trimmedInterestRate = interestRate.trim(); const parsedInterestRate = trimmedInterestRate === '' ? null : Number(trimmedInterestRate); 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: billingCycleForSchedule(cycleType), 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: parseInt(reminderDaysBefore || '3', 10), 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, }; try { let savedBill; if (isNew) { if (data.source_bill_id) { savedBill = await api.duplicateBill(data.source_bill_id, data); } else { savedBill = await api.createBill(data); } toast.success(`${data.name} added`); } else { savedBill = await api.updateBill(bill.id, data); toast.success(`${data.name} updated`); } if (saveTemplate) { const safeTemplateName = templateName.trim() || data.name; await api.saveBillTemplate({ name: safeTemplateName, data }); toast.success('Template saved'); } onSave(savedBill); setDialogOpen(false); } catch (err) { toast.error(err.message || 'Failed to save bill.'); } }, null); async function handleDeactivate() { if (!bill?.id) return; try { const payload = { active: bill.active ? 0 : 1 }; if (bill.active && deactivateReason) payload.inactive_reason = deactivateReason; await api.updateBill(bill.id, payload); toast.success(bill.active ? 'Bill deactivated' : 'Bill reactivated'); onSave?.(); setDialogOpen(false); } catch (err) { toast.error(err.message || 'Failed to update bill.'); } } 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', 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', 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', expectedAmount, validateExpectedAmount)} /> {errors.expectedAmount && ( {errors.expectedAmount} )}
{/* Billing Schedule */}
{/* 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 && (
{/* Bank sync button moved to the Transactions tab → Bank Matching Rules section */}
)}
{/* Reminder lead time — applies to every bill (the notifier honors reminder_days_before for the early "due soon" reminder). */}
setReminderDaysBefore(e.target.value)} />

Get an early reminder this many days before the due date (0-30). Also needs reminders enabled in Settings.

{/* Debt / Snowball Details — collapsible */} {/* Website */}
setWebsite(e.target.value)} />
{/* Username */}
setUsername(e.target.value)} />
{/* Account Info */}
setAccountInfo(e.target.value)} />
{/* Checkboxes */}
{/* Autopay trust indicator — edit mode only */} {/* Notes */}