Compare commits

..

23 Commits

Author SHA1 Message Date
20f32ba0de Rename frontend brand to Aarthalabs 2026-07-22 19:34:02 +05:30
22e162ae26 Keep manual transaction success visible 2026-07-22 18:42:36 +05:30
fbd1c2cc4a Add Sheets-first mirror control 2026-07-17 23:43:11 +05:30
10d386bc7d Add credit score pull UI 2026-07-17 23:36:31 +05:30
28dee336c9 Add bill payment initiation UI 2026-07-17 23:34:05 +05:30
bfda37d4e3 Add accountant workflow UI 2026-07-17 23:30:41 +05:30
c7ee638372 Add developer API key portal 2026-07-17 23:25:36 +05:30
eddd46c049 Add planning workspace UI 2026-07-17 23:22:44 +05:30
3ed58738b1 Add GDPR privacy controls UI 2026-07-17 22:10:29 +05:30
c1ff8a44b8 Add future planning scenarios UI 2026-07-16 23:57:17 +05:30
f154fd5656 Add debt payoff teamwork UI 2026-07-16 23:50:04 +05:30
1dbca1bcd4 Add transaction comments UI 2026-07-16 23:43:23 +05:30
5ed97c2a90 Add money date prompts UI 2026-07-16 23:31:34 +05:30
dada5e7d4f Add household privacy mode UI 2026-07-16 23:22:36 +05:30
69a88163dc Add fair split calculator UI 2026-07-16 23:15:32 +05:30
1649fb8e77 Update frontend e2e tests for current UI 2026-07-16 23:07:32 +05:30
b3b3fa3392 Align tax page with authenticated backend flow 2026-07-16 22:48:01 +05:30
edbcbea5b4 Proxy rotating session nonce cookies 2026-07-16 22:17:21 +05:30
f0e6405b11 Wire browser-safe finance presentation UI 2026-07-16 22:07:33 +05:30
1d7007637d feat: add collaborator management UI 2026-07-16 18:13:30 +05:30
ab273ea7d0 feat: show household health score 2026-07-16 15:38:00 +05:30
59ea77ed44 feat: add household goals UI 2026-07-16 15:33:57 +05:30
2b17c36ed2 feat: implement LedgerOne frontend backlog features 2026-07-16 15:07:02 +05:30
157 changed files with 6008 additions and 614 deletions

View File

@ -11,9 +11,9 @@ import { defaultFaqs } from "../../data/faq";
import { siteInfo } from "../../data/site";
export const metadata = {
title: "About LedgerOne",
title: "About Aarthalabs",
description:
"Learn how LedgerOne builds audit-ready ledgers for US finance, tax, and operations teams.",
"Learn how Aarthalabs builds audit-ready ledgers for US finance, tax, and operations teams.",
keywords: siteInfo.keywords
};
@ -52,9 +52,9 @@ export default function AboutPage() {
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "About LedgerOne",
name: "About Aarthalabs",
description:
"Learn how LedgerOne builds audit-ready ledgers for US finance, tax, and operations teams.",
"Learn how Aarthalabs builds audit-ready ledgers for US finance, tax, and operations teams.",
url: `${siteInfo.url}/about`
},
{
@ -80,12 +80,12 @@ export default function AboutPage() {
Our Story
</div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl leading-tight">
LedgerOne keeps every transaction ready for{" "}
Aarthalabs keeps every transaction ready for{" "}
<span className="heading-hero-accent">ready for audits, review, and action.</span>
</h1>
<div className="space-y-4 text-lg text-muted-foreground">
<p>
We built LedgerOne for teams that manage high volumes of transactions but
We built Aarthalabs for teams that manage high volumes of transactions but
still need each decision documented. Our ledger-first workflow keeps the
raw truth intact while allowing intelligent categorization and rule-driven
automation.
@ -119,7 +119,7 @@ export default function AboutPage() {
Built for US operators
</p>
<p className="mt-1 text-sm text-muted-foreground">
LedgerOne is built around US accounting workflows, audit readiness, and
Aarthalabs is built around US accounting workflows, audit readiness, and
tax reporting cycles.
</p>
</div>
@ -167,7 +167,7 @@ export default function AboutPage() {
</div>
<h2 className="text-3xl font-bold text-foreground">A ledger that holds the full story.</h2>
<p className="text-muted-foreground text-lg">
Traditional tools collapse data into summaries. LedgerOne keeps each raw
Traditional tools collapse data into summaries. Aarthalabs keeps each raw
entry intact and layers in decisions, reviews, and approvals.
</p>
</div>

160
app/accountant/page.tsx Normal file
View File

@ -0,0 +1,160 @@
"use client";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/app-shell";
import { apiFetch } from "@/lib/api";
type Household = { id: string; name: string };
type Person = { id: string; email: string; fullName?: string | null };
type AccountantTask = {
id: string;
title: string;
description?: string | null;
taskType: string;
status: string;
priority: string;
dueDate?: string | null;
completedAt?: string | null;
assignedTo?: Person | null;
createdBy?: Person | null;
};
export default function AccountantPage() {
const [households, setHouseholds] = useState<Household[]>([]);
const [householdId, setHouseholdId] = useState("");
const [tasks, setTasks] = useState<AccountantTask[]>([]);
const [draft, setDraft] = useState({ title: "", description: "", taskType: "review", priority: "medium", dueDate: "" });
const [status, setStatus] = useState("");
const selectedHousehold = useMemo(() => households.find((item) => item.id === householdId), [households, householdId]);
useEffect(() => {
void loadHouseholds();
}, []);
useEffect(() => {
if (householdId) void loadTasks(householdId);
}, [householdId]);
const loadHouseholds = async () => {
const res = await apiFetch<Household[]>("/api/households");
if (res.error) {
setStatus(res.error.message);
return;
}
const list = res.data ?? [];
setHouseholds(list);
setHouseholdId((current) => current || list[0]?.id || "");
};
const loadTasks = async (id = householdId) => {
const res = await apiFetch<AccountantTask[]>(`/api/households/${id}/accountant-tasks`);
if (res.error) {
setStatus(res.error.message);
setTasks([]);
return;
}
setStatus("");
setTasks(res.data ?? []);
};
const createTask = async (event: FormEvent) => {
event.preventDefault();
if (!householdId || !draft.title.trim()) return;
const res = await apiFetch<AccountantTask>(`/api/households/${householdId}/accountant-tasks`, {
method: "POST",
body: JSON.stringify({
title: draft.title,
description: draft.description || undefined,
taskType: draft.taskType,
priority: draft.priority,
dueDate: draft.dueDate || undefined,
}),
});
if (res.error) {
setStatus(res.error.message);
return;
}
setDraft({ title: "", description: "", taskType: "review", priority: "medium", dueDate: "" });
await loadTasks();
};
const updateTask = async (task: AccountantTask, nextStatus: string) => {
if (!householdId) return;
const res = await apiFetch<AccountantTask>(`/api/households/${householdId}/accountant-tasks/${task.id}`, {
method: "PATCH",
body: JSON.stringify({ status: nextStatus }),
});
if (res.error) {
setStatus(res.error.message);
return;
}
await loadTasks();
};
return (
<AppShell title="Accountant Workflow" subtitle="Track household advisor requests, reviews, and follow-ups.">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
{status ? <div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">{status}</div> : null}
<section className="rounded-lg border border-slate-200 bg-white p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-base font-semibold text-slate-950">Household</h2>
<p className="text-sm text-slate-600">{selectedHousehold?.name ?? "Choose a household to manage advisor work."}</p>
</div>
<select value={householdId} onChange={(event) => setHouseholdId(event.target.value)} className="rounded-md border border-slate-300 px-3 py-2 text-sm">
{households.map((household) => (
<option key={household.id} value={household.id}>{household.name}</option>
))}
</select>
</div>
</section>
<form onSubmit={createTask} className="rounded-lg border border-slate-200 bg-white p-4">
<h2 className="text-base font-semibold text-slate-950">New Task</h2>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<input value={draft.title} onChange={(event) => setDraft({ ...draft, title: event.target.value })} placeholder="Task title" className="rounded-md border border-slate-300 px-3 py-2 text-sm" />
<input type="date" value={draft.dueDate} onChange={(event) => setDraft({ ...draft, dueDate: event.target.value })} className="rounded-md border border-slate-300 px-3 py-2 text-sm" />
<select value={draft.taskType} onChange={(event) => setDraft({ ...draft, taskType: event.target.value })} className="rounded-md border border-slate-300 px-3 py-2 text-sm">
<option value="review">Review</option>
<option value="document_request">Document request</option>
<option value="tax_prep">Tax prep</option>
<option value="advice">Advice</option>
</select>
<select value={draft.priority} onChange={(event) => setDraft({ ...draft, priority: event.target.value })} className="rounded-md border border-slate-300 px-3 py-2 text-sm">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<textarea value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} placeholder="Notes or request details" className="mt-3 min-h-24 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" />
<button className="mt-3 rounded-md bg-slate-950 px-4 py-2 text-sm font-medium text-white">Create task</button>
</form>
<section className="grid gap-3">
{tasks.length ? tasks.map((task) => (
<article key={task.id} className="rounded-lg border border-slate-200 bg-white p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="font-semibold text-slate-950">{task.title}</h3>
<p className="mt-1 text-sm text-slate-600">{task.description || "No details added."}</p>
<p className="mt-2 text-xs uppercase text-slate-500">
{task.taskType.replace(/_/g, " ")} · {task.priority} · {task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "No due date"}
</p>
</div>
<div className="flex gap-2">
<button onClick={() => updateTask(task, "open")} className="rounded-md border border-slate-300 px-3 py-1.5 text-xs">Open</button>
<button onClick={() => updateTask(task, "in_progress")} className="rounded-md border border-blue-200 px-3 py-1.5 text-xs text-blue-700">In progress</button>
<button onClick={() => updateTask(task, "completed")} className="rounded-md border border-emerald-200 px-3 py-1.5 text-xs text-emerald-700">Complete</button>
</div>
</div>
<div className="mt-3 text-xs text-slate-500">Status: {task.status.replace(/_/g, " ")}</div>
</article>
)) : (
<div className="rounded-lg border border-dashed border-slate-300 bg-white p-6 text-sm text-slate-500">No accountant tasks yet.</div>
)}
</section>
</div>
</AppShell>
);
}

View File

@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function DELETE(req: NextRequest) {
return proxyRequest(req, "2fa/disable");
return proxyRequest(req, "auth/2fa/disable");
}

View File

@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "2fa/enable");
return proxyRequest(req, "auth/2fa/enable");
}

View File

@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "2fa/generate");
return proxyRequest(req, "auth/2fa/generate");
}

