From 2b17c36ed25aa15da81880d30903edf76dda06b5 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Thu, 16 Jul 2026 15:07:02 +0530 Subject: [PATCH] feat: implement LedgerOne frontend backlog features --- app/api/2fa/disable/route.ts | 2 +- app/api/2fa/enable/route.ts | 2 +- app/api/2fa/generate/route.ts | 2 +- app/api/accounts/link/route.ts | 4 +- app/api/auth/login/route.ts | 13 +- app/api/auth/logout/route.ts | 32 +- app/api/auth/me/route.ts | 4 + app/api/auth/refresh/route.ts | 44 +- app/api/auth/register/route.ts | 13 +- .../auth/social/[provider]/callback/route.ts | 18 + app/api/auth/social/[provider]/url/route.ts | 9 + app/api/billing/checkout/route.ts | 6 + app/api/billing/portal/route.ts | 6 + app/api/billing/subscription/route.ts | 6 + app/api/exports/download/[token]/route.ts | 36 ++ app/api/exports/json/route.ts | 6 + app/api/exports/pdf/route.ts | 6 + app/api/exports/xlsx/route.ts | 6 + app/api/households/[id]/dashboard/route.ts | 10 + app/api/households/route.ts | 10 + app/api/plaid/repair-complete/route.ts | 6 + app/api/plaid/update-link-token/route.ts | 6 + app/api/rules/[id]/execute/route.ts | 6 + app/api/security/risk/route.ts | 6 + app/api/stripe/checkout/route.ts | 2 +- app/api/stripe/portal/route.ts | 2 +- app/api/stripe/subscription/route.ts | 2 +- app/api/tax/returns/[id]/documents/route.ts | 9 + app/api/tax/returns/[id]/efile/route.ts | 16 + app/api/tax/returns/[id]/export/route.ts | 2 +- app/api/tax/returns/[id]/intake/route.ts | 16 + app/api/teller/config/route.ts | 6 + app/api/teller/enrollment/route.ts | 6 + app/api/teller/sync/route.ts | 6 + app/api/transactions/import/batch/route.ts | 6 + app/api/transactions/import/preview/route.ts | 6 + app/app/connect/page.tsx | 249 +++++++++- app/auth/social/callback/page.tsx | 127 +++++ app/compare/vs-monarch/page.tsx | 91 ++++ app/exports/page.tsx | 141 +++++- app/login/page.tsx | 36 +- app/pricing/page.tsx | 81 ++-- app/pricing/pricing-actions.tsx | 54 +++ app/profile/page.tsx | 20 +- app/register/page.tsx | 36 +- app/rules/page.tsx | 296 +++++++++-- app/settings/households/page.tsx | 360 ++++++++++++++ app/settings/page.tsx | 5 + app/settings/subscription/page.tsx | 63 +-- app/tax/page.tsx | 390 +++++++++++---- app/transactions/page.tsx | 458 ++++++++++++++++-- components/app-shell.tsx | 5 +- components/site-footer.tsx | 3 + data/blog.ts | 2 +- data/faq.ts | 32 +- lib/api.ts | 47 +- lib/auth-cookies.ts | 38 ++ lib/backend.ts | 7 + middleware.ts | 2 +- next.config.js | 1 + 60 files changed, 2520 insertions(+), 362 deletions(-) create mode 100644 app/api/auth/social/[provider]/callback/route.ts create mode 100644 app/api/auth/social/[provider]/url/route.ts create mode 100644 app/api/billing/checkout/route.ts create mode 100644 app/api/billing/portal/route.ts create mode 100644 app/api/billing/subscription/route.ts create mode 100644 app/api/exports/download/[token]/route.ts create mode 100644 app/api/exports/json/route.ts create mode 100644 app/api/exports/pdf/route.ts create mode 100644 app/api/exports/xlsx/route.ts create mode 100644 app/api/households/[id]/dashboard/route.ts create mode 100644 app/api/households/route.ts create mode 100644 app/api/plaid/repair-complete/route.ts create mode 100644 app/api/plaid/update-link-token/route.ts create mode 100644 app/api/rules/[id]/execute/route.ts create mode 100644 app/api/security/risk/route.ts create mode 100644 app/api/tax/returns/[id]/documents/route.ts create mode 100644 app/api/tax/returns/[id]/efile/route.ts create mode 100644 app/api/tax/returns/[id]/intake/route.ts create mode 100644 app/api/teller/config/route.ts create mode 100644 app/api/teller/enrollment/route.ts create mode 100644 app/api/teller/sync/route.ts create mode 100644 app/api/transactions/import/batch/route.ts create mode 100644 app/api/transactions/import/preview/route.ts create mode 100644 app/auth/social/callback/page.tsx create mode 100644 app/compare/vs-monarch/page.tsx create mode 100644 app/pricing/pricing-actions.tsx create mode 100644 app/settings/households/page.tsx create mode 100644 lib/auth-cookies.ts diff --git a/app/api/2fa/disable/route.ts b/app/api/2fa/disable/route.ts index 3d6ee9b..4957670 100644 --- a/app/api/2fa/disable/route.ts +++ b/app/api/2fa/disable/route.ts @@ -2,5 +2,5 @@ import { NextRequest } from "next/server"; import { proxyRequest } from "@/lib/backend"; export async function DELETE(req: NextRequest) { - return proxyRequest(req, "2fa/disable"); + return proxyRequest(req, "auth/2fa/disable"); } diff --git a/app/api/2fa/enable/route.ts b/app/api/2fa/enable/route.ts index fa0bfc5..cc300db 100644 --- a/app/api/2fa/enable/route.ts +++ b/app/api/2fa/enable/route.ts @@ -2,5 +2,5 @@ import { NextRequest } from "next/server"; import { proxyRequest } from "@/lib/backend"; export async function POST(req: NextRequest) { - return proxyRequest(req, "2fa/enable"); + return proxyRequest(req, "auth/2fa/enable"); } diff --git a/app/api/2fa/generate/route.ts b/app/api/2fa/generate/route.ts index 5d009ec..4533dca 100644 --- a/app/api/2fa/generate/route.ts +++ b/app/api/2fa/generate/route.ts @@ -2,5 +2,5 @@ import { NextRequest } from "next/server"; import { proxyRequest } from "@/lib/backend"; export async function POST(req: NextRequest) { - return proxyRequest(req, "2fa/generate"); + return proxyRequest(req, "auth/2fa/generate"); } diff --git a/app/api/accounts/link/route.ts b/app/api/accounts/link/route.ts index 250c02f..dcafcb4 100644 --- a/app/api/accounts/link/route.ts +++ b/app/api/accounts/link/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; import { proxyRequest } from "@/lib/backend"; -export async function GET(req: NextRequest) { - return proxyRequest(req, "accounts/link-token"); +export async function POST(req: NextRequest) { + return proxyRequest(req, "accounts/link"); } diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 5acfb79..0f70bf8 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -1,6 +1,15 @@ -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { proxyRequest } from "@/lib/backend"; +import { setAuthCookies } from "@/lib/auth-cookies"; export async function POST(req: NextRequest) { - return proxyRequest(req, "auth/login"); + const res = await proxyRequest(req, "auth/login"); + const payload = await res.clone().json().catch(() => null) as { data?: { accessToken?: string; refreshToken?: string; [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; } diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index c8009a2..5927095 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -1,6 +1,32 @@ -import { NextRequest } from "next/server"; -import { proxyRequest } from "@/lib/backend"; +import { NextRequest, NextResponse } from "next/server"; +import { getBackendUrl } from "@/lib/backend"; +import { REFRESH_COOKIE, clearAuthCookies } from "@/lib/auth-cookies"; 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; } diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts index f449c8b..0e105ef 100644 --- a/app/api/auth/me/route.ts +++ b/app/api/auth/me/route.ts @@ -4,3 +4,7 @@ import { proxyRequest } from "@/lib/backend"; export async function GET(req: NextRequest) { return proxyRequest(req, "auth/me"); } + +export async function DELETE(req: NextRequest) { + return proxyRequest(req, "auth/me"); +} diff --git a/app/api/auth/refresh/route.ts b/app/api/auth/refresh/route.ts index 864d056..8fc0c9f 100644 --- a/app/api/auth/refresh/route.ts +++ b/app/api/auth/refresh/route.ts @@ -1,6 +1,44 @@ -import { NextRequest } from "next/server"; -import { proxyRequest } from "@/lib/backend"; +import { NextRequest, NextResponse } from "next/server"; +import { getBackendUrl } from "@/lib/backend"; +import { REFRESH_COOKIE, clearAuthCookies, setAuthCookies } from "@/lib/auth-cookies"; export async function POST(req: NextRequest) { - return proxyRequest(req, "auth/refresh"); + const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value; + 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; } diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index c630edd..dcd0d08 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -1,6 +1,15 @@ -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { proxyRequest } from "@/lib/backend"; +import { setAuthCookies } from "@/lib/auth-cookies"; export async function POST(req: NextRequest) { - return proxyRequest(req, "auth/register"); + const res = await proxyRequest(req, "auth/register"); + const payload = await res.clone().json().catch(() => null) as { data?: { accessToken?: string; refreshToken?: string; [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; } diff --git a/app/api/auth/social/[provider]/callback/route.ts b/app/api/auth/social/[provider]/callback/route.ts new file mode 100644 index 0000000..ca0061e --- /dev/null +++ b/app/api/auth/social/[provider]/callback/route.ts @@ -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; +} diff --git a/app/api/auth/social/[provider]/url/route.ts b/app/api/auth/social/[provider]/url/route.ts new file mode 100644 index 0000000..5e6d632 --- /dev/null +++ b/app/api/auth/social/[provider]/url/route.ts @@ -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`); +} diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts new file mode 100644 index 0000000..38669ef --- /dev/null +++ b/app/api/billing/checkout/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "billing/checkout"); +} diff --git a/app/api/billing/portal/route.ts b/app/api/billing/portal/route.ts new file mode 100644 index 0000000..81945bf --- /dev/null +++ b/app/api/billing/portal/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "billing/portal"); +} diff --git a/app/api/billing/subscription/route.ts b/app/api/billing/subscription/route.ts new file mode 100644 index 0000000..cb09d25 --- /dev/null +++ b/app/api/billing/subscription/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "billing/subscription"); +} diff --git a/app/api/exports/download/[token]/route.ts b/app/api/exports/download/[token]/route.ts new file mode 100644 index 0000000..fd78629 --- /dev/null +++ b/app/api/exports/download/[token]/route.ts @@ -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 = {}; + 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 } + ); + } +} diff --git a/app/api/exports/json/route.ts b/app/api/exports/json/route.ts new file mode 100644 index 0000000..e885582 --- /dev/null +++ b/app/api/exports/json/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "exports/json"); +} diff --git a/app/api/exports/pdf/route.ts b/app/api/exports/pdf/route.ts new file mode 100644 index 0000000..6ce12e7 --- /dev/null +++ b/app/api/exports/pdf/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "exports/pdf"); +} \ No newline at end of file diff --git a/app/api/exports/xlsx/route.ts b/app/api/exports/xlsx/route.ts new file mode 100644 index 0000000..b0c7160 --- /dev/null +++ b/app/api/exports/xlsx/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "exports/xlsx"); +} \ No newline at end of file diff --git a/app/api/households/[id]/dashboard/route.ts b/app/api/households/[id]/dashboard/route.ts new file mode 100644 index 0000000..cb47bd7 --- /dev/null +++ b/app/api/households/[id]/dashboard/route.ts @@ -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`); +} diff --git a/app/api/households/route.ts b/app/api/households/route.ts new file mode 100644 index 0000000..474e328 --- /dev/null +++ b/app/api/households/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "households"); +} + +export async function POST(req: NextRequest) { + return proxyRequest(req, "households"); +} diff --git a/app/api/plaid/repair-complete/route.ts b/app/api/plaid/repair-complete/route.ts new file mode 100644 index 0000000..329be7d --- /dev/null +++ b/app/api/plaid/repair-complete/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "plaid/repair-complete"); +} diff --git a/app/api/plaid/update-link-token/route.ts b/app/api/plaid/update-link-token/route.ts new file mode 100644 index 0000000..e842317 --- /dev/null +++ b/app/api/plaid/update-link-token/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "plaid/update-link-token"); +} diff --git a/app/api/rules/[id]/execute/route.ts b/app/api/rules/[id]/execute/route.ts new file mode 100644 index 0000000..b507630 --- /dev/null +++ b/app/api/rules/[id]/execute/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest, { params }: { params: { id: string } }) { + return proxyRequest(req, `rules/${params.id}/execute`); +} diff --git a/app/api/security/risk/route.ts b/app/api/security/risk/route.ts new file mode 100644 index 0000000..a376135 --- /dev/null +++ b/app/api/security/risk/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "security/risk"); +} diff --git a/app/api/stripe/checkout/route.ts b/app/api/stripe/checkout/route.ts index 214296b..38669ef 100644 --- a/app/api/stripe/checkout/route.ts +++ b/app/api/stripe/checkout/route.ts @@ -2,5 +2,5 @@ import { NextRequest } from "next/server"; import { proxyRequest } from "@/lib/backend"; export async function POST(req: NextRequest) { - return proxyRequest(req, "stripe/checkout"); + return proxyRequest(req, "billing/checkout"); } diff --git a/app/api/stripe/portal/route.ts b/app/api/stripe/portal/route.ts index a45df15..81945bf 100644 --- a/app/api/stripe/portal/route.ts +++ b/app/api/stripe/portal/route.ts @@ -2,5 +2,5 @@ import { NextRequest } from "next/server"; import { proxyRequest } from "@/lib/backend"; export async function POST(req: NextRequest) { - return proxyRequest(req, "stripe/portal"); + return proxyRequest(req, "billing/portal"); } diff --git a/app/api/stripe/subscription/route.ts b/app/api/stripe/subscription/route.ts index f561308..cb09d25 100644 --- a/app/api/stripe/subscription/route.ts +++ b/app/api/stripe/subscription/route.ts @@ -2,5 +2,5 @@ import { NextRequest } from "next/server"; import { proxyRequest } from "@/lib/backend"; export async function GET(req: NextRequest) { - return proxyRequest(req, "stripe/subscription"); + return proxyRequest(req, "billing/subscription"); } diff --git a/app/api/tax/returns/[id]/documents/route.ts b/app/api/tax/returns/[id]/documents/route.ts new file mode 100644 index 0000000..1b50e05 --- /dev/null +++ b/app/api/tax/returns/[id]/documents/route.ts @@ -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`); +} diff --git a/app/api/tax/returns/[id]/efile/route.ts b/app/api/tax/returns/[id]/efile/route.ts new file mode 100644 index 0000000..f2bd48b --- /dev/null +++ b/app/api/tax/returns/[id]/efile/route.ts @@ -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`); +} diff --git a/app/api/tax/returns/[id]/export/route.ts b/app/api/tax/returns/[id]/export/route.ts index 290f99f..7c29222 100644 --- a/app/api/tax/returns/[id]/export/route.ts +++ b/app/api/tax/returns/[id]/export/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from "next/server"; import { proxyRequest } from "@/lib/backend"; -export async function GET( +export async function POST( req: NextRequest, { params }: { params: { id: string } } ) { diff --git a/app/api/tax/returns/[id]/intake/route.ts b/app/api/tax/returns/[id]/intake/route.ts new file mode 100644 index 0000000..68c2766 --- /dev/null +++ b/app/api/tax/returns/[id]/intake/route.ts @@ -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`); +} diff --git a/app/api/teller/config/route.ts b/app/api/teller/config/route.ts new file mode 100644 index 0000000..ea6cba3 --- /dev/null +++ b/app/api/teller/config/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function GET(req: NextRequest) { + return proxyRequest(req, "teller/config"); +} diff --git a/app/api/teller/enrollment/route.ts b/app/api/teller/enrollment/route.ts new file mode 100644 index 0000000..ff3df62 --- /dev/null +++ b/app/api/teller/enrollment/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "teller/enrollment"); +} diff --git a/app/api/teller/sync/route.ts b/app/api/teller/sync/route.ts new file mode 100644 index 0000000..85f0a4d --- /dev/null +++ b/app/api/teller/sync/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "teller/sync"); +} diff --git a/app/api/transactions/import/batch/route.ts b/app/api/transactions/import/batch/route.ts new file mode 100644 index 0000000..62c3f52 --- /dev/null +++ b/app/api/transactions/import/batch/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "transactions/import/batch"); +} diff --git a/app/api/transactions/import/preview/route.ts b/app/api/transactions/import/preview/route.ts new file mode 100644 index 0000000..9d2ec9f --- /dev/null +++ b/app/api/transactions/import/preview/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyRequest } from "@/lib/backend"; + +export async function POST(req: NextRequest) { + return proxyRequest(req, "transactions/import/preview"); +} diff --git a/app/app/connect/page.tsx b/app/app/connect/page.tsx index b337e4c..db3a42c 100644 --- a/app/app/connect/page.tsx +++ b/app/app/connect/page.tsx @@ -9,17 +9,50 @@ type Account = { institutionName: string; accountType: string; mask?: string | null; + syncStatus?: string | null; + lastSyncError?: string | null; + plaidWebhookCode?: string | null; + tellerConnected?: boolean; }; +type TellerEnrollment = { + accessToken: string; + user?: { id?: string }; + enrollment?: { + id?: string; + institution?: { name?: string }; + }; +}; + +declare global { + interface Window { + TellerConnect?: { + setup(config: { + applicationId: string; + environment?: string; + products: string[]; + enrollmentId?: string; + onSuccess(enrollment: TellerEnrollment): void; + onExit?(): void; + onFailure?(error: unknown): void; + }): { open(): void }; + }; + } +} + export default function ConnectPage() { const [status, setStatus] = useState(""); const [linkToken, setLinkToken] = useState(null); + const [updateAccountId, setUpdateAccountId] = useState(null); + const [linkMode, setLinkMode] = useState<"connect" | "update">("connect"); + const [pendingOpen, setPendingOpen] = useState(false); const [manualMode, setManualMode] = useState(false); const [manualBank, setManualBank] = useState(""); const [manualRouting, setManualRouting] = useState(""); const [manualAccount, setManualAccount] = useState(""); const [manualType, setManualType] = useState("checking"); const [accounts, setAccounts] = useState([]); + const [tellerReady, setTellerReady] = useState(false); const createLinkToken = useCallback(async () => { setStatus("Requesting Plaid link token..."); @@ -43,36 +76,86 @@ export default function ConnectPage() { }, []); const loadAccounts = useCallback(async () => { - const userId = localStorage.getItem("ledgerone_user_id"); - if (!userId) { - return; - } - const res = await fetch(`/api/accounts?user_id=${encodeURIComponent(userId)}`); + const res = await fetch("/api/accounts"); if (!res.ok) { return; } const payload = await res.json(); - setAccounts(payload.data ?? []); + setAccounts(payload.data?.accounts ?? payload.data ?? []); + }, []); + + const loadTellerScript = useCallback(() => { + if (typeof window === "undefined") return Promise.resolve(false); + if (window.TellerConnect) { + setTellerReady(true); + return Promise.resolve(true); + } + return new Promise((resolve) => { + const existing = document.querySelector("script[data-teller-connect]"); + if (existing) { + existing.addEventListener("load", () => { + setTellerReady(Boolean(window.TellerConnect)); + resolve(Boolean(window.TellerConnect)); + }); + existing.addEventListener("error", () => resolve(false)); + return; + } + const script = document.createElement("script"); + script.src = "https://cdn.teller.io/connect/connect.js"; + script.dataset.tellerConnect = "true"; + script.onload = () => { + setTellerReady(Boolean(window.TellerConnect)); + resolve(Boolean(window.TellerConnect)); + }; + script.onerror = () => resolve(false); + document.body.appendChild(script); + }); }, []); useEffect(() => { createLinkToken(); loadAccounts(); - }, [createLinkToken, loadAccounts]); + loadTellerScript(); + }, [createLinkToken, loadAccounts, loadTellerScript]); const onSuccess = useCallback( - async (publicToken: string) => { - const userId = localStorage.getItem("ledgerone_user_id"); - if (!userId) { - setStatus("Missing user id. Please sign in again."); + async (publicToken: string | null) => { + if (linkMode === "update" && updateAccountId) { + setStatus("Finishing bank reconnection..."); + try { + const res = await fetch("/api/plaid/repair-complete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId: updateAccountId }) + }); + 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; } + + if (!publicToken) { + setStatus("Plaid did not return a public token."); + return; + } + setStatus("Exchanging public token..."); try { const res = await fetch("/api/plaid/exchange", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ publicToken, userId }) + body: JSON.stringify({ publicToken }) }); const payload = await res.json(); if (!res.ok || payload.error) { @@ -80,12 +163,14 @@ export default function ConnectPage() { return; } setStatus("Bank account connected."); + setLinkToken(null); await loadAccounts(); + await createLinkToken(); } catch { setStatus("Unable to exchange token."); } }, - [loadAccounts] + [createLinkToken, linkMode, loadAccounts, updateAccountId] ); 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) => { event.preventDefault(); - const userId = localStorage.getItem("ledgerone_user_id"); - if (!userId) { - setStatus("Missing user id. Please sign in again."); - return; - } const payload = { - userId, institutionName: manualBank, accountType: manualType, mask: manualAccount.slice(-4) @@ -132,6 +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 ( Connect with Plaid + + + ) : null} - - Connected - ))} @@ -243,7 +444,7 @@ export default function ConnectPage() { ) : null}

- 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.

diff --git a/app/auth/social/callback/page.tsx b/app/auth/social/callback/page.tsx new file mode 100644 index 0000000..f26f881 --- /dev/null +++ b/app/auth/social/callback/page.tsx @@ -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 = { + 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; + 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 ( +
+
+ {status === "loading" && ( + <> +
+

{message}

+ + )} + {status === "success" && ( + <> +
+ + + +
+

Signed in

+

{message}

+ + )} + {status === "error" && ( + <> +
+ + + +
+

Sign in failed

+

{message}

+ + + )} +
+
+ ); +} + +export default function SocialCallbackPage() { + return ( + +
+
+ } + > + +
+ ); +} diff --git a/app/compare/vs-monarch/page.tsx b/app/compare/vs-monarch/page.tsx new file mode 100644 index 0000000..3307d8d --- /dev/null +++ b/app/compare/vs-monarch/page.tsx @@ -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 ( +
+ + +
+
+
+
+ Comparison +
+

+ Built for ledger control, not just household tracking. +

+

+ 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. +

+
+ +
+
+
Feature
+
LedgerOne
+
Monarch Money
+
+ + {rows.map((row, index) => ( +
+
{row.feature}
+
+ + + + {row.l1} +
+
+ {row.sheet} +
+
+ ))} +
+ +
+

Need export-first financial control?

+ + Start your free trial + +
+
+
+ +
+ +
+ +
+ ); +} diff --git a/app/exports/page.tsx b/app/exports/page.tsx index c42e5eb..32c1177 100644 --- a/app/exports/page.tsx +++ b/app/exports/page.tsx @@ -5,11 +5,15 @@ import { AppShell } from "../../components/app-shell"; import { apiFetch } from "@/lib/api"; 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 GoogleStatus = { connected: boolean; googleEmail?: string; connectedAt?: string }; export default function ExportsPage() { const [csvStatus, setCsvStatus] = useState(""); + const [jsonStatus, setJsonStatus] = useState(""); + const [xlsxStatus, setXlsxStatus] = useState(""); + const [pdfStatus, setPdfStatus] = useState(""); const [sheetsStatus, setSheetsStatus] = useState(""); const [sheetsUrl, setSheetsUrl] = useState(null); const [sheetsLoading, setSheetsLoading] = useState(false); @@ -65,23 +69,64 @@ export default function ExportsPage() { return params; }; - const onExportCsv = async () => { - setCsvStatus("Generating export..."); + const downloadSignedFile = (url: string) => { + 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 query = params.toString() ? `?${params.toString()}` : ""; - const res = await apiFetch(`/api/exports/csv${query}`); + return apiFetch(`/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.data?.csv) { - const blob = new Blob([res.data.csv], { type: "text/csv" }); - const url = URL.createObjectURL(blob); - 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.`); + if (res.data?.downloadUrl) { + downloadSignedFile(res.data.downloadUrl); + setCsvStatus(`Signed one-time link created. Download expires in ${res.data.expiresInSeconds ?? 120} seconds.`); } 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"; return ( - +
{/* Filters */}
@@ -185,7 +230,7 @@ export default function ExportsPage() {
{/* Export cards */} -
+
{/* CSV */}
@@ -208,6 +253,72 @@ export default function ExportsPage() { {csvStatus &&

{csvStatus}

}
+ {/* JSON */} +
+
+
+ + + +
+
+

Download JSON

+

Structured transaction export for custom tools.

+
+
+ + {jsonStatus &&

{jsonStatus}

} +
+ + {/* XLSX */} +
+
+
+ + + +
+
+

Download XLSX

+

Excel workbook with filtered transaction data.

+
+
+ + {xlsxStatus &&

{xlsxStatus}

} +
+ + {/* PDF */} +
+
+
+ + + +
+
+

Download PDF

+

Printable ledger snapshot for audit packets.

+
+
+ + {pdfStatus &&

{pdfStatus}

} +
+ {/* Google Sheets */}
diff --git a/app/login/page.tsx b/app/login/page.tsx index 2b5df7b..25ad480 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -19,8 +19,8 @@ type ApiResponse = { type AuthData = { user: { id: string; email: string; fullName?: string; emailVerified?: boolean }; - accessToken: string; - refreshToken: string; + accessToken?: string; + refreshToken?: string; requiresTwoFactor?: boolean; }; @@ -37,9 +37,31 @@ function LoginForm() { const [staySignedIn, setStaySignedIn] = useState(true); const [totpToken, setTotpToken] = useState(""); const [requiresTwoFactor, setRequiresTwoFactor] = useState(false); + const [socialLoading, setSocialLoading] = useState<"google" | "apple" | null>(null); const [status, setStatus] = useState(""); 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) => { event.preventDefault(); setStatus("Signing in..."); @@ -64,8 +86,6 @@ function LoginForm() { } storeAuthTokens({ - accessToken: payload.data.accessToken, - refreshToken: payload.data.refreshToken, user: payload.data.user, }); setStatus(`Welcome back, ${payload.data.user.email}`); @@ -84,16 +104,20 @@ function LoginForm() { type="button" className={socialButtonClass} aria-label="Continue with Apple" + disabled={socialLoading !== null} + onClick={() => startSocialLogin("apple")} > - Continue with Apple + {socialLoading === "apple" ? "Redirecting..." : "Continue with Apple"}
diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index 797863a..12c8392 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -1,5 +1,4 @@ import Image from "next/image"; -import Link from "next/link"; import { Background } from "../../components/background"; import { ContactSection } from "../../components/contact-section"; import { DemoCta } from "../../components/demo-cta"; @@ -9,23 +8,25 @@ import { SiteFooter } from "../../components/site-footer"; import { SiteHeader } from "../../components/site-header"; import { defaultFaqs } from "../../data/faq"; import { siteInfo } from "../../data/site"; +import { PricingAction } from "./pricing-actions"; export const metadata = { title: "Pricing", 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 }; const plans = [ { - name: "Starter", + id: "free", + name: "Free", price: "Free", tagline: "Best for getting your first ledger online.", badge: "2 accounts", features: [ - "First two connected accounts", - "Unlimited exports", + "2 active accounts", + "Core ledger views", "Rule engine access", "Audit logs included" ], @@ -33,27 +34,44 @@ const plans = [ primary: false }, { - name: "Unlimited", + id: "pro", + name: "Pro", 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", features: [ - "Unlimited connected accounts", - "Priority sync cadence", - "Advanced rule automation", - "Team-ready exports" + "Unlimited active accounts", + "Unlimited secure exports", + "Everything in Pro", + "Dedicated support" ], - cta: "Choose Unlimited", - primary: true + cta: "Choose Elite", + primary: false } -]; +] as const; const comparisons = [ - { label: "Connected accounts", starter: "2", pro: "Unlimited" }, - { label: "Exports", starter: "Unlimited", pro: "Unlimited" }, - { label: "Rule engine", starter: "Core rules", pro: "Advanced rules" }, - { label: "Audit logs", starter: "Included", pro: "Included" }, - { label: "Support", starter: "Standard", pro: "Priority" } + { label: "Connected accounts", free: "2", pro: "10", elite: "Unlimited" }, + { label: "Exports", free: "Not included", pro: "Unlimited", elite: "Unlimited" }, + { label: "Google Sheets sync", free: "Not included", pro: "Included", elite: "Included" }, + { label: "Public API", free: "Not included", pro: "Included", elite: "Included" }, + { label: "Rule engine", free: "Core rules", pro: "Advanced rules", elite: "Advanced rules" }, + { label: "Support", free: "Standard", pro: "Priority", elite: "Dedicated" } ]; export default function PricingPage() { @@ -63,7 +81,7 @@ export default function PricingPage() { "@type": "WebPage", name: "LedgerOne Pricing", 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` }, { @@ -88,12 +106,12 @@ export default function PricingPage() { Simple, transparent pricing.

- Start with the essentials and upgrade only when you need more accounts. - Both plans include unlimited exports and audit logs. + Start with two accounts for free, move to Pro for secure exports and ten accounts, + or choose Elite when your ledger needs unlimited account coverage.

-
+
{plans.map((plan) => (
{plan.price} - {plan.price !== "Free" && /month} + {plan.price.startsWith("$") && /month}

{plan.tagline}

@@ -129,12 +147,7 @@ export default function PricingPage() { ))} - - {plan.cta} - +
))}
@@ -148,16 +161,18 @@ export default function PricingPage() { Feature - Starter - Unlimited + Free + Pro + Elite {comparisons.map((item) => ( {item.label} - {item.starter} + {item.free} {item.pro} + {item.elite} ))} diff --git a/app/pricing/pricing-actions.tsx b/app/pricing/pricing-actions.tsx new file mode 100644 index 0000000..32ecda7 --- /dev/null +++ b/app/pricing/pricing-actions.tsx @@ -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 ( + + {label} + + ); + } + + 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 ( + <> + + {status ?

{status}

: null} + + ); +} diff --git a/app/profile/page.tsx b/app/profile/page.tsx index 13f3f19..d1588d6 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { AppShell } from "../../components/app-shell"; +import { apiFetch } from "@/lib/api"; type ApiResponse = { data: T; @@ -26,7 +27,6 @@ type ProfileData = { }; export default function ProfilePage() { - const [token, setToken] = useState(""); const [status, setStatus] = useState(""); const [fullName, setFullName] = useState(""); const [phone, setPhone] = useState(""); @@ -38,25 +38,12 @@ export default function ProfilePage() { const [postalCode, setPostalCode] = useState(""); const [country, setCountry] = useState(""); - useEffect(() => { - const stored = localStorage.getItem("ledgerone_token") ?? ""; - setToken(stored); - }, []); - const onSubmit = async (event: React.FormEvent) => { event.preventDefault(); - if (!token) { - setStatus("Please sign in to update your profile."); - return; - } setStatus("Saving profile..."); try { - const res = await fetch("/api/auth/profile", { + const payload = await apiFetch("/api/auth/profile", { method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}` - }, body: JSON.stringify({ fullName, phone: phone || undefined, @@ -69,8 +56,7 @@ export default function ProfilePage() { country: country || undefined }) }); - const payload = (await res.json()) as ApiResponse; - if (!res.ok || payload.error) { + if (payload.error) { setStatus(payload.error?.message ?? "Profile update failed."); return; } diff --git a/app/register/page.tsx b/app/register/page.tsx index ecb3394..c4b98ac 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -20,8 +20,8 @@ type ApiResponse = { type AuthData = { user: { id: string; email: string; fullName?: string }; - accessToken: string; - refreshToken: string; + accessToken?: string; + refreshToken?: string; message?: string; }; @@ -39,6 +39,7 @@ export default function RegisterPage() { const [confirmPassword, setConfirmPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [agreeTerms, setAgreeTerms] = useState(false); + const [socialLoading, setSocialLoading] = useState<"google" | "apple" | null>(null); const [status, setStatus] = useState(""); const [isError, setIsError] = useState(false); @@ -83,8 +84,6 @@ export default function RegisterPage() { return; } storeAuthTokens({ - accessToken: payload.data.accessToken, - refreshToken: payload.data.refreshToken, user: payload.data.user, }); 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 (
@@ -120,16 +140,20 @@ export default function RegisterPage() { type="button" className={socialButtonClass} aria-label="Continue with Apple" + disabled={socialLoading !== null} + onClick={() => startSocialLogin("apple")} > - Continue with Apple + {socialLoading === "apple" ? "Redirecting..." : "Continue with Apple"}
diff --git a/app/rules/page.tsx b/app/rules/page.tsx index 3a54d64..3737707 100644 --- a/app/rules/page.tsx +++ b/app/rules/page.tsx @@ -2,12 +2,7 @@ import { useEffect, useState } from "react"; import { AppShell } from "../../components/app-shell"; - -type ApiResponse = { - data: T; - meta: { timestamp: string; version: "v1" }; - error: null | { message: string; code?: string }; -}; +import { apiFetch } from "@/lib/api"; type RuleRow = { id: string; @@ -24,6 +19,9 @@ type Suggestion = { conditions: Record; actions: Record; confidence: number; + reason?: string; + matchCount?: number; + type?: string; }; export default function RulesPage() { @@ -31,28 +29,47 @@ export default function RulesPage() { const [suggestions, setSuggestions] = useState([]); const [status, setStatus] = useState("Loading rules..."); 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({ name: "", priority: "", textContains: "", + textNotContains: "", + textRegex: "", amountGreater: "", amountLess: "", + amountEquals: "", + sourceEquals: "", + categoryEquals: "", + dateAfter: "", + dateBefore: "", setCategory: "", - setHidden: false, + clearCategory: false, + setNote: "", + appendNote: "", + clearNote: false, + hiddenAction: "none", isActive: true }); const load = async () => { - const userId = localStorage.getItem("ledgerone_user_id"); - const query = userId ? `?user_id=${encodeURIComponent(userId)}` : ""; try { - const [rulesRes, suggestionsRes] = await Promise.all([ - fetch(`/api/rules${query}`), - fetch(`/api/rules/suggestions${query}`) + const [rulesPayload, suggestionsPayload] = await Promise.all([ + apiFetch("/api/rules"), + apiFetch("/api/rules/suggestions") ]); - const rulesPayload = (await rulesRes.json()) as ApiResponse; - const suggestionsPayload = (await suggestionsRes.json()) as ApiResponse; - if (!rulesRes.ok || rulesPayload.error) { + if (rulesPayload.error) { setStatus(rulesPayload.error?.message ?? "Unable to load rules."); return; } @@ -69,34 +86,49 @@ export default function RulesPage() { }, []); const onCreate = async () => { - const userId = localStorage.getItem("ledgerone_user_id"); - if (!userId) { - setStatus("Missing user id."); - return; + let conditions: Record; + if (builderMode === "advanced") { + try { + conditions = JSON.parse(advancedConditions) as Record; + } 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 = { - userId, name: form.name || "Untitled rule", priority: form.priority ? Number(form.priority) : undefined, isActive: form.isActive, - conditions: { - textContains: form.textContains || undefined, - amountGreaterThan: form.amountGreater ? Number(form.amountGreater) : undefined, - amountLessThan: form.amountLess ? Number(form.amountLess) : undefined - }, + conditions, actions: { 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 { - const res = await fetch("/api/rules", { + const data = await apiFetch("/api/rules", { method: "POST", - headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); - const data = (await res.json()) as ApiResponse; - if (!res.ok || data.error) { + if (data.error) { setStatus(data.error?.message ?? "Unable to create rule."); return; } @@ -105,10 +137,21 @@ export default function RulesPage() { name: "", priority: "", textContains: "", + textNotContains: "", + textRegex: "", amountGreater: "", amountLess: "", + amountEquals: "", + sourceEquals: "", + categoryEquals: "", + dateAfter: "", + dateBefore: "", setCategory: "", - setHidden: false, + clearCategory: false, + setNote: "", + appendNote: "", + clearNote: false, + hiddenAction: "none", isActive: true }); 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("/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 (
@@ -139,6 +214,30 @@ export default function RulesPage() { {showNew ? (

Create a rule

+
+ + +
+ {builderMode === "advanced" ? ( +