Compare commits

..

38 Commits

Author SHA1 Message Date
1400aa99d6 Enforce positive=CREDIT for all BANK account uploads
Instead of relying on per-profile invertAmountSign settings, the upload
route now overrides invertAmountSign: true for any BANK account using
Strategy A. This ensures positive CSV amounts always map to CREDIT
(deposits) regardless of which bank or whether profile was auto-detected.
Credit card logic is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 09:39:14 -04:00
948ac2afe6 Fix Huntington profile detection: remove optional Split/Tags columns
Huntington CSVs don't always include Split and Tags headers, causing
detection to fail and fall back to the manual mapper with wrong defaults.
Detect on Date, Description, Amount which are always present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 09:37:28 -04:00
705a23c520 Revert bank profiles to invertAmountSign: true
Huntington and Fidelity CSVs use positive for deposits (CREDIT) and
negative for withdrawals (DEBIT). The previous change to false was wrong.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 09:32:36 -04:00
a472749b21 Fix bank account sign convention: positive = DEBIT (withdrawal)
Huntington and Fidelity CSVs use positive amounts for withdrawals/
purchases and negative for deposits/credits. Change invertAmountSign
to false so the normalizer correctly maps them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 22:38:13 -04:00
99e41aab78 Fix pagination resetting to page 1 on navigation
push() depended on searchParams, causing the search debounce effect to
re-fire on every page change and delete the page param. Store searchParams
in a ref so push() is stable and only the search value triggers it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:20:22 -04:00
038539c191 Remove dedupeHash and duplicate skipping from CSV upload
Drop dedupeHash field and unique constraint from Transaction model.
Remove skipDuplicates from createMany. All rows in every upload are
now inserted unconditionally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:02:54 -04:00
decfb19ec6 Add restart: unless-stopped to db and app services
Automatically restarts both containers on crash or server reboot,
unless manually stopped with docker compose down.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:56:33 -04:00
3c13ae3597 Fix budget SelectValue showing ID instead of name in edit dialog
Pass budget name as explicit children to SelectValue so the selected
label renders correctly. Also handle TRANSFER type display in the header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:28:36 -04:00
6f1376cc53 Add TRANSFER transaction type with bulk action and auto-rules
- Add TRANSFER to TransactionType enum; excluded from cash flow queries
- Add TransferRule model: description patterns that auto-mark transactions
  as transfers on upload (takes priority over budget rules)
- Bulk action "Mark as transfer" in transaction table
- Transfer Rules button/dialog on transactions page for managing patterns
- Transfers shown with ⇄ prefix and muted color in transaction list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:15:07 -04:00
34bf24b35d Fix Discover CC sign convention: negative CSV = charge (DEBIT)
Discover CC exports charges as negative amounts and payments/refunds
as positive. invertAmountSign: true maps negative -> DEBIT (charge)
and positive -> CREDIT (payment/refund), which is correct.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:57:19 -04:00
587ac19b18 Revert budget to net DEBIT-CREDIT formula, remove fix-cc-types endpoint
Budget formula correctly adds DEBIT (CC charges stored from positive CSV
values) and subtracts CREDIT (refunds from negative CSV values).
Remove the fix-cc-types endpoint which was flipping CC transaction types
and causing charges to appear as CREDIT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:46:49 -04:00
00d4796008 Fix budget totals going negative
Budget formula now sums DEBIT transactions only, matching the intended
"current-month DEBIT total" behavior. Previously, CREDIT transactions
(CC payments) assigned to a budget would subtract and push totals negative.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:40:03 -04:00
5a795d7e93 Fix stale balances after transaction delete
- Bulk delete now recomputes currentBalanceCents and BalanceSnapshot
  records for every affected account+month after deletion
- Add POST /api/admin/recalculate-balances to fix currently stale
  accounts (zeros out balances and removes orphaned snapshots)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:30:24 -04:00
60dabb6264 Recompute account balance after bulk transaction delete
After deleting transactions, recalculate currentBalanceCents for each
affected account so the account card and net worth dashboard stay accurate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:24:59 -04:00
da938c1fcf Fix account delete blocked by CsvUpload FK constraint
Add onDelete: Cascade to CsvUpload.accountId so deleting an account
cascades to its upload records. Also explicitly delete uploads before
the account in the API route so existing deployed DBs without the
constraint don't error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:18:25 -04:00
0cf4612106 Add INVESTMENT account type with manual portfolio value recording 2026-04-21 11:32:50 -04:00
0ea6a7c698 Fix account select showing cuid instead of name in transaction filters 2026-04-21 11:05:11 -04:00
62ca178308 Remove CC type flip — normalizer already produces correct DEBIT/CREDIT for Discover CC 2026-04-21 10:59:59 -04:00
a9c12b94e1 Budget spend: simple DEBIT+/CREDIT- net formula, consistent with type flip 2026-04-21 10:52:39 -04:00
1a984d1eac Fix budget sign logic: CC CREDIT=spend, CC DEBIT=refund (type-flip aware) 2026-04-21 10:46:05 -04:00
c2b9184f2c Changed budget allocation 2026-04-21 10:43:00 -04:00
8c0e4ad684 budget pulls all Cc transactions instead of just credits 2026-04-21 10:37:33 -04:00
2a63b9120e Budget query needs to look for credit for CCs 2026-04-21 10:25:49 -04:00
e6f5d5a33b reverted budget allocation for credit cards 2026-04-21 09:47:49 -04:00
7ccd64a7bb CC account value consistency 2026-04-21 08:25:16 -04:00
d67a80b413 account specific budget queries 2026-04-20 23:44:06 -04:00
91a33cdfec Month Year selection for Dashboard 2026-04-20 23:31:59 -04:00
d865b02752 wrapped setBudgetTarget to fall back to '__none__' when the value is null. 2026-04-20 23:20:17 -04:00
f4216815e8 added functionality for multi-edit of transactions 2026-04-20 23:17:56 -04:00
jerick
60fc836b73 feat: category import from CSV + budget auto-assign rules
Category:
- Add categoryColumn to NormalizerConfig and NormalizedRow
- Map 'Category' column for Discover CC profile
- Write category to Transaction on upload

