Compare commits
38 Commits
55debd082b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1400aa99d6 | |||
| 948ac2afe6 | |||
| 705a23c520 | |||
| a472749b21 | |||
| 99e41aab78 | |||
| 038539c191 | |||
| decfb19ec6 | |||
| 3c13ae3597 | |||
| 6f1376cc53 | |||
| 34bf24b35d | |||
| 587ac19b18 | |||
| 00d4796008 | |||
| 5a795d7e93 | |||
| 60dabb6264 | |||
| da938c1fcf | |||
| 0cf4612106 | |||
| 0ea6a7c698 | |||
| 62ca178308 | |||
| a9c12b94e1 | |||
| 1a984d1eac | |||
| c2b9184f2c | |||
| 8c0e4ad684 | |||
| 2a63b9120e | |||
| e6f5d5a33b | |||
| 7ccd64a7bb | |||
| d67a80b413 | |||
| 91a33cdfec | |||
| d865b02752 | |||
| f4216815e8 | |||
|
|
60fc836b73 | ||
|
|
efe42ac366 | ||
|
|
42f1d34ddd | ||
|
|
adb5d144a0 | ||
|
|
2e264014b6 | ||
|
|
8b0fba5014 | ||
|
|
874b022139 | ||
|
|
d41ab0c4e8 | ||
|
|
0b4f9f5c0e |
20
README.md
20
README.md
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
44
src/app/api/accounts/[id]/record-value/route.ts
Normal file
44
src/app/api/accounts/[id]/record-value/route.ts
Normal 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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
42
src/app/api/admin/recalculate-balances/route.ts
Normal file
42
src/app/api/admin/recalculate-balances/route.ts
Normal 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 })
|
||||
}
|
||||
16
src/app/api/budget-rules/[id]/route.ts
Normal file
16
src/app/api/budget-rules/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
37
src/app/api/budget-rules/route.ts
Normal file
37
src/app/api/budget-rules/route.ts
Normal 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 })
|
||||
}
|
||||
132
src/app/api/transactions/bulk/route.ts
Normal file
132
src/app/api/transactions/bulk/route.ts
Normal 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 })
|
||||
}
|
||||
19
src/app/api/transfer-rules/[id]/route.ts
Normal file
19
src/app/api/transfer-rules/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
34
src/app/api/transfer-rules/route.ts
Normal file
34
src/app/api/transfer-rules/route.ts
Normal 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 })
|
||||
}
|
||||
@@ -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 }]
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
77
src/components/accounts/RecordValueDialog.tsx
Normal file
77
src/components/accounts/RecordValueDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 ?? []}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
96
src/components/budgets/BudgetRulesDialog.tsx
Normal file
96
src/components/budgets/BudgetRulesDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
src/components/dashboard/MonthYearPicker.tsx
Normal file
52
src/components/dashboard/MonthYearPicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
24
src/components/transactions/TransferRulesButton.tsx
Normal file
24
src/components/transactions/TransferRulesButton.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
94
src/components/transactions/TransferRulesDialog.tsx
Normal file
94
src/components/transactions/TransferRulesDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
21
src/lib/auth.config.ts
Normal 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
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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($)'],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
|
||||
|
||||
6
src/lib/validations/budget-rule.ts
Normal file
6
src/lib/validations/budget-rule.ts
Normal 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(),
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user