View File

@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "accounts/link-token");
export async function POST(req: NextRequest) {
return proxyRequest(req, "accounts/link");
}

View File

@ -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");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
return proxyRequest(req, `api-keys/${params.id}`);
}

10
app/api/api-keys/route.ts Normal file
View File

@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "api-keys");
}
export async function POST(req: NextRequest) {
return proxyRequest(req, "api-keys");
}

View File

@ -1,6 +1,15 @@
import { NextRequest } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { proxyRequest } from "@/lib/backend";
import { setAuthCookies } from "@/lib/auth-cookies";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/login");
const res = await proxyRequest(req, "auth/login");
const payload = await res.clone().json().catch(() => null) as { data?: { accessToken?: string; refreshToken?: string; sessionNonce?: string; [key: string]: unknown } } | null;
if (res.ok && payload?.data?.accessToken && payload.data.refreshToken && payload.data.sessionNonce) {
const { accessToken, refreshToken, sessionNonce, ...safeData } = payload.data;
const safeRes = NextResponse.json({ ...payload, data: safeData }, { status: res.status });
setAuthCookies(safeRes, accessToken, refreshToken, sessionNonce);
return safeRes;
}
return res;
}

View File

@ -1,6 +1,36 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
import { NextRequest, NextResponse } from "next/server";
import { getBackendUrl } from "@/lib/backend";
import { ACCESS_COOKIE, NONCE_COOKIE, REFRESH_COOKIE, clearAuthCookies } from "@/lib/auth-cookies";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/logout");
const accessToken = req.cookies.get(ACCESS_COOKIE)?.value;
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
const sessionNonce = req.cookies.get(NONCE_COOKIE)?.value;
const backendRes = refreshToken
? await fetch(getBackendUrl("auth/logout"), {
method: "POST",
headers: {
"Content-Type": "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
...(sessionNonce ? { "X-LedgerOne-Session-Nonce": sessionNonce } : {}),
"User-Agent": req.headers.get("user-agent") ?? "",
"X-Forwarded-For": req.headers.get("x-forwarded-for") ?? "",
},
body: JSON.stringify({ refreshToken }),
})
: null;
const payload = backendRes
? await backendRes.text()
: JSON.stringify({
data: { message: "Logged out." },
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: null,
});
const res = new NextResponse(payload, {
status: backendRes?.status ?? 200,
headers: { "Content-Type": backendRes?.headers.get("content-type") ?? "application/json" },
});
clearAuthCookies(res);
return res;
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "auth/me/data-export");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "auth/me/privacy-summary");
}

View File

@ -4,3 +4,7 @@ import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "auth/me");
}
export async function DELETE(req: NextRequest) {
return proxyRequest(req, "auth/me");
}

View File

@ -1,6 +1,46 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
import { NextRequest, NextResponse } from "next/server";
import { getBackendUrl } from "@/lib/backend";
import { NONCE_COOKIE, REFRESH_COOKIE, clearAuthCookies, setAuthCookies } from "@/lib/auth-cookies";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/refresh");
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
const sessionNonce = req.cookies.get(NONCE_COOKIE)?.value;
if (!refreshToken) {
const res = NextResponse.json(
{
data: null,
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Missing refresh token." },
},
{ status: 401 },
);
clearAuthCookies(res);
return res;
}
const backendRes = await fetch(getBackendUrl("auth/refresh"), {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": req.headers.get("user-agent") ?? "",
"X-Forwarded-For": req.headers.get("x-forwarded-for") ?? "",
...(sessionNonce ? { "X-LedgerOne-Session-Nonce": sessionNonce } : {}),
},
body: JSON.stringify({ refreshToken }),
});
const payload = await backendRes.text();
const parsed = JSON.parse(payload) as { data?: { accessToken?: string; refreshToken?: string; sessionNonce?: string; [key: string]: unknown } };
const responsePayload = backendRes.ok && parsed.data?.accessToken && parsed.data.refreshToken && parsed.data.sessionNonce
? JSON.stringify({ ...parsed, data: {} })
: payload;
const res = new NextResponse(responsePayload, {
status: backendRes.status,
headers: { "Content-Type": backendRes.headers.get("content-type") ?? "application/json" },
});
if (backendRes.ok && parsed.data?.accessToken && parsed.data.refreshToken && parsed.data.sessionNonce) {
setAuthCookies(res, parsed.data.accessToken, parsed.data.refreshToken, parsed.data.sessionNonce);
} else {
clearAuthCookies(res);
}
return res;
}

View File

@ -1,6 +1,15 @@
import { NextRequest } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { proxyRequest } from "@/lib/backend";
import { setAuthCookies } from "@/lib/auth-cookies";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/register");
const res = await proxyRequest(req, "auth/register");
const payload = await res.clone().json().catch(() => null) as { data?: { accessToken?: string; refreshToken?: string; sessionNonce?: string; [key: string]: unknown } } | null;
if (res.ok && payload?.data?.accessToken && payload.data.refreshToken && payload.data.sessionNonce) {
const { accessToken, refreshToken, sessionNonce, ...safeData } = payload.data;
const safeRes = NextResponse.json({ ...payload, data: safeData }, { status: res.status });
setAuthCookies(safeRes, accessToken, refreshToken, sessionNonce);
return safeRes;
}
return res;
}

View File

@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { proxyRequest } from "@/lib/backend";
import { setAuthCookies } from "@/lib/auth-cookies";
export async function POST(
req: NextRequest,
{ params }: { params: { provider: string } },
) {
const res = await proxyRequest(req, `auth/social/${params.provider}/callback`);
const payload = await res.clone().json().catch(() => null) as { data?: { accessToken?: string; refreshToken?: string; sessionNonce?: string; [key: string]: unknown } } | null;
if (res.ok && payload?.data?.accessToken && payload.data.refreshToken && payload.data.sessionNonce) {
const { accessToken, refreshToken, sessionNonce, ...safeData } = payload.data;
const safeRes = NextResponse.json({ ...payload, data: safeData }, { status: res.status });
setAuthCookies(safeRes, accessToken, refreshToken, sessionNonce);
return safeRes;
}
return res;
}

View File

@ -0,0 +1,9 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(
req: NextRequest,
{ params }: { params: { provider: string } },
) {
return proxyRequest(req, `auth/social/${params.provider}/url`);
}

View File

@ -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}/initiate-payment`);
}

View File

@ -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`);
}

View File