Budget rules:
- Add BudgetRule model (userId, budgetId, pattern)
- API: GET/POST /api/budget-rules, DELETE /api/budget-rules/:id
- Apply rules during upload (first case-insensitive match wins)
- Budgets page fetches and passes rules per budget
- BudgetCard 'Rules' menu item opens BudgetRulesDialog for add/delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 22:56:41 -04:00
jerick
efe42ac366 fix: show account name in upload select after selection
Base UI Select.Value renders the raw value prop (cuid) rather than
the item label. Pass the looked-up account name as children to
override it. Falls back to placeholder when no account matches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 22:30:58 -04:00
jerick
42f1d34ddd fix: move seed command to prisma.config.ts (Prisma 7 requirement)
Prisma 7 no longer reads the seed command from package.json.
It must be set in prisma.config.ts under migrations.seed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 17:01:18 -04:00
jerick
adb5d144a0 fix: allow unauthenticated access to /login to prevent redirect loop
Unauthenticated requests to /login were hitting the auth gate and
redirecting back to /login?callbackUrl=/login, causing a loop.
Let the login page render for unauthenticated users; only redirect
away from /login when the user is already logged in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:49:01 -04:00
jerick
2e264014b6 fix: derive redirect and origin check from Host header
req.url resolves to the internal hostname in Docker standalone mode.
Read the Host header directly so redirects and CSRF origin checks use
whatever host the browser actually used (IP, hostname, or domain).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:44:11 -04:00
jerick
8b0fba5014 fix: use req.url as redirect base so host is preserved
req.nextUrl.origin resolves to localhost inside the container.
Using req.url preserves the Host header the browser sent, so
redirects work when accessing via IP or any external hostname.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:35:47 -04:00
jerick
874b022139 fix: bind app port to 0.0.0.0 so it's reachable from the network
127.0.0.1:3000 only accepted connections from localhost.
DB port intentionally stays on 127.0.0.1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:33:04 -04:00
jerick
d41ab0c4e8 fix: split auth config so middleware uses Edge-compatible module
NextAuth's Credentials provider pulls in Prisma -> pg -> Node.js crypto,
which crashes in the Edge runtime. Extract an auth.config.ts with only
JWT/session callbacks (no DB, no bcrypt) and use NextAuth(authConfig) in
middleware. auth.ts spreads the config and adds the Credentials provider.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:12:15 -04:00
jerick
0b4f9f5c0e fix: add setup service for db push/seed; update README
The production runner image has no node_modules, so prisma CLI and tsx
are unavailable. Add a Compose 'setup' profile service that uses the
builder stage (which has all dev tools) to run db push and db seed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 15:25:02 -04:00
38 changed files with 1247 additions and 132 deletions

View File

@@ -26,14 +26,19 @@ Edit `.env`:
- Change `POSTGRES_PASSWORD` to a strong password
```sh
# 2. Build and start the stack
docker compose up --build -d
# 2. Build the images
docker compose build
# 3. First-run only: apply the schema and create your user
docker compose exec app npx prisma db push
docker compose exec app npx prisma db seed
# 3. Start the database
docker compose up db -d
# 4. Open the app
# 4. First-run only: apply the schema and create your user
docker compose --profile setup run --rm setup
# 5. Start the app
docker compose up app -d
# 6. Open the app
open http://localhost:3000
```
@@ -65,8 +70,7 @@ docker compose down
docker compose down -v
# Re-seed after a wipe
docker compose exec app npx prisma db push
docker compose exec app npx prisma db seed
docker compose --profile setup run --rm setup
# Check health
curl http://localhost:3000/api/health

View File

@@ -1,6 +1,7 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
@@ -17,8 +18,9 @@ services:
app:
build: .
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
- "0.0.0.0:3000:3000"
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
@@ -33,5 +35,23 @@ services:
retries: 3
start_period: 60s
# First-run setup: applies the schema and seeds the user.
# Uses the builder stage so all dev tools (prisma CLI, tsx, bcryptjs) are available.
# Run once with: docker compose --profile setup run --rm setup
setup:
build:
context: .
target: builder
command: sh -c "npx prisma db push && npx prisma db seed"
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
SEED_EMAIL: ${SEED_EMAIL}
SEED_PASSWORD: ${SEED_PASSWORD}
depends_on:
db:
condition: service_healthy
profiles:
- setup
volumes:
postgres_data:

View File

