Compare commits
23 Commits
f59009af57
...
20f32ba0de
| Author | SHA1 | Date | |
|---|---|---|---|
| 20f32ba0de | |||
| 22e162ae26 | |||
| fbd1c2cc4a | |||
| 10d386bc7d | |||
| 28dee336c9 | |||
| bfda37d4e3 | |||
| c7ee638372 | |||
| eddd46c049 | |||
| 3ed58738b1 | |||
| c1ff8a44b8 | |||
| f154fd5656 | |||
| 1dbca1bcd4 | |||
| 5ed97c2a90 | |||
| dada5e7d4f | |||
| 69a88163dc | |||
| 1649fb8e77 | |||
| b3b3fa3392 | |||
| edbcbea5b4 | |||
| f0e6405b11 | |||
| 1d7007637d | |||
| ab273ea7d0 | |||
| 59ea77ed44 | |||
| 2b17c36ed2 |
@ -11,9 +11,9 @@ import { defaultFaqs } from "../../data/faq";
|
|||||||
import { siteInfo } from "../../data/site";
|
import { siteInfo } from "../../data/site";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "About LedgerOne",
|
title: "About Aarthalabs",
|
||||||
description:
|
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
|
keywords: siteInfo.keywords
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -52,9 +52,9 @@ export default function AboutPage() {
|
|||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
name: "About LedgerOne",
|
name: "About Aarthalabs",
|
||||||
description:
|
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`
|
url: `${siteInfo.url}/about`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -80,12 +80,12 @@ export default function AboutPage() {
|
|||||||
Our Story
|
Our Story
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl leading-tight">
|
<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>
|
<span className="heading-hero-accent">ready for audits, review, and action.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<div className="space-y-4 text-lg text-muted-foreground">
|
<div className="space-y-4 text-lg text-muted-foreground">
|
||||||
<p>
|
<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
|
still need each decision documented. Our ledger-first workflow keeps the
|
||||||
raw truth intact while allowing intelligent categorization and rule-driven
|
raw truth intact while allowing intelligent categorization and rule-driven
|
||||||
automation.
|
automation.
|
||||||
@ -119,7 +119,7 @@ export default function AboutPage() {
|
|||||||
Built for US operators
|
Built for US operators
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<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.
|
tax reporting cycles.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -167,7 +167,7 @@ export default function AboutPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-bold text-foreground">A ledger that holds the full story.</h2>
|
<h2 className="text-3xl font-bold text-foreground">A ledger that holds the full story.</h2>
|
||||||
<p className="text-muted-foreground text-lg">
|
<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.
|
entry intact and layers in decisions, reviews, and approvals.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
160
app/accountant/page.tsx
Normal file
160
app/accountant/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
|
|||||||
import { proxyRequest } from "@/lib/backend";
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest) {
|
export async function DELETE(req: NextRequest) {
|
||||||
return proxyRequest(req, "2fa/disable");
|
return proxyRequest(req, "auth/2fa/disable");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
|
|||||||
import { proxyRequest } from "@/lib/backend";
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
return proxyRequest(req, "2fa/enable");
|
return proxyRequest(req, "auth/2fa/enable");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
|
|||||||
import { proxyRequest } from "@/lib/backend";
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
return proxyRequest(req, "2fa/generate");
|
return proxyRequest(req, "auth/2fa/generate");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { proxyRequest } from "@/lib/backend";
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
return proxyRequest(req, "accounts/link-token");
|
return proxyRequest(req, "accounts/link");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
|
|||||||
import { proxyRequest } from "@/lib/backend";
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
return proxyRequest(req, "accounts");
|
return proxyRequest(req, "view/accounts");
|
||||||
}
|
}
|
||||||
|
|||||||
6
app/api/api-keys/[id]/route.ts
Normal file
6
app/api/api-keys/[id]/route.ts
Normal 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
10
app/api/api-keys/route.ts
Normal 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");
|
||||||
|
}
|
||||||
@ -1,6 +1,15 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { proxyRequest } from "@/lib/backend";
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
import { setAuthCookies } from "@/lib/auth-cookies";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,36 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { proxyRequest } from "@/lib/backend";
|
import { getBackendUrl } from "@/lib/backend";
|
||||||
|
import { ACCESS_COOKIE, NONCE_COOKIE, REFRESH_COOKIE, clearAuthCookies } from "@/lib/auth-cookies";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
6
app/api/auth/me/data-export/route.ts
Normal file
6
app/api/auth/me/data-export/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/auth/me/privacy-summary/route.ts
Normal file
6
app/api/auth/me/privacy-summary/route.ts
Normal 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");
|
||||||
|
}
|
||||||
@ -4,3 +4,7 @@ import { proxyRequest } from "@/lib/backend";
|
|||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
return proxyRequest(req, "auth/me");
|
return proxyRequest(req, "auth/me");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
return proxyRequest(req, "auth/me");
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,46 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { proxyRequest } from "@/lib/backend";
|
import { getBackendUrl } from "@/lib/backend";
|
||||||
|
import { NONCE_COOKIE, REFRESH_COOKIE, clearAuthCookies, setAuthCookies } from "@/lib/auth-cookies";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { proxyRequest } from "@/lib/backend";
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
import { setAuthCookies } from "@/lib/auth-cookies";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
18
app/api/auth/social/[provider]/callback/route.ts
Normal file
18
app/api/auth/social/[provider]/callback/route.ts
Normal 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;
|
||||||
|
}
|
||||||
9
app/api/auth/social/[provider]/url/route.ts
Normal file
9
app/api/auth/social/[provider]/url/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
6
app/api/bill-pay/bills/[id]/initiate-payment/route.ts
Normal file
6
app/api/bill-pay/bills/[id]/initiate-payment/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
6
app/api/bill-pay/bills/[id]/pay/route.ts
Normal file
6
app/api/bill-pay/bills/[id]/pay/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
6
app/api/bill-pay/bills/[id]/route.ts
Normal file
6
app/api/bill-pay/bills/[id]/route.ts
Normal 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}`);
|
||||||
|
}
|
||||||
10
app/api/bill-pay/bills/route.ts
Normal file
10
app/api/bill-pay/bills/route.ts
Normal 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");
|
||||||
|
}
|
||||||
10
app/api/bill-pay/payees/route.ts
Normal file
10
app/api/bill-pay/payees/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/bill-pay/summary/route.ts
Normal file
6
app/api/bill-pay/summary/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/billing/checkout/route.ts
Normal file
6
app/api/billing/checkout/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/billing/portal/route.ts
Normal file
6
app/api/billing/portal/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/billing/subscription/route.ts
Normal file
6
app/api/billing/subscription/route.ts
Normal 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");
|
||||||
|
}
|
||||||
10
app/api/credit-score/entries/route.ts
Normal file
10
app/api/credit-score/entries/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/credit-score/pull/route.ts
Normal file
6
app/api/credit-score/pull/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/credit-score/summary/route.ts
Normal file
6
app/api/credit-score/summary/route.ts
Normal 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");
|
||||||
|
}
|
||||||
36
app/api/exports/download/[token]/route.ts
Normal file
36
app/api/exports/download/[token]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/api/exports/json/route.ts
Normal file
6
app/api/exports/json/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/exports/pdf/route.ts
Normal file
6
app/api/exports/pdf/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/exports/xlsx/route.ts
Normal file
6
app/api/exports/xlsx/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/google/data-system-mode/route.ts
Normal file
6
app/api/google/data-system-mode/route.ts
Normal 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");
|
||||||
|
}
|
||||||
@ -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}`);
|
||||||
|
}
|
||||||
10
app/api/households/[id]/accountant-tasks/route.ts
Normal file
10
app/api/households/[id]/accountant-tasks/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
10
app/api/households/[id]/dashboard/route.ts
Normal file
10
app/api/households/[id]/dashboard/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
9
app/api/households/[id]/debt-payoff/route.ts
Normal file
9
app/api/households/[id]/debt-payoff/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
9
app/api/households/[id]/fair-split/route.ts
Normal file
9
app/api/households/[id]/fair-split/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
9
app/api/households/[id]/future-scenarios/route.ts
Normal file
9
app/api/households/[id]/future-scenarios/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
10
app/api/households/[id]/goals/[goalId]/route.ts
Normal file
10
app/api/households/[id]/goals/[goalId]/route.ts
Normal 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}`);
|
||||||
|
}
|
||||||
14
app/api/households/[id]/goals/route.ts
Normal file
14
app/api/households/[id]/goals/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
14
app/api/households/[id]/invites/route.ts
Normal file
14
app/api/households/[id]/invites/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
10
app/api/households/[id]/members/[memberId]/route.ts
Normal file
10
app/api/households/[id]/members/[memberId]/route.ts
Normal 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}`);
|
||||||
|
}
|
||||||
10
app/api/households/[id]/members/route.ts
Normal file
10
app/api/households/[id]/members/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
9
app/api/households/[id]/money-date-prompts/route.ts
Normal file
9
app/api/households/[id]/money-date-prompts/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
9
app/api/households/[id]/privacy/route.ts
Normal file
9
app/api/households/[id]/privacy/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
10
app/api/households/route.ts
Normal file
10
app/api/households/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/notifications/[id]/read/route.ts
Normal file
6
app/api/notifications/[id]/read/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
10
app/api/notifications/preferences/route.ts
Normal file
10
app/api/notifications/preferences/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/notifications/push-subscriptions/route.ts
Normal file
6
app/api/notifications/push-subscriptions/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/notifications/read-all/route.ts
Normal file
6
app/api/notifications/read-all/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/notifications/route.ts
Normal file
6
app/api/notifications/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/notifications/test/route.ts
Normal file
6
app/api/notifications/test/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/notifications/vapid-public-key/route.ts
Normal file
6
app/api/notifications/vapid-public-key/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/plaid/repair-complete/route.ts
Normal file
6
app/api/plaid/repair-complete/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/plaid/update-link-token/route.ts
Normal file
6
app/api/plaid/update-link-token/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/planning/budgets/[id]/route.ts
Normal file
6
app/api/planning/budgets/[id]/route.ts
Normal 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}`);
|
||||||
|
}
|
||||||
10
app/api/planning/budgets/route.ts
Normal file
10
app/api/planning/budgets/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/planning/goals/[id]/route.ts
Normal file
6
app/api/planning/goals/[id]/route.ts
Normal 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}`);
|
||||||
|
}
|
||||||
10
app/api/planning/goals/route.ts
Normal file
10
app/api/planning/goals/route.ts
Normal 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");
|
||||||
|
}
|
||||||
10
app/api/planning/investments/route.ts
Normal file
10
app/api/planning/investments/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/planning/net-worth/route.ts
Normal file
6
app/api/planning/net-worth/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/planning/net-worth/snapshots/route.ts
Normal file
6
app/api/planning/net-worth/snapshots/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/planning/recurring/detect/route.ts
Normal file
6
app/api/planning/recurring/detect/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/planning/recurring/route.ts
Normal file
6
app/api/planning/recurring/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/rules/[id]/execute/route.ts
Normal file
6
app/api/rules/[id]/execute/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
6
app/api/security/risk/route.ts
Normal file
6
app/api/security/risk/route.ts
Normal 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");
|
||||||
|
}
|
||||||
@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
|
|||||||
import { proxyRequest } from "@/lib/backend";
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
return proxyRequest(req, "stripe/checkout");
|
return proxyRequest(req, "billing/checkout");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
|
|||||||
import { proxyRequest } from "@/lib/backend";
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
return proxyRequest(req, "stripe/portal");
|
return proxyRequest(req, "billing/portal");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
|
|||||||
import { proxyRequest } from "@/lib/backend";
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
return proxyRequest(req, "stripe/subscription");
|
return proxyRequest(req, "billing/subscription");
|
||||||
}
|
}
|
||||||
|
|||||||
9
app/api/tax/returns/[id]/documents/route.ts
Normal file
9
app/api/tax/returns/[id]/documents/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
16
app/api/tax/returns/[id]/efile/route.ts
Normal file
16
app/api/tax/returns/[id]/efile/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { proxyRequest } from "@/lib/backend";
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
export async function GET(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: { id: string } }
|
||||||
) {
|
) {
|
||||||
|
|||||||
16
app/api/tax/returns/[id]/intake/route.ts
Normal file
16
app/api/tax/returns/[id]/intake/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
6
app/api/teller/config/route.ts
Normal file
6
app/api/teller/config/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/teller/enrollment/route.ts
Normal file
6
app/api/teller/enrollment/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/teller/sync/route.ts
Normal file
6
app/api/teller/sync/route.ts
Normal 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");
|
||||||
|
}
|
||||||
16
app/api/transactions/[id]/comments/route.ts
Normal file
16
app/api/transactions/[id]/comments/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
6
app/api/transactions/import/batch/route.ts
Normal file
6
app/api/transactions/import/batch/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/transactions/import/preview/route.ts
Normal file
6
app/api/transactions/import/preview/route.ts
Normal 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");
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import { NextRequest } from "next/server";
|
|||||||
import { proxyRequest } from "@/lib/backend";
|
import { proxyRequest } from "@/lib/backend";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
return proxyRequest(req, "transactions");
|
return proxyRequest(req, "view/transactions");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
|||||||
6
app/api/view/accounts/route.ts
Normal file
6
app/api/view/accounts/route.ts
Normal 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");
|
||||||
|
}
|
||||||
6
app/api/view/transactions/route.ts
Normal file
6
app/api/view/transactions/route.ts
Normal 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");
|
||||||
|
}
|
||||||
@ -5,21 +5,72 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { usePlaidLink } from "react-plaid-link";
|
import { usePlaidLink } from "react-plaid-link";
|
||||||
|
|
||||||
type Account = {
|
type Account = {
|
||||||
id: string;
|
viewRef: string;
|
||||||
institutionName: string;
|
institutionName: string;
|
||||||
accountType: string;
|
accountType: string;
|
||||||
mask?: string | null;
|
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() {
|
export default function ConnectPage() {
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [linkToken, setLinkToken] = useState<string | null>(null);
|
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 [manualMode, setManualMode] = useState(false);
|
||||||
const [manualBank, setManualBank] = useState("");
|
const [manualBank, setManualBank] = useState("");
|
||||||
const [manualRouting, setManualRouting] = useState("");
|
const [manualRouting, setManualRouting] = useState("");
|
||||||
const [manualAccount, setManualAccount] = useState("");
|
const [manualAccount, setManualAccount] = useState("");
|
||||||
const [manualType, setManualType] = useState("checking");
|
const [manualType, setManualType] = useState("checking");
|
||||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||||
|
const [tellerReady, setTellerReady] = useState(false);
|
||||||
|
const [googleStatus, setGoogleStatus] = useState<GoogleStatus | null>(null);
|
||||||
|
|
||||||
const createLinkToken = useCallback(async () => {
|
const createLinkToken = useCallback(async () => {
|
||||||
setStatus("Requesting Plaid link token...");
|
setStatus("Requesting Plaid link token...");
|
||||||
@ -43,36 +94,94 @@ export default function ConnectPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadAccounts = useCallback(async () => {
|
const loadAccounts = useCallback(async () => {
|
||||||
const userId = localStorage.getItem("ledgerone_user_id");
|
const res = await fetch("/api/accounts");
|
||||||
if (!userId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const res = await fetch(`/api/accounts?user_id=${encodeURIComponent(userId)}`);
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = await res.json();
|
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(() => {
|
useEffect(() => {
|
||||||
createLinkToken();
|
createLinkToken();
|
||||||
loadAccounts();
|
loadAccounts();
|
||||||
}, [createLinkToken, loadAccounts]);
|
loadGoogleStatus();
|
||||||
|
loadTellerScript();
|
||||||
|
}, [createLinkToken, loadAccounts, loadGoogleStatus, loadTellerScript]);
|
||||||
|
|
||||||
const onSuccess = useCallback(
|
const onSuccess = useCallback(
|
||||||
async (publicToken: string) => {
|
async (publicToken: string | null) => {
|
||||||
const userId = localStorage.getItem("ledgerone_user_id");
|
if (linkMode === "update" && updateAccountRef) {
|
||||||
if (!userId) {
|
setStatus("Finishing bank reconnection...");
|
||||||
setStatus("Missing user id. Please sign in again.");
|
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;
|
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...");
|
setStatus("Exchanging public token...");
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/plaid/exchange", {
|
const res = await fetch("/api/plaid/exchange", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ publicToken, userId })
|
body: JSON.stringify({ publicToken })
|
||||||
});
|
});
|
||||||
const payload = await res.json();
|
const payload = await res.json();
|
||||||
if (!res.ok || payload.error) {
|
if (!res.ok || payload.error) {
|
||||||
@ -80,12 +189,14 @@ export default function ConnectPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus("Bank account connected.");
|
setStatus("Bank account connected.");
|
||||||
|
setLinkToken(null);
|
||||||
await loadAccounts();
|
await loadAccounts();
|
||||||
|
await createLinkToken();
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("Unable to exchange token.");
|
setStatus("Unable to exchange token.");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadAccounts]
|
[createLinkToken, linkMode, loadAccounts, updateAccountRef]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { open, ready } = usePlaidLink({
|
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) => {
|
const onManualSubmit = (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const userId = localStorage.getItem("ledgerone_user_id");
|
|
||||||
if (!userId) {
|
|
||||||
setStatus("Missing user id. Please sign in again.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload = {
|
const payload = {
|
||||||
userId,
|
|
||||||
institutionName: manualBank,
|
institutionName: manualBank,
|
||||||
accountType: manualType,
|
accountType: manualType,
|
||||||
mask: manualAccount.slice(-4)
|
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 (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
title="Connect a bank"
|
title="Connect a bank"
|
||||||
@ -151,6 +362,21 @@ export default function ConnectPage() {
|
|||||||
>
|
>
|
||||||
Connect with Plaid
|
Connect with Plaid
|
||||||
</button>
|
</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
|
<button
|
||||||
type="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"
|
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>
|
</p>
|
||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<div
|
<div
|
||||||
key={account.id}
|
key={account.viewRef}
|
||||||
className="flex items-center justify-between rounded-xl border border-border bg-secondary/30 px-4 py-3 text-sm"
|
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>
|
<div>
|
||||||
<p className="font-bold text-foreground">{account.institutionName}</p>
|
<p className="font-bold text-foreground">{account.institutionName}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{account.accountType} {account.mask ? `- ${account.mask}` : ""}
|
{account.accountType} {account.mask ? `- ${account.mask}` : ""}
|
||||||
</p>
|
</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>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span className="rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
<span className="rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
||||||
Connected
|
{account.tellerConnected ? "teller" : account.syncStatus ?? "connected"}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -243,9 +486,37 @@ export default function ConnectPage() {
|
|||||||
</form>
|
</form>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="mt-4 text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</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>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,11 +33,10 @@ type MerchantInsight = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type TxRow = {
|
type TxRow = {
|
||||||
id: string;
|
viewRef: string;
|
||||||
date: string;
|
date: string;
|
||||||
description: string;
|
description: string;
|
||||||
category?: string | null;
|
category?: string | null;
|
||||||
accountId?: string | null;
|
|
||||||
amount: string;
|
amount: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -507,14 +506,14 @@ export default function AppHomePage() {
|
|||||||
apiFetch<Summary>("/api/transactions/summary"),
|
apiFetch<Summary>("/api/transactions/summary"),
|
||||||
apiFetch<CashflowPoint[]>("/api/transactions/cashflow?months=6"),
|
apiFetch<CashflowPoint[]>("/api/transactions/cashflow?months=6"),
|
||||||
apiFetch<MerchantInsight[]>("/api/transactions/merchants?limit=5"),
|
apiFetch<MerchantInsight[]>("/api/transactions/merchants?limit=5"),
|
||||||
apiFetch<{ accounts: { id: string }[]; total: number }>("/api/accounts"),
|
apiFetch<{ accounts: { viewRef: string }[]; total: number }>("/api/view/accounts?limit=5"),
|
||||||
apiFetch<{ transactions: TxRow[]; total: number }>("/api/transactions?limit=5"),
|
apiFetch<{ transactions: TxRow[]; total: number }>("/api/view/transactions?limit=5"),
|
||||||
])
|
])
|
||||||
.then(([summaryRes, cashflowRes, merchantsRes, accountsRes, txRes]) => {
|
.then(([summaryRes, cashflowRes, merchantsRes, accountsRes, txRes]) => {
|
||||||
if (!summaryRes.error) setSummary(summaryRes.data);
|
if (!summaryRes.error) setSummary(summaryRes.data);
|
||||||
if (!cashflowRes.error) setCashflow(cashflowRes.data ?? []);
|
if (!cashflowRes.error) setCashflow(cashflowRes.data ?? []);
|
||||||
if (!merchantsRes.error) setMerchants(merchantsRes.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 ?? []);
|
if (!txRes.error) setRecentTxs(txRes.data?.transactions ?? []);
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
@ -737,7 +736,7 @@ export default function AppHomePage() {
|
|||||||
const fmtAmt = formatCurrency(amt);
|
const fmtAmt = formatCurrency(amt);
|
||||||
const isIncome = amt >= 0;
|
const isIncome = amt >= 0;
|
||||||
return (
|
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 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 text-foreground font-medium">{tx.description}</td>
|
||||||
<td className="py-3">
|
<td className="py-3">
|
||||||
|
|||||||
127
app/auth/social/callback/page.tsx
Normal file
127
app/auth/social/callback/page.tsx
Normal 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
324
app/bills/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -42,7 +42,7 @@ export default function BlogPage() {
|
|||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
name: "LedgerOne Blog",
|
name: "Aarthalabs Blog",
|
||||||
description: "Insights on financial control, audit readiness, and ledger automation.",
|
description: "Insights on financial control, audit readiness, and ledger automation.",
|
||||||
url: `${siteInfo.url}/blog`
|
url: `${siteInfo.url}/blog`
|
||||||
},
|
},
|
||||||
|
|||||||
@ -15,7 +15,7 @@ const inputClass =
|
|||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Book a Demo",
|
title: "Book a Demo",
|
||||||
description:
|
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
|
keywords: siteInfo.keywords
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -24,9 +24,9 @@ export default function BookDemoPage() {
|
|||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
name: "Book a LedgerOne Demo",
|
name: "Book a Aarthalabs Demo",
|
||||||
description:
|
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`
|
url: `${siteInfo.url}/book-demo`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -52,7 +52,7 @@ export default function BookDemoPage() {
|
|||||||
Book a demo
|
Book a demo
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl leading-tight">
|
<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>
|
</h1>
|
||||||
<p className="text-lg text-muted-foreground">
|
<p className="text-lg text-muted-foreground">
|
||||||
We will walk you through account connections, rule automation, and
|
We will walk you through account connections, rule automation, and
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import { PageSchema } from "../../../components/page-schema";
|
|||||||
import { siteInfo } from "../../../data/site";
|
import { siteInfo } from "../../../data/site";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "LedgerOne vs Copilot",
|
title: "Aarthalabs vs Copilot",
|
||||||
description: "Compare LedgerOne's cross-platform business solution with Copilot's personal finance app.",
|
description: "Compare Aarthalabs's cross-platform business solution with Copilot's personal finance app.",
|
||||||
keywords: siteInfo.keywords
|
keywords: siteInfo.keywords
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -16,8 +16,8 @@ export default function CompareCopilotPage() {
|
|||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
name: "LedgerOne vs Copilot",
|
name: "Aarthalabs vs Copilot",
|
||||||
description: "Comparison of LedgerOne and Copilot.",
|
description: "Comparison of Aarthalabs and Copilot.",
|
||||||
url: `${siteInfo.url}/compare/vs-copilot`
|
url: `${siteInfo.url}/compare/vs-copilot`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@ -36,14 +36,14 @@ export default function CompareCopilotPage() {
|
|||||||
Financial control for everyone.
|
Financial control for everyone.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
|
<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="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">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 className="col-span-1 text-center">Copilot</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
91
app/compare/vs-monarch/page.tsx
Normal file
91
app/compare/vs-monarch/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,8 +6,8 @@ import { PageSchema } from "../../../components/page-schema";
|
|||||||
import { siteInfo } from "../../../data/site";
|
import { siteInfo } from "../../../data/site";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "LedgerOne vs Quicken",
|
title: "Aarthalabs vs Quicken",
|
||||||
description: "Move from legacy desktop software to LedgerOne's modern, cloud-native financial platform.",
|
description: "Move from legacy desktop software to Aarthalabs's modern, cloud-native financial platform.",
|
||||||
keywords: siteInfo.keywords
|
keywords: siteInfo.keywords
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -16,8 +16,8 @@ export default function CompareQuickenPage() {
|
|||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
name: "LedgerOne vs Quicken",
|
name: "Aarthalabs vs Quicken",
|
||||||
description: "Comparison of LedgerOne and Quicken.",
|
description: "Comparison of Aarthalabs and Quicken.",
|
||||||
url: `${siteInfo.url}/compare/vs-quicken`
|
url: `${siteInfo.url}/compare/vs-quicken`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@ -36,14 +36,14 @@ export default function CompareQuickenPage() {
|
|||||||
The modern alternative to Quicken.
|
The modern alternative to Quicken.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
|
<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="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">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 className="col-span-1 text-center">Quicken</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import { PageSchema } from "../../../components/page-schema";
|
|||||||
import { siteInfo } from "../../../data/site";
|
import { siteInfo } from "../../../data/site";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "LedgerOne vs Spreadsheets",
|
title: "Aarthalabs vs Spreadsheets",
|
||||||
description: "See why modern businesses are switching from manual spreadsheets to LedgerOne's automated financial platform.",
|
description: "See why modern businesses are switching from manual spreadsheets to Aarthalabs's automated financial platform.",
|
||||||
keywords: siteInfo.keywords
|
keywords: siteInfo.keywords
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -16,8 +16,8 @@ export default function CompareSpreadsheetsPage() {
|
|||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
name: "LedgerOne vs Spreadsheets",
|
name: "Aarthalabs vs Spreadsheets",
|
||||||
description: "Comparison of LedgerOne automated platform versus manual spreadsheets.",
|
description: "Comparison of Aarthalabs automated platform versus manual spreadsheets.",
|
||||||
url: `${siteInfo.url}/compare/vs-spreadsheets`
|
url: `${siteInfo.url}/compare/vs-spreadsheets`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@ -36,14 +36,14 @@ export default function CompareSpreadsheetsPage() {
|
|||||||
Stop breaking your spreadsheets.
|
Stop breaking your spreadsheets.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
|
<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="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">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 className="col-span-1 text-center">Spreadsheets</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import { PageSchema } from "../../../components/page-schema";
|
|||||||
import { siteInfo } from "../../../data/site";
|
import { siteInfo } from "../../../data/site";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "LedgerOne vs YNAB",
|
title: "Aarthalabs vs YNAB",
|
||||||
description: "Compare LedgerOne's audit-ready financial platform with YNAB's zero-based budgeting tool.",
|
description: "Compare Aarthalabs's audit-ready financial platform with YNAB's zero-based budgeting tool.",
|
||||||
keywords: siteInfo.keywords
|
keywords: siteInfo.keywords
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -16,8 +16,8 @@ export default function CompareYnabPage() {
|
|||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
name: "LedgerOne vs YNAB",
|
name: "Aarthalabs vs YNAB",
|
||||||
description: "Comparison of LedgerOne and YNAB.",
|
description: "Comparison of Aarthalabs and YNAB.",
|
||||||
url: `${siteInfo.url}/compare/vs-ynab`
|
url: `${siteInfo.url}/compare/vs-ynab`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@ -36,14 +36,14 @@ export default function CompareYnabPage() {
|
|||||||
Beyond Budgeting.
|
Beyond Budgeting.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glass-panel rounded-3xl overflow-hidden shadow-sm border border-border">
|
<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="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">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 className="col-span-1 text-center">YNAB</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { siteInfo } from "../../data/site";
|
|||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Contact Us",
|
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
|
keywords: siteInfo.keywords
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -18,8 +18,8 @@ export default function ContactPage() {
|
|||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
name: "Contact LedgerOne",
|
name: "Contact Aarthalabs",
|
||||||
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.",
|
||||||
url: `${siteInfo.url}/contact`
|
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">
|
<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" />
|
<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>
|
</svg>
|
||||||
support@ledgerone.com
|
support@aarthalabs.com
|
||||||
</p>
|
</p>
|
||||||
<p className="flex items-center gap-3">
|
<p className="flex items-center gap-3">
|
||||||
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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
266
app/credit-score/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -56,7 +56,7 @@ export default function DemoPage() {
|
|||||||
<div className="mx-auto max-w-6xl px-6 lg:px-8 space-y-8">
|
<div className="mx-auto max-w-6xl px-6 lg:px-8 space-y-8">
|
||||||
<header className="space-y-3">
|
<header className="space-y-3">
|
||||||
<p className="text-xs font-semibold tracking-[0.25em] text-emerald-400 uppercase">
|
<p className="text-xs font-semibold tracking-[0.25em] text-emerald-400 uppercase">
|
||||||
Demo · LedgerOne
|
Demo · Aarthalabs
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-3xl sm:text-4xl font-semibold tracking-tight text-slate-50">
|
<h1 className="text-3xl sm:text-4xl font-semibold tracking-tight text-slate-50">
|
||||||
AI-powered cash control dashboard
|
AI-powered cash control dashboard
|
||||||
@ -246,7 +246,7 @@ export default function DemoPage() {
|
|||||||
AI
|
AI
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-300">
|
<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>
|
<p className="text-[11px] text-slate-500">Monitors cash flow in real-time</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
178
app/developer/page.tsx
Normal file
178
app/developer/page.tsx
Normal 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
Loading…
x
Reference in New Issue
Block a user