@ -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}`);
}

View File

@ -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");
}

View File

@ -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");
}

View File

@ -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");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "billing/checkout");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "billing/portal");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "billing/subscription");
}

View File

@ -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");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "credit-score/pull");
}

View File

@ -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");
}

View File

@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { getBackendUrl } from "@/lib/backend";
export async function GET(
req: NextRequest,
{ params }: { params: { token: string } }
) {
const targetUrl = getBackendUrl(`exports/download/${params.token}`);
const forwardedFor = req.headers.get("x-forwarded-for") ?? "";
const userAgent = req.headers.get("user-agent") ?? "";
const headers: Record<string, string> = {};
if (forwardedFor) headers["X-Forwarded-For"] = forwardedFor;
if (userAgent) headers["User-Agent"] = userAgent;
try {
const res = await fetch(targetUrl, { method: "GET", headers });
const body = await res.arrayBuffer();
return new NextResponse(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/octet-stream",
"Content-Disposition": res.headers.get("content-disposition") ?? "attachment",
"Cache-Control": "no-store",
},
});
} catch {
return NextResponse.json(
{
data: null,
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Backend unavailable." },
},
{ status: 503 }
);
}
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "exports/json");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "exports/pdf");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "exports/xlsx");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "google/data-system-mode");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function PATCH(req: NextRequest, { params }: { params: { id: string; taskId: string } }) {
return proxyRequest(req, `households/${params.id}/accountant-tasks/${params.taskId}`);
}

View File

@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
return proxyRequest(req, `households/${params.id}/accountant-tasks`);
}
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
return proxyRequest(req, `households/${params.id}/accountant-tasks`);
}

View File

@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
type RouteContext = {
params: { id: string };
};
export async function GET(req: NextRequest, { params }: RouteContext) {
return proxyRequest(req, `households/${params.id}/dashboard`);
}

View File

@ -0,0 +1,9 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
return proxyRequest(req, `households/${params.id}/debt-payoff`);
}

View File

@ -0,0 +1,9 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
return proxyRequest(req, `households/${params.id}/fair-split`);
}

View File

@ -0,0 +1,9 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
return proxyRequest(req, `households/${params.id}/future-scenarios`);
}

View File

@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
type RouteContext = {
params: { id: string; goalId: string };
};
export async function PATCH(req: NextRequest, { params }: RouteContext) {
return proxyRequest(req, `households/${params.id}/goals/${params.goalId}`);
}

View File

@ -0,0 +1,14 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
type RouteContext = {
params: { id: string };
};
export async function GET(req: NextRequest, { params }: RouteContext) {
return proxyRequest(req, `households/${params.id}/goals`);
}
export async function POST(req: NextRequest, { params }: RouteContext) {
return proxyRequest(req, `households/${params.id}/goals`);
}

View File

@ -0,0 +1,14 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
type RouteContext = {
params: { id: string };
};
export async function GET(req: NextRequest, { params }: RouteContext) {
return proxyRequest(req, `households/${params.id}/invites`);
}
export async function POST(req: NextRequest, { params }: RouteContext) {
return proxyRequest(req, `households/${params.id}/invites`);
}

View File

@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
type RouteContext = {
params: { id: string; memberId: string };
};
export async function PATCH(req: NextRequest, { params }: RouteContext) {
return proxyRequest(req, `households/${params.id}/members/${params.memberId}`);
}

View File

@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
type RouteContext = {
params: { id: string };
};
export async function GET(req: NextRequest, { params }: RouteContext) {
return proxyRequest(req, `households/${params.id}/members`);
}

View File

@ -0,0 +1,9 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
return proxyRequest(req, `households/${params.id}/money-date-prompts`);
}

View File

@ -0,0 +1,9 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
return proxyRequest(req, `households/${params.id}/privacy`);
}

View File

@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "households");
}
export async function POST(req: NextRequest) {
return proxyRequest(req, "households");
}

View File

@ -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`);
}

View File

@ -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");
}

View File

@ -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");
}

View File

@ -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");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "notifications");
}

View File

@ -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");
}

View File

@ -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");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "plaid/repair-complete");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "plaid/update-link-token");
}

View File

@ -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, `planning/budgets/${params.id}`);
}

View File

@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "planning/budgets");
}
export async function POST(req: NextRequest) {
return proxyRequest(req, "planning/budgets");
}

View File

@ -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, `planning/goals/${params.id}`);
}

View File

@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "planning/goals");
}
export async function POST(req: NextRequest) {
return proxyRequest(req, "planning/goals");
}

View File

@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "planning/investments");
}
export async function POST(req: NextRequest) {
return proxyRequest(req, "planning/investments");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "planning/net-worth");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "planning/net-worth/snapshots");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "planning/recurring/detect");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "planning/recurring");
}

View File

@ -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, `rules/${params.id}/execute`);
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "security/risk");
}

View File

@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "stripe/checkout");
return proxyRequest(req, "billing/checkout");
}

View File

@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "stripe/portal");
return proxyRequest(req, "billing/portal");
}

View File

@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "stripe/subscription");
return proxyRequest(req, "billing/subscription");
}

View File

@ -0,0 +1,9 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } },
) {
return proxyRequest(req, `tax/returns/${params.id}/documents`);
}

View File

@ -0,0 +1,16 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } },
) {
return proxyRequest(req, `tax/returns/${params.id}/efile`);
}
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } },
) {
return proxyRequest(req, `tax/returns/${params.id}/efile`);
}

View File

@ -1,7 +1,7 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {

View File

@ -0,0 +1,16 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } },
) {
return proxyRequest(req, `tax/returns/${params.id}/intake`);
}
export async function PUT(
req: NextRequest,
{ params }: { params: { id: string } },
) {
return proxyRequest(req, `tax/returns/${params.id}/intake`);
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "teller/config");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "teller/enrollment");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "teller/sync");
}

View File

@ -0,0 +1,16 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
return proxyRequest(req, `transactions/${params.id}/comments`);
}
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
return proxyRequest(req, `transactions/${params.id}/comments`);
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions/import/batch");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions/import/preview");
}

View File

@ -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) {

View File

@ -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");
}

View File

@ -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");
}

View File

