feat: implement LedgerOne frontend backlog features

This commit is contained in:
MOHAN 2026-07-16 15:07:02 +05:30
parent f59009af57
commit 2b17c36ed2
60 changed files with 2520 additions and 362 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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; [key: string]: unknown } } | null;
if (res.ok && payload?.data?.accessToken && payload.data.refreshToken) {
const { accessToken, refreshToken, ...safeData } = payload.data;
const safeRes = NextResponse.json({ ...payload, data: safeData }, { status: res.status });
setAuthCookies(safeRes, accessToken, refreshToken);
return safeRes;
}
return res;
} }

View File

@ -1,6 +1,32 @@
import { NextRequest } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { proxyRequest } from "@/lib/backend"; import { getBackendUrl } from "@/lib/backend";
import { 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 refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
const backendRes = refreshToken
? await fetch(getBackendUrl("auth/logout"), {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": req.headers.get("user-agent") ?? "",
"X-Forwarded-For": req.headers.get("x-forwarded-for") ?? "",
},
body: JSON.stringify({ refreshToken }),
})
: null;
const payload = backendRes
? await backendRes.text()
: JSON.stringify({
data: { message: "Logged out." },
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: null,
});
const res = new NextResponse(payload, {
status: backendRes?.status ?? 200,
headers: { "Content-Type": backendRes?.headers.get("content-type") ?? "application/json" },
});
clearAuthCookies(res);
return res;
} }

View File

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

View File

@ -1,6 +1,44 @@
import { NextRequest } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { proxyRequest } from "@/lib/backend"; import { getBackendUrl } from "@/lib/backend";
import { 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;
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") ?? "",
},
body: JSON.stringify({ refreshToken }),
});
const payload = await backendRes.text();
const parsed = JSON.parse(payload) as { data?: { accessToken?: string; refreshToken?: string; [key: string]: unknown } };
const responsePayload = backendRes.ok && parsed.data?.accessToken && parsed.data.refreshToken
? 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) {
setAuthCookies(res, parsed.data.accessToken, parsed.data.refreshToken);
} else {
clearAuthCookies(res);
}
return res;
} }

View File

