import { useEffect, useState } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { toast } from "sonner"; import { toDateString } from "@/lib/format"; import { isDebtAccount, isIlliquidAsset } from "@/lib/accounts"; import { DatePicker } from "@/components/elements/DatePicker"; import type { Account } from "@/types/finance"; /** The eight the database's CHECK constraint accepts, anything else is a 500. */ const ACCOUNT_TYPES: { value: Account["type"]; label: string }[] = [ { value: "checking", label: "Checking" }, { value: "savings", label: "Savings" }, { value: "credit", label: "Credit card" }, { value: "investment", label: "Investment" }, { value: "loan", label: "Loan" }, { value: "vehicle", label: "Vehicle" }, { value: "property", label: "Property" }, { value: "cash", label: "Cash on hand" }, ]; const PROPERTY_TYPES: { value: NonNullable; label: string }[] = [ { value: "primary", label: "Primary residence" }, { value: "rental", label: "Rental" }, { value: "other", label: "Other" }, ]; interface Props { open: boolean; onOpenChange: (open: boolean) => void; /** Present when editing; absent when adding. */ account?: Account | null; onAdd: (a: Account) => Promise; onUpdate: (id: string, updates: Partial) => Promise; /** Limit the Type picker (the Accounts page offers bank types, the Assets * page the rest). Defaults to all eight. */ types?: Account["type"][]; } /** * Add or edit a manual account. * * Previously the only editable field anywhere was the nickname, so cash and any * institution Plaid does not reach simply could not be represented. */ export function AccountDialog({ open, onOpenChange, account, onAdd, onUpdate, types }: Props) { const typeOptions = types ? ACCOUNT_TYPES.filter((t) => types.includes(t.value) || t.value === account?.type) : ACCOUNT_TYPES; const editing = !!account; // A Plaid account's figures are owned by the sync and overwritten on the next // run, so only the nickname is editable for those. const plaidManaged = !!account?.id.startsWith("plaid-"); const [name, setName] = useState(""); const [institution, setInstitution] = useState(""); const [type, setType] = useState("checking"); const [balance, setBalance] = useState(""); const [creditLimit, setCreditLimit] = useState(""); const [mask, setMask] = useState(""); const [originalPrincipal, setOriginalPrincipal] = useState(""); const [interestRate, setInterestRate] = useState(""); const [monthlyPayment, setMonthlyPayment] = useState(""); const [termMonths, setTermMonths] = useState(""); const [originationDate, setOriginationDate] = useState(""); const [purchasePrice, setPurchasePrice] = useState(""); const [purchaseDate, setPurchaseDate] = useState(""); const [propertyType, setPropertyType] = useState>("primary"); const [saving, setSaving] = useState(false); useEffect(() => { if (!open) return; setName(account?.name ?? ""); setInstitution(account?.institution ?? ""); setType(account?.type ?? types?.[0] ?? "checking"); setBalance(account ? String(account.balance) : ""); setCreditLimit(account?.creditLimit != null ? String(account.creditLimit) : ""); setMask(account?.mask ?? ""); setOriginalPrincipal(account?.originalPrincipal != null ? String(account.originalPrincipal) : ""); setInterestRate(account?.interestRate != null ? String(account.interestRate) : ""); setMonthlyPayment(account?.monthlyPayment != null ? String(account.monthlyPayment) : ""); setTermMonths(account?.termMonths != null ? String(account.termMonths) : ""); setOriginationDate(account?.originationDate ?? ""); setPurchasePrice(account?.purchasePrice != null ? String(account.purchasePrice) : ""); setPurchaseDate(account?.purchaseDate ?? ""); setPropertyType(account?.propertyType ?? "primary"); }, [open, account, types]); const submit = async () => { const trimmedName = name.trim(); if (!trimmedName) { toast.error("Give the account a name"); return; } const parsedBalance = parseFloat(balance); if (Number.isNaN(parsedBalance)) { toast.error("Enter a balance"); return; } // A credit card's or loan's balance is what you owe, stored negative so // that summing every account's balance gives net worth. const isDebt = isDebtAccount(type); const signedBalance = isDebt ? -Math.abs(parsedBalance) : parsedBalance; const limit = creditLimit.trim() ? Math.abs(parseFloat(creditLimit)) : null; const loanFields = type === "loan" ? { originalPrincipal: originalPrincipal.trim() ? Math.abs(parseFloat(originalPrincipal)) : null, interestRate: interestRate.trim() ? parseFloat(interestRate) : null, monthlyPayment: monthlyPayment.trim() ? Math.abs(parseFloat(monthlyPayment)) : null, termMonths: termMonths.trim() ? Math.round(parseFloat(termMonths)) : null, originationDate: originationDate || null, } : { originalPrincipal: null, interestRate: null, monthlyPayment: null, termMonths: null, originationDate: null, }; const assetFields = isIlliquidAsset(type) ? { purchasePrice: purchasePrice.trim() ? Math.abs(parseFloat(purchasePrice)) : null, purchaseDate: purchaseDate || null, propertyType: type === "property" ? propertyType : null, } : { purchasePrice: null, purchaseDate: null, propertyType: null, }; setSaving(true); try { if (editing) { await onUpdate(account!.id, { name: trimmedName, institution: institution.trim() || trimmedName, type, balance: signedBalance, creditLimit: type === "credit" ? limit : null, available: type === "credit" && limit != null ? limit - Math.abs(signedBalance) : null, mask: mask.trim() || undefined, ...loanFields, ...assetFields, }); toast.success("Account updated"); } else { await onAdd({ // `manual-` keeps it clear of the `plaid-` rows that the Plaid sync // deletes and rebuilds wholesale on every run. id: `manual-acct-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, name: trimmedName, institution: institution.trim() || trimmedName, type, balance: signedBalance, available: type === "credit" && limit != null ? limit - Math.abs(signedBalance) : null, creditLimit: type === "credit" ? limit : null, lastSynced: toDateString(new Date()), mask: mask.trim(), ...loanFields, ...assetFields, }); toast.success("Account added"); } onOpenChange(false); } catch { toast.error(editing ? "Couldn't update the account" : "Couldn't add the account"); } finally { setSaving(false); } }; return ( {editing ? "Edit account" : "Add account"} {plaidManaged ? (

This account syncs from your bank, so its balance and details are managed there. You can rename it from the card's pencil icon.

) : (
setName(e.target.value)} placeholder={type === "cash" ? "e.g. Wallet, Cash at home" : "e.g. Chase Savings"} autoFocus />
setInstitution(e.target.value)} placeholder="Optional" />
setBalance(e.target.value)} placeholder="0.00" onKeyDown={(e) => e.key === "Enter" && void submit()} />
{type === "credit" ? (
setCreditLimit(e.target.value)} placeholder="Optional" />
) : isIlliquidAsset(type) ? ( type === "property" && (
) ) : type === "cash" ? null : (
setMask(e.target.value.slice(0, 4))} placeholder="Optional" />
)}
{isIlliquidAsset(type) && (

Purchase details

setPurchasePrice(e.target.value)} placeholder="Optional" />
setPurchaseDate(d ?? "")} maxDate={new Date()} clearable />
)} {type === "loan" && (

Loan terms

setOriginalPrincipal(e.target.value)} placeholder="Optional" />
setInterestRate(e.target.value)} placeholder="Optional" />
setMonthlyPayment(e.target.value)} placeholder="Optional" />
setTermMonths(e.target.value)} placeholder="Optional" />
setOriginationDate(d ?? "")} maxDate={new Date()} clearable />
)} {isDebtAccount(type) && (

Enter what you owe as a positive number, it's stored as a debt so your net worth adds up.

)}
)} {!plaidManaged && ( )}
); }