@ -5,21 +5,72 @@ 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;
syncStatus?: string | null;
lastSyncError?: string | null;
plaidWebhookCode?: string | null;
tellerConnected?: boolean;
};
type TellerEnrollment = {
accessToken: string;
user?: { id?: string };
enrollment?: {
id?: string;
institution?: { name?: string };
};
};
type GoogleStatus = {
connected: boolean;
googleEmail?: string;
driveMirror?: {
enabled: boolean;
status: string;
url?: string | null;
lastSyncedAt?: string | null;
};
dataSystem?: {
mode: string;
operationalSystemOfRecord: string;
userOwnedMirror: boolean;
note: string;
};
};
declare global {
interface Window {
TellerConnect?: {
setup(config: {
applicationId: string;
environment?: string;
products: string[];
enrollmentId?: string;
onSuccess(enrollment: TellerEnrollment): void;
onExit?(): void;
onFailure?(error: unknown): void;
}): { open(): void };
};
}
}
export default function ConnectPage() {
const [status, setStatus] = useState("");
const [linkToken, setLinkToken] = useState<string | null>(null);
const [updateAccountRef, setUpdateAccountRef] = useState<string | null>(null);
const [linkMode, setLinkMode] = useState<"connect" | "update">("connect");
const [pendingOpen, setPendingOpen] = useState(false);
const [manualMode, setManualMode] = useState(false);
const [manualBank, setManualBank] = useState("");
const [manualRouting, setManualRouting] = useState("");
const [manualAccount, setManualAccount] = useState("");
const [manualType, setManualType] = useState("checking");
const [accounts, setAccounts] = useState<Account[]>([]);
const [tellerReady, setTellerReady] = useState(false);
const [googleStatus, setGoogleStatus] = useState<GoogleStatus | null>(null);
const createLinkToken = useCallback(async () => {
setStatus("Requesting Plaid link token...");
@ -43,36 +94,94 @@ export default function ConnectPage() {
}, []);
const loadAccounts = useCallback(async () => {
const userId = localStorage.getItem("ledgerone_user_id");
if (!userId) {
return;
}
const res = await fetch(`/api/accounts?user_id=${encodeURIComponent(userId)}`);
const res = await fetch("/api/accounts");
if (!res.ok) {
return;
}
const payload = await res.json();
setAccounts(payload.data ?? []);
setAccounts(payload.data?.accounts ?? payload.data ?? []);
}, []);
const loadGoogleStatus = useCallback(async () => {
const res = await fetch("/api/google/status");
if (!res.ok) return;
const payload = await res.json();
setGoogleStatus(payload.data ?? payload);
}, []);
const loadTellerScript = useCallback(() => {
if (typeof window === "undefined") return Promise.resolve(false);
if (window.TellerConnect) {
setTellerReady(true);
return Promise.resolve(true);
}
return new Promise<boolean>((resolve) => {
const existing = document.querySelector<HTMLScriptElement>("script[data-teller-connect]");
if (existing) {
existing.addEventListener("load", () => {
setTellerReady(Boolean(window.TellerConnect));
resolve(Boolean(window.TellerConnect));
});
existing.addEventListener("error", () => resolve(false));
return;
}
const script = document.createElement("script");
script.src = "https://cdn.teller.io/connect/connect.js";
script.dataset.tellerConnect = "true";
script.onload = () => {
setTellerReady(Boolean(window.TellerConnect));
resolve(Boolean(window.TellerConnect));
};
script.onerror = () => resolve(false);
document.body.appendChild(script);
});
}, []);
useEffect(() => {
createLinkToken();
loadAccounts();
}, [createLinkToken, loadAccounts]);
loadGoogleStatus();
loadTellerScript();
}, [createLinkToken, loadAccounts, loadGoogleStatus, loadTellerScript]);
const onSuccess = useCallback(
async (publicToken: string) => {
const userId = localStorage.getItem("ledgerone_user_id");
if (!userId) {
setStatus("Missing user id. Please sign in again.");
async (publicToken: string | null) => {
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: updateAccountRef })
});
const payload = await res.json();
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Unable to finish bank reconnection.");
return;
}
setStatus("Bank connection repaired.");
setUpdateAccountRef(null);
setLinkMode("connect");
setLinkToken(null);
await loadAccounts();
await createLinkToken();
} catch {
setStatus("Unable to finish bank reconnection.");
}
return;
}
if (!publicToken) {
setStatus("Plaid did not return a public token.");
return;
}
setStatus("Exchanging public token...");
try {
const res = await fetch("/api/plaid/exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ publicToken, userId })
body: JSON.stringify({ publicToken })
});
const payload = await res.json();
if (!res.ok || payload.error) {
@ -80,12 +189,14 @@ export default function ConnectPage() {
return;
}
setStatus("Bank account connected.");
setLinkToken(null);
await loadAccounts();
await createLinkToken();
} catch {
setStatus("Unable to exchange token.");
}
},
[loadAccounts]
[createLinkToken, linkMode, loadAccounts, updateAccountRef]
);
const { open, ready } = usePlaidLink({
@ -96,15 +207,16 @@ export default function ConnectPage() {
}
});
useEffect(() => {
if (pendingOpen && ready) {
setPendingOpen(false);
open();
}
}, [open, pendingOpen, ready]);
const onManualSubmit = (event: React.FormEvent) => {
event.preventDefault();
const userId = localStorage.getItem("ledgerone_user_id");
if (!userId) {
setStatus("Missing user id. Please sign in again.");
return;
}
const payload = {
userId,
institutionName: manualBank,
accountType: manualType,
mask: manualAccount.slice(-4)
@ -132,6 +244,105 @@ export default function ConnectPage() {
});
};
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: accountRef })
});
const payload = await res.json();
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Unable to create update-mode link token.");
return;
}
const token = payload.data?.linkToken ?? payload.data?.link_token;
if (!token) {
setStatus("Unable to create update-mode link token.");
return;
}
setUpdateAccountRef(accountRef);
setLinkMode("update");
setLinkToken(token);
setStatus("Reconnect token ready. Opening Plaid...");
setPendingOpen(true);
} catch {
setStatus("Unable to create update-mode link token.");
}
};
const needsReconnect = (account: Account) =>
["needs_reauth", "attention_required"].includes(account.syncStatus ?? "");
const startTellerConnect = async () => {
setStatus("Preparing Teller Connect...");
const loaded = await loadTellerScript();
if (!loaded || !window.TellerConnect) {
setStatus("Unable to load Teller Connect.");
return;
}
const configRes = await fetch("/api/teller/config");
const configPayload = await configRes.json();
if (!configRes.ok || configPayload.error) {
setStatus(configPayload.error?.message ?? "Teller is not configured.");
return;
}
const connect = window.TellerConnect.setup({
applicationId: configPayload.data.applicationId,
environment: configPayload.data.environment,
products: configPayload.data.products,
onSuccess: async (enrollment) => {
setStatus("Importing Teller accounts...");
const res = await fetch("/api/teller/enrollment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(enrollment)
});
const payload = await res.json();
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Unable to import Teller accounts.");
return;
}
setStatus(`Teller connected ${payload.data?.accountCount ?? 0} account(s).`);
await loadAccounts();
},
onExit: () => setStatus("Teller Connect closed."),
onFailure: () => setStatus("Teller Connect failed.")
});
connect.open();
};
const syncTeller = async () => {
setStatus("Syncing Teller transactions...");
const res = await fetch("/api/teller/sync", { method: "POST" });
const payload = await res.json();
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Unable to sync Teller transactions.");
return;
}
setStatus(`Synced ${payload.data?.created ?? 0} Teller transaction(s).`);
await loadAccounts();
};
const enableSheetsMirror = async () => {
setStatus("Enabling Sheets-first mirror mode...");
const res = await fetch("/api/google/data-system-mode", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: "google_sheets_mirror" })
});
const payload = await res.json();
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Unable to enable Sheets-first mirror mode.");
return;
}
setStatus("Sheets-first mirror mode enabled.");
await loadGoogleStatus();
};
return (
<AppShell
title="Connect a bank"
@ -151,6 +362,21 @@ export default function ConnectPage() {
>
Connect with Plaid
</button>
<button
type="button"
className="rounded-full border border-border bg-background px-5 py-2 text-sm font-semibold text-foreground hover:bg-secondary transition-colors"
onClick={startTellerConnect}
>
Connect with Teller
</button>
<button
type="button"
className="rounded-full border border-border bg-background px-5 py-2 text-sm font-semibold text-foreground hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={syncTeller}
disabled={!accounts.some((account) => account.tellerConnected)}
>
Sync Teller
</button>
<button
type="button"
className="rounded-full border border-border bg-background px-5 py-2 text-sm font-semibold text-foreground hover:bg-secondary transition-colors"
@ -167,18 +393,35 @@ export default function ConnectPage() {
</p>
{accounts.map((account) => (
<div
key={account.id}
className="flex items-center justify-between rounded-xl border border-border bg-secondary/30 px-4 py-3 text-sm"
key={account.viewRef}
className="flex flex-col gap-3 rounded-xl border border-border bg-secondary/30 px-4 py-3 text-sm md:flex-row md:items-center md:justify-between"
>
<div>
<p className="font-bold text-foreground">{account.institutionName}</p>
<p className="text-xs text-muted-foreground">
{account.accountType} {account.mask ? `- ${account.mask}` : ""}
</p>
{account.lastSyncError ? (
<p className="mt-1 text-xs text-destructive">{account.lastSyncError}</p>
) : null}
{account.plaidWebhookCode ? (
<p className="mt-1 text-xs text-muted-foreground">{account.plaidWebhookCode}</p>
) : null}
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
{account.tellerConnected ? "teller" : account.syncStatus ?? "connected"}
</span>
{needsReconnect(account) ? (
<button
type="button"
className="rounded-full bg-primary px-4 py-2 text-xs font-bold text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
onClick={() => startUpdateMode(account.viewRef)}
>
Reconnect
</button>
) : null}
</div>
<span className="rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
Connected
</span>
</div>
))}
</div>
@ -243,9 +486,37 @@ export default function ConnectPage() {
</form>
) : null}
<p className="mt-4 text-xs text-muted-foreground">
Your first two connections are free. Upgrade to add unlimited accounts.
Free includes two connections. Pro supports ten active accounts, and Elite supports unlimited accounts.
</p>
</div>
<div className="glass-panel mt-6 p-6 rounded-2xl shadow-sm">
<h2 className="text-lg font-bold text-foreground">Google Sheets ownership</h2>
<p className="mt-2 text-sm text-muted-foreground">
Keep a user-owned Google Sheets mirror synced from Aarthalabs transaction changes.
</p>
<div className="mt-4 rounded-xl border border-border bg-secondary/30 px-4 py-3 text-sm">
<p className="font-semibold text-foreground">
{googleStatus?.connected ? `Connected: ${googleStatus.googleEmail}` : "Google is not connected"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Mode: {googleStatus?.dataSystem?.mode ?? "backend_db"} · Mirror: {googleStatus?.driveMirror?.status ?? "not_started"}
</p>
{googleStatus?.driveMirror?.url ? (
<a className="mt-2 inline-block text-xs font-semibold text-primary" href={googleStatus.driveMirror.url} target="_blank" rel="noreferrer">
Open Google Sheet
</a>
) : null}
</div>
<button
type="button"
className="mt-4 rounded-full border border-border bg-background px-5 py-2 text-sm font-semibold text-foreground hover:bg-secondary transition-colors disabled:opacity-50"
onClick={enableSheetsMirror}
disabled={!googleStatus?.connected || googleStatus?.dataSystem?.mode === "google_sheets_mirror"}
>
Enable Sheets-first mirror
</button>
</div>
</AppShell>
);
}

View File

@ -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<Summary>("/api/transactions/summary"),
apiFetch<CashflowPoint[]>("/api/transactions/cashflow?months=6"),
apiFetch<MerchantInsight[]>("/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 (
<tr key={tx.id} className="border-b border-border hover:bg-secondary/30 transition-colors">
<tr key={tx.viewRef} className="border-b border-border hover:bg-secondary/30 transition-colors">
<td className="py-3 pl-2 font-medium">{new Date(tx.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}</td>
<td className="py-3 text-foreground font-medium">{tx.description}</td>
<td className="py-3">

View File

@ -0,0 +1,127 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { storeAuthTokens } from "@/lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type SocialAuthData = {
user: { id: string; email: string; fullName?: string; emailVerified?: boolean };
accessToken?: string;
refreshToken?: string;
next?: string;
};
function decodeProvider(state: string | null) {
if (!state) return "";
try {
const [body] = state.split(".");
const normalized = body.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(body.length / 4) * 4, "=");
const json = JSON.parse(atob(normalized)) as { provider?: string };
return json.provider === "apple" || json.provider === "google" ? json.provider : "";
} catch {
return "";
}
}
function SocialCallbackContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
const [message, setMessage] = useState("Completing sign in...");
useEffect(() => {
const error = searchParams.get("error");
const code = searchParams.get("code");
const state = searchParams.get("state");
const provider = decodeProvider(state);
if (error) {
setStatus("error");
setMessage(error === "access_denied" ? "You declined social sign in." : `Provider returned an error: ${error}`);
return;
}
if (!provider || !code || !state) {
setStatus("error");
setMessage("Social sign in callback is missing required data.");
return;
}
fetch(`/api/auth/social/${provider}/callback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, state }),
})
.then(async (res) => {
const payload = (await res.json()) as ApiResponse<SocialAuthData>;
if (!res.ok || payload.error) throw new Error(payload.error?.message ?? "Social sign in failed.");
storeAuthTokens({
user: payload.data.user,
});
setStatus("success");
setMessage(`Signed in as ${payload.data.user.email}.`);
setTimeout(() => router.replace(payload.data.next || "/app"), 800);
})
.catch((err: Error) => {
setStatus("error");
setMessage(err.message || "Social sign in failed.");
});
}, [router, searchParams]);
return (
<div className="min-h-screen flex items-center justify-center bg-background px-6">
<div className="glass-panel rounded-2xl p-10 text-center max-w-sm w-full shadow-lg">
{status === "loading" && (
<>
<div className="h-12 w-12 rounded-full border-4 border-primary border-t-transparent animate-spin mx-auto mb-4" />
<p className="text-sm text-muted-foreground">{message}</p>
</>
)}
{status === "success" && (
<>
<div className="h-12 w-12 rounded-full bg-green-500/10 flex items-center justify-center mx-auto mb-4">
<svg className="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm font-semibold text-foreground">Signed in</p>
<p className="text-xs text-muted-foreground mt-1">{message}</p>
</>
)}
{status === "error" && (
<>
<div className="h-12 w-12 rounded-full bg-red-500/10 flex items-center justify-center mx-auto mb-4">
<svg className="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-sm font-semibold text-foreground">Sign in failed</p>
<p className="text-xs text-muted-foreground mt-1">{message}</p>
<button onClick={() => router.replace("/login")} className="mt-4 text-xs text-primary hover:underline">
Back to sign in
</button>
</>
)}
</div>
</div>
);
}
export default function SocialCallbackPage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="h-12 w-12 rounded-full border-4 border-primary border-t-transparent animate-spin" />
</div>
}
>
<SocialCallbackContent />
</Suspense>
);
}

324
app/bills/page.tsx Normal file
View File

@ -0,0 +1,324 @@
"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<Payee[]>([]);
const [bills, setBills] = useState<Bill[]>([]);
const [summary, setSummary] = useState<BillSummary | null>(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<Payee[]>("/api/bill-pay/payees"),
apiFetch<Bill[]>("/api/bill-pay/bills"),
apiFetch<BillSummary>("/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<Payee>("/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<Bill>("/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 initiatePayment = async (bill: Bill) => {
const res = await apiFetch(`/api/bill-pay/bills/${bill.id}/initiate-payment`, {
method: "POST",
body: JSON.stringify({
amount: Number(bill.amount),
scheduledFor: new Date().toISOString(),
method: "ach",
memo: `Aarthalabs payment for ${bill.name}`,
}),
});
if (res.error) {
setStatus(res.error.message ?? "Unable to initiate payment.");
return;
}
setStatus(`${bill.name} payment initiated.`);
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 (
<AppShell title="Bills" subtitle="Track payees, due dates, reminders, and recorded payments.">
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Active bills</p>
<p className="mt-2 text-2xl font-bold text-foreground">{summary?.activeCount ?? 0}</p>
</div>
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Due next 30 days</p>
<p className="mt-2 text-2xl font-bold text-foreground">{summary?.upcomingCount ?? 0}</p>
</div>
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Overdue</p>
<p className="mt-2 text-2xl font-bold text-foreground">{summary?.overdueCount ?? 0}</p>
</div>
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Total due next 30 days</p>
<p className="mt-2 text-2xl font-bold text-foreground">{money(summary?.totalDueNext30 ?? 0)}</p>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[0.85fr_1.15fr]">
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<p className="text-sm font-bold text-foreground">Payees</p>
<div className="mt-4 grid gap-3">
<div>
<label className={labelCls}>Name</label>
<input value={payeeForm.name} onChange={(event) => setPayeeForm((prev) => ({ ...prev, name: event.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Category</label>
<input value={payeeForm.category} onChange={(event) => setPayeeForm((prev) => ({ ...prev, category: event.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Account last 4</label>
<input maxLength={4} value={payeeForm.accountNumberLast4} onChange={(event) => setPayeeForm((prev) => ({ ...prev, accountNumberLast4: event.target.value }))} className={inputCls} />
</div>
<button onClick={createPayee} className="rounded-lg bg-primary px-4 py-2.5 text-sm font-bold text-primary-foreground hover:bg-primary/90">Add Payee</button>
</div>
<div className="mt-6 divide-y divide-border">
{payees.length === 0 && <p className="py-4 text-sm text-muted-foreground">No payees yet.</p>}
{payees.map((payee) => (
<div key={payee.id} className="py-3">
<p className="text-sm font-semibold text-foreground">{payee.name}</p>
<p className="text-xs text-muted-foreground">{payee.category || "Uncategorized"}{payee.accountNumberLast4 ? ` · ${payee.accountNumberLast4}` : ""}</p>
</div>
))}
</div>
</section>
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<p className="text-sm font-bold text-foreground">New Bill</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<label className={labelCls}>Payee</label>
<select value={billForm.payeeId} onChange={(event) => setBillForm((prev) => ({ ...prev, payeeId: event.target.value }))} className={inputCls}>
<option value="">No payee</option>
{payees.map((payee) => <option key={payee.id} value={payee.id}>{payee.name}</option>)}
</select>
</div>
<div>
<label className={labelCls}>Bill name</label>
<input value={billForm.name} onChange={(event) => setBillForm((prev) => ({ ...prev, name: event.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Amount</label>
<input type="number" step="0.01" value={billForm.amount} onChange={(event) => setBillForm((prev) => ({ ...prev, amount: event.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Due date</label>
<input type="date" value={billForm.dueDate} onChange={(event) => setBillForm((prev) => ({ ...prev, dueDate: event.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Recurrence</label>
<select value={billForm.recurrence} onChange={(event) => setBillForm((prev) => ({ ...prev, recurrence: event.target.value }))} className={inputCls}>
<option value="none">None</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
</select>
</div>
<div>
<label className={labelCls}>Reminder days</label>
<input type="number" min={0} max={30} value={billForm.reminderDays} onChange={(event) => setBillForm((prev) => ({ ...prev, reminderDays: event.target.value }))} className={inputCls} />
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" checked={billForm.autopay} onChange={(event) => setBillForm((prev) => ({ ...prev, autopay: event.target.checked }))} className="rounded border-border text-primary focus:ring-primary" />
Autopay enabled externally
</label>
<button onClick={createBill} className="rounded-lg bg-primary px-4 py-2.5 text-sm font-bold text-primary-foreground hover:bg-primary/90">Add Bill</button>
</div>
{status && <p className="mt-4 rounded-lg border border-border bg-background/60 px-4 py-3 text-sm text-muted-foreground">{status}</p>}
</section>
</div>
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-sm font-bold text-foreground">Bill Schedule</p>
<select value={filter} onChange={(event) => setFilter(event.target.value)} className="rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-foreground">
<option value="all">All</option>
<option value="pending">Pending</option>
<option value="scheduled">Scheduled</option>
<option value="overdue">Overdue</option>
<option value="paid">Paid</option>
<option value="skipped">Skipped</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div className="mt-4 divide-y divide-border">
{filteredBills.length === 0 && <p className="py-8 text-sm text-muted-foreground">No bills for this view.</p>}
{filteredBills.map((bill) => (
<article key={bill.id} className="py-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-bold text-foreground">{bill.name}</p>
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">{bill.computedStatus}</span>
{bill.autopay && <span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">autopay</span>}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{money(bill.amount, bill.currency)} due {new Date(bill.dueDate).toLocaleDateString()} · {bill.recurrence}
</p>
{bill.payee && <p className="mt-1 text-xs text-muted-foreground">Payee: {bill.payee.name}</p>}
</div>
<div className="flex flex-wrap gap-2">
{bill.status !== "paid" && (
<button onClick={() => initiatePayment(bill)} className="rounded-lg bg-primary px-3 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90">Initiate Payment</button>
)}
{bill.status !== "paid" && (
<button onClick={() => markPaid(bill)} className="rounded-lg border border-primary/30 px-3 py-2 text-xs font-bold text-primary hover:bg-primary/10">Mark Paid</button>
)}
{bill.status !== "skipped" && bill.status !== "paid" && (
<button onClick={() => updateStatus(bill, "skipped")} className="rounded-lg border border-border px-3 py-2 text-xs font-medium text-foreground hover:bg-secondary/40">Skip</button>
)}
{bill.status !== "cancelled" && bill.status !== "paid" && (
<button onClick={() => updateStatus(bill, "cancelled")} className="rounded-lg border border-border px-3 py-2 text-xs font-medium text-foreground hover:bg-secondary/40">Cancel</button>
)}
</div>
</div>
</article>
))}
</div>
</section>
</div>
</AppShell>
);
}

View File

@ -42,7 +42,7 @@ export default function BlogPage() {
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne Blog",
name: "Aarthalabs Blog",
description: "Insights on financial control, audit readiness, and ledger automation.",
url: `${siteInfo.url}/blog`
},

View File

@ -15,7 +15,7 @@ const inputClass =
export const metadata = {
title: "Book a Demo",
description:
"Book a LedgerOne demo to see audit-ready ledgering and transparent rules in action.",
"Book a Aarthalabs demo to see audit-ready ledgering and transparent rules in action.",
keywords: siteInfo.keywords
};
@ -24,9 +24,9 @@ export default function BookDemoPage() {
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "Book a LedgerOne Demo",
name: "Book a Aarthalabs Demo",
description:
"Book a LedgerOne demo to see audit-ready ledgering and transparent rules in action.",
"Book a Aarthalabs demo to see audit-ready ledgering and transparent rules in action.",
url: `${siteInfo.url}/book-demo`
},
{
@ -52,7 +52,7 @@ export default function BookDemoPage() {
Book a demo
</div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl leading-tight">
Schedule time with the LedgerOne team.
Schedule time with the Aarthalabs team.
</h1>
<p className="text-lg text-muted-foreground">
We will walk you through account connections, rule automation, and

View File

@ -6,8 +6,8 @@ import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site";
export const metadata = {
title: "LedgerOne vs Copilot",
description: "Compare LedgerOne's cross-platform business solution with Copilot's personal finance app.",
title: "Aarthalabs vs Copilot",
description: "Compare Aarthalabs's cross-platform business solution with Copilot's personal finance app.",
keywords: siteInfo.keywords
};
@ -16,8 +16,8 @@ export default function CompareCopilotPage() {
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne vs Copilot",
description: "Comparison of LedgerOne and Copilot.",
name: "Aarthalabs vs Copilot",
description: "Comparison of Aarthalabs and Copilot.",
url: `${siteInfo.url}/compare/vs-copilot`
}
];
@ -36,14 +36,14 @@ export default function CompareCopilotPage() {
Financial control for everyone.
</h1>
<p className="text-lg text-muted-foreground">
Copilot is great for iPhone users. LedgerOne is for serious business owners who need access everywhere, on any device.
Copilot is great for iPhone users. Aarthalabs is for serious business owners who need access everywhere, on any device.
</p>
</div>
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
<div className="col-span-1">Feature</div>
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
<div className="col-span-1 text-center text-foreground">Aarthalabs</div>
<div className="col-span-1 text-center">Copilot</div>
</div>

View File

@ -0,0 +1,91 @@
import Link from "next/link";
import { Background } from "../../../components/background";
import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header";
import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site";
export const metadata = {
title: "Aarthalabs vs Monarch Money",
description:
"Compare Aarthalabs's export-first ledger workflow with Monarch Money's household finance platform.",
keywords: siteInfo.keywords
};
export default function CompareMonarchPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "Aarthalabs vs Monarch Money",
description: "Comparison of Aarthalabs and Monarch Money.",
url: `${siteInfo.url}/compare/vs-monarch`
}
];
const rows = [
{ feature: "Primary Focus", l1: "Export-ready ledgers and audit workflow", sheet: "Household budgeting and net worth" },
{ feature: "Exports", l1: "CSV, JSON, XLSX, PDF, and Google Sheets", sheet: "Consumer finance exports" },
{ feature: "Rules", l1: "Transparent rules with regex and DSL mode", sheet: "Consumer categorization rules" },
{ feature: "Data Ownership", l1: "User-owned Google Sheets mirror plus secured exports", sheet: "App-managed financial workspace" },
{ feature: "Developer Access", l1: "Public API key flow for power users", sheet: "No public API-first workflow" },
{ feature: "Tax Workflow", l1: "Tax intake, package export, and sandbox e-file flow", sheet: "Personal finance reporting" },
];
return (
<div className="page-soft-bg min-h-screen font-sans text-foreground flex flex-col relative overflow-hidden">
<Background />
<SiteHeader />
<main className="relative z-10 flex-1 pt-24 pb-16">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="text-center max-w-3xl mx-auto mb-10">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary/50 border border-border text-xs font-medium text-muted-foreground mb-6">
Comparison
</div>
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl mb-6">
Built for ledger control, not just household tracking.
</h1>
<p className="text-lg text-muted-foreground">
Monarch Money is built around household financial visibility. Aarthalabs is built for users who need structured transactions, export control, rules, audit trails, and tax-ready handoff.
</p>
</div>
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
<div className="col-span-1">Feature</div>
<div className="col-span-1 text-center text-foreground">Aarthalabs</div>
<div className="col-span-1 text-center">Monarch Money</div>
</div>
{rows.map((row, index) => (
<div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${index % 2 === 0 ? "bg-background/50" : "bg-secondary/10"}`}>
<div className="col-span-1 font-medium text-foreground">{row.feature}</div>
<div className="col-span-1 text-center font-bold text-primary flex justify-center items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
{row.l1}
</div>
<div className="col-span-1 text-center text-muted-foreground">
{row.sheet}
</div>
</div>
))}
</div>
<div className="mt-16 text-center">
<h2 className="text-2xl font-bold text-foreground mb-6">Need export-first financial control?</h2>
<Link href="/register" className="btn-primary">
Start your free trial
</Link>
</div>
</div>
</main>
<div className="relative z-10">
<SiteFooter />
</div>
<PageSchema schema={schema} />
</div>
);
}