@ -1,6 +1,15 @@
import { NextRequest } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { proxyRequest } from "@/lib/backend"; import { 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; [key: string]: unknown } } | null;
if (res.ok && payload?.data?.accessToken && payload.data.refreshToken) {
const { accessToken, refreshToken, ...safeData } = payload.data;
const safeRes = NextResponse.json({ ...payload, data: safeData }, { status: res.status });
setAuthCookies(safeRes, accessToken, refreshToken);
return safeRes;
}
return res;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,5 +2,5 @@ import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend"; 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");
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { NextRequest } from "next/server"; import { 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 } }
) { ) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,17 +9,50 @@ type Account = {
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 };
};
};
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 [updateAccountId, setUpdateAccountId] = 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 createLinkToken = useCallback(async () => { const createLinkToken = useCallback(async () => {
setStatus("Requesting Plaid link token..."); setStatus("Requesting Plaid link token...");
@ -43,36 +76,86 @@ 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 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]); loadTellerScript();
}, [createLinkToken, loadAccounts, loadTellerScript]);
const onSuccess = useCallback( const onSuccess = useCallback(
async (publicToken: string) => { async (publicToken: string | null) => {
const userId = localStorage.getItem("ledgerone_user_id"); if (linkMode === "update" && updateAccountId) {
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: updateAccountId })
});
const payload = await res.json();
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Unable to finish bank reconnection.");
return;
}
setStatus("Bank connection repaired.");
setUpdateAccountId(null);
setLinkMode("connect");
setLinkToken(null);
await loadAccounts();
await createLinkToken();
} catch {
setStatus("Unable to finish bank reconnection.");
}
return; 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 +163,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, updateAccountId]
); );
const { open, ready } = usePlaidLink({ const { open, ready } = usePlaidLink({
@ -96,15 +181,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 +218,89 @@ export default function ConnectPage() {
}); });
}; };
const startUpdateMode = async (accountId: 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 })
});
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;
}
setUpdateAccountId(accountId);
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();
};
return ( return (
<AppShell <AppShell
title="Connect a bank" title="Connect a bank"
@ -151,6 +320,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"
@ -168,17 +352,34 @@ export default function ConnectPage() {
{accounts.map((account) => ( {accounts.map((account) => (
<div <div
key={account.id} key={account.id}
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 className="flex items-center gap-2">
<span className="rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
{account.tellerConnected ? "teller" : account.syncStatus ?? "connected"}
</span>
{needsReconnect(account) ? (
<button
type="button"
className="rounded-full bg-primary px-4 py-2 text-xs font-bold text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
onClick={() => startUpdateMode(account.id)}
>
Reconnect
</button>
) : null}
</div> </div>
<span className="rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
Connected
</span>
</div> </div>
))} ))}
</div> </div>
@ -243,7 +444,7 @@ 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>
</AppShell> </AppShell>

View File

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

View File

@ -0,0 +1,91 @@
import Link from "next/link";
import { Background } from "../../../components/background";
import { SiteFooter } from "../../../components/site-footer";
import { SiteHeader } from "../../../components/site-header";
import { PageSchema } from "../../../components/page-schema";
import { siteInfo } from "../../../data/site";
export const metadata = {
title: "LedgerOne vs Monarch Money",
description:
"Compare LedgerOne'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: "LedgerOne vs Monarch Money",
description: "Comparison of LedgerOne 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. LedgerOne 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">LedgerOne</div>
<div className="col-span-1 text-center">Monarch Money</div>
</div>
{rows.map((row, index) => (
<div key={row.feature} className={`grid grid-cols-3 p-6 items-center border-b border-border last:border-0 ${index % 2 === 0 ? "bg-background/50" : "bg-secondary/10"}`}>
<div className="col-span-1 font-medium text-foreground">{row.feature}</div>
<div className="col-span-1 text-center font-bold text-primary flex justify-center items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
{row.l1}
</div>
<div className="col-span-1 text-center text-muted-foreground">
{row.sheet}
</div>
</div>
))}
</div>
<div className="mt-16 text-center">
<h2 className="text-2xl font-bold text-foreground mb-6">Need export-first financial control?</h2>
<Link href="/register" className="btn-primary">
Start your free trial
</Link>
</div>
</div>
</main>
<div className="relative z-10">
<SiteFooter />
</div>
<PageSchema schema={schema} />
</div>
);
}

View File

@ -5,11 +5,15 @@ import { AppShell } from "../../components/app-shell";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
type ExportData = { status: string; csv?: string; rowCount?: number }; type ExportData = { status: string; csv?: string; rowCount?: number };
type SignedExportData = { status: string; downloadUrl?: string; expiresAt?: string; expiresInSeconds?: number; singleUse?: boolean };
type SheetsData = { spreadsheetUrl?: string; url?: string; spreadsheetId?: string; rowCount?: number }; type SheetsData = { spreadsheetUrl?: string; url?: string; spreadsheetId?: string; rowCount?: number };
type GoogleStatus = { connected: boolean; googleEmail?: string; connectedAt?: string }; type GoogleStatus = { connected: boolean; googleEmail?: string; connectedAt?: string };
export default function ExportsPage() { export default function ExportsPage() {
const [csvStatus, setCsvStatus] = useState(""); const [csvStatus, setCsvStatus] = useState("");
const [jsonStatus, setJsonStatus] = useState("");
const [xlsxStatus, setXlsxStatus] = useState("");
const [pdfStatus, setPdfStatus] = useState("");
const [sheetsStatus, setSheetsStatus] = useState(""); const [sheetsStatus, setSheetsStatus] = useState("");
const [sheetsUrl, setSheetsUrl] = useState<string | null>(null); const [sheetsUrl, setSheetsUrl] = useState<string | null>(null);
const [sheetsLoading, setSheetsLoading] = useState(false); const [sheetsLoading, setSheetsLoading] = useState(false);
@ -65,23 +69,64 @@ export default function ExportsPage() {
return params; return params;
}; };
const onExportCsv = async () => { const downloadSignedFile = (url: string) => {
setCsvStatus("Generating export..."); const a = document.createElement("a");
a.href = url;
a.rel = "noopener";
a.click();
};
const requestSignedDownload = async (format: "csv" | "json" | "xlsx" | "pdf") => {
const params = buildParams(); const params = buildParams();
const query = params.toString() ? `?${params.toString()}` : ""; const query = params.toString() ? `?${params.toString()}` : "";
const res = await apiFetch<ExportData>(`/api/exports/csv${query}`); return apiFetch<SignedExportData>(`/api/exports/${format}${query}`);
};
const onExportCsv = async () => {
setCsvStatus("Generating export...");
const res = await requestSignedDownload("csv");
if (res.error) { setCsvStatus(res.error.message ?? "Export failed."); return; } if (res.error) { setCsvStatus(res.error.message ?? "Export failed."); return; }
if (res.data?.csv) { if (res.data?.downloadUrl) {
const blob = new Blob([res.data.csv], { type: "text/csv" }); downloadSignedFile(res.data.downloadUrl);
const url = URL.createObjectURL(blob); setCsvStatus(`Signed one-time link created. Download expires in ${res.data.expiresInSeconds ?? 120} seconds.`);
const a = document.createElement("a");
a.href = url;
a.download = `ledgerone-export-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
setCsvStatus(`Export ready (${res.data.rowCount ?? 0} rows) — file downloaded.`);
} else { } else {
setCsvStatus("Export ready."); setCsvStatus("Export link created.");
}
};
const onExportJson = async () => {
setJsonStatus("Generating JSON export...");
const res = await requestSignedDownload("json");
if (res.error) { setJsonStatus(res.error.message ?? "JSON export failed."); return; }
if (res.data?.downloadUrl) {
downloadSignedFile(res.data.downloadUrl);
setJsonStatus(`Signed one-time link created. Download expires in ${res.data.expiresInSeconds ?? 120} seconds.`);
} else {
setJsonStatus("JSON export link created.");
}
};
const onExportXlsx = async () => {
setXlsxStatus("Generating XLSX export...");
const res = await requestSignedDownload("xlsx");
if (res.error) { setXlsxStatus(res.error.message ?? "XLSX export failed."); return; }
if (res.data?.downloadUrl) {
downloadSignedFile(res.data.downloadUrl);
setXlsxStatus(`Signed one-time link created. Download expires in ${res.data.expiresInSeconds ?? 120} seconds.`);
} else {
setXlsxStatus("XLSX export link created.");
}
};
const onExportPdf = async () => {
setPdfStatus("Generating PDF export...");
const res = await requestSignedDownload("pdf");
if (res.error) { setPdfStatus(res.error.message ?? "PDF export failed."); return; }
if (res.data?.downloadUrl) {
downloadSignedFile(res.data.downloadUrl);
setPdfStatus(`Signed one-time link created. Download expires in ${res.data.expiresInSeconds ?? 120} seconds.`);
} else {
setPdfStatus("PDF export link created.");
} }
}; };
@ -140,7 +185,7 @@ export default function ExportsPage() {
const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider"; const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider";
return ( return (
<AppShell title="Exports" subtitle="Generate CSV datasets or export to Google Sheets."> <AppShell title="Exports" subtitle="Generate CSV, JSON, XLSX, PDF, or Google Sheets exports.">
<div className="glass-panel p-8 rounded-2xl shadow-sm space-y-6"> <div className="glass-panel p-8 rounded-2xl shadow-sm space-y-6">
{/* Filters */} {/* Filters */}
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
@ -185,7 +230,7 @@ export default function ExportsPage() {
<div className="mt-6" /> <div className="mt-6" />
{/* Export cards */} {/* Export cards */}
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
{/* CSV */} {/* CSV */}
<div className="rounded-xl border border-border bg-secondary/10 p-6"> <div className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@ -208,6 +253,72 @@ export default function ExportsPage() {
{csvStatus && <p className="mt-2 text-xs text-muted-foreground">{csvStatus}</p>} {csvStatus && <p className="mt-2 text-xs text-muted-foreground">{csvStatus}</p>}
</div> </div>
{/* JSON */}
<div className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-amber-500/10 flex items-center justify-center flex-shrink-0">
<svg className="h-5 w-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l-3 3 3 3m8-6l3 3-3 3M13 5l-2 14" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">Download JSON</p>
<p className="mt-1 text-xs text-muted-foreground">Structured transaction export for custom tools.</p>
</div>
</div>
<button
onClick={onExportJson}
className="mt-4 w-full rounded-lg border border-amber-500/30 bg-amber-500/10 py-2.5 px-4 text-sm font-bold text-amber-500 hover:bg-amber-500/20 transition-all"
>
Export JSON
</button>
{jsonStatus && <p className="mt-2 text-xs text-muted-foreground">{jsonStatus}</p>}
</div>
{/* XLSX */}
<div className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0">
<svg className="h-5 w-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-6m3 6V7m3 10v-4m4 8H5a2 2 0 01-2-2V5a2 2 0 012-2h8l6 6v10a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">Download XLSX</p>
<p className="mt-1 text-xs text-muted-foreground">Excel workbook with filtered transaction data.</p>
</div>
</div>
<button
onClick={onExportXlsx}
className="mt-4 w-full rounded-lg border border-blue-500/30 bg-blue-500/10 py-2.5 px-4 text-sm font-bold text-blue-500 hover:bg-blue-500/20 transition-all"
>
Export XLSX
</button>
{xlsxStatus && <p className="mt-2 text-xs text-muted-foreground">{xlsxStatus}</p>}
</div>
{/* PDF */}
<div className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-red-500/10 flex items-center justify-center flex-shrink-0">
<svg className="h-5 w-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9l-6-6H7a2 2 0 00-2 2v14a2 2 0 002 2zm2-8h6m-6 4h6m-6-8h2" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">Download PDF</p>
<p className="mt-1 text-xs text-muted-foreground">Printable ledger snapshot for audit packets.</p>
</div>
</div>
<button
onClick={onExportPdf}
className="mt-4 w-full rounded-lg border border-red-500/30 bg-red-500/10 py-2.5 px-4 text-sm font-bold text-red-500 hover:bg-red-500/20 transition-all"
>
Export PDF
</button>
{pdfStatus && <p className="mt-2 text-xs text-muted-foreground">{pdfStatus}</p>}
</div>
{/* Google Sheets */} {/* Google Sheets */}
<div className="rounded-xl border border-border bg-secondary/10 p-6"> <div className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">

View File

@ -19,8 +19,8 @@ type ApiResponse<T> = {
type AuthData = { type AuthData = {
user: { id: string; email: string; fullName?: string; emailVerified?: boolean }; user: { id: string; email: string; fullName?: string; emailVerified?: boolean };
accessToken: string; accessToken?: string;
refreshToken: string; refreshToken?: string;
requiresTwoFactor?: boolean; requiresTwoFactor?: boolean;
}; };
@ -37,9 +37,31 @@ function LoginForm() {
const [staySignedIn, setStaySignedIn] = useState(true); const [staySignedIn, setStaySignedIn] = useState(true);
const [totpToken, setTotpToken] = useState(""); const [totpToken, setTotpToken] = useState("");
const [requiresTwoFactor, setRequiresTwoFactor] = useState(false); const [requiresTwoFactor, setRequiresTwoFactor] = useState(false);
const [socialLoading, setSocialLoading] = useState<"google" | "apple" | null>(null);
const [status, setStatus] = useState<string>(""); const [status, setStatus] = useState<string>("");
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
const startSocialLogin = async (provider: "google" | "apple") => {
setSocialLoading(provider);
setStatus(`Redirecting to ${provider === "google" ? "Google" : "Apple"}...`);
setIsError(false);
try {
const res = await fetch(`/api/auth/social/${provider}/url?next=${encodeURIComponent(nextPath)}`);
const payload = (await res.json()) as ApiResponse<{ authUrl: string }>;
if (!res.ok || payload.error || !payload.data?.authUrl) {
setStatus(payload.error?.message ?? `${provider === "google" ? "Google" : "Apple"} sign in is unavailable.`);
setIsError(true);
setSocialLoading(null);
return;
}
window.location.href = payload.data.authUrl;
} catch {
setStatus(`${provider === "google" ? "Google" : "Apple"} sign in failed to start.`);
setIsError(true);
setSocialLoading(null);
}
};
const onSubmit = async (event: React.FormEvent) => { const onSubmit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
setStatus("Signing in..."); setStatus("Signing in...");
@ -64,8 +86,6 @@ function LoginForm() {
} }
storeAuthTokens({ storeAuthTokens({
accessToken: payload.data.accessToken,
refreshToken: payload.data.refreshToken,
user: payload.data.user, user: payload.data.user,
}); });
setStatus(`Welcome back, ${payload.data.user.email}`); setStatus(`Welcome back, ${payload.data.user.email}`);
@ -84,16 +104,20 @@ function LoginForm() {
type="button" type="button"
className={socialButtonClass} className={socialButtonClass}
aria-label="Continue with Apple" aria-label="Continue with Apple"
disabled={socialLoading !== null}
onClick={() => startSocialLogin("apple")}
> >
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden> <svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" /> <path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
</svg> </svg>
Continue with Apple {socialLoading === "apple" ? "Redirecting..." : "Continue with Apple"}
</button> </button>
<button <button
type="button" type="button"
className={socialButtonClass} className={socialButtonClass}
aria-label="Continue with Google" aria-label="Continue with Google"
disabled={socialLoading !== null}
onClick={() => startSocialLogin("google")}
> >
<svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden> <svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden>
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" /> <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
@ -101,7 +125,7 @@ function LoginForm() {
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" /> <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" /> <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg> </svg>
Continue with Google {socialLoading === "google" ? "Redirecting..." : "Continue with Google"}
</button> </button>
</div> </div>

View File

@ -1,5 +1,4 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link";
import { Background } from "../../components/background"; import { Background } from "../../components/background";
import { ContactSection } from "../../components/contact-section"; import { ContactSection } from "../../components/contact-section";
import { DemoCta } from "../../components/demo-cta"; import { DemoCta } from "../../components/demo-cta";
@ -9,23 +8,25 @@ import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header"; import { SiteHeader } from "../../components/site-header";
import { defaultFaqs } from "../../data/faq"; import { defaultFaqs } from "../../data/faq";
import { siteInfo } from "../../data/site"; import { siteInfo } from "../../data/site";
import { PricingAction } from "./pricing-actions";
export const metadata = { export const metadata = {
title: "Pricing", title: "Pricing",
description: description:
"LedgerOne pricing: first two connected accounts are free. Unlimited accounts are $9 per month.", "LedgerOne pricing: Free includes two accounts, Pro includes ten accounts, and Elite includes unlimited accounts.",
keywords: siteInfo.keywords keywords: siteInfo.keywords
}; };
const plans = [ const plans = [
{ {
name: "Starter", id: "free",
name: "Free",
price: "Free", price: "Free",
tagline: "Best for getting your first ledger online.", tagline: "Best for getting your first ledger online.",
badge: "2 accounts", badge: "2 accounts",
features: [ features: [
"First two connected accounts", "2 active accounts",
"Unlimited exports", "Core ledger views",
"Rule engine access", "Rule engine access",
"Audit logs included" "Audit logs included"
], ],
@ -33,27 +34,44 @@ const plans = [
primary: false primary: false
}, },
{ {
name: "Unlimited", id: "pro",
name: "Pro",
price: "$9", price: "$9",
tagline: "Scale your ledger without limits.", tagline: "For teams that need secure exports and more accounts.",
badge: "10 accounts",
features: [
"10 active accounts",
"Unlimited secure exports",
"Google Sheets sync",
"Public API access"
],
cta: "Choose Pro",
primary: true
},
{
id: "elite",
name: "Elite",
price: "Custom",
tagline: "For larger ledgers and dedicated support.",
badge: "Unlimited accounts", badge: "Unlimited accounts",
features: [ features: [
"Unlimited connected accounts", "Unlimited active accounts",
"Priority sync cadence", "Unlimited secure exports",
"Advanced rule automation", "Everything in Pro",
"Team-ready exports" "Dedicated support"
], ],
cta: "Choose Unlimited", cta: "Choose Elite",
primary: true primary: false
} }
]; ] as const;
const comparisons = [ const comparisons = [
{ label: "Connected accounts", starter: "2", pro: "Unlimited" }, { label: "Connected accounts", free: "2", pro: "10", elite: "Unlimited" },
{ label: "Exports", starter: "Unlimited", pro: "Unlimited" }, { label: "Exports", free: "Not included", pro: "Unlimited", elite: "Unlimited" },
{ label: "Rule engine", starter: "Core rules", pro: "Advanced rules" }, { label: "Google Sheets sync", free: "Not included", pro: "Included", elite: "Included" },
{ label: "Audit logs", starter: "Included", pro: "Included" }, { label: "Public API", free: "Not included", pro: "Included", elite: "Included" },
{ label: "Support", starter: "Standard", pro: "Priority" } { label: "Rule engine", free: "Core rules", pro: "Advanced rules", elite: "Advanced rules" },
{ label: "Support", free: "Standard", pro: "Priority", elite: "Dedicated" }
]; ];
export default function PricingPage() { export default function PricingPage() {
@ -63,7 +81,7 @@ export default function PricingPage() {
"@type": "WebPage", "@type": "WebPage",
name: "LedgerOne Pricing", name: "LedgerOne Pricing",
description: description:
"LedgerOne pricing: first two connected accounts are free. Unlimited accounts are $9 per month.", "LedgerOne pricing: Free includes two accounts, Pro includes ten accounts, and Elite includes unlimited accounts.",
url: `${siteInfo.url}/pricing` url: `${siteInfo.url}/pricing`
}, },
{ {
@ -88,12 +106,12 @@ export default function PricingPage() {
Simple, transparent pricing. Simple, transparent pricing.
</h1> </h1>
<p className="mt-6 text-lg text-muted-foreground"> <p className="mt-6 text-lg text-muted-foreground">
Start with the essentials and upgrade only when you need more accounts. Start with two accounts for free, move to Pro for secure exports and ten accounts,
Both plans include unlimited exports and audit logs. or choose Elite when your ledger needs unlimited account coverage.
</p> </p>
</div> </div>
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto"> <div className="grid gap-8 md:grid-cols-3 max-w-6xl mx-auto">
{plans.map((plan) => ( {plans.map((plan) => (
<div <div
key={plan.name} key={plan.name}
@ -112,7 +130,7 @@ export default function PricingPage() {
</div> </div>
<div className="mt-4 flex items-baseline text-foreground"> <div className="mt-4 flex items-baseline text-foreground">
<span className="text-4xl font-bold tracking-tight">{plan.price}</span> <span className="text-4xl font-bold tracking-tight">{plan.price}</span>
{plan.price !== "Free" && <span className="ml-1 text-xl font-semibold text-muted-foreground">/month</span>} {plan.price.startsWith("$") && <span className="ml-1 text-xl font-semibold text-muted-foreground">/month</span>}
</div> </div>
<p className="mt-2 text-sm text-muted-foreground">{plan.tagline}</p> <p className="mt-2 text-sm text-muted-foreground">{plan.tagline}</p>
@ -129,12 +147,7 @@ export default function PricingPage() {
))} ))}
</ul> </ul>
<Link <PricingAction plan={plan.id} label={plan.cta} primary={plan.primary} />
href="/register"
className={`mt-8 block w-full text-center ${plan.primary ? "btn-primary" : "btn-secondary"}`}
>
{plan.cta}
</Link>
</div> </div>
))} ))}
</div> </div>
@ -148,16 +161,18 @@ export default function PricingPage() {
<thead className="bg-secondary/30"> <thead className="bg-secondary/30">
<tr> <tr>
<th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Feature</th> <th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Feature</th>
<th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Starter</th> <th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Free</th>
<th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Unlimited</th> <th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Pro</th>
<th scope="col" className="px-6 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Elite</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-background divide-y divide-border"> <tbody className="bg-background divide-y divide-border">
{comparisons.map((item) => ( {comparisons.map((item) => (
<tr key={item.label}> <tr key={item.label}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">{item.label}</td> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">{item.label}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">{item.starter}</td> <td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">{item.free}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">{item.pro}</td> <td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">{item.pro}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">{item.elite}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@ -0,0 +1,54 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { apiFetch } from "@/lib/api";
type PricingActionProps = {
plan: "free" | "pro" | "elite";
label: string;
primary: boolean;
};
export function PricingAction({ plan, label, primary }: PricingActionProps) {
const [status, setStatus] = useState("");
const [loading, setLoading] = useState(false);
const className = `mt-8 block w-full text-center ${primary ? "btn-primary" : "btn-secondary"} disabled:opacity-60`;
if (plan === "free") {
return (
<Link href="/register" className={className}>
{label}
</Link>
);
}
const startCheckout = async () => {
setLoading(true);
setStatus("Opening checkout...");
const appUrl = window.location.origin;
const response = await apiFetch<{ url: string }>("/api/billing/checkout", {
method: "POST",
body: JSON.stringify({
plan,
successUrl: `${appUrl}/settings/subscription?upgraded=1`,
cancelUrl: `${appUrl}/pricing`,
}),
});
setLoading(false);
if (response.error || !response.data?.url) {
setStatus(response.error?.message ?? "Sign in before choosing a paid plan.");
return;
}
window.location.href = response.data.url;
};
return (
<>
<button type="button" onClick={startCheckout} disabled={loading} className={className}>
{loading ? "Opening..." : label}
</button>
{status ? <p className="mt-3 text-center text-xs text-muted-foreground">{status}</p> : null}
</>
);
}

View File

@ -2,6 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AppShell } from "../../components/app-shell"; import { AppShell } from "../../components/app-shell";
import { apiFetch } from "@/lib/api";
type ApiResponse<T> = { type ApiResponse<T> = {
data: T; data: T;
@ -26,7 +27,6 @@ type ProfileData = {
}; };
export default function ProfilePage() { export default function ProfilePage() {
const [token, setToken] = useState("");
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [fullName, setFullName] = useState(""); const [fullName, setFullName] = useState("");
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
@ -38,25 +38,12 @@ export default function ProfilePage() {
const [postalCode, setPostalCode] = useState(""); const [postalCode, setPostalCode] = useState("");
const [country, setCountry] = useState(""); const [country, setCountry] = useState("");
useEffect(() => {
const stored = localStorage.getItem("ledgerone_token") ?? "";
setToken(stored);
}, []);
const onSubmit = async (event: React.FormEvent) => { const onSubmit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
if (!token) {
setStatus("Please sign in to update your profile.");
return;
}
setStatus("Saving profile..."); setStatus("Saving profile...");
try { try {
const res = await fetch("/api/auth/profile", { const payload = await apiFetch<ProfileData>("/api/auth/profile", {
method: "PATCH", method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ body: JSON.stringify({
fullName, fullName,
phone: phone || undefined, phone: phone || undefined,
@ -69,8 +56,7 @@ export default function ProfilePage() {
country: country || undefined country: country || undefined
}) })
}); });
const payload = (await res.json()) as ApiResponse<ProfileData>; if (payload.error) {
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Profile update failed."); setStatus(payload.error?.message ?? "Profile update failed.");
return; return;
} }

View File

@ -20,8 +20,8 @@ type ApiResponse<T> = {
type AuthData = { type AuthData = {
user: { id: string; email: string; fullName?: string }; user: { id: string; email: string; fullName?: string };
accessToken: string; accessToken?: string;
refreshToken: string; refreshToken?: string;
message?: string; message?: string;
}; };
@ -39,6 +39,7 @@ export default function RegisterPage() {
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [agreeTerms, setAgreeTerms] = useState(false); const [agreeTerms, setAgreeTerms] = useState(false);
const [socialLoading, setSocialLoading] = useState<"google" | "apple" | null>(null);
const [status, setStatus] = useState<string>(""); const [status, setStatus] = useState<string>("");
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
@ -83,8 +84,6 @@ export default function RegisterPage() {
return; return;
} }
storeAuthTokens({ storeAuthTokens({
accessToken: payload.data.accessToken,
refreshToken: payload.data.refreshToken,
user: payload.data.user, user: payload.data.user,
}); });
setStatus("Account created! Redirecting..."); setStatus("Account created! Redirecting...");
@ -95,6 +94,27 @@ export default function RegisterPage() {
} }
}; };
const startSocialLogin = async (provider: "google" | "apple") => {
setSocialLoading(provider);
setStatus(`Redirecting to ${provider === "google" ? "Google" : "Apple"}...`);
setIsError(false);
try {
const res = await fetch(`/api/auth/social/${provider}/url?next=${encodeURIComponent("/app")}`);
const payload = (await res.json()) as ApiResponse<{ authUrl: string }>;
if (!res.ok || payload.error || !payload.data?.authUrl) {
setStatus(payload.error?.message ?? `${provider === "google" ? "Google" : "Apple"} sign up is unavailable.`);
setIsError(true);
setSocialLoading(null);
return;
}
window.location.href = payload.data.authUrl;
} catch {
setStatus(`${provider === "google" ? "Google" : "Apple"} sign up failed to start.`);
setIsError(true);
setSocialLoading(null);
}
};
return ( return (
<div className="page-soft-bg min-h-screen font-sans text-foreground flex flex-col relative overflow-hidden"> <div className="page-soft-bg min-h-screen font-sans text-foreground flex flex-col relative overflow-hidden">
<Background /> <Background />
@ -120,16 +140,20 @@ export default function RegisterPage() {
type="button" type="button"
className={socialButtonClass} className={socialButtonClass}
aria-label="Continue with Apple" aria-label="Continue with Apple"
disabled={socialLoading !== null}
onClick={() => startSocialLogin("apple")}
> >
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden> <svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" /> <path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
</svg> </svg>
Continue with Apple {socialLoading === "apple" ? "Redirecting..." : "Continue with Apple"}
</button> </button>
<button <button
type="button" type="button"
className={socialButtonClass} className={socialButtonClass}
aria-label="Continue with Google" aria-label="Continue with Google"
disabled={socialLoading !== null}
onClick={() => startSocialLogin("google")}
> >
<svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden> <svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden>
<path <path
@ -149,7 +173,7 @@ export default function RegisterPage() {
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/> />
</svg> </svg>
Continue with Google {socialLoading === "google" ? "Redirecting..." : "Continue with Google"}
</button> </button>
</div> </div>

View File

@ -2,12 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AppShell } from "../../components/app-shell"; import { AppShell } from "../../components/app-shell";
import { apiFetch } from "@/lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type RuleRow = { type RuleRow = {
id: string; id: string;
@ -24,6 +19,9 @@ type Suggestion = {
conditions: Record<string, unknown>; conditions: Record<string, unknown>;
actions: Record<string, unknown>; actions: Record<string, unknown>;
confidence: number; confidence: number;
reason?: string;
matchCount?: number;
type?: string;
}; };
export default function RulesPage() { export default function RulesPage() {
@ -31,28 +29,47 @@ export default function RulesPage() {
const [suggestions, setSuggestions] = useState<Suggestion[]>([]); const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [status, setStatus] = useState("Loading rules..."); const [status, setStatus] = useState("Loading rules...");
const [showNew, setShowNew] = useState(false); const [showNew, setShowNew] = useState(false);
const [builderMode, setBuilderMode] = useState<"simple" | "advanced">("simple");
const [advancedConditions, setAdvancedConditions] = useState(`{
"all": [
{ "field": "description", "operator": "contains", "value": "coffee" },
{
"any": [
{ "field": "amount", "operator": ">", "value": 5 },
{ "field": "source", "operator": "equals", "value": "csv" }
]
}
]
}`);
const [form, setForm] = useState({ const [form, setForm] = useState({
name: "", name: "",
priority: "", priority: "",
textContains: "", textContains: "",
textNotContains: "",
textRegex: "",
amountGreater: "", amountGreater: "",
amountLess: "", amountLess: "",
amountEquals: "",
sourceEquals: "",
categoryEquals: "",
dateAfter: "",
dateBefore: "",
setCategory: "", setCategory: "",
setHidden: false, clearCategory: false,
setNote: "",
appendNote: "",
clearNote: false,
hiddenAction: "none",
isActive: true isActive: true
}); });
const load = async () => { const load = async () => {
const userId = localStorage.getItem("ledgerone_user_id");
const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
try { try {
const [rulesRes, suggestionsRes] = await Promise.all([ const [rulesPayload, suggestionsPayload] = await Promise.all([
fetch(`/api/rules${query}`), apiFetch<RuleRow[]>("/api/rules"),
fetch(`/api/rules/suggestions${query}`) apiFetch<Suggestion[]>("/api/rules/suggestions")
]); ]);
const rulesPayload = (await rulesRes.json()) as ApiResponse<RuleRow[]>; if (rulesPayload.error) {
const suggestionsPayload = (await suggestionsRes.json()) as ApiResponse<Suggestion[]>;
if (!rulesRes.ok || rulesPayload.error) {
setStatus(rulesPayload.error?.message ?? "Unable to load rules."); setStatus(rulesPayload.error?.message ?? "Unable to load rules.");
return; return;
} }
@ -69,34 +86,49 @@ export default function RulesPage() {
}, []); }, []);
const onCreate = async () => { const onCreate = async () => {
const userId = localStorage.getItem("ledgerone_user_id"); let conditions: Record<string, unknown>;
if (!userId) { if (builderMode === "advanced") {
setStatus("Missing user id."); try {
return; conditions = JSON.parse(advancedConditions) as Record<string, unknown>;
} catch {
setStatus("Advanced rule DSL must be valid JSON.");
return;
}
} else {
conditions = {
textContains: form.textContains || undefined,
textNotContains: form.textNotContains || undefined,
textRegex: form.textRegex || undefined,
amountGreaterThan: form.amountGreater ? Number(form.amountGreater) : undefined,
amountLessThan: form.amountLess ? Number(form.amountLess) : undefined,
amountEquals: form.amountEquals ? Number(form.amountEquals) : undefined,
sourceEquals: form.sourceEquals || undefined,
categoryEquals: form.categoryEquals || undefined,
dateAfter: form.dateAfter || undefined,
dateBefore: form.dateBefore || undefined
};
} }
const payload = { const payload = {
userId,
name: form.name || "Untitled rule", name: form.name || "Untitled rule",
priority: form.priority ? Number(form.priority) : undefined, priority: form.priority ? Number(form.priority) : undefined,
isActive: form.isActive, isActive: form.isActive,
conditions: { conditions,
textContains: form.textContains || undefined,
amountGreaterThan: form.amountGreater ? Number(form.amountGreater) : undefined,
amountLessThan: form.amountLess ? Number(form.amountLess) : undefined
},
actions: { actions: {
setCategory: form.setCategory || undefined, setCategory: form.setCategory || undefined,
setHidden: form.setHidden clearCategory: form.clearCategory || undefined,
setNote: form.setNote || undefined,
appendNote: form.appendNote || undefined,
clearNote: form.clearNote || undefined,
setHidden: form.hiddenAction === "hide" ? true : form.hiddenAction === "unhide" ? false : undefined
} }
}; };
try { try {
const res = await fetch("/api/rules", { const data = await apiFetch<RuleRow>("/api/rules", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
const data = (await res.json()) as ApiResponse<RuleRow>; if (data.error) {
if (!res.ok || data.error) {
setStatus(data.error?.message ?? "Unable to create rule."); setStatus(data.error?.message ?? "Unable to create rule.");
return; return;
} }
@ -105,10 +137,21 @@ export default function RulesPage() {
name: "", name: "",
priority: "", priority: "",
textContains: "", textContains: "",
textNotContains: "",
textRegex: "",
amountGreater: "", amountGreater: "",
amountLess: "", amountLess: "",
amountEquals: "",
sourceEquals: "",
categoryEquals: "",
dateAfter: "",
dateBefore: "",
setCategory: "", setCategory: "",
setHidden: false, clearCategory: false,
setNote: "",
appendNote: "",
clearNote: false,
hiddenAction: "none",
isActive: true isActive: true
}); });
setRules((prev) => [data.data, ...prev]); setRules((prev) => [data.data, ...prev]);
@ -118,6 +161,38 @@ export default function RulesPage() {
} }
}; };
const onExecute = async (ruleId: string) => {
setStatus("Executing rule...");
const result = await apiFetch<{ applied: number; status: string }>(`/api/rules/${ruleId}/execute`, {
method: "POST",
});
if (result.error) {
setStatus(result.error.message ?? "Unable to execute rule.");
return;
}
setStatus(`Rule ${result.data.status}. Applied to ${result.data.applied ?? 0} transaction(s).`);
};
const onAcceptSuggestion = async (item: Suggestion) => {
setStatus("Saving suggested rule...");
const result = await apiFetch<RuleRow>("/api/rules", {
method: "POST",
body: JSON.stringify({
name: item.name,
conditions: item.conditions,
actions: item.actions,
isActive: true,
}),
});
if (result.error) {
setStatus(result.error.message ?? "Unable to save suggested rule.");
return;
}
setRules((prev) => [result.data, ...prev]);
setSuggestions((prev) => prev.filter((suggestion) => suggestion.id !== item.id));
setStatus("Suggested rule saved.");
};
return ( return (
<AppShell title="Rules" subtitle="Priority-ordered rules with full transparency."> <AppShell title="Rules" subtitle="Priority-ordered rules with full transparency.">
<div className="mb-6 flex flex-wrap items-center justify-between gap-3"> <div className="mb-6 flex flex-wrap items-center justify-between gap-3">
@ -139,6 +214,30 @@ export default function RulesPage() {
{showNew ? ( {showNew ? (
<div className="mb-6 rounded-xl border border-border bg-background/50 p-4"> <div className="mb-6 rounded-xl border border-border bg-background/50 p-4">
<p className="text-sm font-bold text-foreground">Create a rule</p> <p className="text-sm font-bold text-foreground">Create a rule</p>
<div className="mt-4 inline-flex rounded-xl border border-border bg-background p-1 text-xs font-semibold">
<button
type="button"
onClick={() => setBuilderMode("simple")}
className={`rounded-lg px-3 py-1.5 ${builderMode === "simple" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"}`}
>
Simple
</button>
<button
type="button"
onClick={() => setBuilderMode("advanced")}
className={`rounded-lg px-3 py-1.5 ${builderMode === "advanced" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"}`}
>
Advanced DSL
</button>
</div>
{builderMode === "advanced" ? (
<textarea
value={advancedConditions}
onChange={(event) => setAdvancedConditions(event.target.value)}
spellCheck={false}
className="mt-4 min-h-56 w-full rounded-xl border border-border bg-background px-3 py-2 font-mono text-xs text-foreground focus:border-primary focus:ring-primary"
/>
) : null}
<div className="mt-4 grid gap-3 md:grid-cols-2"> <div className="mt-4 grid gap-3 md:grid-cols-2">
<input <input
type="text" type="text"
@ -156,6 +255,8 @@ export default function RulesPage() {
placeholder="Priority (optional)" placeholder="Priority (optional)"
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary" className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/> />
{builderMode === "simple" ? (
<>
<input <input
type="text" type="text"
value={form.textContains} value={form.textContains}
@ -165,6 +266,26 @@ export default function RulesPage() {
placeholder="Description contains" placeholder="Description contains"
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary" className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/> />
<input
type="text"
value={form.textNotContains}
onChange={(event) =>
setForm((prev) => ({ ...prev, textNotContains: event.target.value }))
}
placeholder="Description does not contain"
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/>
<input
type="text"
value={form.textRegex}
onChange={(event) =>
setForm((prev) => ({ ...prev, textRegex: event.target.value }))
}
placeholder="Description regex"
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/>
</>
) : null}
<input <input
type="text" type="text"
value={form.setCategory} value={form.setCategory}
@ -174,6 +295,26 @@ export default function RulesPage() {
placeholder="Set category" placeholder="Set category"
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary" className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/> />
<input
type="text"
value={form.setNote}
onChange={(event) =>
setForm((prev) => ({ ...prev, setNote: event.target.value }))
}
placeholder="Set note"
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/>
<input
type="text"
value={form.appendNote}
onChange={(event) =>
setForm((prev) => ({ ...prev, appendNote: event.target.value }))
}
placeholder="Append note"
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/>
{builderMode === "simple" ? (
<>
<input <input
type="number" type="number"
value={form.amountGreater} value={form.amountGreater}
@ -192,18 +333,85 @@ export default function RulesPage() {
placeholder="Amount less than" placeholder="Amount less than"
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary" className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/> />
<input
type="number"
value={form.amountEquals}
onChange={(event) =>
setForm((prev) => ({ ...prev, amountEquals: event.target.value }))
}
placeholder="Amount equals"
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/>
<input
type="text"
value={form.sourceEquals}
onChange={(event) =>
setForm((prev) => ({ ...prev, sourceEquals: event.target.value }))
}
placeholder="Source equals, e.g. plaid"
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/>
<input
type="text"
value={form.categoryEquals}
onChange={(event) =>
setForm((prev) => ({ ...prev, categoryEquals: event.target.value }))
}
placeholder="Current category equals"
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/>
<input
type="date"
value={form.dateAfter}
onChange={(event) =>
setForm((prev) => ({ ...prev, dateAfter: event.target.value }))
}
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/>
<input
type="date"
value={form.dateBefore}
onChange={(event) =>
setForm((prev) => ({ ...prev, dateBefore: event.target.value }))
}
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
/>
</>
) : null}
<select
value={form.hiddenAction}
onChange={(event) =>
setForm((prev) => ({ ...prev, hiddenAction: event.target.value }))
}
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
>
<option value="none">Do not change hidden state</option>
<option value="hide">Hide matching transactions</option>
<option value="unhide">Unhide matching transactions</option>
</select>
</div> </div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-muted-foreground"> <div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
checked={form.setHidden} checked={form.clearCategory}
onChange={(event) => onChange={(event) =>
setForm((prev) => ({ ...prev, setHidden: event.target.checked })) setForm((prev) => ({ ...prev, clearCategory: event.target.checked }))
} }
className="rounded border-border text-primary focus:ring-primary" className="rounded border-border text-primary focus:ring-primary"
/> />
Hide matching transactions Clear category
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={form.clearNote}
onChange={(event) =>
setForm((prev) => ({ ...prev, clearNote: event.target.checked }))
}
className="rounded border-border text-primary focus:ring-primary"
/>
Clear note
</label> </label>
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<input <input
@ -260,6 +468,13 @@ export default function RulesPage() {
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
<span className="font-semibold text-foreground">Actions:</span> {JSON.stringify(rule.actions)} <span className="font-semibold text-foreground">Actions:</span> {JSON.stringify(rule.actions)}
</div> </div>
<button
type="button"
onClick={() => onExecute(rule.id)}
className="mt-3 rounded-full border border-border bg-background px-3 py-1.5 text-xs font-semibold text-foreground hover:bg-secondary transition-colors"
>
Run rule
</button>
</div> </div>
))} ))}
</div> </div>
@ -268,7 +483,7 @@ export default function RulesPage() {
<div className="glass-panel p-6 rounded-2xl shadow-sm"> <div className="glass-panel p-6 rounded-2xl shadow-sm">
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground font-bold">AI Suggestions</p> <p className="text-xs uppercase tracking-[0.3em] text-muted-foreground font-bold">AI Suggestions</p>
<h2 className="mt-3 text-xl font-bold text-foreground">Pattern-based rule ideas</h2> <h2 className="mt-3 text-xl font-bold text-foreground">Rule suggestions</h2>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
{suggestions.length ? ( {suggestions.length ? (
suggestions.map((item) => ( suggestions.map((item) => (
@ -277,6 +492,9 @@ export default function RulesPage() {
className="rounded-xl border border-border bg-background/50 p-4" className="rounded-xl border border-border bg-background/50 p-4"
> >
<p className="font-bold text-foreground">{item.name}</p> <p className="font-bold text-foreground">{item.name}</p>
{item.reason ? (
<p className="mt-2 text-xs text-muted-foreground">{item.reason}</p>
) : null}
<p className="mt-2 text-xs text-muted-foreground"> <p className="mt-2 text-xs text-muted-foreground">
<span className="font-semibold text-foreground">Conditions:</span> {JSON.stringify(item.conditions)} <span className="font-semibold text-foreground">Conditions:</span> {JSON.stringify(item.conditions)}
</p> </p>
@ -285,7 +503,15 @@ export default function RulesPage() {
</p> </p>
<p className="mt-2 text-xs font-medium text-primary"> <p className="mt-2 text-xs font-medium text-primary">
Confidence: {(item.confidence * 100).toFixed(0)}% Confidence: {(item.confidence * 100).toFixed(0)}%
{item.matchCount ? ` - ${item.matchCount} matches` : ""}
</p> </p>
<button
type="button"
onClick={() => onAcceptSuggestion(item)}
className="mt-3 rounded-full bg-primary px-3 py-1.5 text-xs font-bold text-primary-foreground hover:bg-primary/90 transition-colors"
>
Accept suggestion
</button>
</div> </div>
)) ))
) : ( ) : (

View File

@ -0,0 +1,360 @@
"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;
members?: HouseholdMember[];
};
type HouseholdMember = {
id: string;
userId: string;
role: string;
joinedAt: string;
user?: {
email: string;
fullName?: string | null;
};
};
type HouseholdAccount = {
displayId: string;
institutionName: string;
accountType: string;
mask?: string | null;
currentBalance: number;
availableBalance: number;
isoCurrencyCode: string;
ownerUserId?: string | null;
ownershipType: "mine" | "theirs" | "joint" | string;
lastBalanceSync?: string | null;
syncStatus: string;
};
type CashflowMonth = {
month: string;
income: number;
expenses: number;
net: number;
transactionCount: number;
};
type RecentTransaction = {
date: string;
description: string;
amount: number;
source: string;
category: string;
account?: {
institutionName?: string;
mask?: string | null;
ownerUserId?: string | null;
ownershipType?: string;
};
};
type DashboardData = {
household: Household;
members: HouseholdMember[];
summary: {
memberCount: number;
accountCount: number;
totalBalance: number;
availableBalance: number;
monthlyIncome: number;
monthlyExpenses: number;
monthlyNet: number;
};
ownershipBreakdown: Record<string, { accountCount: number; balance: number }>;
accounts: HouseholdAccount[];
cashflow: CashflowMonth[];
recentTransactions: RecentTransaction[];
};
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
const inputClass = "w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20";
const cardClass = "rounded-xl border border-border bg-secondary/10 p-5";
function formatMoney(value: number) {
return money.format(value || 0);
}
function monthLabel(value: string) {
const [year, month] = value.split("-").map(Number);
return new Date(year, month - 1, 1).toLocaleDateString("en-US", { month: "short" });
}
function accountLabel(account: HouseholdAccount | RecentTransaction["account"]) {
if (!account) return "Household account";
const mask = account.mask ? ` ending ${account.mask}` : "";
return `${account.institutionName ?? "Account"}${mask}`;
}
export default function HouseholdSettingsPage() {
const [households, setHouseholds] = useState<Household[]>([]);
const [selectedId, setSelectedId] = useState("");
const [dashboard, setDashboard] = useState<DashboardData | null>(null);
const [newName, setNewName] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState("");
const selectedHousehold = useMemo(
() => households.find((household) => household.id === selectedId) ?? null,
[households, selectedId],
);
useEffect(() => {
loadHouseholds();
}, []);
useEffect(() => {
if (!selectedId) {
setDashboard(null);
return;
}
loadDashboard(selectedId);
}, [selectedId]);
const loadHouseholds = async () => {
setLoading(true);
const res = await apiFetch<Household[]>("/api/households");
setLoading(false);
if (res.error) {
setStatus(res.error.message ?? "Unable to load households.");
return;
}
const list = res.data ?? [];
setHouseholds(list);
setSelectedId((current) => current || list[0]?.id || "");
};
const loadDashboard = async (id: string) => {
setStatus("Loading shared dashboard...");
const res = await apiFetch<DashboardData>(`/api/households/${id}/dashboard`);
if (res.error) {
setDashboard(null);
setStatus(res.error.message ?? "Unable to load shared dashboard.");
return;
}
setDashboard(res.data);
setStatus("");
};
const createHousehold = async (event: FormEvent) => {
event.preventDefault();
const name = newName.trim();
if (!name) return;
setSaving(true);
const res = await apiFetch<Household>("/api/households", {
method: "POST",
body: JSON.stringify({ name }),
});
setSaving(false);
if (res.error) {
setStatus(res.error.message ?? "Unable to create household.");
return;
}
setNewName("");
await loadHouseholds();
if (res.data?.id) setSelectedId(res.data.id);
};
const maxCashflow = Math.max(
1,
...(dashboard?.cashflow ?? []).map((month) => Math.max(month.income, month.expenses)),
);
return (
<AppShell title="Households" subtitle="Shared financial dashboard for partner and family money.">
<div className="space-y-6">
<div className="glass-panel rounded-2xl p-6 shadow-sm">
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
<div>
<p className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Active household</p>
<div className="mt-3 flex flex-col gap-3 sm:flex-row">
<select value={selectedId} onChange={(event) => setSelectedId(event.target.value)} className={inputClass}>
{households.map((household) => (
<option key={household.id} value={household.id}>
{household.name}
</option>
))}
{!households.length && <option value="">No households yet</option>}
</select>
<button
type="button"
onClick={() => selectedId && loadDashboard(selectedId)}
disabled={!selectedId}
className="rounded-lg border border-border px-4 py-2 text-sm font-semibold text-foreground hover:bg-secondary disabled:opacity-50"
>
Refresh
</button>
</div>
{selectedHousehold && (
<p className="mt-3 text-sm text-muted-foreground">
{selectedHousehold.name} combines joint, mine, and partner-owned accounts without exposing stable account IDs in the page data.
</p>
)}
</div>
<form onSubmit={createHousehold} className="rounded-xl border border-border bg-background/60 p-4">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Create household</label>
<input
value={newName}
onChange={(event) => setNewName(event.target.value)}
placeholder="Household name"
className={`${inputClass} mt-2`}
/>
<button
type="submit"
disabled={saving || !newName.trim()}
className="mt-3 w-full rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{saving ? "Creating..." : "Create"}
</button>
</form>
</div>
{status && <p className="mt-4 text-sm text-muted-foreground">{status}</p>}
</div>
{loading ? (
<div className="glass-panel rounded-2xl p-8 text-sm text-muted-foreground">Loading households...</div>
) : dashboard ? (
<>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className={cardClass}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total balance</p>
<p className="mt-2 text-3xl font-bold text-foreground">{formatMoney(dashboard.summary.totalBalance)}</p>
<p className="mt-1 text-sm text-muted-foreground">{dashboard.summary.accountCount} shared accounts</p>
</div>
<div className={cardClass}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Available</p>
<p className="mt-2 text-3xl font-bold text-foreground">{formatMoney(dashboard.summary.availableBalance)}</p>
<p className="mt-1 text-sm text-muted-foreground">Current liquid view</p>
</div>
<div className={cardClass}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Monthly net</p>
<p className={`mt-2 text-3xl font-bold ${dashboard.summary.monthlyNet >= 0 ? "text-green-500" : "text-red-500"}`}>
{formatMoney(dashboard.summary.monthlyNet)}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{formatMoney(dashboard.summary.monthlyIncome)} in, {formatMoney(dashboard.summary.monthlyExpenses)} out
</p>
</div>
<div className={cardClass}>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Members</p>
<p className="mt-2 text-3xl font-bold text-foreground">{dashboard.summary.memberCount}</p>
<p className="mt-1 text-sm text-muted-foreground">Active household access</p>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<div className="glass-panel rounded-2xl p-6 shadow-sm">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-bold text-foreground">Accounts by ownership</h2>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Mine / theirs / joint</span>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
{(["mine", "theirs", "joint"] as const).map((key) => {
const item = dashboard.ownershipBreakdown[key] ?? { accountCount: 0, balance: 0 };
return (
<div key={key} className="rounded-xl border border-border bg-background/50 p-4">
<p className="text-sm font-semibold capitalize text-foreground">{key}</p>
<p className="mt-2 text-2xl font-bold text-foreground">{formatMoney(item.balance)}</p>
<p className="mt-1 text-xs text-muted-foreground">{item.accountCount} accounts</p>
</div>
);
})}
</div>
<div className="mt-5 space-y-3">
{dashboard.accounts.map((account) => (
<div key={account.displayId} className="flex flex-col gap-3 rounded-xl border border-border bg-background/40 p-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-semibold text-foreground">{accountLabel(account)}</p>
<p className="text-sm capitalize text-muted-foreground">
{account.accountType} · {account.ownershipType} · {account.syncStatus}
</p>
</div>
<p className="text-lg font-bold text-foreground">{formatMoney(account.currentBalance)}</p>
</div>
))}
{!dashboard.accounts.length && <p className="text-sm text-muted-foreground">No household accounts linked yet.</p>}
</div>
</div>
<div className="glass-panel rounded-2xl p-6 shadow-sm">
<h2 className="text-lg font-bold text-foreground">Members</h2>
<div className="mt-4 space-y-3">
{dashboard.members.map((member) => (
<div key={member.id} className="flex items-center justify-between gap-3 rounded-xl border border-border bg-background/40 p-4">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{member.user?.fullName || member.user?.email || member.userId}</p>
<p className="truncate text-sm text-muted-foreground">{member.user?.email}</p>
</div>
<span className="rounded-full border border-border bg-secondary/60 px-3 py-1 text-xs font-semibold capitalize text-muted-foreground">
{member.role}
</span>
</div>
))}
</div>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
<div className="glass-panel rounded-2xl p-6 shadow-sm">
<h2 className="text-lg font-bold text-foreground">Six-month cashflow</h2>
<div className="mt-5 space-y-4">
{dashboard.cashflow.map((month) => (
<div key={month.month}>
<div className="mb-2 flex items-center justify-between text-sm">
<span className="font-semibold text-foreground">{monthLabel(month.month)}</span>
<span className={month.net >= 0 ? "text-green-500" : "text-red-500"}>{formatMoney(month.net)}</span>
</div>
<div className="grid h-2 grid-cols-2 overflow-hidden rounded-full bg-secondary">
<div className="bg-green-500" style={{ width: `${Math.max(4, (month.income / maxCashflow) * 100)}%` }} />
<div className="justify-self-end bg-red-500" style={{ width: `${Math.max(4, (month.expenses / maxCashflow) * 100)}%` }} />
</div>
</div>
))}
</div>
</div>
<div className="glass-panel rounded-2xl p-6 shadow-sm">
<h2 className="text-lg font-bold text-foreground">Recent shared transactions</h2>
<div className="mt-4 divide-y divide-border">
{dashboard.recentTransactions.map((transaction, index) => (
<div key={`${transaction.date}-${index}`} className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{transaction.description}</p>
<p className="text-sm text-muted-foreground">
{new Date(transaction.date).toLocaleDateString()} · {transaction.category} · {accountLabel(transaction.account)}
</p>
</div>
<p className={`font-bold ${transaction.amount < 0 ? "text-green-500" : "text-foreground"}`}>
{formatMoney(transaction.amount)}
</p>
</div>
))}
{!dashboard.recentTransactions.length && <p className="py-4 text-sm text-muted-foreground">No shared transactions yet.</p>}
</div>
</div>
</div>
</>
) : (
<div className="glass-panel rounded-2xl p-8 text-sm text-muted-foreground">
Create a household to start viewing shared accounts and cashflow.
</div>
)}
</div>
</AppShell>
);
}

View File

@ -17,6 +17,11 @@ const settingsItems = [
description: "View plan details, upgrade options, and billing cadence.", description: "View plan details, upgrade options, and billing cadence.",
href: "/settings/subscription", href: "/settings/subscription",
}, },
{
title: "Households",
description: "Manage shared financial dashboards, partner access, and account ownership.",
href: "/settings/households",
},
]; ];
export default function SettingsPage() { export default function SettingsPage() {

View File

@ -13,7 +13,7 @@ type ApiResponse<T> = {
type SubscriptionData = { type SubscriptionData = {
plan?: string; plan?: string;
status?: string; status?: string;
billingCycleAnchor?: number; currentPeriodEnd?: string;
cancelAtPeriodEnd?: boolean; cancelAtPeriodEnd?: boolean;
}; };
@ -24,9 +24,14 @@ const PLAN_LABELS: Record<string, string> = {
}; };
const PLAN_DESCRIPTIONS: Record<string, string> = { const PLAN_DESCRIPTIONS: Record<string, string> = {
free: "Up to 2 accounts, basic CSV export, 30-day history.", free: "Up to 2 active accounts, no paid exports, and core ledger tools.",
pro: "Unlimited accounts, Google Sheets, 24-month history, priority support.", pro: "Up to 10 active accounts, secure exports, Google Sheets sync, and public API access.",
elite: "Everything in Pro + tax return module, AI rule suggestions, dedicated support.", elite: "Unlimited active accounts with every Pro capability and dedicated support.",
};
const PLAN_PRICES: Record<"pro" | "elite", string> = {
pro: "$9/mo",
elite: "Custom",
}; };
export default function SubscriptionPage() { export default function SubscriptionPage() {
@ -36,7 +41,7 @@ export default function SubscriptionPage() {
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
useEffect(() => { useEffect(() => {
apiFetch<SubscriptionData>("/api/stripe/subscription") apiFetch<SubscriptionData>("/api/billing/subscription")
.then((res) => { .then((res) => {
if (!res.error) setSub(res.data); if (!res.error) setSub(res.data);
}) })
@ -48,7 +53,7 @@ export default function SubscriptionPage() {
setActionLoading(true); setActionLoading(true);
setActionStatus("Redirecting to checkout..."); setActionStatus("Redirecting to checkout...");
const appUrl = typeof window !== "undefined" ? window.location.origin : ""; const appUrl = typeof window !== "undefined" ? window.location.origin : "";
const res = await apiFetch<{ url: string }>("/api/stripe/checkout", { const res = await apiFetch<{ url: string }>("/api/billing/checkout", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
plan, plan,
@ -68,7 +73,7 @@ export default function SubscriptionPage() {
setActionLoading(true); setActionLoading(true);
setActionStatus("Redirecting to billing portal..."); setActionStatus("Redirecting to billing portal...");
const appUrl = typeof window !== "undefined" ? window.location.origin : ""; const appUrl = typeof window !== "undefined" ? window.location.origin : "";
const res = await apiFetch<{ url: string }>("/api/stripe/portal", { const res = await apiFetch<{ url: string }>("/api/billing/portal", {
method: "POST", method: "POST",
body: JSON.stringify({ returnUrl: `${appUrl}/settings/subscription` }), body: JSON.stringify({ returnUrl: `${appUrl}/settings/subscription` }),
}); });
@ -86,7 +91,7 @@ export default function SubscriptionPage() {
return ( return (
<AppShell title="Subscription" subtitle="Manage your plan and billing details."> <AppShell title="Subscription" subtitle="Manage your plan and billing details.">
<div className="max-w-2xl space-y-6"> <div className="max-w-4xl space-y-6">
{/* Current plan card */} {/* Current plan card */}
<div className="glass-panel rounded-2xl p-8"> <div className="glass-panel rounded-2xl p-8">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Current Plan</p> <p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Current Plan</p>
@ -109,6 +114,11 @@ export default function SubscriptionPage() {
{sub?.cancelAtPeriodEnd && ( {sub?.cancelAtPeriodEnd && (
<p className="mt-2 text-xs text-yellow-500">Cancels at end of billing period.</p> <p className="mt-2 text-xs text-yellow-500">Cancels at end of billing period.</p>
)} )}
{sub?.currentPeriodEnd && (
<p className="mt-2 text-xs text-muted-foreground">
Current period ends {new Date(sub.currentPeriodEnd).toLocaleDateString()}.
</p>
)}
</> </>
)} )}
@ -123,39 +133,34 @@ export default function SubscriptionPage() {
)} )}
</div> </div>
{/* Upgrade options */} {currentPlan !== "elite" && (
{currentPlan === "free" && (
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{(["pro", "elite"] as const).map((plan) => ( {(["pro", "elite"] as const)
.filter((plan) => currentPlan === "free" || plan === "elite")
.map((plan) => (
<div key={plan} className="glass-panel rounded-2xl p-6 border border-border hover:border-primary/50 transition-all"> <div key={plan} className="glass-panel rounded-2xl p-6 border border-border hover:border-primary/50 transition-all">
<p className="text-lg font-bold text-foreground capitalize">{plan}</p> <div className="flex items-start justify-between gap-3">
<p className="mt-1 text-sm text-muted-foreground">{PLAN_DESCRIPTIONS[plan]}</p> <div>
<p className="text-lg font-bold text-foreground">{PLAN_LABELS[plan]}</p>
<p className="mt-1 text-sm font-semibold text-primary">{PLAN_PRICES[plan]}</p>
</div>
{currentPlan === plan && (
<span className="rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary">Current</span>
)}
</div>
<p className="mt-3 text-sm text-muted-foreground">{PLAN_DESCRIPTIONS[plan]}</p>
<button <button
onClick={() => handleUpgrade(plan)} onClick={() => handleUpgrade(plan)}
disabled={actionLoading} disabled={actionLoading || currentPlan === plan}
className="mt-4 w-full rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all disabled:opacity-50" className="mt-4 w-full rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all disabled:opacity-50"
> >
Upgrade to {PLAN_LABELS[plan]} {currentPlan === plan ? "Current plan" : `Upgrade to ${PLAN_LABELS[plan]}`}
</button> </button>
</div> </div>
))} ))}
</div> </div>
)} )}
{currentPlan === "pro" && (
<div className="glass-panel rounded-2xl p-6 border border-border hover:border-primary/50 transition-all">
<p className="text-lg font-bold text-foreground">Elite</p>
<p className="mt-1 text-sm text-muted-foreground">{PLAN_DESCRIPTIONS.elite}</p>
<button
onClick={() => handleUpgrade("elite")}
disabled={actionLoading}
className="mt-4 rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all disabled:opacity-50"
>
Upgrade to Elite
</button>
</div>
)}
{actionStatus && ( {actionStatus && (
<p className="text-sm text-muted-foreground">{actionStatus}</p> <p className="text-sm text-muted-foreground">{actionStatus}</p>
)} )}

View File

@ -2,27 +2,81 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AppShell } from "../../components/app-shell"; import { AppShell } from "../../components/app-shell";
import { apiFetch } from "@/lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type TaxReturn = { type TaxReturn = {
id: string; id: string;
taxYear: number; taxYear: number;
filingType: "individual" | "business"; filingType: "individual" | "business";
jurisdictions: string[]; jurisdictions: string[];
status: "draft" | "ready" | "exported"; status: "draft" | "ready" | "exported" | "efile_submitted" | "efile_accepted";
summary?: { intake?: TaxIntake; intakeReadiness?: TaxReadiness; eFile?: EFileStatus };
updatedAt: string; updatedAt: string;
documents?: TaxDocument[];
};
type TaxDocument = {
id: string;
docType: string;
metadata?: Record<string, unknown>;
};
type TaxIntake = {
taxpayer: {
name?: string;
filingStatus?: string;
address?: string;
};
income: {
wages?: number;
business?: number;
interest?: number;
dividends?: number;
total?: number;
};
deductions: {
standard?: boolean;
charitable?: number;
studentLoanInterest?: number;
};
credits: {
education?: number;
childTax?: number;
};
notes?: string;
};
type TaxReadiness = {
complete: boolean;
missingFields: string[];
};
type EFileStatus = {
provider?: string;
providerMode?: string;
submissionId?: string;
status?: string;
acknowledgementId?: string;
lastCheckedAt?: string;
};
const emptyIntake: TaxIntake = {
taxpayer: { name: "", filingStatus: "Single", address: "" },
income: { wages: 0, business: 0, interest: 0, dividends: 0, total: 0 },
deductions: { standard: true, charitable: 0, studentLoanInterest: 0 },
credits: { education: 0, childTax: 0 },
notes: "",
}; };
export default function TaxPage() { export default function TaxPage() {
const [returns, setReturns] = useState<TaxReturn[]>([]); const [returns, setReturns] = useState<TaxReturn[]>([]);
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [useLocal, setUseLocal] = useState(true); const [loading, setLoading] = useState(true);
const [useSample, setUseSample] = useState(true); const [useSample, setUseSample] = useState(true);
const [selectedReturnId, setSelectedReturnId] = useState("");
const [intake, setIntake] = useState<TaxIntake>(emptyIntake);
const [readiness, setReadiness] = useState<TaxReadiness | null>(null);
const [efileConsent, setEfileConsent] = useState(false);
const [year, setYear] = useState(new Date().getFullYear()); const [year, setYear] = useState(new Date().getFullYear());
const [filingType, setFilingType] = useState<"individual" | "business">("individual"); const [filingType, setFilingType] = useState<"individual" | "business">("individual");
const [jurisdictions, setJurisdictions] = useState<string[]>(["CA", "NY"]); const [jurisdictions, setJurisdictions] = useState<string[]>(["CA", "NY"]);
@ -51,12 +105,12 @@ export default function TaxPage() {
childTax: 0 childTax: 0
}, },
documents: [ documents: [
"W-2 (Northwind Labs)", { docType: "w2_or_1099", label: "W-2 / 1099 income forms" },
"1099-INT (City Bank)", { docType: "interest_and_dividend_forms", label: "1099-INT / 1099-DIV forms" },
"1099-DIV (Index Fund)", { docType: "deduction_support", label: "Deduction support" },
"1099-NEC (Doe Consulting)", { docType: "identity_information", label: "Identity information" },
"Health Insurance 1095-A", { docType: "state_withholding_statement", label: "State withholding statement" },
"State withholding statement" { docType: "health_insurance_statement", label: "Health insurance 1095-A" }
] ]
}; };
@ -113,46 +167,21 @@ export default function TaxPage() {
{ code: "WY", name: "Wyoming" } { code: "WY", name: "Wyoming" }
]; ];
const localKey = "ledgerone_tax_returns";
const ensureUserId = () => {
if (typeof window === "undefined") {
return "";
}
let userId = localStorage.getItem("ledgerone_user_id");
if (!userId) {
userId = `demo_${crypto.randomUUID()}`;
localStorage.setItem("ledgerone_user_id", userId);
}
return userId;
};
const loadReturns = async () => { const loadReturns = async () => {
if (useLocal && typeof window !== "undefined") { setLoading(true);
const raw = localStorage.getItem(localKey); const payload = await apiFetch<TaxReturn[]>("/api/tax/returns");
setReturns(raw ? (JSON.parse(raw) as TaxReturn[]) : []); setLoading(false);
setStatus("Running in local-only mode."); if (payload.error) {
setStatus(payload.error.message ?? "Unable to load returns.");
return; return;
} }
const userId = ensureUserId(); setReturns(payload.data ?? []);
const query = userId ? `?user_id=${encodeURIComponent(userId)}` : ""; if (!selectedReturnId && payload.data?.[0]) {
try { setSelectedReturnId(payload.data[0].id);
const res = await fetch(`/api/tax/returns${query}`); setIntake({ ...emptyIntake, ...(payload.data[0].summary?.intake ?? {}) });
const payload = (await res.json()) as ApiResponse<TaxReturn[]>; setReadiness(payload.data[0].summary?.intakeReadiness ?? null);
if (!res.ok || payload.error) {
throw new Error(payload.error?.message ?? "Unable to load returns.");
}
setReturns(payload.data);
setUseLocal(false);
return;
} catch {
if (typeof window !== "undefined") {
const raw = localStorage.getItem(localKey);
setReturns(raw ? (JSON.parse(raw) as TaxReturn[]) : []);
setUseLocal(true);
setStatus("Running in local-only mode (no backend).");
}
} }
setStatus("");
}; };
useEffect(() => { useEffect(() => {
@ -162,66 +191,117 @@ export default function TaxPage() {
}, []); }, []);
const createReturn = async () => { const createReturn = async () => {
const userId = ensureUserId();
setStatus("Creating return..."); setStatus("Creating return...");
const payload = { const payload = {
userId,
taxYear: year, taxYear: year,
filingType, filingType,
jurisdictions jurisdictions
}; };
if (useLocal) { const response = await apiFetch<TaxReturn>("/api/tax/returns", {
const nextReturn: TaxReturn = {
id: `local_${crypto.randomUUID()}`,
taxYear: payload.taxYear,
filingType: payload.filingType,
jurisdictions: payload.jurisdictions,
status: "draft",
updatedAt: new Date().toISOString()
};
const next = [...returns, nextReturn];
localStorage.setItem(localKey, JSON.stringify(next));
setReturns(next);
setStatus("Return created locally.");
return;
}
const res = await fetch("/api/tax/returns", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
const response = (await res.json()) as ApiResponse<TaxReturn>; if (response.error) {
if (!res.ok || response.error) {
setStatus(response.error?.message ?? "Unable to create return."); setStatus(response.error?.message ?? "Unable to create return.");
return; return;
} }
if (useSample) {
await Promise.all(
sampleProfile.documents.map((document) =>
apiFetch(`/api/tax/returns/${response.data.id}/documents`, {
method: "POST",
body: JSON.stringify({
docType: document.docType,
metadata: { source: "sample", label: document.label, taxpayer: sampleProfile.taxpayer.name },
}),
}),
),
);
}
setStatus("Return created."); setStatus("Return created.");
await loadReturns(); await loadReturns();
setSelectedReturnId(response.data.id);
if (useSample) {
const sampleIntake = intakeFromSample();
setIntake(sampleIntake);
await saveIntake(response.data.id, sampleIntake, true);
}
};
const intakeFromSample = (): TaxIntake => {
const wages = sampleProfile.income.w2.reduce((sum, item) => sum + item.wages, 0);
const business = sampleProfile.income.selfEmployment.reduce((sum, item) => sum + item.income - item.expenses, 0);
const interest = sampleProfile.income.interest.reduce((sum, item) => sum + item.amount, 0);
const dividends = sampleProfile.income.dividends.reduce((sum, item) => sum + item.amount, 0);
return {
taxpayer: {
name: sampleProfile.taxpayer.name,
filingStatus: sampleProfile.taxpayer.filingStatus,
address: sampleProfile.taxpayer.address,
},
income: { wages, business, interest, dividends, total: wages + business + interest + dividends },
deductions: sampleProfile.deductions,
credits: sampleProfile.credits,
notes: "Sample intake generated from John Doe dataset.",
};
};
const selectReturn = async (ret: TaxReturn) => {
setSelectedReturnId(ret.id);
setStatus("Loading intake...");
const response = await apiFetch<{ intake: TaxIntake; readiness: TaxReadiness }>(`/api/tax/returns/${ret.id}/intake`);
if (response.error) {
setStatus(response.error.message ?? "Unable to load intake.");
return;
}
setIntake({ ...emptyIntake, ...(response.data.intake ?? {}) });
setReadiness(response.data.readiness);
setStatus("");
};
const saveIntake = async (returnId = selectedReturnId, nextIntake = intake, submit = false) => {
if (!returnId) {
setStatus("Select a return before saving intake.");
return;
}
const total =
Number(nextIntake.income.wages ?? 0) +
Number(nextIntake.income.business ?? 0) +
Number(nextIntake.income.interest ?? 0) +
Number(nextIntake.income.dividends ?? 0);
const normalized = { ...nextIntake, income: { ...nextIntake.income, total } };
setStatus(submit ? "Submitting intake..." : "Saving intake...");
const response = await apiFetch<{ intake: TaxIntake; readiness: TaxReadiness }>(`/api/tax/returns/${returnId}/intake`, {
method: "PUT",
body: JSON.stringify({ intake: normalized, submit }),
});
if (response.error) {
setStatus(response.error.message ?? "Unable to save intake.");
return;
}
setIntake(response.data.intake);
setReadiness(response.data.readiness);
setStatus(submit ? "Intake submitted." : "Intake saved.");
await loadReturns();
};
const updateIntake = (section: keyof TaxIntake, key: string, value: string | number | boolean) => {
setIntake((current) => ({
...current,
[section]: {
...(current[section] as Record<string, unknown>),
[key]: value,
},
}));
}; };
const exportReturn = async (id: string) => { const exportReturn = async (id: string) => {
setStatus("Exporting return..."); setStatus("Exporting return...");
if (useLocal) { const response = await apiFetch<{
const ret = returns.find((item) => item.id === id);
const payload = {
return: ret,
documents: useSample ? sampleProfile.documents : [],
sampleData: useSample ? sampleProfile : null
};
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: "application/json"
});
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer");
setStatus("Export ready (local).");
return;
}
const res = await fetch(`/api/tax/returns/${id}/export`, { method: "POST" });
const response = (await res.json()) as ApiResponse<{
return: TaxReturn; return: TaxReturn;
documents: unknown[]; documents: unknown[];
}>; }>(`/api/tax/returns/${id}/export`, { method: "POST" });
if (!res.ok || response.error) { if (response.error) {
setStatus(response.error?.message ?? "Export failed."); setStatus(response.error?.message ?? "Export failed.");
return; return;
} }
@ -234,6 +314,31 @@ export default function TaxPage() {
await loadReturns(); await loadReturns();
}; };
const submitEFile = async (id: string) => {
setStatus("Submitting e-file package...");
const response = await apiFetch<{ eFile: EFileStatus }>(`/api/tax/returns/${id}/efile`, {
method: "POST",
body: JSON.stringify({ consentAccepted: efileConsent }),
});
if (response.error) {
setStatus(response.error?.message ?? "E-file submission failed.");
return;
}
setStatus(`E-file submitted to ${response.data.eFile.provider ?? "provider"}.`);
await loadReturns();
};
const refreshEFileStatus = async (id: string) => {
setStatus("Checking e-file status...");
const response = await apiFetch<{ eFile?: EFileStatus; status?: string }>(`/api/tax/returns/${id}/efile`);
if (response.error) {
setStatus(response.error?.message ?? "Unable to check e-file status.");
return;
}
setStatus(response.data.eFile?.status ? `E-file status: ${response.data.eFile.status}.` : "Return has not been e-filed.");
await loadReturns();
};
return ( return (
<AppShell title="Tax" subtitle="Prepare returns and export audit-ready packages."> <AppShell title="Tax" subtitle="Prepare returns and export audit-ready packages.">
<div className="grid gap-6 lg:grid-cols-[1.05fr_0.95fr]"> <div className="grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
@ -314,8 +419,8 @@ export default function TaxPage() {
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Required documents</p> <p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Required documents</p>
<div className="mt-3 grid gap-2 text-xs text-muted-foreground"> <div className="mt-3 grid gap-2 text-xs text-muted-foreground">
{sampleProfile.documents.map((doc) => ( {sampleProfile.documents.map((doc) => (
<div key={doc} className="flex items-center justify-between"> <div key={doc.docType} className="flex items-center justify-between">
<span>{doc}</span> <span>{doc.label}</span>
<span className="rounded-full bg-secondary px-2 py-1 text-foreground font-medium">Pending</span> <span className="rounded-full bg-secondary px-2 py-1 text-foreground font-medium">Pending</span>
</div> </div>
))} ))}
@ -335,7 +440,9 @@ export default function TaxPage() {
<div className="glass-panel p-6 rounded-2xl shadow-sm"> <div className="glass-panel p-6 rounded-2xl shadow-sm">
<h2 className="text-lg font-bold text-foreground">Your returns</h2> <h2 className="text-lg font-bold text-foreground">Your returns</h2>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
{returns.length ? ( {loading ? (
<p className="text-sm text-muted-foreground">Loading returns...</p>
) : returns.length ? (
returns.map((ret) => ( returns.map((ret) => (
<div key={ret.id} className="rounded-xl border border-border bg-background/50 p-4"> <div key={ret.id} className="rounded-xl border border-border bg-background/50 p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -346,21 +453,55 @@ export default function TaxPage() {
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
States: {ret.jurisdictions.join(", ")} States: {ret.jurisdictions.join(", ")}
</p> </p>
{useSample ? ( {ret.documents?.length ? (
<p className="text-xs text-muted-foreground">Sample: John Doe</p> <p className="text-xs text-muted-foreground">
Documents: {ret.documents.length}
</p>
) : null} ) : null}
</div> </div>
<span className="rounded-full bg-secondary px-2 py-1 text-xs font-medium text-foreground"> <span className="rounded-full bg-secondary px-2 py-1 text-xs font-medium text-foreground">
{ret.status} {ret.status}
</span> </span>
</div> </div>
<button
type="button"
onClick={() => selectReturn(ret)}
className="mt-3 rounded-lg border border-border bg-secondary/30 px-3 py-2 text-xs font-bold text-foreground hover:bg-secondary transition-colors"
>
Open intake
</button>
<button <button
type="button" type="button"
onClick={() => exportReturn(ret.id)} onClick={() => exportReturn(ret.id)}
className="mt-3 rounded-lg bg-primary px-3 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90 transition-colors" className="ml-2 mt-3 rounded-lg bg-primary px-3 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90 transition-colors"
> >
Export package Export package
</button> </button>
<div className="mt-3 flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input type="checkbox" checked={efileConsent} onChange={(event) => setEfileConsent(event.target.checked)} />
E-file consent
</label>
<button
type="button"
onClick={() => submitEFile(ret.id)}
className="rounded-lg bg-primary px-3 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90 transition-colors"
>
Submit e-file
</button>
<button
type="button"
onClick={() => refreshEFileStatus(ret.id)}
className="rounded-lg border border-border bg-secondary/30 px-3 py-2 text-xs font-bold text-foreground hover:bg-secondary transition-colors"
>
Check status
</button>
</div>
{ret.summary?.eFile?.submissionId ? (
<p className="mt-2 text-xs text-muted-foreground">
E-file: {ret.summary.eFile.status} - {ret.summary.eFile.submissionId}
</p>
) : null}
</div> </div>
)) ))
) : ( ) : (
@ -369,6 +510,63 @@ export default function TaxPage() {
</div> </div>
</div> </div>
</div> </div>
<div className="mt-6 glass-panel p-6 rounded-2xl shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-lg font-bold text-foreground">Tax intake</h2>
<p className="mt-1 text-sm text-muted-foreground">
Capture taxpayer details, income, deductions, and credits before export.
</p>
</div>
{readiness ? (
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${readiness.complete ? "bg-primary/10 text-primary" : "bg-yellow-500/10 text-yellow-500"}`}>
{readiness.complete ? "Ready" : `${readiness.missingFields.length} missing`}
</span>
) : null}
</div>
{selectedReturnId ? (
<div className="mt-5 grid gap-5 lg:grid-cols-4">
<div className="grid gap-3">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Taxpayer</p>
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" placeholder="Name" value={intake.taxpayer.name ?? ""} onChange={(e) => updateIntake("taxpayer", "name", e.target.value)} />
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" placeholder="Filing status" value={intake.taxpayer.filingStatus ?? ""} onChange={(e) => updateIntake("taxpayer", "filingStatus", e.target.value)} />
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" placeholder="Address" value={intake.taxpayer.address ?? ""} onChange={(e) => updateIntake("taxpayer", "address", e.target.value)} />
</div>
<div className="grid gap-3">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Income</p>
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" type="number" placeholder="Wages" value={intake.income.wages ?? 0} onChange={(e) => updateIntake("income", "wages", Number(e.target.value))} />
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" type="number" placeholder="Business net" value={intake.income.business ?? 0} onChange={(e) => updateIntake("income", "business", Number(e.target.value))} />
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" type="number" placeholder="Interest" value={intake.income.interest ?? 0} onChange={(e) => updateIntake("income", "interest", Number(e.target.value))} />
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" type="number" placeholder="Dividends" value={intake.income.dividends ?? 0} onChange={(e) => updateIntake("income", "dividends", Number(e.target.value))} />
</div>
<div className="grid gap-3">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Deductions / Credits</p>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input type="checkbox" checked={Boolean(intake.deductions.standard)} onChange={(e) => updateIntake("deductions", "standard", e.target.checked)} />
Standard deduction
</label>
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" type="number" placeholder="Charitable" value={intake.deductions.charitable ?? 0} onChange={(e) => updateIntake("deductions", "charitable", Number(e.target.value))} />
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" type="number" placeholder="Student loan interest" value={intake.deductions.studentLoanInterest ?? 0} onChange={(e) => updateIntake("deductions", "studentLoanInterest", Number(e.target.value))} />
<input className="rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" type="number" placeholder="Education credit" value={intake.credits.education ?? 0} onChange={(e) => updateIntake("credits", "education", Number(e.target.value))} />
</div>
<div className="grid gap-3">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Review</p>
<textarea className="min-h-32 rounded-xl border border-border bg-background/50 px-4 py-3 text-sm" placeholder="Reviewer notes" value={intake.notes ?? ""} onChange={(e) => setIntake((current) => ({ ...current, notes: e.target.value }))} />
{readiness?.missingFields?.length ? (
<p className="text-xs text-yellow-500">Missing: {readiness.missingFields.join(", ")}</p>
) : null}
<div className="flex gap-2">
<button type="button" onClick={() => saveIntake()} className="rounded-lg border border-border bg-secondary/30 px-3 py-2 text-xs font-bold text-foreground hover:bg-secondary">Save draft</button>
<button type="button" onClick={() => saveIntake(selectedReturnId, intake, true)} className="rounded-lg bg-primary px-3 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90">Submit intake</button>
</div>
</div>
</div>
) : (
<p className="mt-4 text-sm text-muted-foreground">Create or select a return to start intake.</p>
)}
</div>
</AppShell> </AppShell>
); );
} }

View File

@ -3,13 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { AppShell } from "../../components/app-shell"; import { AppShell } from "../../components/app-shell";
import { apiFetch, getStoredToken } from "@/lib/api"; import { apiFetch } from "@/lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type TransactionRow = { type TransactionRow = {
id: string; id: string;
@ -18,6 +12,14 @@ type TransactionRow = {
amount: string; amount: string;
category?: string | null; category?: string | null;
note?: string | null; note?: string | null;
attribution?: "mine" | "yours" | "ours";
split?: {
mode: "none" | "equal" | "custom";
minePercent: number;
yoursPercent: number;
mineAmount: number;
yoursAmount: number;
};
status?: string; status?: string;
hidden?: boolean; hidden?: boolean;
date: string; date: string;
@ -34,15 +36,65 @@ type Account = {
type ImportResult = { type ImportResult = {
imported: number; imported: number;
skipped: number; skipped: number;
total?: number;
errors?: string[]; errors?: string[];
}; };
type ImportBatchResult = {
totalFiles: number;
processedFiles: number;
failedFiles: number;
imported: number;
skipped: number;
total: number;
results: Array<{
fileName: string;
imported: number;
skipped: number;
total: number;
error?: string;
}>;
};
type CsvMapping = {
date: string;
description: string;
amount: string;
category?: string;
notes?: string;
amountMultiplier?: 1 | -1;
};
type CsvPreview = {
fileName: string;
headerSignature: string;
headers: string[];
sampleRows: Record<string, string>[];
mapping: Partial<CsvMapping>;
remembered: boolean;
};
type CashflowRow = {
month: string;
income: string;
expense: string;
net: string;
};
type MerchantInsight = {
merchant: string;
total: string;
count: number;
};
export default function TransactionsPage() { export default function TransactionsPage() {
const [rows, setRows] = useState<TransactionRow[]>([]); const [rows, setRows] = useState<TransactionRow[]>([]);
const [status, setStatus] = useState("Loading transactions..."); const [status, setStatus] = useState("Loading transactions...");
const [summary, setSummary] = useState<{ const [summary, setSummary] = useState<{
total: string; count: number; income?: string; expense?: string; net?: string; total: string; count: number; income?: string; expense?: string; net?: string;
} | null>(null); } | null>(null);
const [cashflow, setCashflow] = useState<CashflowRow[]>([]);
const [merchants, setMerchants] = useState<MerchantInsight[]>([]);
const [datePreset, setDatePreset] = useState("this_month"); const [datePreset, setDatePreset] = useState("this_month");
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [accounts, setAccounts] = useState<Account[]>([]); const [accounts, setAccounts] = useState<Account[]>([]);
@ -52,6 +104,14 @@ export default function TransactionsPage() {
const [showImport, setShowImport] = useState(false); const [showImport, setShowImport] = useState(false);
const [importStatus, setImportStatus] = useState(""); const [importStatus, setImportStatus] = useState("");
const [importLoading, setImportLoading] = useState(false); const [importLoading, setImportLoading] = useState(false);
const [csvFiles, setCsvFiles] = useState<File[]>([]);
const [csvPreview, setCsvPreview] = useState<CsvPreview | null>(null);
const [csvMapping, setCsvMapping] = useState<CsvMapping>({
date: "",
description: "",
amount: "",
amountMultiplier: 1,
});
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [manualForm, setManualForm] = useState({ const [manualForm, setManualForm] = useState({
accountId: "", accountId: "",
@ -60,9 +120,21 @@ export default function TransactionsPage() {
amount: "", amount: "",
category: "", category: "",
note: "", note: "",
attribution: "mine" as "mine" | "yours" | "ours",
splitMode: "none" as "none" | "equal" | "custom",
splitMinePercent: "50",
splitYoursPercent: "50",
}); });
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState({ category: "", note: "", hidden: false }); const [editForm, setEditForm] = useState({
category: "",
note: "",
attribution: "mine" as "mine" | "yours" | "ours",
splitMode: "none" as "none" | "equal" | "custom",
splitMinePercent: "50",
splitYoursPercent: "50",
hidden: false,
});
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
startDate: "", endDate: "", minAmount: "", maxAmount: "", startDate: "", endDate: "", minAmount: "", maxAmount: "",
category: "", source: "", search: "", includeHidden: false, category: "", source: "", search: "", includeHidden: false,
@ -125,10 +197,20 @@ export default function TransactionsPage() {
if (!res.error) setSummary(res.data); if (!res.error) setSummary(res.data);
}; };
const loadInsights = async () => {
const [cashflowRes, merchantsRes] = await Promise.all([
apiFetch<CashflowRow[]>("/api/transactions/cashflow?months=6"),
apiFetch<MerchantInsight[]>("/api/transactions/merchants?limit=6"),
]);
if (!cashflowRes.error) setCashflow(cashflowRes.data ?? []);
if (!merchantsRes.error) setMerchants(merchantsRes.data ?? []);
};
useEffect(() => { useEffect(() => {
applyPreset("this_month"); applyPreset("this_month");
load(); load();
loadSummary(); loadSummary();
loadInsights();
loadAccounts(); loadAccounts();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@ -156,31 +238,97 @@ export default function TransactionsPage() {
setStatus("Sync complete."); setStatus("Sync complete.");
await load(); await load();
await loadSummary(); await loadSummary();
await loadInsights();
setIsSyncing(false); setIsSyncing(false);
}; };
const onImportCsv = async (file: File) => { const onPreviewCsv = async (files: File[]) => {
const csvFiles = files.filter((file) => file.name.toLowerCase().endsWith(".csv"));
if (!csvFiles.length) {
setImportStatus("Select one or more CSV files.");
return;
}
setCsvFiles(csvFiles);
setCsvPreview(null);
setImportLoading(true); setImportLoading(true);
setImportStatus("Uploading..."); setImportStatus(`Reading ${csvFiles[0].name}...`);
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", csvFiles[0]);
const token = getStoredToken();
try { try {
const res = await fetch("/api/transactions/import", { const payload = await apiFetch<CsvPreview>("/api/transactions/import/preview", {
method: "POST", method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData, body: formData,
}); });
const payload = (await res.json()) as ApiResponse<ImportResult>; if (payload.error) {
if (!res.ok || payload.error) { setImportStatus(payload.error?.message ?? "Preview failed.");
setImportLoading(false);
return;
}
const preview = payload.data;
setCsvPreview(preview);
setCsvMapping({
date: preview.mapping.date ?? "",
description: preview.mapping.description ?? "",
amount: preview.mapping.amount ?? "",
category: preview.mapping.category,
notes: preview.mapping.notes,
amountMultiplier: preview.mapping.amountMultiplier === -1 ? -1 : 1,
});
setImportStatus(
`${csvFiles.length} CSV file${csvFiles.length === 1 ? "" : "s"} selected. ${preview.remembered ? "Using remembered mapping." : "Review the column mapping before import."}`
);
} catch {
setImportStatus("Preview failed. Please try again.");
}
setImportLoading(false);
};
const onImportCsv = async () => {
const selectedFiles = csvFiles.filter((file) => file.name.toLowerCase().endsWith(".csv"));
if (!selectedFiles.length) {
setImportStatus("Select one or more CSV files.");
return;
}
if (!csvMapping.date || !csvMapping.description || !csvMapping.amount) {
setImportStatus("Map date, description, and amount columns before importing.");
return;
}
setImportLoading(true);
setImportStatus(`Uploading ${selectedFiles.length} file${selectedFiles.length === 1 ? "" : "s"}...`);
const formData = new FormData();
for (const file of selectedFiles) {
formData.append("files", file);
}
formData.append("mapping", JSON.stringify(csvMapping));
try {
const payload = await apiFetch<ImportBatchResult>("/api/transactions/import/batch", {
method: "POST",
body: formData,
});
if (payload.error) {
setImportStatus(payload.error?.message ?? "Import failed."); setImportStatus(payload.error?.message ?? "Import failed.");
setImportLoading(false); setImportLoading(false);
return; return;
} }
const r = payload.data; const r = payload.data;
setImportStatus(`Imported ${r.imported} transaction${r.imported === 1 ? "" : "s"}, skipped ${r.skipped} duplicate${r.skipped === 1 ? "" : "s"}.`); const failed = r.failedFiles
? ` ${r.failedFiles} file${r.failedFiles === 1 ? "" : "s"} failed.`
: "";
const failedNames = r.results
.filter((result) => result.error)
.map((result) => `${result.fileName}: ${result.error}`)
.join(" ");
setImportStatus(
`Processed ${r.processedFiles}/${r.totalFiles} file${r.totalFiles === 1 ? "" : "s"}. Imported ${r.imported} transaction${r.imported === 1 ? "" : "s"}, skipped ${r.skipped} duplicate${r.skipped === 1 ? "" : "s"}.${failed}${failedNames ? ` ${failedNames}` : ""}`
);
setCsvFiles([]);
setCsvPreview(null);
await load(); await load();
await loadSummary(); await loadSummary();
await loadInsights();
} catch { } catch {
setImportStatus("Import failed. Please try again."); setImportStatus("Import failed. Please try again.");
} }
@ -196,6 +344,23 @@ export default function TransactionsPage() {
}; };
}; };
const splitPayload = (mode: "none" | "equal" | "custom", mine: string, yours: string) => {
if (mode === "custom") {
return {
splitMode: mode,
splitMinePercent: Number.parseFloat(mine),
splitYoursPercent: Number.parseFloat(yours),
};
}
return { splitMode: mode };
};
const splitLabel = (row: TransactionRow) => {
if (!row.split || row.split.mode === "none") return "No split";
if (row.split.mode === "equal") return "50/50";
return `${row.split.minePercent}/${row.split.yoursPercent}`;
};
const onManualCreate = async (event: React.FormEvent) => { const onManualCreate = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
const amount = Number.parseFloat(manualForm.amount); const amount = Number.parseFloat(manualForm.amount);
@ -210,19 +375,30 @@ export default function TransactionsPage() {
amount, amount,
category: manualForm.category || undefined, category: manualForm.category || undefined,
note: manualForm.note || undefined, note: manualForm.note || undefined,
attribution: manualForm.attribution,
...splitPayload(manualForm.splitMode, manualForm.splitMinePercent, manualForm.splitYoursPercent),
}), }),
}); });
if (res.error) { setStatus(res.error.message ?? "Unable to save transaction."); return; } if (res.error) { setStatus(res.error.message ?? "Unable to save transaction."); return; }
setManualForm((prev) => ({ ...prev, description: "", amount: "", category: "", note: "" })); setManualForm((prev) => ({ ...prev, description: "", amount: "", category: "", note: "", attribution: "mine", splitMode: "none", splitMinePercent: "50", splitYoursPercent: "50" }));
setShowManual(false); setShowManual(false);
setStatus("Manual transaction saved."); setStatus("Manual transaction saved.");
await load(); await load();
await loadSummary(); await loadSummary();
await loadInsights();
}; };
const startEdit = (row: TransactionRow) => { const startEdit = (row: TransactionRow) => {
setEditingId(row.id); setEditingId(row.id);
setEditForm({ category: row.category ?? "", note: row.note ?? "", hidden: Boolean(row.hidden) }); setEditForm({
category: row.category ?? "",
note: row.note ?? "",
attribution: row.attribution ?? "mine",
splitMode: row.split?.mode ?? "none",
splitMinePercent: String(row.split?.minePercent ?? 50),
splitYoursPercent: String(row.split?.yoursPercent ?? 50),
hidden: Boolean(row.hidden),
});
}; };
const saveEdit = async () => { const saveEdit = async () => {
@ -233,6 +409,8 @@ export default function TransactionsPage() {
body: JSON.stringify({ body: JSON.stringify({
userCategory: editForm.category || undefined, userCategory: editForm.category || undefined,
userNotes: editForm.note || undefined, userNotes: editForm.note || undefined,
attribution: editForm.attribution,
...splitPayload(editForm.splitMode, editForm.splitMinePercent, editForm.splitYoursPercent),
isHidden: editForm.hidden, isHidden: editForm.hidden,
}), }),
}); });
@ -241,6 +419,7 @@ export default function TransactionsPage() {
setStatus("Transaction updated."); setStatus("Transaction updated.");
await load(); await load();
await loadSummary(); await loadSummary();
await loadInsights();
}; };
const inputCls = "mt-2 w-full rounded-md border border-border bg-background/50 px-3 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none"; const inputCls = "mt-2 w-full rounded-md border border-border bg-background/50 px-3 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
@ -288,16 +467,20 @@ export default function TransactionsPage() {
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { onDrop={(e) => {
e.preventDefault(); e.preventDefault();
const file = e.dataTransfer.files[0]; onPreviewCsv(Array.from(e.dataTransfer.files));
if (file && file.name.endsWith(".csv")) onImportCsv(file);
}} }}
> >
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept=".csv" accept=".csv"
multiple
className="hidden" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) onImportCsv(f); }} onChange={(e) => {
const files = Array.from(e.target.files ?? []);
if (files.length) onPreviewCsv(files);
e.currentTarget.value = "";
}}
/> />
<svg className="mx-auto h-8 w-8 text-muted-foreground mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="mx-auto h-8 w-8 text-muted-foreground mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
@ -305,9 +488,88 @@ export default function TransactionsPage() {
{importLoading ? ( {importLoading ? (
<p className="text-sm text-muted-foreground">Uploading...</p> <p className="text-sm text-muted-foreground">Uploading...</p>
) : ( ) : (
<p className="text-sm text-muted-foreground">Drop a CSV file here or <span className="text-primary font-medium">click to browse</span></p> <p className="text-sm text-muted-foreground">Drop CSV files here or <span className="text-primary font-medium">click to browse</span></p>
)} )}
</div> </div>
{csvPreview && (
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div className="rounded-xl border border-border bg-background/40 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-foreground">Column mapping</p>
<span className="rounded-full bg-secondary px-2 py-1 text-[11px] font-medium text-muted-foreground">
{csvPreview.remembered ? "Remembered" : "New"}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{[
["date", "Date"],
["description", "Description"],
["amount", "Amount"],
["category", "Category"],
["notes", "Notes"],
].map(([key, label]) => (
<label key={key} className={labelCls}>
{label}
<select
value={(csvMapping[key as keyof CsvMapping] as string | undefined) ?? ""}
onChange={(e) => setCsvMapping((prev) => ({ ...prev, [key]: e.target.value || undefined }))}
className={inputCls}
>
<option value="">Not mapped</option>
{csvPreview.headers.map((header) => (
<option key={header} value={header}>{header}</option>
))}
</select>
</label>
))}
<label className={labelCls}>
Amount sign
<select
value={csvMapping.amountMultiplier ?? 1}
onChange={(e) => setCsvMapping((prev) => ({ ...prev, amountMultiplier: e.target.value === "-1" ? -1 : 1 }))}
className={inputCls}
>
<option value={1}>Keep file values</option>
<option value={-1}>Flip income/expense signs</option>
</select>
</label>
</div>
<button
type="button"
onClick={onImportCsv}
disabled={importLoading}
className="mt-4 w-full rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
>
{importLoading ? "Importing..." : `Import ${csvFiles.length} file${csvFiles.length === 1 ? "" : "s"}`}
</button>
</div>
<div className="overflow-hidden rounded-xl border border-border bg-background/40">
<div className="border-b border-border px-4 py-3">
<p className="text-sm font-semibold text-foreground">Preview: {csvPreview.fileName}</p>
</div>
<div className="max-h-72 overflow-auto">
<table className="w-full text-left text-xs">
<thead className="bg-secondary/30 text-muted-foreground">
<tr>
{csvPreview.headers.map((header) => (
<th key={header} className="whitespace-nowrap px-3 py-2 font-semibold">{header}</th>
))}
</tr>
</thead>
<tbody>
{csvPreview.sampleRows.map((row, index) => (
<tr key={index} className="border-t border-border text-foreground">
{csvPreview.headers.map((header) => (
<td key={header} className="max-w-48 truncate px-3 py-2">{row[header] ?? ""}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{importStatus && ( {importStatus && (
<p className="mt-3 text-sm text-muted-foreground">{importStatus}</p> <p className="mt-3 text-sm text-muted-foreground">{importStatus}</p>
)} )}
@ -348,6 +610,34 @@ export default function TransactionsPage() {
<label className={labelCls}>Note</label> <label className={labelCls}>Note</label>
<input type="text" value={manualForm.note} onChange={(e) => setManualForm((p) => ({ ...p, note: e.target.value }))} className={inputCls} /> <input type="text" value={manualForm.note} onChange={(e) => setManualForm((p) => ({ ...p, note: e.target.value }))} className={inputCls} />
</div> </div>
<div>
<label className={labelCls}>Attribution</label>
<select value={manualForm.attribution} onChange={(e) => setManualForm((p) => ({ ...p, attribution: e.target.value as "mine" | "yours" | "ours" }))} className={inputCls}>
<option value="mine">Mine</option>
<option value="yours">Yours</option>
<option value="ours">Ours</option>
</select>
</div>
<div>
<label className={labelCls}>Split</label>
<select value={manualForm.splitMode} onChange={(e) => setManualForm((p) => ({ ...p, splitMode: e.target.value as "none" | "equal" | "custom" }))} className={inputCls}>
<option value="none">No split</option>
<option value="equal">50/50</option>
<option value="custom">Custom</option>
</select>
</div>
{manualForm.splitMode === "custom" && (
<>
<div>
<label className={labelCls}>Mine %</label>
<input type="number" min="0" max="100" step="0.01" value={manualForm.splitMinePercent} onChange={(e) => setManualForm((p) => ({ ...p, splitMinePercent: e.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Yours %</label>
<input type="number" min="0" max="100" step="0.01" value={manualForm.splitYoursPercent} onChange={(e) => setManualForm((p) => ({ ...p, splitYoursPercent: e.target.value }))} className={inputCls} />
</div>
</>
)}
<div className="md:col-span-3 flex justify-end gap-2"> <div className="md:col-span-3 flex justify-end gap-2">
<button type="button" onClick={() => setShowManual(false)} className="px-4 py-2 rounded-lg border border-border text-sm text-foreground hover:bg-secondary">Cancel</button> <button type="button" onClick={() => setShowManual(false)} className="px-4 py-2 rounded-lg border border-border text-sm text-foreground hover:bg-secondary">Cancel</button>
<button type="submit" className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90">Save</button> <button type="submit" className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90">Save</button>
@ -386,6 +676,10 @@ export default function TransactionsPage() {
<label className={labelCls}>Category</label> <label className={labelCls}>Category</label>
<input type="text" value={filters.category} onChange={(e) => setFilters((p) => ({ ...p, category: e.target.value }))} className={inputCls} /> <input type="text" value={filters.category} onChange={(e) => setFilters((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
</div> </div>
<div>
<label className={labelCls}>Source</label>
<input type="text" value={filters.source} onChange={(e) => setFilters((p) => ({ ...p, source: e.target.value }))} className={inputCls} placeholder="plaid, manual, csv..." />
</div>
<div> <div>
<label className={labelCls}>Min amount</label> <label className={labelCls}>Min amount</label>
<input type="number" step="0.01" value={filters.minAmount} onChange={(e) => setFilters((p) => ({ ...p, minAmount: e.target.value }))} className={inputCls} /> <input type="number" step="0.01" value={filters.minAmount} onChange={(e) => setFilters((p) => ({ ...p, minAmount: e.target.value }))} className={inputCls} />
@ -401,7 +695,7 @@ export default function TransactionsPage() {
</label> </label>
</div> </div>
<div className="flex items-end"> <div className="flex items-end">
<button onClick={() => { load(); loadSummary(); }} className="w-full px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90">Apply</button> <button onClick={() => { load(); loadSummary(); loadInsights(); }} className="w-full px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90">Apply</button>
</div> </div>
</div> </div>
</div> </div>
@ -424,6 +718,62 @@ export default function TransactionsPage() {
</div> </div>
)} )}
{(cashflow.length > 0 || merchants.length > 0) && (
<div className="mb-6 grid gap-4 lg:grid-cols-2">
{cashflow.length > 0 && (
<div className="glass-panel rounded-xl p-4 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-bold text-foreground">Cashflow</p>
<span className="text-xs text-muted-foreground">Last 6 months</span>
</div>
<div className="space-y-3">
{cashflow.map((item) => {
const income = Number.parseFloat(item.income);
const expense = Number.parseFloat(item.expense);
const max = Math.max(income, expense, 1);
return (
<div key={item.month} className="grid grid-cols-[5rem_1fr_4.5rem] items-center gap-3 text-xs">
<span className="font-medium text-muted-foreground">{item.month}</span>
<div className="space-y-1">
<div className="h-2 rounded-full bg-secondary">
<div className="h-2 rounded-full bg-primary" style={{ width: `${Math.min((income / max) * 100, 100)}%` }} />
</div>
<div className="h-2 rounded-full bg-secondary">
<div className="h-2 rounded-full bg-foreground/70" style={{ width: `${Math.min((expense / max) * 100, 100)}%` }} />
</div>
</div>
<span className={Number.parseFloat(item.net) >= 0 ? "text-primary font-semibold" : "text-foreground font-semibold"}>
${Number.parseFloat(item.net).toFixed(0)}
</span>
</div>
);
})}
</div>
</div>
)}
{merchants.length > 0 && (
<div className="glass-panel rounded-xl p-4 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-bold text-foreground">Top merchants</p>
<span className="text-xs text-muted-foreground">By spend</span>
</div>
<div className="space-y-3">
{merchants.map((merchant) => (
<div key={merchant.merchant} className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background/40 px-3 py-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{merchant.merchant}</p>
<p className="text-xs text-muted-foreground">{merchant.count} transactions</p>
</div>
<p className="text-sm font-bold text-foreground">${Number.parseFloat(merchant.total).toFixed(2)}</p>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Transaction table */} {/* Transaction table */}
<div className="glass-panel rounded-2xl shadow-sm overflow-hidden"> <div className="glass-panel rounded-2xl shadow-sm overflow-hidden">
{status && ( {status && (
@ -436,6 +786,8 @@ export default function TransactionsPage() {
<th className="px-4 py-3">Date</th> <th className="px-4 py-3">Date</th>
<th className="px-4 py-3">Description</th> <th className="px-4 py-3">Description</th>
<th className="px-4 py-3">Category</th> <th className="px-4 py-3">Category</th>
<th className="px-4 py-3">Attribution</th>
<th className="px-4 py-3">Split</th>
<th className="px-4 py-3 text-right">Amount</th> <th className="px-4 py-3 text-right">Amount</th>
<th className="px-4 py-3 text-right">Actions</th> <th className="px-4 py-3 text-right">Actions</th>
</tr> </tr>
@ -444,8 +796,8 @@ export default function TransactionsPage() {
{rows.map((row) => {rows.map((row) =>
editingId === row.id ? ( editingId === row.id ? (
<tr key={row.id} className="border-b border-border bg-secondary/10"> <tr key={row.id} className="border-b border-border bg-secondary/10">
<td className="px-4 py-3" colSpan={2}> <td className="px-4 py-3" colSpan={4}>
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
<input <input
type="text" type="text"
value={editForm.category} value={editForm.category}
@ -460,6 +812,48 @@ export default function TransactionsPage() {
placeholder="Note" placeholder="Note"
className="flex-1 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none" className="flex-1 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
/> />
<select
value={editForm.attribution}
onChange={(e) => setEditForm((p) => ({ ...p, attribution: e.target.value as "mine" | "yours" | "ours" }))}
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
>
<option value="mine">Mine</option>
<option value="yours">Yours</option>
<option value="ours">Ours</option>
</select>
<select
value={editForm.splitMode}
onChange={(e) => setEditForm((p) => ({ ...p, splitMode: e.target.value as "none" | "equal" | "custom" }))}
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
>
<option value="none">No split</option>
<option value="equal">50/50</option>
<option value="custom">Custom</option>
</select>
{editForm.splitMode === "custom" && (
<>
<input
type="number"
min="0"
max="100"
step="0.01"
value={editForm.splitMinePercent}
onChange={(e) => setEditForm((p) => ({ ...p, splitMinePercent: e.target.value }))}
placeholder="Mine %"
className="w-20 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
/>
<input
type="number"
min="0"
max="100"
step="0.01"
value={editForm.splitYoursPercent}
onChange={(e) => setEditForm((p) => ({ ...p, splitYoursPercent: e.target.value }))}
placeholder="Yours %"
className="w-20 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
/>
</>
)}
<label className="flex items-center gap-1 text-xs text-foreground"> <label className="flex items-center gap-1 text-xs text-foreground">
<input type="checkbox" checked={editForm.hidden} onChange={(e) => setEditForm((p) => ({ ...p, hidden: e.target.checked }))} /> <input type="checkbox" checked={editForm.hidden} onChange={(e) => setEditForm((p) => ({ ...p, hidden: e.target.checked }))} />
Hide Hide
@ -488,6 +882,16 @@ export default function TransactionsPage() {
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
)} )}
</td> </td>
<td className="px-4 py-3">
<span className="inline-flex rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium capitalize text-foreground">
{row.attribution ?? "mine"}
</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-foreground">
{splitLabel(row)}
</span>
</td>
<td className={`px-4 py-3 text-right font-bold ${formatAmount(row.amount).tone}`}> <td className={`px-4 py-3 text-right font-bold ${formatAmount(row.amount).tone}`}>
{formatAmount(row.amount).display} {formatAmount(row.amount).display}
</td> </td>
@ -499,7 +903,7 @@ export default function TransactionsPage() {
)} )}
{!rows.length && !status && ( {!rows.length && !status && (
<tr> <tr>
<td colSpan={5} className="px-4 py-12 text-center text-sm text-muted-foreground"> <td colSpan={7} className="px-4 py-12 text-center text-sm text-muted-foreground">
No transactions found. Try adjusting your filters or sync your accounts. No transactions found. Try adjusting your filters or sync your accounts.
</td> </td>
</tr> </tr>

View File

@ -13,6 +13,7 @@ const navItems = [
{ href: "/rules", label: "Rules" }, { href: "/rules", label: "Rules" },
{ href: "/exports", label: "Exports" }, { href: "/exports", label: "Exports" },
{ href: "/tax", label: "Tax" }, { href: "/tax", label: "Tax" },
{ href: "/settings/households", label: "Households" },
{ href: "/settings", label: "Settings" }, { href: "/settings", label: "Settings" },
]; ];
@ -66,7 +67,7 @@ export function AppShell({ title, subtitle, children }: AppShellProps) {
.catch(() => {}); .catch(() => {});
// Fetch subscription plan // Fetch subscription plan
apiFetch<SubscriptionData>("/api/stripe/subscription") apiFetch<SubscriptionData>("/api/billing/subscription")
.then((res) => { .then((res) => {
if (!res.error && res.data?.plan) { if (!res.error && res.data?.plan) {
const p = res.data.plan; const p = res.data.plan;
@ -77,12 +78,10 @@ export function AppShell({ title, subtitle, children }: AppShellProps) {
}, []); }, []);
const onLogout = async () => { const onLogout = async () => {
const refreshToken = localStorage.getItem("ledgerone_refresh_token") ?? "";
try { try {
await fetch("/api/auth/logout", { await fetch("/api/auth/logout", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
}); });
} catch {} } catch {}
clearAuth(); clearAuth();

View File

@ -36,6 +36,9 @@ export function SiteFooter() {
<Link className="text-sm text-muted-foreground hover:text-primary transition-colors" href="/compare/vs-copilot"> <Link className="text-sm text-muted-foreground hover:text-primary transition-colors" href="/compare/vs-copilot">
Vs Copilot Vs Copilot
</Link> </Link>
<Link className="text-sm text-muted-foreground hover:text-primary transition-colors" href="/compare/vs-monarch">
Vs Monarch
</Link>
<Link className="text-sm text-muted-foreground hover:text-primary transition-colors" href="/pricing"> <Link className="text-sm text-muted-foreground hover:text-primary transition-colors" href="/pricing">
Pricing Pricing
</Link> </Link>

View File

@ -39,7 +39,7 @@ export const blogPosts: BlogPost[] = [
"Most teams start with two or three accounts. As the business grows, those connections multiply, and so do the inconsistencies.", "Most teams start with two or three accounts. As the business grows, those connections multiply, and so do the inconsistencies.",
"A connected account strategy keeps the growth sustainable. The first step is standardizing the way accounts are labeled, synced, and categorized.", "A connected account strategy keeps the growth sustainable. The first step is standardizing the way accounts are labeled, synced, and categorized.",
"LedgerOne gives teams a way to normalize incoming data without overwriting raw transactions. That means you can keep the original feed intact while building your internal logic on top.", "LedgerOne gives teams a way to normalize incoming data without overwriting raw transactions. That means you can keep the original feed intact while building your internal logic on top.",
"When you are ready to scale beyond the first two accounts, the upgrade is simple and predictable. Unlimited accounts open the door to consistent reporting across every source.", "When you are ready to scale beyond the first two accounts, the upgrade is simple and predictable. Pro covers ten active accounts, while Elite opens the door to unlimited reporting coverage across every source.",
"The teams that succeed with scale are the ones who document every rule, every decision, and every export. That is what LedgerOne is built to deliver." "The teams that succeed with scale are the ones who document every rule, every decision, and every export. That is what LedgerOne is built to deliver."
] ]
}, },

View File

@ -12,7 +12,7 @@ export const defaultFaqs = [
{ {
question: "How much does LedgerOne costi", question: "How much does LedgerOne costi",
answer: answer:
"Your first two connected accounts are free. Unlimited accounts are $9 per month." "Free includes two connected accounts. Pro includes ten accounts and paid export access. Elite includes unlimited accounts."
}, },
{ {
question: "What counts as a connected accounti", question: "What counts as a connected accounti",
@ -32,7 +32,7 @@ export const defaultFaqs = [
{ {
question: "Can I export my data anytimei", question: "Can I export my data anytimei",
answer: answer:
"Yes. You can export unlimited CSV and JSON files with full metadata." "Yes. Pro and Elite users can export unlimited CSV, JSON, XLSX, and PDF files through secure download links."
}, },
{ {
question: "Does LedgerOne support audit trailsi", question: "Does LedgerOne support audit trailsi",
@ -47,7 +47,7 @@ export const defaultFaqs = [
{ {
question: "What is the sync frequencyi", question: "What is the sync frequencyi",
answer: answer:
"Sync cadence depends on the connector. Unlimited accounts include priority sync intervals." "Sync cadence depends on the connector. Pro and Elite plans include expanded connected-account coverage."
}, },
{ {
question: "Is my data securei", question: "Is my data securei",
@ -82,7 +82,7 @@ export const defaultFaqs = [
{ {
question: "Is there a free trial for unlimited accountsi", question: "Is there a free trial for unlimited accountsi",
answer: answer:
"We currently offer the first two accounts free. Contact us for trial options." "Free includes two accounts. Contact us if you need to evaluate Pro or Elite before upgrading."
}, },
{ {
question: "Can I customize categories and tagsi", question: "Can I customize categories and tagsi",

View File

@ -1,4 +1,4 @@
// Client-side fetch helper with automatic Bearer token injection and 401 auto-refresh. // Client-side fetch helper with automatic HttpOnly cookie refresh.
export interface ApiResponse<T = unknown> { export interface ApiResponse<T = unknown> {
data: T; data: T;
@ -6,13 +6,10 @@ export interface ApiResponse<T = unknown> {
error: null | { message: string; code?: string }; error: null | { message: string; code?: string };
} }
const TOKEN_KEY = "ledgerone_token";
const REFRESH_KEY = "ledgerone_refresh_token";
const USER_KEY = "ledgerone_user"; const USER_KEY = "ledgerone_user";
export function getStoredToken(): string { export function getStoredToken(): string {
if (typeof window === "undefined") return ""; return "";
return localStorage.getItem(TOKEN_KEY) ?? "";
} }
export function getStoredUser<T = unknown>(): T | null { export function getStoredUser<T = unknown>(): T | null {
@ -26,51 +23,36 @@ export function getStoredUser<T = unknown>(): T | null {
} }
export function storeAuthTokens(data: { export function storeAuthTokens(data: {
accessToken: string; accessToken?: string;
refreshToken: string; refreshToken?: string;
user: unknown; user: unknown;
}): void { }): void {
localStorage.setItem(TOKEN_KEY, data.accessToken);
localStorage.setItem(REFRESH_KEY, data.refreshToken);
localStorage.setItem(USER_KEY, JSON.stringify(data.user)); localStorage.setItem(USER_KEY, JSON.stringify(data.user));
// Set a non-HttpOnly cookie so Next.js middleware can detect auth state
document.cookie = "ledgerone_auth=1; path=/; max-age=2592000; SameSite=Lax";
} }
export function clearAuth(): void { export function clearAuth(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(USER_KEY); localStorage.removeItem(USER_KEY);
document.cookie = "ledgerone_auth=; path=/; max-age=0";
} }
async function tryRefresh(): Promise<string | null> { async function tryRefresh(): Promise<boolean> {
const refreshToken = localStorage.getItem(REFRESH_KEY);
if (!refreshToken) return null;
try { try {
const res = await fetch("/api/auth/refresh", { const res = await fetch("/api/auth/refresh", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
}); });
if (!res.ok) { if (!res.ok) {
clearAuth(); clearAuth();
return null; return false;
} }
const payload = (await res.json()) as ApiResponse<{ const payload = (await res.json()) as ApiResponse<unknown>;
accessToken: string; if (payload.error) {
refreshToken: string;
}>;
if (payload.error || !payload.data?.accessToken) {
clearAuth(); clearAuth();
return null; return false;
} }
localStorage.setItem(TOKEN_KEY, payload.data.accessToken); return true;
localStorage.setItem(REFRESH_KEY, payload.data.refreshToken);
return payload.data.accessToken;
} catch { } catch {
clearAuth(); clearAuth();
return null; return false;
} }
} }
@ -78,11 +60,9 @@ export async function apiFetch<T = unknown>(
path: string, path: string,
options: RequestInit = {} options: RequestInit = {}
): Promise<ApiResponse<T>> { ): Promise<ApiResponse<T>> {
const token = getStoredToken();
const headers: Record<string, string> = { const headers: Record<string, string> = {
...(options.headers as Record<string, string>), ...(options.headers as Record<string, string>),
}; };
if (token) headers["Authorization"] = `Bearer ${token}`;
if ( if (
options.body && options.body &&
typeof options.body === "string" && typeof options.body === "string" &&
@ -95,9 +75,8 @@ export async function apiFetch<T = unknown>(
// Auto-refresh on 401 // Auto-refresh on 401
if (res.status === 401) { if (res.status === 401) {
const newToken = await tryRefresh(); const refreshed = await tryRefresh();
if (newToken) { if (refreshed) {
headers["Authorization"] = `Bearer ${newToken}`;
res = await fetch(path, { ...options, headers }); res = await fetch(path, { ...options, headers });
} else { } else {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {

38
lib/auth-cookies.ts Normal file
View File

@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
const IS_PROD = process.env.NODE_ENV === "production";
export const ACCESS_COOKIE = "ledgerone_access";
export const REFRESH_COOKIE = "ledgerone_refresh";
export const AUTH_COOKIE = "ledgerone_auth";
const cookieOptions = {
httpOnly: true,
secure: IS_PROD,
sameSite: "lax" as const,
path: "/",
};
export function setAuthCookies(res: NextResponse, accessToken: string, refreshToken: string) {
res.cookies.set(ACCESS_COOKIE, accessToken, {
...cookieOptions,
maxAge: 60,
});
res.cookies.set(REFRESH_COOKIE, refreshToken, {
...cookieOptions,
maxAge: 30 * 24 * 60 * 60,
});
res.cookies.set(AUTH_COOKIE, "1", {
...cookieOptions,
maxAge: 30 * 24 * 60 * 60,
});
}
export function clearAuthCookies(res: NextResponse) {
for (const name of [ACCESS_COOKIE, REFRESH_COOKIE, AUTH_COOKIE]) {
res.cookies.set(name, "", {
...cookieOptions,
maxAge: 0,
});
}
}

View File

@ -2,6 +2,7 @@
// Forwards requests to the NestJS backend, including the Bearer token. // Forwards requests to the NestJS backend, including the Bearer token.
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { ACCESS_COOKIE } from "./auth-cookies";
const BASE_URL = process.env.LEDGERONE_API_URL ?? "http://localhost:3051"; const BASE_URL = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
@ -27,10 +28,16 @@ export async function proxyRequest(
const method = options.method ?? req.method; const method = options.method ?? req.method;
const auth = req.headers.get("authorization") ?? ""; const auth = req.headers.get("authorization") ?? "";
const cookieAccessToken = req.cookies.get(ACCESS_COOKIE)?.value ?? "";
const contentType = req.headers.get("content-type") ?? ""; const contentType = req.headers.get("content-type") ?? "";
const userAgent = req.headers.get("user-agent") ?? "";
const forwardedFor = req.headers.get("x-forwarded-for") ?? "";
const headers: Record<string, string> = { ...options.extraHeaders }; const headers: Record<string, string> = { ...options.extraHeaders };
if (auth) headers["Authorization"] = auth; if (auth) headers["Authorization"] = auth;
else if (cookieAccessToken) headers["Authorization"] = `Bearer ${cookieAccessToken}`;
if (userAgent) headers["User-Agent"] = userAgent;
if (forwardedFor) headers["X-Forwarded-For"] = forwardedFor;
let body: BodyInit | null | undefined = undefined; let body: BodyInit | null | undefined = undefined;

View File

@ -19,7 +19,7 @@ export function middleware(req: NextRequest) {
if (!isProtected) return NextResponse.next(); if (!isProtected) return NextResponse.next();
// ledgerone_auth cookie is set by storeAuthTokens() in lib/api.ts // ledgerone_auth is an HttpOnly marker cookie set by the auth API routes.
const authCookie = req.cookies.get("ledgerone_auth"); const authCookie = req.cookies.get("ledgerone_auth");
if (!authCookie?.value) { if (!authCookie?.value) {
const loginUrl = new URL("/login", req.url); const loginUrl = new URL("/login", req.url);

View File

@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
output: "standalone",
images: { images: {
remotePatterns: [ remotePatterns: [
{ {