@@ -7,6 +7,7 @@ export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seed.ts",
},
datasource: {
url: process.env["DATABASE_URL"],

View File

@@ -15,11 +15,14 @@ model User {
updatedAt DateTime @updatedAt
accounts Account[]
budgets Budget[]
budgetRules BudgetRule[]
transferRules TransferRule[]
}
enum AccountType {
BANK
CREDIT_CARD
INVESTMENT
}
model Account {
@@ -43,6 +46,7 @@ model Account {
enum TransactionType {
DEBIT
CREDIT
TRANSFER
}
model Transaction {
@@ -59,11 +63,9 @@ model Transaction {
type TransactionType
category String?
notes String?
dedupeHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([dedupeHash])
@@index([accountId, date])
@@index([date])
@@index([budgetId])
@@ -80,6 +82,30 @@ model Budget {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transactions Transaction[]
rules BudgetRule[]
@@index([userId])
}
model BudgetRule {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
budgetId String
budget Budget @relation(fields: [budgetId], references: [id], onDelete: Cascade)
pattern String
createdAt DateTime @default(now())
@@index([userId])
@@index([budgetId])
}
model TransferRule {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pattern String
createdAt DateTime @default(now())
@@index([userId])
}
@@ -87,7 +113,7 @@ model Budget {
model CsvUpload {
id String @id @default(cuid())
accountId String
account Account @relation(fields: [accountId], references: [id])
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
fileName String
rowCount Int
importedCount Int

View File

@@ -1,34 +1,55 @@
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { monthBounds } from '@/lib/utils/dates'
import { BudgetList } from '@/components/budgets/BudgetList'
import { MonthYearPicker } from '@/components/dashboard/MonthYearPicker'
export default async function BudgetsPage() {
type SearchParams = Promise<Record<string, string | string[] | undefined>>
export default async function BudgetsPage({ searchParams }: { searchParams: SearchParams }) {
const session = await auth()
if (!session?.user?.id) return null
const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
const userId = session.user.id
const sp = await searchParams
const get = (k: string) => (Array.isArray(sp[k]) ? sp[k][0] : sp[k]) ?? ''
const [budgets, spendRows] = await Promise.all([
const now = new Date()
const month = Number(get('month')) || (now.getMonth() + 1)
const year = Number(get('year')) || now.getFullYear()
const selectedDate = new Date(year, month - 1, 1)
const { start, end } = monthBounds(selectedDate)
const [budgets, spendRows, rules] = await Promise.all([
prisma.budget.findMany({
where: { userId: session.user.id },
where: { userId },
orderBy: { createdAt: 'asc' },
}),
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId", COALESCE(SUM(t."amountCents"), 0)::bigint AS total
SELECT t."budgetId",
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t."amountCents" ELSE -t."amountCents" END), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${session.user.id}
WHERE a."userId" = ${userId}
AND t."budgetId" IS NOT NULL
AND t.type = 'DEBIT'
AND t.date >= ${monthStart}
AND t.date <= ${monthEnd}
AND t.date >= ${start}
AND t.date <= ${end}
GROUP BY t."budgetId"
`,
prisma.budgetRule.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
select: { id: true, budgetId: true, pattern: true },
}),
])
const spendMap = new Map(spendRows.map((r) => [r.budgetId, Number(r.total)]))
const rulesMap = new Map<string, { id: string; pattern: string }[]>()
for (const rule of rules) {
const existing = rulesMap.get(rule.budgetId) ?? []
existing.push({ id: rule.id, pattern: rule.pattern })
rulesMap.set(rule.budgetId, existing)
}
const budgetsWithSpend = budgets.map((b) => ({
id: b.id,
@@ -37,10 +58,15 @@ export default async function BudgetsPage() {
color: b.color,
isActive: b.isActive,
spendCents: spendMap.get(b.id) ?? 0,
rules: rulesMap.get(b.id) ?? [],
}))
return (
<div className="p-6">
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Budgets</h1>
<MonthYearPicker />
</div>
<BudgetList budgets={budgetsWithSpend} />
</div>
)

View File

@@ -5,26 +5,34 @@ import { NetWorthCard } from '@/components/dashboard/NetWorthCard'
import { CashFlowCard } from '@/components/dashboard/CashFlowCard'
import { RecentTransactions } from '@/components/dashboard/RecentTransactions'
import { BudgetSummary } from '@/components/dashboard/BudgetSummary'
import { MonthYearPicker } from '@/components/dashboard/MonthYearPicker'
export default async function DashboardPage() {
type SearchParams = Promise<Record<string, string | string[] | undefined>>
export default async function DashboardPage({ searchParams }: { searchParams: SearchParams }) {
const session = await auth()
if (!session?.user?.id) return null
const userId = session.user.id
const { start, end } = monthBounds()
const sp = await searchParams
const get = (k: string) => (Array.isArray(sp[k]) ? sp[k][0] : sp[k]) ?? ''
const [accounts, cashFlowRows, budgets, spendRows, recentTx] = await Promise.all([
prisma.account.findMany({
where: { userId, isActive: true },
select: { id: true, name: true, type: true, currentBalanceCents: true },
orderBy: { name: 'asc' },
}),
const now = new Date()
const month = Number(get('month')) || (now.getMonth() + 1)
const year = Number(get('year')) || now.getFullYear()
const isCurrentMonth = month === (now.getMonth() + 1) && year === now.getFullYear()
const selectedDate = new Date(year, month - 1, 1)
const { start, end } = monthBounds(selectedDate)
const [cashFlowRows, budgets, spendRows, recentTx] = await Promise.all([
prisma.$queryRaw<{ type: string; total: bigint }[]>`
SELECT t.type, COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND a.type = 'BANK'
AND t.type != 'TRANSFER'
AND t.date >= ${start}
AND t.date <= ${end}
GROUP BY t.type
@@ -34,26 +42,56 @@ export default async function DashboardPage() {
orderBy: { name: 'asc' },
}),
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId", COALESCE(SUM(t."amountCents"), 0)::bigint AS total
SELECT t."budgetId",
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t."amountCents" ELSE -t."amountCents" END), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND t."budgetId" IS NOT NULL
AND t.type = 'DEBIT'
AND t.date >= ${start}
AND t.date <= ${end}
GROUP BY t."budgetId"
`,
prisma.transaction.findMany({
where: { account: { userId } },
where: {
account: { userId },
date: { gte: start, lte: end },
},
include: { account: { select: { name: true } } },
orderBy: { date: 'desc' },
take: 5,
}),
])
const bankAccounts = accounts.filter((a) => a.type === 'BANK')
const netWorthCents = bankAccounts.reduce((s, a) => s + a.currentBalanceCents, 0)
// Net worth: live balances for current month, snapshots for past months
let netWorthCents: number
let netWorthAccounts: { id: string; name: string; balanceCents: number }[]
if (isCurrentMonth) {
const accounts = await prisma.account.findMany({
where: { userId, isActive: true, type: { in: ['BANK', 'INVESTMENT'] } },
select: { id: true, name: true, currentBalanceCents: true },
orderBy: { name: 'asc' },
})
netWorthAccounts = accounts.map((a) => ({ id: a.id, name: a.name, balanceCents: a.currentBalanceCents }))
netWorthCents = netWorthAccounts.reduce((s, a) => s + a.balanceCents, 0)
} else {
const snapshots = await prisma.$queryRaw<{ accountId: string; name: string; balanceCents: bigint }[]>`
SELECT bs."accountId", a.name, bs."balanceCents"
FROM "BalanceSnapshot" bs
JOIN "Account" a ON bs."accountId" = a.id
WHERE a."userId" = ${userId}
AND a.type IN ('BANK', 'INVESTMENT')
AND bs.year = ${year}
AND bs.month = ${month}
`
netWorthAccounts = snapshots.map((s) => ({
id: s.accountId,
name: s.name,
balanceCents: Number(s.balanceCents),
}))
netWorthCents = netWorthAccounts.reduce((s, a) => s + a.balanceCents, 0)
}
const cfMap = Object.fromEntries(cashFlowRows.map((r) => [r.type, Number(r.total)]))
const creditsCents = cfMap['CREDIT'] ?? 0
@@ -80,15 +118,15 @@ export default async function DashboardPage() {
return (
<div className="p-6 space-y-6">
<div>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-sm text-muted-foreground">{monthLabel()}</p>
<MonthYearPicker />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<NetWorthCard netWorthCents={netWorthCents} bankAccounts={bankAccounts} />
<NetWorthCard netWorthCents={netWorthCents} bankAccounts={netWorthAccounts} />
<CashFlowCard
monthLabel={monthLabel()}
monthLabel={monthLabel(selectedDate)}
creditsCents={creditsCents}
debitsCents={debitsCents}
netCents={creditsCents - debitsCents}

View File

@@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma'
import { Prisma } from '@/generated/prisma/client'
import { TransactionFilters } from '@/components/transactions/TransactionFilters'
import { TransactionTable } from '@/components/transactions/TransactionTable'
import { TransferRulesButton } from '@/components/transactions/TransferRulesButton'
const PAGE_LIMIT = 50
@@ -35,7 +36,7 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
}),
}
const [transactions, total, accounts, budgets] = await Promise.all([
const [transactions, total, accounts, budgets, transferRules] = await Promise.all([
prisma.transaction.findMany({
where,
include: {
@@ -57,6 +58,11 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
select: { id: true, name: true, color: true },
orderBy: { name: 'asc' },
}),
prisma.transferRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { id: true, pattern: true },
}),
])
// Serialize dates for client components
@@ -69,7 +75,10 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Transactions</h1>
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold">Transactions</h1>
<TransferRulesButton rules={transferRules} />
</div>
<TransactionFilters accounts={accounts} />
<TransactionTable
transactions={rows}

View File

@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const schema = z.object({
valueCents: z.number().int(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
})
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { id } = await params
const account = await prisma.account.findFirst({
where: { id, userId: session.user.id, type: 'INVESTMENT' },
})
if (!account) return NextResponse.json({ error: 'Account not found' }, { status: 404 })
const result = schema.safeParse(await req.json())
if (!result.success) return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
const { valueCents, date } = result.data
const d = new Date(date + 'T12:00:00')
const year = d.getFullYear()
const month = d.getMonth() + 1
await prisma.account.update({
where: { id },
data: { currentBalanceCents: valueCents },
})
await prisma.balanceSnapshot.upsert({
where: { accountId_year_month: { accountId: id, year, month } },
update: { balanceCents: valueCents, computedAt: new Date() },
create: { accountId: id, year, month, balanceCents: valueCents },
})
return NextResponse.json({ ok: true })
}

View File

@@ -58,6 +58,7 @@ export async function DELETE(_req: Request, { params }: Params) {
})
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.csvUpload.deleteMany({ where: { accountId: id } })
await prisma.account.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function POST() {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const userId = session.user.id
const accounts = await prisma.account.findMany({
where: { userId },
select: { id: true },
})
for (const account of accounts) {
const [balRow] = await prisma.$queryRaw<[{ balance: bigint }]>`
SELECT COALESCE(SUM(
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
), 0)::bigint AS balance
FROM "Transaction"
WHERE "accountId" = ${account.id}
`
await prisma.account.update({
where: { id: account.id },
data: { currentBalanceCents: Number(balRow.balance) },
})
}
// Remove all snapshots for accounts that now have no transactions
await prisma.$executeRaw`
DELETE FROM "BalanceSnapshot"
WHERE "accountId" IN (
SELECT id FROM "Account" WHERE "userId" = ${userId}
)
AND NOT EXISTS (
SELECT 1 FROM "Transaction" WHERE "accountId" = "BalanceSnapshot"."accountId"
)
`
return NextResponse.json({ ok: true, accounts: accounts.length })
}

View File

@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { id } = await params
const rule = await prisma.budgetRule.findFirst({ where: { id, userId: session.user.id } })
if (!rule) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.budgetRule.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createBudgetRuleSchema } from '@/lib/validations/budget-rule'
export async function GET() {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const rules = await prisma.budgetRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { id: true, budgetId: true, pattern: true },
})
return NextResponse.json(rules)
}
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await req.json()
const parsed = createBudgetRuleSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
const { budgetId, pattern } = parsed.data
// Verify budget belongs to this user
const budget = await prisma.budget.findFirst({ where: { id: budgetId, userId: session.user.id } })
if (!budget) return NextResponse.json({ error: 'Budget not found' }, { status: 404 })
const rule = await prisma.budgetRule.create({
data: { userId: session.user.id, budgetId, pattern },
select: { id: true, budgetId: true, pattern: true },
})
return NextResponse.json(rule, { status: 201 })
}

View File

@@ -0,0 +1,132 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const bulkSchema = z.discriminatedUnion('action', [
z.object({
action: z.literal('delete'),
ids: z.array(z.string()).min(1),
}),
z.object({
action: z.literal('assignBudget'),
ids: z.array(z.string()).min(1),
budgetId: z.string().nullable(),
}),
z.object({
action: z.literal('addNotes'),
ids: z.array(z.string()).min(1),
notes: z.string().max(500),
}),
z.object({
action: z.literal('markTransfer'),
ids: z.array(z.string()).min(1),
}),
])
async function recomputeAccount(accountId: string) {
const [balRow] = await prisma.$queryRaw<[{ balance: bigint }]>`
SELECT COALESCE(SUM(
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
), 0)::bigint AS balance
FROM "Transaction"
WHERE "accountId" = ${accountId}
`
await prisma.account.update({
where: { id: accountId },
data: { currentBalanceCents: Number(balRow.balance) },
})
}
async function recomputeSnapshot(accountId: string, year: number, month: number) {
const endOfMonth = new Date(year, month, 0, 23, 59, 59, 999)
const [snap] = await prisma.$queryRaw<[{ balance: bigint }]>`
SELECT COALESCE(SUM(
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
), 0)::bigint AS balance
FROM "Transaction"
WHERE "accountId" = ${accountId}
AND date <= ${endOfMonth}
`
const balanceCents = Number(snap.balance)
if (balanceCents === 0) {
await prisma.balanceSnapshot.deleteMany({
where: { accountId, year, month },
})
} else {
await prisma.balanceSnapshot.upsert({
where: { accountId_year_month: { accountId, year, month } },
update: { balanceCents, computedAt: new Date() },
create: { accountId, year, month, balanceCents },
})
}
}
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await req.json()
const result = bulkSchema.safeParse(body)
if (!result.success) return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
const { action, ids } = result.data
const userId = session.user.id
// Verify all transaction IDs belong to this user
const owned = await prisma.transaction.findMany({
where: { id: { in: ids }, account: { userId } },
select: { id: true, accountId: true, date: true },
})
if (owned.length !== ids.length) {
return NextResponse.json({ error: 'One or more transactions not found' }, { status: 404 })
}
if (action === 'delete') {
// Collect affected account+month combos before deleting
const accountIds = [...new Set(owned.map((t) => t.accountId))]
const monthKeys = new Map<string, { accountId: string; year: number; month: number }>()
for (const t of owned) {
const y = t.date.getFullYear()
const m = t.date.getMonth() + 1
monthKeys.set(`${t.accountId}-${y}-${m}`, { accountId: t.accountId, year: y, month: m })
}
await prisma.transaction.deleteMany({ where: { id: { in: ids } } })
// Recompute current balance and snapshots for affected accounts/months
await Promise.all(accountIds.map(recomputeAccount))
await Promise.all([...monthKeys.values()].map((k) => recomputeSnapshot(k.accountId, k.year, k.month)))
return NextResponse.json({ deleted: ids.length })
}
if (action === 'assignBudget') {
const { budgetId } = result.data
if (budgetId !== null) {
const budget = await prisma.budget.findFirst({ where: { id: budgetId, userId } })
if (!budget) return NextResponse.json({ error: 'Budget not found' }, { status: 404 })
}
await prisma.transaction.updateMany({
where: { id: { in: ids } },
data: { budgetId },
})
return NextResponse.json({ updated: ids.length })
}
if (action === 'addNotes') {
const { notes } = result.data
await prisma.transaction.updateMany({
where: { id: { in: ids } },
data: { notes: notes || null },
})
return NextResponse.json({ updated: ids.length })
}
// markTransfer
await prisma.transaction.updateMany({
where: { id: { in: ids } },
data: { type: 'TRANSFER' },
})
return NextResponse.json({ updated: ids.length })
}

View File

@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
type Params = { params: Promise<{ id: string }> }
export async function DELETE(_req: Request, { params }: Params) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { id } = await params
const rule = await prisma.transferRule.findFirst({
where: { id, userId: session.user.id },
})
if (!rule) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.transferRule.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const schema = z.object({
pattern: z.string().min(1).max(200).trim(),
})
export async function GET() {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const rules = await prisma.transferRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { id: true, pattern: true },
})
return NextResponse.json(rules)
}
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const result = schema.safeParse(await req.json())
if (!result.success) return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
const rule = await prisma.transferRule.create({
data: { userId: session.user.id, pattern: result.data.pattern },
select: { id: true, pattern: true },
})
return NextResponse.json(rule, { status: 201 })
}

View File

@@ -74,8 +74,42 @@ export async function POST(req: Request) {
config = result.data
}
// For all BANK accounts using a single amount column, positive = deposit (CREDIT),
// negative = withdrawal (DEBIT). Override whatever the profile or manual mapping says.
if (account.type === 'BANK' && config.strategy === 'A') {
config = { ...config, invertAmountSign: true }
}
const normalized = normalizeRows(allRows, accountId, config)
// Apply transfer rules first, then budget rules (transfer takes priority)
const [budgetRules, transferRules] = await Promise.all([
prisma.budgetRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { pattern: true, budgetId: true },
}),
prisma.transferRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { pattern: true },
}),
])
const rowsWithBudgets = normalized.map((row) => {
const desc = row.description.toLowerCase()
for (const rule of transferRules) {
if (desc.includes(rule.pattern.toLowerCase())) {
return { ...row, type: 'TRANSFER' as const, budgetId: null }
}
}
for (const rule of budgetRules) {
if (desc.includes(rule.pattern.toLowerCase())) {
return { ...row, budgetId: rule.budgetId }
}
}
return { ...row, budgetId: null }
})
const upload = await prisma.csvUpload.create({
data: {
accountId,
@@ -89,17 +123,18 @@ export async function POST(req: Request) {
try {
const { count: importedCount } = await prisma.transaction.createMany({
data: normalized.map((r) => ({
data: rowsWithBudgets.map((r) => ({
accountId,
uploadId: upload.id,
date: r.date,
description: r.description,
amountCents: r.amountCents,
type: r.type,
dedupeHash: r.dedupeHash,
category: r.category ?? null,
budgetId: r.budgetId,
})),
skipDuplicates: true,
})
const skippedCount = normalized.length - importedCount
// Recompute current balance
@@ -118,7 +153,7 @@ export async function POST(req: Request) {
// Upsert balance snapshots for each affected month
const months = [
...new Map(
normalized.map((r) => {
rowsWithBudgets.map((r) => {
const y = r.date.getFullYear()
const m = r.date.getMonth() + 1
return [`${y}-${m}`, { year: y, month: m }]

View File

@@ -2,9 +2,7 @@ import { Badge } from '@/components/ui/badge'
import { AccountType } from '@/generated/prisma/client'
export function AccountBadge({ type }: { type: AccountType }) {
return type === 'BANK' ? (
<Badge variant="secondary">Bank</Badge>
) : (
<Badge variant="outline">Credit Card</Badge>
)
if (type === 'BANK') return <Badge variant="secondary">Bank</Badge>
if (type === 'INVESTMENT') return <Badge variant="secondary" className="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">Investment</Badge>
return <Badge variant="outline">Credit Card</Badge>
}

View File

@@ -9,14 +9,16 @@ import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { MoreHorizontal, Pencil, Trash2, EyeOff, Eye } from 'lucide-react'
import { MoreHorizontal, Pencil, Trash2, EyeOff, Eye, TrendingUp } from 'lucide-react'
import { AccountBadge } from './AccountBadge'
import { CreateAccountDialog } from './CreateAccountDialog'
import { RecordValueDialog } from './RecordValueDialog'
import { formatCents } from '@/lib/utils/currency'
export function AccountCard({ account }: { account: Account }) {
const router = useRouter()
const [editOpen, setEditOpen] = useState(false)
const [recordOpen, setRecordOpen] = useState(false)
async function handleToggleActive() {
await fetch(`/api/accounts/${account.id}`, {
@@ -56,9 +58,13 @@ export function AccountCard({ account }: { account: Account }) {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditOpen(true)}>
<Pencil className="h-4 w-4 mr-2" />
Edit
<Pencil className="h-4 w-4 mr-2" />Edit
</DropdownMenuItem>
{account.type === 'INVESTMENT' && (
<DropdownMenuItem onClick={() => setRecordOpen(true)}>
<TrendingUp className="h-4 w-4 mr-2" />Record value
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleToggleActive}>
{account.isActive
? <><EyeOff className="h-4 w-4 mr-2" />Deactivate</>
@@ -87,11 +93,15 @@ export function AccountCard({ account }: { account: Account }) {
</CardContent>
</Card>
<CreateAccountDialog
open={editOpen}
onOpenChange={setEditOpen}
account={account}
<CreateAccountDialog open={editOpen} onOpenChange={setEditOpen} account={account} />
{account.type === 'INVESTMENT' && (
<RecordValueDialog
open={recordOpen}
onOpenChange={setRecordOpen}
accountId={account.id}
accountName={account.name}
/>
)}
</>
)
}

View File

@@ -93,6 +93,7 @@ export function CreateAccountDialog({ open, onOpenChange, account }: Props) {
<SelectContent>
<SelectItem value="BANK">Bank</SelectItem>
<SelectItem value="CREDIT_CARD">Credit Card</SelectItem>
<SelectItem value="INVESTMENT">Investment</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -0,0 +1,77 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
accountId: string
accountName: string
}
export function RecordValueDialog({ open, onOpenChange, accountId, accountName }: Props) {
const router = useRouter()
const [value, setValue] = useState('')
const [date, setDate] = useState(() => new Date().toISOString().split('T')[0])
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const num = parseFloat(value.replace(/[$,]/g, ''))
if (isNaN(num)) { setError('Enter a valid dollar amount'); return }
setError('')
setSaving(true)
const res = await fetch(`/api/accounts/${accountId}/record-value`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ valueCents: Math.round(num * 100), date }),
})
setSaving(false)
if (!res.ok) { setError('Failed to save. Please try again.'); return }
onOpenChange(false)
router.refresh()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Record portfolio value {accountName}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-1">
<div className="space-y-2">
<Label htmlFor="rv-value">Portfolio value</Label>
<Input
id="rv-value"
placeholder="e.g. 12500.00"
value={value}
onChange={(e) => setValue(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="rv-date">As of date</Label>
<Input
id="rv-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -7,11 +7,17 @@ import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { MoreHorizontal, Pencil, Trash2, EyeOff, Eye } from 'lucide-react'
import { MoreHorizontal, Pencil, Trash2, EyeOff, Eye, ListFilter } from 'lucide-react'
import { BudgetProgress } from './BudgetProgress'
import { CreateBudgetDialog } from './CreateBudgetDialog'
import { BudgetRulesDialog } from './BudgetRulesDialog'
import { formatCents } from '@/lib/utils/currency'
export interface BudgetRule {
id: string
pattern: string
}
export interface BudgetWithSpend {
id: string
name: string
@@ -19,11 +25,13 @@ export interface BudgetWithSpend {
color: string | null
isActive: boolean
spendCents: number
rules?: BudgetRule[]
}
export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
const router = useRouter()
const [editOpen, setEditOpen] = useState(false)
const [rulesOpen, setRulesOpen] = useState(false)
async function handleToggle() {
await fetch(`/api/budgets/${budget.id}`, {
@@ -62,6 +70,9 @@ export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
<DropdownMenuItem onClick={() => setEditOpen(true)}>
<Pencil className="h-4 w-4 mr-2" />Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setRulesOpen(true)}>
<ListFilter className="h-4 w-4 mr-2" />Rules
</DropdownMenuItem>
<DropdownMenuItem onClick={handleToggle}>
{budget.isActive
? <><EyeOff className="h-4 w-4 mr-2" />Deactivate</>
@@ -92,7 +103,7 @@ export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
{budget.limitCents ? (
<BudgetProgress spendCents={budget.spendCents} limitCents={budget.limitCents} />
) : (
<p className="text-xs text-muted-foreground">This month</p>
<p className="text-xs text-muted-foreground">All time</p>
)}
{!budget.isActive && (
@@ -102,6 +113,13 @@ export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
</Card>
<CreateBudgetDialog open={editOpen} onOpenChange={setEditOpen} budget={budget} />
<BudgetRulesDialog
open={rulesOpen}
onOpenChange={setRulesOpen}
budgetId={budget.id}
budgetName={budget.name}
rules={budget.rules ?? []}
/>
</>
)
}

View File

@@ -0,0 +1,96 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Trash2, Plus } from 'lucide-react'
interface Rule {
id: string
pattern: string
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
budgetId: string
budgetName: string
rules: Rule[]
}
export function BudgetRulesDialog({ open, onOpenChange, budgetId, budgetName, rules }: Props) {
const router = useRouter()
const [pattern, setPattern] = useState('')
const [adding, setAdding] = useState(false)
async function handleAdd(e: React.FormEvent) {
e.preventDefault()
if (!pattern.trim()) return
setAdding(true)
await fetch('/api/budget-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ budgetId, pattern: pattern.trim() }),
})
setPattern('')
setAdding(false)
router.refresh()
}
async function handleDelete(id: string) {
await fetch(`/api/budget-rules/${id}`, { method: 'DELETE' })
router.refresh()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Auto-assign rules {budgetName}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Transactions whose description contains a pattern (case-insensitive) are automatically
assigned to this budget on upload. First matching rule wins.
</p>
{rules.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">No rules yet.</p>
) : (
<ul className="space-y-1.5">
{rules.map((rule) => (
<li
key={rule.id}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="font-mono truncate">{rule.pattern}</span>
<button
onClick={() => handleDelete(rule.id)}
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Delete rule"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
<form onSubmit={handleAdd} className="flex gap-2">
<Input
placeholder="e.g. Amazon, Netflix, Starbucks"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
className="flex-1"
/>
<Button type="submit" disabled={adding || !pattern.trim()} size="sm">
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,52 @@
'use client'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function MonthYearPicker() {
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const now = new Date()
const currentMonth = now.getMonth() + 1
const currentYear = now.getFullYear()
const month = Number(searchParams.get('month')) || currentMonth
const year = Number(searchParams.get('year')) || currentYear
const isCurrentMonth = month === currentMonth && year === currentYear
function navigate(delta: number) {
let m = month + delta
let y = year
if (m < 1) { m = 12; y-- }
if (m > 12) { m = 1; y++ }
const params = new URLSearchParams(searchParams.toString())
params.set('month', String(m))
params.set('year', String(y))
router.push(`${pathname}?${params.toString()}`)
}
const label = new Date(year, month - 1, 1).toLocaleDateString('en-US', {
month: 'long', year: 'numeric',
})
return (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => navigate(-1)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium min-w-[140px] text-center">{label}</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigate(1)}
disabled={isCurrentMonth}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)
}

View File

@@ -4,7 +4,7 @@ import { formatCents } from '@/lib/utils/currency'
interface BankAccount {
id: string
name: string
currentBalanceCents: number
balanceCents: number
}
interface NetWorthCardProps {
@@ -26,7 +26,7 @@ export function NetWorthCard({ netWorthCents, bankAccounts }: NetWorthCardProps)
<div key={a.id} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground truncate">{a.name}</span>
<span className="tabular-nums font-medium ml-4 shrink-0">
{formatCents(a.currentBalanceCents)}
{formatCents(a.balanceCents)}
</span>
</div>
))}

View File

@@ -80,8 +80,8 @@ export function EditTransactionDialog({ transaction, onOpenChange, budgets }: Pr
{transaction.description}
</DialogTitle>
<p className="text-sm text-muted-foreground">
{date} · <span className={transaction.type === 'CREDIT' ? 'text-green-600' : ''}>
{transaction.type === 'CREDIT' ? '+' : '-'}{formatCents(transaction.amountCents)}
{date} · <span className={transaction.type === 'CREDIT' ? 'text-green-600' : transaction.type === 'TRANSFER' ? 'text-muted-foreground' : ''}>
{transaction.type === 'TRANSFER' ? '⇄ ' : transaction.type === 'CREDIT' ? '+' : '-'}{formatCents(transaction.amountCents)}
</span>
</p>
</DialogHeader>
@@ -101,7 +101,11 @@ export function EditTransactionDialog({ transaction, onOpenChange, budgets }: Pr
<Label htmlFor="budget">Budget</Label>
<Select value={budgetId} onValueChange={(v) => setBudgetId(v ?? '')}>
<SelectTrigger id="budget">
<SelectValue placeholder="No budget" />
<SelectValue placeholder="No budget">
{budgetId
? (budgets.find((b) => b.id === budgetId)?.name ?? 'No budget')
: 'No budget'}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="">No budget</SelectItem>

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -20,17 +20,19 @@ export function TransactionFilters({ accounts }: { accounts: AccountOption[] })
const sp = (key: string) => searchParams.get(key) ?? ''
const [search, setSearch] = useState(sp('search'))
const searchParamsRef = useRef(searchParams)
useEffect(() => { searchParamsRef.current = searchParams }, [searchParams])
const push = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString())
const params = new URLSearchParams(searchParamsRef.current.toString())
for (const [k, v] of Object.entries(updates)) {
if (v) params.set(k, v); else params.delete(k)
}
params.delete('page')
router.replace(`${pathname}?${params.toString()}`)
},
[searchParams, pathname, router],
[pathname, router],
)
// Debounce search → URL
@@ -52,7 +54,9 @@ export function TransactionFilters({ accounts }: { accounts: AccountOption[] })
<Label className="text-xs">Account</Label>
<Select value={sp('accountId')} onValueChange={(v) => push({ accountId: v ?? '' })}>
<SelectTrigger className="h-8 w-44 text-sm">
<SelectValue placeholder="All accounts" />
<SelectValue placeholder="All accounts">
{accounts.find((a) => a.id === sp('accountId'))?.name ?? 'All accounts'}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="">All accounts</SelectItem>

View File

@@ -2,11 +2,15 @@
import { useState } from 'react'
import Link from 'next/link'
import { usePathname, useSearchParams } from 'next/navigation'
import { usePathname, useSearchParams, useRouter } from 'next/navigation'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Pencil, ChevronLeft, ChevronRight } from 'lucide-react'
import { Pencil, ChevronLeft, ChevronRight, Trash2, Tag, StickyNote, X, ArrowLeftRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { formatCents } from '@/lib/utils/currency'
import { EditTransactionDialog } from './EditTransactionDialog'
import { cn } from '@/lib/utils'
@@ -46,16 +50,86 @@ export function TransactionTable({
}: Props) {
const pathname = usePathname()
const searchParams = useSearchParams()
const router = useRouter()
const [editing, setEditing] = useState<TransactionRow | null>(null)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [bulkDialog, setBulkDialog] = useState<'delete' | 'budget' | 'notes' | 'transfer' | null>(null)
const [budgetTarget, setBudgetTarget] = useState<string>('__none__')
const [notesValue, setNotesValue] = useState('')
const [working, setWorking] = useState(false)
const totalPages = Math.ceil(total / limit)
const allIds = transactions.map((t) => t.id)
const allSelected = allIds.length > 0 && allIds.every((id) => selected.has(id))
const someSelected = selected.size > 0
function toggleAll() {
if (allSelected) {
setSelected((prev) => {
const next = new Set(prev)
allIds.forEach((id) => next.delete(id))
return next
})
} else {
setSelected((prev) => new Set([...prev, ...allIds]))
}
}
function toggleOne(id: string) {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function clearSelection() {
setSelected(new Set())
}
function pageHref(p: number) {
const params = new URLSearchParams(searchParams.toString())
params.set('page', String(p))
return `${pathname}?${params.toString()}`
}
if (transactions.length === 0) {
async function bulkPost(body: object) {
setWorking(true)
const res = await fetch('/api/transactions/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
setWorking(false)
if (res.ok) {
clearSelection()
setBulkDialog(null)
router.refresh()
}
}
async function handleDelete() {
await bulkPost({ action: 'delete', ids: [...selected] })
}
async function handleAssignBudget() {
await bulkPost({
action: 'assignBudget',
ids: [...selected],
budgetId: budgetTarget === '__none__' ? null : budgetTarget,
})
}
async function handleAddNotes() {
await bulkPost({ action: 'addNotes', ids: [...selected], notes: notesValue })
}
async function handleMarkTransfer() {
await bulkPost({ action: 'markTransfer', ids: [...selected] })
}
if (transactions.length === 0 && !someSelected) {
return (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
No transactions found.
@@ -63,12 +137,63 @@ export function TransactionTable({
)
}
const colSpan = showAccount ? 8 : 7
return (
<>
{/* Bulk action bar */}
{someSelected && (
<div className="flex items-center gap-3 rounded-md border bg-muted/50 px-4 py-2 mb-2">
<span className="text-sm font-medium">{selected.size} selected</span>
<div className="flex items-center gap-2 ml-auto">
<Button
size="sm"
variant="outline"
onClick={() => { setNotesValue(''); setBulkDialog('notes') }}
>
<StickyNote className="h-3.5 w-3.5 mr-1.5" />Notes
</Button>
<Button
size="sm"
variant="outline"
onClick={() => { setBudgetTarget('__none__'); setBulkDialog('budget') }}
>
<Tag className="h-3.5 w-3.5 mr-1.5" />Budget
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setBulkDialog('transfer')}
>
<ArrowLeftRight className="h-3.5 w-3.5 mr-1.5" />Transfer
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => setBulkDialog('delete')}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />Delete
</Button>
<Button size="sm" variant="ghost" onClick={clearSelection}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
<div className="rounded-md border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8 px-3">
<input
type="checkbox"
className="h-4 w-4 rounded border"
checked={allSelected}
onChange={toggleAll}
aria-label="Select all"
/>
</TableHead>
<TableHead className="w-28">Date</TableHead>
<TableHead>Description</TableHead>
{showAccount && <TableHead className="w-36">Account</TableHead>}
@@ -81,11 +206,22 @@ export function TransactionTable({
<TableBody>
{transactions.map((tx) => {
const isCredit = tx.type === 'CREDIT'
const isTransfer = tx.type === 'TRANSFER'
const isSelected = selected.has(tx.id)
const dateStr = new Date(tx.date).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
})
return (
<TableRow key={tx.id}>
<TableRow key={tx.id} className={isSelected ? 'bg-muted/40' : undefined}>
<TableCell className="px-3">
<input
type="checkbox"
className="h-4 w-4 rounded border"
checked={isSelected}
onChange={() => toggleOne(tx.id)}
aria-label="Select transaction"
/>
</TableCell>
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
{dateStr}
</TableCell>
@@ -101,8 +237,8 @@ export function TransactionTable({
</TableCell>
)}
<TableCell className="text-right tabular-nums font-medium">
<span className={cn('text-sm', isCredit ? 'text-green-600' : '')}>
{isCredit ? '+' : '-'}{formatCents(tx.amountCents)}
<span className={cn('text-sm', isTransfer ? 'text-muted-foreground' : isCredit ? 'text-green-600' : '')}>
{isTransfer ? '⇄ ' : isCredit ? '+' : '-'}{formatCents(tx.amountCents)}
</span>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
@@ -180,6 +316,101 @@ export function TransactionTable({
onOpenChange={(open) => { if (!open) setEditing(null) }}
budgets={budgets}
/>
{/* Delete confirm dialog */}
<Dialog open={bulkDialog === 'delete'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete {selected.size} transaction{selected.size !== 1 ? 's' : ''}?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">This cannot be undone.</p>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
<Button variant="destructive" onClick={handleDelete} disabled={working}>
{working ? 'Deleting…' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Assign budget dialog */}
<Dialog open={bulkDialog === 'budget'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Assign budget</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Apply to {selected.size} selected transaction{selected.size !== 1 ? 's' : ''}.
</p>
<Select value={budgetTarget} onValueChange={(v) => setBudgetTarget(v ?? '__none__')}>
<SelectTrigger>
<SelectValue>
{budgetTarget === '__none__'
? 'No budget'
: (budgets.find((b) => b.id === budgetTarget)?.name ?? 'No budget')}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No budget</SelectItem>
{budgets.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
<Button onClick={handleAssignBudget} disabled={working}>
{working ? 'Saving…' : 'Apply'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Mark as transfer dialog */}
<Dialog open={bulkDialog === 'transfer'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Mark as transfer?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{selected.size} transaction{selected.size !== 1 ? 's' : ''} will be marked as transfers
and excluded from cash flow calculations.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
<Button onClick={handleMarkTransfer} disabled={working}>
{working ? 'Saving…' : 'Mark as transfer'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add notes dialog */}
<Dialog open={bulkDialog === 'notes'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Set notes</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Apply to {selected.size} selected transaction{selected.size !== 1 ? 's' : ''}. Leave blank to clear.
</p>
<Textarea
placeholder="Add a note…"
value={notesValue}
onChange={(e) => setNotesValue(e.target.value)}
maxLength={500}
rows={3}
/>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkDialog(null)}>Cancel</Button>
<Button onClick={handleAddNotes} disabled={working}>
{working ? 'Saving…' : 'Apply'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,24 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { ArrowLeftRight } from 'lucide-react'
import { TransferRulesDialog } from './TransferRulesDialog'
interface Rule {
id: string
pattern: string
}
export function TransferRulesButton({ rules }: { rules: Rule[] }) {
const [open, setOpen] = useState(false)
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<ArrowLeftRight className="h-4 w-4 mr-2" />
Transfer rules
</Button>
<TransferRulesDialog open={open} onOpenChange={setOpen} rules={rules} />
</>
)
}

View File

@@ -0,0 +1,94 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Trash2, Plus } from 'lucide-react'
interface Rule {
id: string
pattern: string
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
rules: Rule[]
}
export function TransferRulesDialog({ open, onOpenChange, rules }: Props) {
const router = useRouter()
const [pattern, setPattern] = useState('')
const [adding, setAdding] = useState(false)
async function handleAdd(e: React.FormEvent) {
e.preventDefault()
if (!pattern.trim()) return
setAdding(true)
await fetch('/api/transfer-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pattern: pattern.trim() }),
})
setPattern('')
setAdding(false)
router.refresh()
}
async function handleDelete(id: string) {
await fetch(`/api/transfer-rules/${id}`, { method: 'DELETE' })
router.refresh()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Transfer rules</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Transactions whose description matches a pattern (case-insensitive) are automatically
marked as transfers on upload and excluded from cash flow.
</p>
{rules.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">No rules yet.</p>
) : (
<ul className="space-y-1.5">
{rules.map((rule) => (
<li
key={rule.id}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="font-mono truncate">{rule.pattern}</span>
<button
onClick={() => handleDelete(rule.id)}
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Delete rule"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
<form onSubmit={handleAdd} className="flex gap-2">
<Input
placeholder="e.g. Fidelity Transfer, Schwab"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
className="flex-1"
/>
<Button type="submit" disabled={adding || !pattern.trim()} size="sm">
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -97,7 +97,9 @@ export function UploadForm({ accounts }: Props) {
) : (
<Select value={accountId} onValueChange={(v) => setAccountId(v ?? '')}>
<SelectTrigger id="account" className="w-72">
<SelectValue placeholder="Select account" />
<SelectValue placeholder="Select account">
{accounts.find((a) => a.id === accountId)?.name}
</SelectValue>
</SelectTrigger>
<SelectContent>
{accounts.map((a) => (

21
src/lib/auth.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { NextAuthConfig } from 'next-auth'
// Edge-compatible config — no Node.js-only imports (no Prisma, no bcrypt).
// Used by middleware for JWT verification. Providers are added in auth.ts.
export const authConfig = {
session: { strategy: 'jwt' as const, maxAge: 60 * 60 },
secret: process.env.NEXTAUTH_SECRET,
trustHost: true,
pages: { signIn: '/login' },
callbacks: {
jwt({ token, user }) {
if (user) token.id = user.id
return token
},
session({ session, token }) {
if (token.id) session.user.id = token.id as string
return session
},
},
providers: [],
} satisfies NextAuthConfig

View File

@@ -3,6 +3,7 @@ import Credentials from 'next-auth/providers/credentials'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { authConfig } from '@/lib/auth.config'
const loginSchema = z.object({
email: z.string().email(),
@@ -10,6 +11,7 @@ const loginSchema = z.object({
})
export const { handlers, signIn, signOut, auth } = NextAuth({
...authConfig,
providers: [
Credentials({
credentials: {
@@ -32,23 +34,4 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
},
}),
],
session: {
strategy: 'jwt',
maxAge: 60 * 60, // 1 hour
},
secret: process.env.NEXTAUTH_SECRET,
trustHost: true,
pages: {
signIn: '/login',
},
callbacks: {
jwt({ token, user }) {
if (user) token.id = user.id
return token
},
session({ session, token }) {
if (token.id) session.user.id = token.id as string
return session
},
},
})

View File

@@ -4,6 +4,7 @@ export interface NormalizerConfig {
strategy: ParseStrategy
dateColumn: string
descriptionColumn: string
categoryColumn?: string
// Strategy A
amountColumn?: string
invertAmountSign?: boolean
@@ -39,7 +40,8 @@ export const bankProfiles: BankProfile[] = [
dateColumn: 'Trans. Date',
descriptionColumn: 'Description',
amountColumn: 'Amount',
invertAmountSign: false, // positive = DEBIT (charge), negative = CREDIT (payment)
invertAmountSign: true, // negative = DEBIT (charge), positive = CREDIT (payment/refund)
categoryColumn: 'Category',
detectColumns: ['Trans. Date', 'Post Date', 'Description', 'Amount', 'Category'],
},
{
@@ -50,7 +52,7 @@ export const bankProfiles: BankProfile[] = [
dateColumn: 'Date',
descriptionColumn: 'Description',
amountColumn: 'Amount',
invertAmountSign: true, // negative = DEBIT, positive = CREDIT
invertAmountSign: true, // negative = DEBIT (withdrawal), positive = CREDIT (deposit)
detectColumns: ['Date', 'Description', 'Amount', 'Split', 'Tags'],
},
{
@@ -61,7 +63,7 @@ export const bankProfiles: BankProfile[] = [
dateColumn: 'Run Date',
descriptionColumn: 'Description',
amountColumn: 'Amount($)',
invertAmountSign: true, // negative = DEBIT (purchase), positive = CREDIT
invertAmountSign: true, // negative = DEBIT (purchase/withdrawal), positive = CREDIT (deposit/dividend)
detectColumns: ['Run Date', 'Description', 'Amount($)'],
},
]

View File

@@ -1,4 +1,3 @@
import { createHash } from 'crypto'
import type { NormalizerConfig } from './bank-profiles'
export interface NormalizedRow {
@@ -6,7 +5,7 @@ export interface NormalizedRow {
description: string
amountCents: number
type: 'DEBIT' | 'CREDIT'
dedupeHash: string
category?: string
}
export function parseCents(raw: string): number {
@@ -50,18 +49,6 @@ function strategyB(
return { amountCents: Math.abs(parseCents(creditRaw)), type: 'CREDIT' }
}
function dedupeHash(
accountId: string,
date: Date,
description: string,
amountCents: number,
): string {
const dateStr = date.toISOString().split('T')[0]
return createHash('sha256')
.update(`${accountId}|${dateStr}|${description}|${amountCents}`)
.digest('hex')
}
export function normalizeRows(
rows: Record<string, string>[],
accountId: string,
@@ -92,12 +79,13 @@ export function normalizeRows(
if (amountCents === 0) continue
const rawCategory = config.categoryColumn ? (row[config.categoryColumn] ?? '').trim() : ''
out.push({
date,
description,
amountCents,
type,
dedupeHash: dedupeHash(accountId, date, description, amountCents),
category: rawCategory || undefined,
})
} catch {
// skip unparseable rows

View File

@@ -3,7 +3,7 @@ import { z } from 'zod'
export const createAccountSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
institution: z.string().max(100).optional(),
type: z.enum(['BANK', 'CREDIT_CARD']),
type: z.enum(['BANK', 'CREDIT_CARD', 'INVESTMENT']),
currency: z.string().length(3).default('USD'),
})

View File

@@ -0,0 +1,6 @@
import { z } from 'zod'
export const createBudgetRuleSchema = z.object({
budgetId: z.string().cuid(),
pattern: z.string().min(1).max(200).trim(),
})

View File

@@ -4,7 +4,7 @@ export const transactionQuerySchema = z.object({
accountId: z.string().optional(),
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
type: z.enum(['DEBIT', 'CREDIT']).optional(),
type: z.enum(['DEBIT', 'CREDIT', 'TRANSFER']).optional(),
search: z.string().optional(),
budgetId: z.string().optional(),
page: z.coerce.number().min(1).default(1),

View File

@@ -1,6 +1,10 @@
import { auth } from '@/lib/auth'
import NextAuth from 'next-auth'
import { authConfig } from '@/lib/auth.config'
import { NextResponse, type NextRequest } from 'next/server'
// Use the Edge-compatible config so no Node.js-only modules are bundled here.
const { auth } = NextAuth(authConfig)
// Process-local store — adequate for a self-hosted single-instance deployment.
const rateLimitStore = new Map<string, { count: number; resetAt: number }>()
const RATE_LIMIT = 10
@@ -29,10 +33,22 @@ function isRateLimited(ip: string): boolean {
function hasValidOrigin(req: NextRequest): boolean {
const origin = req.headers.get('origin')
if (!origin) return true // non-browser (curl, server-to-server)
const expected = process.env.NEXTAUTH_URL
? new URL(process.env.NEXTAUTH_URL).origin
: req.nextUrl.origin
return origin === expected
// Accept the host the browser actually used to reach this server.
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host')
const proto = req.headers.get('x-forwarded-proto') ?? 'http'
const requestOrigin = host ? `${proto}://${host}` : null
if (requestOrigin && origin === requestOrigin) return true
// Also accept the statically configured NEXTAUTH_URL origin (reverse-proxy setups).
if (process.env.NEXTAUTH_URL && origin === new URL(process.env.NEXTAUTH_URL).origin) return true
return false
}
// Build an absolute URL using the Host header the browser sent, not the
// internal hostname Next.js resolves to inside Docker.
function siteUrl(req: NextRequest, path: string): URL {
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? 'localhost:3000'
const proto = req.headers.get('x-forwarded-proto') ?? 'http'
return new URL(path, `${proto}://${host}`)
}
export default auth((req) => {
@@ -68,13 +84,17 @@ export default auth((req) => {
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const loginUrl = new URL('/login', req.nextUrl.origin)
if (pathname !== '/login') {
const loginUrl = siteUrl(req, '/login')
loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
}
// Logged-in users hitting /login get sent to the dashboard
if (pathname === '/login') {
return NextResponse.redirect(new URL('/dashboard', req.nextUrl.origin))
return NextResponse.redirect(siteUrl(req, '/dashboard'))
}
return NextResponse.next()