View File

@ -6,8 +6,8 @@ import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site";
export const metadata = {
title: "LedgerOne vs Quicken",
description: "Move from legacy desktop software to LedgerOne's modern, cloud-native financial platform.",
title: "Aarthalabs vs Quicken",
description: "Move from legacy desktop software to Aarthalabs's modern, cloud-native financial platform.",
keywords: siteInfo.keywords
};
@ -16,8 +16,8 @@ export default function CompareQuickenPage() {
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne vs Quicken",
description: "Comparison of LedgerOne and Quicken.",
name: "Aarthalabs vs Quicken",
description: "Comparison of Aarthalabs and Quicken.",
url: `${siteInfo.url}/compare/vs-quicken`
}
];
@ -36,14 +36,14 @@ export default function CompareQuickenPage() {
The modern alternative to Quicken.
</h1>
<p className="text-lg text-muted-foreground">
Stop syncing desktop files. LedgerOne gives you the power of Quicken with the speed, security, and accessibility of the modern web.
Stop syncing desktop files. Aarthalabs gives you the power of Quicken with the speed, security, and accessibility of the modern web.
</p>
</div>
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
<div className="col-span-1">Feature</div>
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
<div className="col-span-1 text-center text-foreground">Aarthalabs</div>
<div className="col-span-1 text-center">Quicken</div>
</div>

View File

@ -6,8 +6,8 @@ import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site";
export const metadata = {
title: "LedgerOne vs Spreadsheets",
description: "See why modern businesses are switching from manual spreadsheets to LedgerOne's automated financial platform.",
title: "Aarthalabs vs Spreadsheets",
description: "See why modern businesses are switching from manual spreadsheets to Aarthalabs's automated financial platform.",
keywords: siteInfo.keywords
};
@ -16,8 +16,8 @@ export default function CompareSpreadsheetsPage() {
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne vs Spreadsheets",
description: "Comparison of LedgerOne automated platform versus manual spreadsheets.",
name: "Aarthalabs vs Spreadsheets",
description: "Comparison of Aarthalabs automated platform versus manual spreadsheets.",
url: `${siteInfo.url}/compare/vs-spreadsheets`
}
];
@ -36,14 +36,14 @@ export default function CompareSpreadsheetsPage() {
Stop breaking your spreadsheets.
</h1>
<p className="text-lg text-muted-foreground">
Spreadsheets are great for scratchpads, but terrible for financial systems. See why LedgerOne is the upgrade your business needs.
Spreadsheets are great for scratchpads, but terrible for financial systems. See why Aarthalabs is the upgrade your business needs.
</p>
</div>
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
<div className="col-span-1">Feature</div>
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
<div className="col-span-1 text-center text-foreground">Aarthalabs</div>
<div className="col-span-1 text-center">Spreadsheets</div>
</div>

View File

@ -6,8 +6,8 @@ import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site";
export const metadata = {
title: "LedgerOne vs YNAB",
description: "Compare LedgerOne's audit-ready financial platform with YNAB's zero-based budgeting tool.",
title: "Aarthalabs vs YNAB",
description: "Compare Aarthalabs's audit-ready financial platform with YNAB's zero-based budgeting tool.",
keywords: siteInfo.keywords
};
@ -16,8 +16,8 @@ export default function CompareYnabPage() {
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne vs YNAB",
description: "Comparison of LedgerOne and YNAB.",
name: "Aarthalabs vs YNAB",
description: "Comparison of Aarthalabs and YNAB.",
url: `${siteInfo.url}/compare/vs-ynab`
}
];
@ -36,14 +36,14 @@ export default function CompareYnabPage() {
Beyond Budgeting.
</h1>
<p className="text-lg text-muted-foreground">
YNAB is great for personal envelopes. LedgerOne is built for business growth, audit trails, and total financial control.
YNAB is great for personal envelopes. Aarthalabs is built for business growth, audit trails, and total financial control.
</p>
</div>
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
<div className="grid grid-cols-3 bg-secondary/30 border-b border-border p-6 text-sm font-bold text-muted-foreground uppercase tracking-wider">
<div className="col-span-1">Feature</div>
<div className="col-span-1 text-center text-foreground">LedgerOne</div>
<div className="col-span-1 text-center text-foreground">Aarthalabs</div>
<div className="col-span-1 text-center">YNAB</div>
</div>

View File

@ -9,7 +9,7 @@ import { siteInfo } from "../../data/site";
export const metadata = {
title: "Contact Us",
description: "Get in touch with the LedgerOne team for support, sales, or partnerships.",
description: "Get in touch with the Aarthalabs team for support, sales, or partnerships.",
keywords: siteInfo.keywords
};
@ -18,8 +18,8 @@ export default function ContactPage() {
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "Contact LedgerOne",
description: "Get in touch with the LedgerOne team for support, sales, or partnerships.",
name: "Contact Aarthalabs",
description: "Get in touch with the Aarthalabs team for support, sales, or partnerships.",
url: `${siteInfo.url}/contact`
},
{
@ -116,7 +116,7 @@ export default function ContactPage() {
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
support@ledgerone.com
support@aarthalabs.com
</p>
<p className="flex items-center gap-3">
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">

266
app/credit-score/page.tsx Normal file
View File

@ -0,0 +1,266 @@
"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<string, unknown>;
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<CreditScoreSummary | null>(null);
const [entries, setEntries] = useState<CreditScoreEntry[]>([]);
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<CreditScoreSummary>("/api/credit-score/summary"),
apiFetch<CreditScoreEntry[]>(`/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<CreditScoreEntry>("/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 pullScore = async () => {
const bureau = ["experian", "equifax", "transunion"].includes(form.bureau) ? form.bureau : "experian";
const res = await apiFetch<CreditScoreEntry>("/api/credit-score/pull", {
method: "POST",
body: JSON.stringify({
bureau,
consent: {
acceptedAt: new Date().toISOString(),
purpose: "credit_score_monitoring",
},
}),
});
if (res.error) {
setStatus(res.error.message ?? "Unable to pull credit score.");
return;
}
setStatus(`${bureau} score pulled: ${res.data.score}.`);
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 (
<AppShell title="Credit Score" subtitle="Track bureau scores, history, factors, and score movement alerts.">
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Latest score</p>
<p className="mt-2 text-3xl font-bold text-foreground">{summary?.latest?.score ?? "--"}</p>
<p className="mt-1 text-xs text-muted-foreground">{scoreBand(summary?.latest?.score)}</p>
</div>
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Last change</p>
<p className="mt-2 text-3xl font-bold text-foreground">
{summary?.change === null || summary?.change === undefined ? "--" : `${summary.change > 0 ? "+" : ""}${summary.change}`}
</p>
<p className="mt-1 text-xs text-muted-foreground">Compared with previous same-bureau entry</p>
</div>
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Average score</p>
<p className="mt-2 text-3xl font-bold text-foreground">{summary?.averageScore ?? "--"}</p>
<p className="mt-1 text-xs text-muted-foreground">Across saved entries</p>
</div>
<div className="rounded-xl border border-border bg-secondary/10 p-5">
<p className="text-xs text-muted-foreground">Entries</p>
<p className="mt-2 text-3xl font-bold text-foreground">{summary?.entryCount ?? 0}</p>
<p className="mt-1 text-xs text-muted-foreground">Manual or imported history</p>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<p className="text-sm font-bold text-foreground">Add Score Entry</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<label className={labelCls}>Score</label>
<input type="number" min={300} max={850} value={form.score} onChange={(event) => setForm((prev) => ({ ...prev, score: event.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Bureau</label>
<select value={form.bureau} onChange={(event) => setForm((prev) => ({ ...prev, bureau: event.target.value }))} className={inputCls}>
{bureaus.map((bureau) => <option key={bureau.value} value={bureau.value}>{bureau.label}</option>)}
</select>
</div>
<div>
<label className={labelCls}>Model</label>
<select value={form.model} onChange={(event) => setForm((prev) => ({ ...prev, model: event.target.value }))} className={inputCls}>
<option value="fico_8">FICO 8</option>
<option value="fico_9">FICO 9</option>
<option value="vantage_score_3">VantageScore 3</option>
<option value="vantage_score_4">VantageScore 4</option>
</select>
</div>
<div>
<label className={labelCls}>Score date</label>
<input type="date" value={form.scoreDate} onChange={(event) => setForm((prev) => ({ ...prev, scoreDate: event.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Positive factors</label>
<input value={form.positiveFactors} onChange={(event) => setForm((prev) => ({ ...prev, positiveFactors: event.target.value }))} className={inputCls} placeholder="low utilization, on-time payments" />
</div>
<div>
<label className={labelCls}>Negative factors</label>
<input value={form.negativeFactors} onChange={(event) => setForm((prev) => ({ ...prev, negativeFactors: event.target.value }))} className={inputCls} placeholder="hard inquiry, high balance" />
</div>
</div>
<div className="mt-4 flex flex-wrap gap-3">
<button onClick={addEntry} className="rounded-lg bg-primary px-4 py-2.5 text-sm font-bold text-primary-foreground hover:bg-primary/90">
Add Score
</button>
<button onClick={pullScore} className="rounded-lg border border-primary/30 px-4 py-2.5 text-sm font-bold text-primary hover:bg-primary/10">
Pull From Provider
</button>
</div>
{status && <p className="mt-4 rounded-lg border border-border bg-background/60 px-4 py-3 text-sm text-muted-foreground">{status}</p>}
</section>
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-sm font-bold text-foreground">Bureau Snapshot</p>
<select value={filter} onChange={(event) => setFilter(event.target.value)} className="rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-foreground">
<option value="all">All bureaus</option>
{bureaus.map((bureau) => <option key={bureau.value} value={bureau.value}>{bureau.label}</option>)}
</select>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
{(summary?.latestByBureau ?? []).map((entry) => (
<div key={entry.bureau} className="rounded-lg border border-border bg-background/50 p-4">
<p className="text-xs uppercase text-muted-foreground">{entry.bureau}</p>
<p className="mt-2 text-2xl font-bold text-foreground">{entry.score}</p>
<p className="text-xs text-muted-foreground">{new Date(entry.scoreDate).toLocaleDateString()}</p>
</div>
))}
{(summary?.latestByBureau?.length ?? 0) === 0 && <p className="text-sm text-muted-foreground">No bureau scores yet.</p>}
</div>
</section>
</div>
<section className="rounded-xl border border-border bg-secondary/10 p-6">
<p className="text-sm font-bold text-foreground">Score History</p>
<div className="mt-4 divide-y divide-border">
{filteredEntries.length === 0 && <p className="py-8 text-sm text-muted-foreground">No score entries for this view.</p>}
{filteredEntries.map((entry) => {
const factors = entry.factors ?? {};
const positive = Array.isArray(factors.positive) ? factors.positive : [];
const negative = Array.isArray(factors.negative) ? factors.negative : [];
return (
<article key={entry.id} className="py-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<p className="text-lg font-bold text-foreground">{entry.score}</p>
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">{entry.bureau}</span>
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">{entry.model}</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">{new Date(entry.scoreDate).toLocaleDateString()} · {entry.source}</p>
{(positive.length > 0 || negative.length > 0) && (
<p className="mt-2 text-xs text-muted-foreground">
{positive.length > 0 ? `Positive: ${positive.join(", ")}` : ""}
{positive.length > 0 && negative.length > 0 ? " · " : ""}
{negative.length > 0 ? `Negative: ${negative.join(", ")}` : ""}
</p>
)}
</div>
<p className="text-sm font-semibold text-foreground">{scoreBand(entry.score)}</p>
</div>
</article>
);
})}
</div>
</section>
</div>
</AppShell>
);
}

View File

@ -56,7 +56,7 @@ export default function DemoPage() {
<div className="mx-auto max-w-6xl px-6 lg:px-8 space-y-8">
<header className="space-y-3">
<p className="text-xs font-semibold tracking-[0.25em] text-emerald-400 uppercase">
Demo · LedgerOne
Demo · Aarthalabs
</p>
<h1 className="text-3xl sm:text-4xl font-semibold tracking-tight text-slate-50">
AI-powered cash control dashboard
@ -246,7 +246,7 @@ export default function DemoPage() {
AI
</div>
<div className="text-xs text-slate-300">
<p className="font-medium">LedgerOne Copilot</p>
<p className="font-medium">Aarthalabs Copilot</p>
<p className="text-[11px] text-slate-500">Monitors cash flow in real-time</p>
</div>
</div>

178
app/developer/page.tsx Normal file
View File

@ -0,0 +1,178 @@
"use client";
import { useEffect, useState } from "react";
import { AppShell } from "@/components/app-shell";
import { apiFetch } from "@/lib/api";
type ApiKey = {
id: string;
name: string;
prefix: string;
scopes: string[];
lastUsedAt: string | null;
revokedAt: string | null;
expiresAt: string | null;
createdAt: string;
};
type KeyListResponse = { keys: ApiKey[] };
type CreatedKey = ApiKey & { key: string };
const apiExample = `curl -H "Authorization: Bearer l1_your_api_key" \\
"https://api.aarthalabs.com/api/public/v1/transactions?limit=25"`;
const sdkExample = `async function ledgerOne(path, apiKey) {
const res = await fetch(\`https://api.aarthalabs.com/api/public/v1\${path}\`, {
headers: { Authorization: \`Bearer \${apiKey}\` },
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
const transactions = await ledgerOne("/transactions?limit=25", process.env.LEDGERONE_API_KEY);`;
export default function DeveloperPage() {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [name, setName] = useState("Automation key");
const [createdKey, setCreatedKey] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null);
const loadKeys = async () => {
setLoading(true);
const res = await apiFetch<KeyListResponse>("/api/api-keys");
if (res.error) {
setMessage(res.error.message);
} else {
setKeys(res.data.keys);
setMessage(null);
}
setLoading(false);
};
useEffect(() => {
void loadKeys();
}, []);
const createKey = async () => {
const res = await apiFetch<CreatedKey>("/api/api-keys", {
method: "POST",
body: JSON.stringify({ name, scopes: ["transactions:read"] }),
});
if (res.error) {
setMessage(res.error.message);
return;
}
setCreatedKey(res.data.key);
setName("Automation key");
await loadKeys();
};
const revokeKey = async (id: string) => {
const res = await apiFetch<{ revoked: boolean }>(`/api/api-keys/${id}`, { method: "DELETE" });
if (res.error) {
setMessage(res.error.message);
return;
}
await loadKeys();
};
return (
<AppShell title="Developer" subtitle="API keys, public endpoint docs, and integration examples.">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
{message ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">{message}</div>
) : null}
{createdKey ? (
<section className="rounded-lg border border-emerald-200 bg-emerald-50 p-4">
<h2 className="text-sm font-semibold text-emerald-950">New API key</h2>
<p className="mt-1 text-xs text-emerald-800">This secret is shown once. Store it in your password manager or server environment.</p>
<pre className="mt-3 overflow-x-auto rounded-md bg-white p-3 text-xs text-slate-900">{createdKey}</pre>
</section>
) : null}
<section className="grid gap-4 lg:grid-cols-[1fr_1.2fr]">
<div className="rounded-lg border border-slate-200 bg-white p-4">
<h2 className="text-base font-semibold text-slate-950">Create Key</h2>
<label className="mt-4 block text-sm font-medium text-slate-700">Key name</label>
<input
value={name}
onChange={(event) => setName(event.target.value)}
className="mt-2 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
<button
onClick={createKey}
className="mt-4 rounded-md bg-slate-950 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800"
>
Create API key
</button>
</div>
<div className="rounded-lg border border-slate-200 bg-white p-4">
<h2 className="text-base font-semibold text-slate-950">Read Endpoints</h2>
<div className="mt-3 grid gap-2 text-sm text-slate-700 sm:grid-cols-2">
<span>/public/v1/transactions</span>
<span>/public/v1/transactions/summary</span>
<span>/public/v1/transactions/cashflow</span>
<span>/public/v1/transactions/merchants</span>
</div>
<pre className="mt-4 overflow-x-auto rounded-md bg-slate-950 p-3 text-xs text-slate-50">{apiExample}</pre>
</div>
</section>
<section className="rounded-lg border border-slate-200 bg-white p-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-slate-950">API Keys</h2>
<button onClick={loadKeys} className="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-700">
Refresh
</button>
</div>
<div className="mt-4 overflow-x-auto">
<table className="w-full min-w-[680px] text-left text-sm">
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500">
<tr>
<th className="py-2">Name</th>
<th className="py-2">Prefix</th>
<th className="py-2">Scopes</th>
<th className="py-2">Last used</th>
<th className="py-2">Status</th>
<th className="py-2 text-right">Action</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td className="py-4 text-slate-500" colSpan={6}>Loading keys...</td></tr>
) : keys.length ? (
keys.map((key) => (
<tr key={key.id} className="border-b border-slate-100">
<td className="py-3 font-medium text-slate-900">{key.name}</td>
<td className="py-3 text-slate-600">{key.prefix}</td>
<td className="py-3 text-slate-600">{key.scopes.join(", ")}</td>
<td className="py-3 text-slate-600">{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString() : "Never"}</td>
<td className="py-3 text-slate-600">{key.revokedAt ? "Revoked" : "Active"}</td>
<td className="py-3 text-right">
{!key.revokedAt ? (
<button onClick={() => revokeKey(key.id)} className="rounded-md border border-red-200 px-3 py-1.5 text-xs font-medium text-red-700">
Revoke
</button>
) : null}
</td>
</tr>
))
) : (
<tr><td className="py-4 text-slate-500" colSpan={6}>No API keys yet.</td></tr>
)}
</tbody>
</table>
</div>
</section>
<section className="rounded-lg border border-slate-200 bg-white p-4">
<h2 className="text-base font-semibold text-slate-950">Minimal JavaScript Client</h2>
<pre className="mt-3 overflow-x-auto rounded-md bg-slate-950 p-3 text-xs text-slate-50">{sdkExample}</pre>
</section>
</div>
</AppShell>
);
}

Some files were not shown because too many files have changed in this diff Show More