diff --git a/app/api/accounts/route.ts b/app/api/accounts/route.ts index 5b1b440..dbf6003 100644 --- a/app/api/accounts/route.ts +++ b/app/api/accounts/route.ts @@ -2,5 +2,5 @@ import { NextRequest } from "next/server"; import { proxyRequest } from "@/lib/backend"; export async function GET(req: NextRequest) { - return proxyRequest(req, "accounts"); + return proxyRequest(req, "view/accounts"); } diff --git a/app/api/bill-pay/bills/[id]/pay/route.ts b/app/api/bill-pay/bills/[id]/pay/route.ts new file mode 100644 index 0000000..4e3dbcc --- /dev/null +++ b/app/api/bill-pay/bills/[id]/pay/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest, { params }: { params: { id: string } }) { + return proxyRequest(req, `bill-pay/bills/${params.id}/pay`); +} diff --git a/app/api/bill-pay/bills/[id]/route.ts b/app/api/bill-pay/bills/[id]/route.ts new file mode 100644 index 0000000..045b0ff --- /dev/null +++ b/app/api/bill-pay/bills/[id]/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) { + return proxyRequest(req, `bill-pay/bills/${params.id}`); +} diff --git a/app/api/bill-pay/bills/route.ts b/app/api/bill-pay/bills/route.ts new file mode 100644 index 0000000..b387c9e --- /dev/null +++ b/app/api/bill-pay/bills/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "bill-pay/bills"); +} + +export async function POST(req: NextRequest) { + return proxyRequest(req, "bill-pay/bills"); +} diff --git a/app/api/bill-pay/payees/route.ts b/app/api/bill-pay/payees/route.ts new file mode 100644 index 0000000..5d7da65 --- /dev/null +++ b/app/api/bill-pay/payees/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "bill-pay/payees"); +} + +export async function POST(req: NextRequest) { + return proxyRequest(req, "bill-pay/payees"); +} diff --git a/app/api/bill-pay/summary/route.ts b/app/api/bill-pay/summary/route.ts new file mode 100644 index 0000000..dfeb8f3 --- /dev/null +++ b/app/api/bill-pay/summary/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "bill-pay/summary"); +} diff --git a/app/api/credit-score/entries/route.ts b/app/api/credit-score/entries/route.ts new file mode 100644 index 0000000..4dd73d1 --- /dev/null +++ b/app/api/credit-score/entries/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "credit-score/entries"); +} + +export async function POST(req: NextRequest) { + return proxyRequest(req, "credit-score/entries"); +} diff --git a/app/api/credit-score/summary/route.ts b/app/api/credit-score/summary/route.ts new file mode 100644 index 0000000..baab993 --- /dev/null +++ b/app/api/credit-score/summary/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "credit-score/summary"); +} diff --git a/app/api/notifications/[id]/read/route.ts b/app/api/notifications/[id]/read/route.ts new file mode 100644 index 0000000..62c0c6b --- /dev/null +++ b/app/api/notifications/[id]/read/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) { + return proxyRequest(req, `notifications/${params.id}/read`); +} diff --git a/app/api/notifications/preferences/route.ts b/app/api/notifications/preferences/route.ts new file mode 100644 index 0000000..62b33b7 --- /dev/null +++ b/app/api/notifications/preferences/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "notifications/preferences"); +} + +export async function PATCH(req: NextRequest) { + return proxyRequest(req, "notifications/preferences"); +} diff --git a/app/api/notifications/push-subscriptions/route.ts b/app/api/notifications/push-subscriptions/route.ts new file mode 100644 index 0000000..09484d2 --- /dev/null +++ b/app/api/notifications/push-subscriptions/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "notifications/push-subscriptions"); +} diff --git a/app/api/notifications/read-all/route.ts b/app/api/notifications/read-all/route.ts new file mode 100644 index 0000000..059b4d7 --- /dev/null +++ b/app/api/notifications/read-all/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "notifications/read-all"); +} diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts new file mode 100644 index 0000000..e6d6620 --- /dev/null +++ b/app/api/notifications/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "notifications"); +} diff --git a/app/api/notifications/test/route.ts b/app/api/notifications/test/route.ts new file mode 100644 index 0000000..d9c2ef7 --- /dev/null +++ b/app/api/notifications/test/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "notifications/test"); +} diff --git a/app/api/notifications/vapid-public-key/route.ts b/app/api/notifications/vapid-public-key/route.ts new file mode 100644 index 0000000..95149d1 --- /dev/null +++ b/app/api/notifications/vapid-public-key/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "notifications/vapid-public-key"); +} diff --git a/app/api/transactions/route.ts b/app/api/transactions/route.ts index 79477f2..5dd69b4 100644 --- a/app/api/transactions/route.ts +++ b/app/api/transactions/route.ts @@ -2,7 +2,7 @@ import { NextRequest } from "next/server"; import { proxyRequest } from "@/lib/backend"; export async function GET(req: NextRequest) { - return proxyRequest(req, "transactions"); + return proxyRequest(req, "view/transactions"); } export async function POST(req: NextRequest) { diff --git a/app/api/view/accounts/route.ts b/app/api/view/accounts/route.ts new file mode 100644 index 0000000..dbf6003 --- /dev/null +++ b/app/api/view/accounts/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "view/accounts"); +} diff --git a/app/api/view/transactions/route.ts b/app/api/view/transactions/route.ts new file mode 100644 index 0000000..14d9cef --- /dev/null +++ b/app/api/view/transactions/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "view/transactions"); +} diff --git a/app/app/connect/page.tsx b/app/app/connect/page.tsx index db3a42c..4dd43f6 100644 --- a/app/app/connect/page.tsx +++ b/app/app/connect/page.tsx @@ -5,7 +5,7 @@ import { useCallback, useEffect, useState } from "react"; import { usePlaidLink } from "react-plaid-link"; type Account = { - id: string; + viewRef: string; institutionName: string; accountType: string; mask?: string | null; @@ -43,7 +43,7 @@ declare global { export default function ConnectPage() { const [status, setStatus] = useState(""); const [linkToken, setLinkToken] = useState(null); - const [updateAccountId, setUpdateAccountId] = useState(null); + const [updateAccountRef, setUpdateAccountRef] = useState(null); const [linkMode, setLinkMode] = useState<"connect" | "update">("connect"); const [pendingOpen, setPendingOpen] = useState(false); const [manualMode, setManualMode] = useState(false); @@ -120,13 +120,13 @@ export default function ConnectPage() { const onSuccess = useCallback( async (publicToken: string | null) => { - if (linkMode === "update" && updateAccountId) { + if (linkMode === "update" && updateAccountRef) { setStatus("Finishing bank reconnection..."); try { const res = await fetch("/api/plaid/repair-complete", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ accountId: updateAccountId }) + body: JSON.stringify({ accountId: updateAccountRef }) }); const payload = await res.json(); if (!res.ok || payload.error) { @@ -134,7 +134,7 @@ export default function ConnectPage() { return; } setStatus("Bank connection repaired."); - setUpdateAccountId(null); + setUpdateAccountRef(null); setLinkMode("connect"); setLinkToken(null); await loadAccounts(); @@ -170,7 +170,7 @@ export default function ConnectPage() { setStatus("Unable to exchange token."); } }, - [createLinkToken, linkMode, loadAccounts, updateAccountId] + [createLinkToken, linkMode, loadAccounts, updateAccountRef] ); const { open, ready } = usePlaidLink({ @@ -218,13 +218,13 @@ export default function ConnectPage() { }); }; - const startUpdateMode = async (accountId: string) => { + const startUpdateMode = async (accountRef: string) => { setStatus("Requesting Plaid update-mode link token..."); try { const res = await fetch("/api/plaid/update-link-token", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ accountId }) + body: JSON.stringify({ accountId: accountRef }) }); const payload = await res.json(); if (!res.ok || payload.error) { @@ -236,7 +236,7 @@ export default function ConnectPage() { setStatus("Unable to create update-mode link token."); return; } - setUpdateAccountId(accountId); + setUpdateAccountRef(accountRef); setLinkMode("update"); setLinkToken(token); setStatus("Reconnect token ready. Opening Plaid..."); @@ -351,7 +351,7 @@ export default function ConnectPage() {

{accounts.map((account) => (
@@ -374,7 +374,7 @@ export default function ConnectPage() { diff --git a/app/app/page.tsx b/app/app/page.tsx index 2701ba4..2526b28 100644 --- a/app/app/page.tsx +++ b/app/app/page.tsx @@ -33,11 +33,10 @@ type MerchantInsight = { }; type TxRow = { - id: string; + viewRef: string; date: string; description: string; category?: string | null; - accountId?: string | null; amount: string; }; @@ -507,14 +506,14 @@ export default function AppHomePage() { apiFetch("/api/transactions/summary"), apiFetch("/api/transactions/cashflow?months=6"), apiFetch("/api/transactions/merchants?limit=5"), - apiFetch<{ accounts: { id: string }[]; total: number }>("/api/accounts"), - apiFetch<{ transactions: TxRow[]; total: number }>("/api/transactions?limit=5"), + apiFetch<{ accounts: { viewRef: string }[]; total: number }>("/api/view/accounts?limit=5"), + apiFetch<{ transactions: TxRow[]; total: number }>("/api/view/transactions?limit=5"), ]) .then(([summaryRes, cashflowRes, merchantsRes, accountsRes, txRes]) => { if (!summaryRes.error) setSummary(summaryRes.data); if (!cashflowRes.error) setCashflow(cashflowRes.data ?? []); if (!merchantsRes.error) setMerchants(merchantsRes.data ?? []); - if (!accountsRes.error) setAccountCount(accountsRes.data?.accounts?.length ?? 0); + if (!accountsRes.error) setAccountCount(accountsRes.data?.total ?? accountsRes.data?.accounts?.length ?? 0); if (!txRes.error) setRecentTxs(txRes.data?.transactions ?? []); }) .catch(() => undefined) @@ -737,7 +736,7 @@ export default function AppHomePage() { const fmtAmt = formatCurrency(amt); const isIncome = amt >= 0; return ( - + {new Date(tx.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })} {tx.description} diff --git a/app/bills/page.tsx b/app/bills/page.tsx new file mode 100644 index 0000000..a61055c --- /dev/null +++ b/app/bills/page.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { AppShell } from "@/components/app-shell"; +import { apiFetch } from "@/lib/api"; + +type Payee = { + id: string; + name: string; + category?: string | null; + accountNumberLast4?: string | null; +}; + +type Bill = { + id: string; + payeeId?: string | null; + name: string; + amount: string | number; + currency: string; + dueDate: string; + status: "pending" | "scheduled" | "paid" | "skipped" | "cancelled"; + computedStatus: string; + recurrence: string; + autopay: boolean; + reminderDays: number; + paidAt?: string | null; + payee?: Payee | null; +}; + +type BillSummary = { + activeCount: number; + upcomingCount: number; + overdueCount: number; + totalDueNext30: string; +}; + +const today = new Date().toISOString().slice(0, 10); + +export default function BillsPage() { + const [payees, setPayees] = useState([]); + const [bills, setBills] = useState([]); + const [summary, setSummary] = useState(null); + const [status, setStatus] = useState(""); + const [filter, setFilter] = useState("all"); + const [payeeForm, setPayeeForm] = useState({ name: "", category: "", accountNumberLast4: "" }); + const [billForm, setBillForm] = useState({ + payeeId: "", + name: "", + amount: "", + dueDate: today, + status: "pending", + recurrence: "none", + autopay: false, + reminderDays: "3", + notes: "", + }); + + const filteredBills = useMemo(() => { + if (filter === "all") return bills; + if (filter === "overdue") return bills.filter((bill) => bill.computedStatus === "overdue"); + return bills.filter((bill) => bill.status === filter); + }, [bills, filter]); + + const money = (value: string | number, currency = "USD") => + new Intl.NumberFormat("en-US", { style: "currency", currency }).format(Number(value ?? 0)); + + const load = async () => { + const [payeesRes, billsRes, summaryRes] = await Promise.all([ + apiFetch("/api/bill-pay/payees"), + apiFetch("/api/bill-pay/bills"), + apiFetch("/api/bill-pay/summary"), + ]); + if (!payeesRes.error) setPayees(payeesRes.data ?? []); + if (!billsRes.error) setBills(billsRes.data ?? []); + if (!summaryRes.error) setSummary(summaryRes.data ?? null); + }; + + useEffect(() => { + load().catch(() => setStatus("Unable to load bills.")); + }, []); + + const createPayee = async () => { + if (!payeeForm.name.trim()) { + setStatus("Payee name is required."); + return; + } + const res = await apiFetch("/api/bill-pay/payees", { + method: "POST", + body: JSON.stringify(payeeForm), + }); + if (res.error) { + setStatus(res.error.message ?? "Unable to create payee."); + return; + } + setPayeeForm({ name: "", category: "", accountNumberLast4: "" }); + setStatus("Payee created."); + await load(); + }; + + const createBill = async () => { + if (!billForm.name.trim() || !billForm.amount || !billForm.dueDate) { + setStatus("Bill name, amount, and due date are required."); + return; + } + const payload = { + ...billForm, + payeeId: billForm.payeeId || undefined, + amount: Number(billForm.amount), + reminderDays: Number(billForm.reminderDays || 3), + }; + const res = await apiFetch("/api/bill-pay/bills", { + method: "POST", + body: JSON.stringify(payload), + }); + if (res.error) { + setStatus(res.error.message ?? "Unable to create bill."); + return; + } + setBillForm({ payeeId: "", name: "", amount: "", dueDate: today, status: "pending", recurrence: "none", autopay: false, reminderDays: "3", notes: "" }); + setStatus("Bill created."); + await load(); + }; + + const markPaid = async (bill: Bill) => { + const res = await apiFetch(`/api/bill-pay/bills/${bill.id}/pay`, { + method: "POST", + body: JSON.stringify({ + amount: Number(bill.amount), + paidAt: new Date().toISOString(), + method: bill.autopay ? "autopay" : "manual", + }), + }); + if (res.error) { + setStatus(res.error.message ?? "Unable to mark bill paid."); + return; + } + setStatus(`${bill.name} marked paid.`); + await load(); + }; + + const updateStatus = async (bill: Bill, nextStatus: Bill["status"]) => { + const res = await apiFetch(`/api/bill-pay/bills/${bill.id}`, { + method: "PATCH", + body: JSON.stringify({ status: nextStatus }), + }); + if (res.error) { + setStatus(res.error.message ?? "Unable to update bill."); + return; + } + setStatus("Bill updated."); + await load(); + }; + + const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none"; + const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider"; + + return ( + +
+
+
+

Active bills

+

{summary?.activeCount ?? 0}

+
+
+

Due next 30 days

+

{summary?.upcomingCount ?? 0}

+
+
+

Overdue

+

{summary?.overdueCount ?? 0}

+
+
+

Total due next 30 days

+

{money(summary?.totalDueNext30 ?? 0)}

+
+
+ +
+
+

Payees

+
+
+ + setPayeeForm((prev) => ({ ...prev, name: event.target.value }))} className={inputCls} /> +
+
+ + setPayeeForm((prev) => ({ ...prev, category: event.target.value }))} className={inputCls} /> +
+
+ + setPayeeForm((prev) => ({ ...prev, accountNumberLast4: event.target.value }))} className={inputCls} /> +
+ +
+
+ {payees.length === 0 &&

No payees yet.

} + {payees.map((payee) => ( +
+

{payee.name}

+

{payee.category || "Uncategorized"}{payee.accountNumberLast4 ? ` · ${payee.accountNumberLast4}` : ""}

+
+ ))} +
+
+ +
+

New Bill

+
+
+ + +
+
+ + setBillForm((prev) => ({ ...prev, name: event.target.value }))} className={inputCls} /> +
+
+ + setBillForm((prev) => ({ ...prev, amount: event.target.value }))} className={inputCls} /> +
+
+ + setBillForm((prev) => ({ ...prev, dueDate: event.target.value }))} className={inputCls} /> +
+
+ + +
+
+ + setBillForm((prev) => ({ ...prev, reminderDays: event.target.value }))} className={inputCls} /> +
+ + +
+ {status &&

{status}

} +
+
+ +
+
+

Bill Schedule

+ +
+ +
+ {filteredBills.length === 0 &&

No bills for this view.

} + {filteredBills.map((bill) => ( +
+
+
+
+

{bill.name}

+ {bill.computedStatus} + {bill.autopay && autopay} +
+

+ {money(bill.amount, bill.currency)} due {new Date(bill.dueDate).toLocaleDateString()} · {bill.recurrence} +

+ {bill.payee &&

Payee: {bill.payee.name}

} +
+
+ {bill.status !== "paid" && ( + + )} + {bill.status !== "skipped" && bill.status !== "paid" && ( + + )} + {bill.status !== "cancelled" && bill.status !== "paid" && ( + + )} +
+
+
+ ))} +
+
+
+
+ ); +} diff --git a/app/credit-score/page.tsx b/app/credit-score/page.tsx new file mode 100644 index 0000000..55f60ff --- /dev/null +++ b/app/credit-score/page.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { AppShell } from "@/components/app-shell"; +import { apiFetch } from "@/lib/api"; + +type CreditScoreEntry = { + id: string; + score: number; + bureau: string; + source: string; + model: string; + scoreDate: string; + factors?: Record; + change?: number | null; +}; + +type CreditScoreSummary = { + latest: CreditScoreEntry | null; + previous: CreditScoreEntry | null; + change: number | null; + averageScore: number | null; + entryCount: number; + latestByBureau: CreditScoreEntry[]; + trend: CreditScoreEntry[]; +}; + +const bureaus = [ + { value: "experian", label: "Experian" }, + { value: "equifax", label: "Equifax" }, + { value: "transunion", label: "TransUnion" }, + { value: "unknown", label: "Unknown" }, +]; + +const today = new Date().toISOString().slice(0, 10); + +export default function CreditScorePage() { + const [summary, setSummary] = useState(null); + const [entries, setEntries] = useState([]); + const [filter, setFilter] = useState("all"); + const [status, setStatus] = useState(""); + const [form, setForm] = useState({ + score: "", + bureau: "experian", + source: "manual", + model: "fico_8", + scoreDate: today, + positiveFactors: "", + negativeFactors: "", + }); + + const filteredEntries = useMemo(() => { + if (filter === "all") return entries; + return entries.filter((entry) => entry.bureau === filter); + }, [entries, filter]); + + const scoreBand = (score?: number | null) => { + if (!score) return "No score"; + if (score >= 800) return "Exceptional"; + if (score >= 740) return "Very good"; + if (score >= 670) return "Good"; + if (score >= 580) return "Fair"; + return "Needs work"; + }; + + const load = async () => { + const query = filter === "all" ? "" : `?bureau=${filter}`; + const [summaryRes, entriesRes] = await Promise.all([ + apiFetch("/api/credit-score/summary"), + apiFetch(`/api/credit-score/entries${query}`), + ]); + if (!summaryRes.error) setSummary(summaryRes.data ?? null); + if (!entriesRes.error) setEntries(entriesRes.data ?? []); + }; + + useEffect(() => { + load().catch(() => setStatus("Unable to load credit score history.")); + }, [filter]); + + const addEntry = async () => { + const score = Number(form.score); + if (!score || score < 300 || score > 850) { + setStatus("Score must be between 300 and 850."); + return; + } + const factors = { + positive: form.positiveFactors.split(",").map((item) => item.trim()).filter(Boolean), + negative: form.negativeFactors.split(",").map((item) => item.trim()).filter(Boolean), + }; + const res = await apiFetch("/api/credit-score/entries", { + method: "POST", + body: JSON.stringify({ + score, + bureau: form.bureau, + source: form.source, + model: form.model, + scoreDate: form.scoreDate, + factors, + }), + }); + if (res.error) { + setStatus(res.error.message ?? "Unable to add score entry."); + return; + } + const change = res.data?.change; + setStatus(change === null || change === undefined ? "Credit score entry added." : `Credit score entry added. Change: ${change > 0 ? "+" : ""}${change}.`); + setForm((prev) => ({ ...prev, score: "", positiveFactors: "", negativeFactors: "" })); + await load(); + }; + + const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none"; + const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider"; + + return ( + +
+
+
+

Latest score

+

{summary?.latest?.score ?? "--"}

+

{scoreBand(summary?.latest?.score)}

+
+
+

Last change

+

+ {summary?.change === null || summary?.change === undefined ? "--" : `${summary.change > 0 ? "+" : ""}${summary.change}`} +

+

Compared with previous same-bureau entry

+
+
+

Average score

+

{summary?.averageScore ?? "--"}

+

Across saved entries

+
+
+

Entries

+

{summary?.entryCount ?? 0}

+

Manual or imported history

+
+
+ +
+
+

Add Score Entry

+
+
+ + setForm((prev) => ({ ...prev, score: event.target.value }))} className={inputCls} /> +
+
+ + +
+
+ + +
+
+ + setForm((prev) => ({ ...prev, scoreDate: event.target.value }))} className={inputCls} /> +
+
+ + setForm((prev) => ({ ...prev, positiveFactors: event.target.value }))} className={inputCls} placeholder="low utilization, on-time payments" /> +
+
+ + setForm((prev) => ({ ...prev, negativeFactors: event.target.value }))} className={inputCls} placeholder="hard inquiry, high balance" /> +
+
+ + {status &&

{status}

} +
+ +
+
+

Bureau Snapshot

+ +
+
+ {(summary?.latestByBureau ?? []).map((entry) => ( +
+

{entry.bureau}

+

{entry.score}

+

{new Date(entry.scoreDate).toLocaleDateString()}

+
+ ))} + {(summary?.latestByBureau?.length ?? 0) === 0 &&

No bureau scores yet.

} +
+
+
+ +
+

Score History

+
+ {filteredEntries.length === 0 &&

No score entries for this view.

} + {filteredEntries.map((entry) => { + const factors = entry.factors ?? {}; + const positive = Array.isArray(factors.positive) ? factors.positive : []; + const negative = Array.isArray(factors.negative) ? factors.negative : []; + return ( +
+
+
+
+

{entry.score}

+ {entry.bureau} + {entry.model} +
+

{new Date(entry.scoreDate).toLocaleDateString()} · {entry.source}

+ {(positive.length > 0 || negative.length > 0) && ( +

+ {positive.length > 0 ? `Positive: ${positive.join(", ")}` : ""} + {positive.length > 0 && negative.length > 0 ? " · " : ""} + {negative.length > 0 ? `Negative: ${negative.join(", ")}` : ""} +

+ )} +
+

{scoreBand(entry.score)}

+
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/app/notifications/page.tsx b/app/notifications/page.tsx new file mode 100644 index 0000000..be1fbe3 --- /dev/null +++ b/app/notifications/page.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { AppShell } from "@/components/app-shell"; +import { apiFetch } from "@/lib/api"; + +type NotificationPreference = { + emailEnabled: boolean; + pushEnabled: boolean; + minSeverity: "info" | "warning" | "critical"; +}; + +type LedgerNotification = { + id: string; + type: string; + severity: "info" | "warning" | "critical"; + title: string; + body: string; + channels: string[]; + readAt?: string | null; + createdAt: string; +}; + +type VapidStatus = { + enabled: boolean; + publicKey: string | null; +}; + +function urlBase64ToUint8Array(base64String: string) { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; i += 1) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +export default function NotificationsPage() { + const [notifications, setNotifications] = useState([]); + const [preferences, setPreferences] = useState({ + emailEnabled: true, + pushEnabled: false, + minSeverity: "info", + }); + const [vapid, setVapid] = useState({ enabled: false, publicKey: null }); + const [status, setStatus] = useState(""); + const [loading, setLoading] = useState(true); + + const unreadCount = useMemo(() => notifications.filter((item) => !item.readAt).length, [notifications]); + + const load = async () => { + setLoading(true); + const [listRes, prefRes, vapidRes] = await Promise.all([ + apiFetch("/api/notifications"), + apiFetch("/api/notifications/preferences"), + apiFetch("/api/notifications/vapid-public-key"), + ]); + if (!listRes.error) setNotifications(listRes.data ?? []); + if (!prefRes.error && prefRes.data) setPreferences(prefRes.data); + if (!vapidRes.error && vapidRes.data) setVapid(vapidRes.data); + setLoading(false); + }; + + useEffect(() => { + load(); + }, []); + + const updatePreferences = async (patch: Partial) => { + const next = { ...preferences, ...patch }; + setPreferences(next); + const res = await apiFetch("/api/notifications/preferences", { + method: "PATCH", + body: JSON.stringify(patch), + }); + if (res.error) { + setStatus(res.error.message ?? "Could not update notification preferences."); + return; + } + if (res.data) setPreferences(res.data); + setStatus("Notification preferences updated."); + }; + + const enablePush = async () => { + setStatus(""); + if (!vapid.enabled || !vapid.publicKey) { + setStatus("Push notifications need VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY on the backend."); + return; + } + if (!("serviceWorker" in navigator) || !("PushManager" in window)) { + setStatus("This browser does not support web push notifications."); + return; + } + + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + setStatus("Browser notification permission was not granted."); + return; + } + + const registration = await navigator.serviceWorker.register("/sw.js"); + const existing = await registration.pushManager.getSubscription(); + const subscription = existing ?? await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapid.publicKey), + }); + + const res = await apiFetch("/api/notifications/push-subscriptions", { + method: "POST", + body: JSON.stringify(subscription.toJSON()), + }); + if (res.error) { + setStatus(res.error.message ?? "Could not save push subscription."); + return; + } + setPreferences((prev) => ({ ...prev, pushEnabled: true })); + setStatus("Push notifications enabled for this browser."); + }; + + const sendTest = async () => { + setStatus("Sending test notification..."); + const res = await apiFetch("/api/notifications/test", { method: "POST" }); + if (res.error) { + setStatus(res.error.message ?? "Test notification failed."); + return; + } + setStatus("Test notification sent."); + await load(); + }; + + const markRead = async (id: string) => { + const res = await apiFetch(`/api/notifications/${id}/read`, { method: "PATCH" }); + if (!res.error) { + setNotifications((items) => items.map((item) => item.id === id ? { ...item, readAt: new Date().toISOString() } : item)); + } + }; + + const markAllRead = async () => { + const res = await apiFetch("/api/notifications/read-all", { method: "POST" }); + if (!res.error) { + const now = new Date().toISOString(); + setNotifications((items) => items.map((item) => ({ ...item, readAt: item.readAt ?? now }))); + } + }; + + const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none"; + + return ( + +
+
+
+

SMTP Email

+

Send important LedgerOne alerts through the configured SMTP transport.

+ +
+ +
+

Browser Push

+

+ {vapid.enabled ? "Register this browser for web push alerts." : "Backend VAPID keys are not configured."} +

+ +
+ +
+

Delivery Threshold

+

Email and push delivery only run at or above this severity.

+ +
+
+ +
+
+
+

Notification Center

+

{unreadCount} unread alert{unreadCount === 1 ? "" : "s"}

+
+
+ + +
+
+ + {status &&

{status}

} + +
+ {loading &&

Loading notifications...

} + {!loading && notifications.length === 0 &&

No notifications yet.

} + {!loading && notifications.map((item) => ( +
+
+
+
+ {!item.readAt && } +

{item.title}

+ {item.severity} +
+

{item.body}

+

+ {new Date(item.createdAt).toLocaleString()} · {item.channels.join(", ") || "in_app"} +

+
+ {!item.readAt && ( + + )} +
+
+ ))} +
+
+
+
+ ); +} diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx index 0095c30..19a7485 100644 --- a/app/transactions/page.tsx +++ b/app/transactions/page.tsx @@ -6,7 +6,7 @@ import { AppShell } from "../../components/app-shell"; import { apiFetch } from "@/lib/api"; type TransactionRow = { - id: string; + viewRef: string; name?: string; description?: string; amount: string; @@ -23,11 +23,10 @@ type TransactionRow = { status?: string; hidden?: boolean; date: string; - accountId?: string | null; }; type Account = { - id: string; + viewRef: string; institutionName: string; accountType: string; mask?: string | null; @@ -69,7 +68,7 @@ type CsvPreview = { fileName: string; headerSignature: string; headers: string[]; - sampleRows: Record[]; + rowCount: number; mapping: Partial; remembered: boolean; }; @@ -114,7 +113,7 @@ export default function TransactionsPage() { }); const fileInputRef = useRef(null); const [manualForm, setManualForm] = useState({ - accountId: "", + accountRef: "", date: new Date().toISOString().slice(0, 10), description: "", amount: "", @@ -125,7 +124,7 @@ export default function TransactionsPage() { splitMinePercent: "50", splitYoursPercent: "50", }); - const [editingId, setEditingId] = useState(null); + const [editingRef, setEditingRef] = useState(null); const [editForm, setEditForm] = useState({ category: "", note: "", @@ -369,7 +368,7 @@ export default function TransactionsPage() { const res = await apiFetch("/api/transactions/manual", { method: "POST", body: JSON.stringify({ - accountId: manualForm.accountId || undefined, + accountId: manualForm.accountRef || undefined, date: manualForm.date, description: manualForm.description, amount, @@ -389,7 +388,7 @@ export default function TransactionsPage() { }; const startEdit = (row: TransactionRow) => { - setEditingId(row.id); + setEditingRef(row.viewRef); setEditForm({ category: row.category ?? "", note: row.note ?? "", @@ -402,9 +401,9 @@ export default function TransactionsPage() { }; const saveEdit = async () => { - if (!editingId) return; + if (!editingRef) return; setStatus("Saving edits..."); - const res = await apiFetch(`/api/transactions/${editingId}/derived`, { + const res = await apiFetch(`/api/transactions/${editingRef}/derived`, { method: "PATCH", body: JSON.stringify({ userCategory: editForm.category || undefined, @@ -415,7 +414,7 @@ export default function TransactionsPage() { }), }); if (res.error) { setStatus(res.error.message ?? "Unable to save edits."); return; } - setEditingId(null); + setEditingRef(null); setStatus("Transaction updated."); await load(); await loadSummary(); @@ -545,25 +544,27 @@ export default function TransactionsPage() {
-

Preview: {csvPreview.fileName}

+

Columns: {csvPreview.fileName}

+

{csvPreview.rowCount} detected row{csvPreview.rowCount === 1 ? "" : "s"}

- {csvPreview.headers.map((header) => ( - - ))} + + - {csvPreview.sampleRows.map((row, index) => ( - - {csvPreview.headers.map((header) => ( - - ))} - - ))} + {csvPreview.headers.map((header) => { + const mapped = Object.entries(csvMapping).find(([, value]) => value === header)?.[0]; + return ( + + + + + ); + })}
{header}ColumnMapped as
{row[header] ?? ""}
{header}{mapped ?? "Not mapped"}
@@ -599,10 +600,10 @@ export default function TransactionsPage() {
- setManualForm((p) => ({ ...p, accountRef: e.target.value }))} className={inputCls}> {accounts.map((a) => ( - + ))}
@@ -794,8 +795,8 @@ export default function TransactionsPage() { {rows.map((row) => - editingId === row.id ? ( - + editingRef === row.viewRef ? ( +
- +
) : ( - + {new Date(row.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })} diff --git a/components/app-shell.tsx b/components/app-shell.tsx index a16f341..532c0bc 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -10,8 +10,11 @@ const navItems = [ { href: "/app", label: "Dashboard" }, { href: "/app/connect", label: "Accounts" }, { href: "/transactions", label: "Transactions" }, + { href: "/bills", label: "Bills" }, + { href: "/credit-score", label: "Credit Score" }, { href: "/rules", label: "Rules" }, { href: "/exports", label: "Exports" }, + { href: "/notifications", label: "Notifications" }, { href: "/tax", label: "Tax" }, { href: "/settings/households", label: "Households" }, { href: "/settings", label: "Settings" }, diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..2acb64f --- /dev/null +++ b/public/sw.js @@ -0,0 +1,36 @@ +self.addEventListener("push", (event) => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch { + data = {}; + } + + const title = data.title || "LedgerOne"; + const options = { + body: data.body || "You have a new LedgerOne notification.", + icon: "/favicon.ico", + badge: "/favicon.ico", + data: { + url: data.url || "/notifications", + }, + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const url = event.notification.data?.url || "/notifications"; + + event.waitUntil( + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => { + for (const client of clients) { + if ("focus" in client && client.url.includes(url)) { + return client.focus(); + } + } + return self.clients.openWindow(url); + }), + ); +});