Compare commits
19 Commits
1a984d1eac
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1400aa99d6 | |||
| 948ac2afe6 | |||
| 705a23c520 | |||
| a472749b21 | |||
| 99e41aab78 | |||
| 038539c191 | |||
| decfb19ec6 | |||
| 3c13ae3597 | |||
| 6f1376cc53 | |||
| 34bf24b35d | |||
| 587ac19b18 | |||
| 00d4796008 | |||
| 5a795d7e93 | |||
| 60dabb6264 | |||
| da938c1fcf | |||
| 0cf4612106 | |||
| 0ea6a7c698 | |||
| 62ca178308 | |||
| a9c12b94e1 |
@@ -1,6 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
@@ -17,6 +18,7 @@ services:
|
|||||||
|
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:3000:3000"
|
- "0.0.0.0:3000:3000"
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ model User {
|
|||||||
accounts Account[]
|
accounts Account[]
|
||||||
budgets Budget[]
|
budgets Budget[]
|
||||||
budgetRules BudgetRule[]
|
budgetRules BudgetRule[]
|
||||||
|
transferRules TransferRule[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AccountType {
|
enum AccountType {
|
||||||
BANK
|
BANK
|
||||||
CREDIT_CARD
|
CREDIT_CARD
|
||||||
|
INVESTMENT
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
@@ -44,6 +46,7 @@ model Account {
|
|||||||
enum TransactionType {
|
enum TransactionType {
|
||||||
DEBIT
|
DEBIT
|
||||||
CREDIT
|
CREDIT
|
||||||
|
TRANSFER
|
||||||
}
|
}
|
||||||
|
|
||||||
model Transaction {
|
model Transaction {
|
||||||
@@ -60,11 +63,9 @@ model Transaction {
|
|||||||
type TransactionType
|
type TransactionType
|
||||||
category String?
|
category String?
|
||||||
notes String?
|
notes String?
|
||||||
dedupeHash String
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([dedupeHash])
|
|
||||||
@@index([accountId, date])
|
@@index([accountId, date])
|
||||||
@@index([date])
|
@@index([date])
|
||||||
@@index([budgetId])
|
@@index([budgetId])
|
||||||
@@ -99,10 +100,20 @@ model BudgetRule {
|
|||||||
@@index([budgetId])
|
@@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])
|
||||||
|
}
|
||||||
|
|
||||||
model CsvUpload {
|
model CsvUpload {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
accountId String
|
accountId String
|
||||||
account Account @relation(fields: [accountId], references: [id])
|
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||||
fileName String
|
fileName String
|
||||||
rowCount Int
|
rowCount Int
|
||||||
importedCount Int
|
importedCount Int
|
||||||
|
|||||||
@@ -27,14 +27,7 @@ export default async function BudgetsPage({ searchParams }: { searchParams: Sear
|
|||||||
}),
|
}),
|
||||||
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
|
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
|
||||||
SELECT t."budgetId",
|
SELECT t."budgetId",
|
||||||
COALESCE(SUM(
|
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t."amountCents" ELSE -t."amountCents" END), 0)::bigint AS total
|
||||||
CASE
|
|
||||||
WHEN a.type = 'CREDIT_CARD' AND t.type = 'CREDIT' THEN t."amountCents"
|
|
||||||
WHEN a.type = 'CREDIT_CARD' AND t.type = 'DEBIT' THEN -t."amountCents"
|
|
||||||
WHEN t.type = 'DEBIT' THEN t."amountCents"
|
|
||||||
ELSE -t."amountCents"
|
|
||||||
END
|
|
||||||
), 0)::bigint AS total
|
|
||||||
FROM "Transaction" t
|
FROM "Transaction" t
|
||||||
JOIN "Account" a ON t."accountId" = a.id
|
JOIN "Account" a ON t."accountId" = a.id
|
||||||
WHERE a."userId" = ${userId}
|
WHERE a."userId" = ${userId}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
|
|||||||
JOIN "Account" a ON t."accountId" = a.id
|
JOIN "Account" a ON t."accountId" = a.id
|
||||||
WHERE a."userId" = ${userId}
|
WHERE a."userId" = ${userId}
|
||||||
AND a.type = 'BANK'
|
AND a.type = 'BANK'
|
||||||
|
AND t.type != 'TRANSFER'
|
||||||
AND t.date >= ${start}
|
AND t.date >= ${start}
|
||||||
AND t.date <= ${end}
|
AND t.date <= ${end}
|
||||||
GROUP BY t.type
|
GROUP BY t.type
|
||||||
@@ -42,14 +43,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
|
|||||||
}),
|
}),
|
||||||
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
|
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
|
||||||
SELECT t."budgetId",
|
SELECT t."budgetId",
|
||||||
COALESCE(SUM(
|
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t."amountCents" ELSE -t."amountCents" END), 0)::bigint AS total
|
||||||
CASE
|
|
||||||
WHEN a.type = 'CREDIT_CARD' AND t.type = 'CREDIT' THEN t."amountCents"
|
|
||||||
WHEN a.type = 'CREDIT_CARD' AND t.type = 'DEBIT' THEN -t."amountCents"
|
|
||||||
WHEN t.type = 'DEBIT' THEN t."amountCents"
|
|
||||||
ELSE -t."amountCents"
|
|
||||||
END
|
|
||||||
), 0)::bigint AS total
|
|
||||||
FROM "Transaction" t
|
FROM "Transaction" t
|
||||||
JOIN "Account" a ON t."accountId" = a.id
|
JOIN "Account" a ON t."accountId" = a.id
|
||||||
WHERE a."userId" = ${userId}
|
WHERE a."userId" = ${userId}
|
||||||
@@ -75,7 +69,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
|
|||||||
|
|
||||||
if (isCurrentMonth) {
|
if (isCurrentMonth) {
|
||||||
const accounts = await prisma.account.findMany({
|
const accounts = await prisma.account.findMany({
|
||||||
where: { userId, isActive: true, type: 'BANK' },
|
where: { userId, isActive: true, type: { in: ['BANK', 'INVESTMENT'] } },
|
||||||
select: { id: true, name: true, currentBalanceCents: true },
|
select: { id: true, name: true, currentBalanceCents: true },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
})
|
})
|
||||||
@@ -87,7 +81,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
|
|||||||
FROM "BalanceSnapshot" bs
|
FROM "BalanceSnapshot" bs
|
||||||
JOIN "Account" a ON bs."accountId" = a.id
|
JOIN "Account" a ON bs."accountId" = a.id
|
||||||
WHERE a."userId" = ${userId}
|
WHERE a."userId" = ${userId}
|
||||||
AND a.type = 'BANK'
|
AND a.type IN ('BANK', 'INVESTMENT')
|
||||||
AND bs.year = ${year}
|
AND bs.year = ${year}
|
||||||
AND bs.month = ${month}
|
AND bs.month = ${month}
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma'
|
|||||||
import { Prisma } from '@/generated/prisma/client'
|
import { Prisma } from '@/generated/prisma/client'
|
||||||
import { TransactionFilters } from '@/components/transactions/TransactionFilters'
|
import { TransactionFilters } from '@/components/transactions/TransactionFilters'
|
||||||
import { TransactionTable } from '@/components/transactions/TransactionTable'
|
import { TransactionTable } from '@/components/transactions/TransactionTable'
|
||||||
|
import { TransferRulesButton } from '@/components/transactions/TransferRulesButton'
|
||||||
|
|
||||||
const PAGE_LIMIT = 50
|
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({
|
prisma.transaction.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
@@ -57,6 +58,11 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
|
|||||||
select: { id: true, name: true, color: true },
|
select: { id: true, name: true, color: true },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
}),
|
}),
|
||||||
|
prisma.transferRule.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { id: true, pattern: true },
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Serialize dates for client components
|
// Serialize dates for client components
|
||||||
@@ -69,7 +75,10 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<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} />
|
<TransactionFilters accounts={accounts} />
|
||||||
<TransactionTable
|
<TransactionTable
|
||||||
transactions={rows}
|
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 })
|
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
|
|
||||||
|
await prisma.csvUpload.deleteMany({ where: { accountId: id } })
|
||||||
await prisma.account.delete({ where: { id } })
|
await prisma.account.delete({ where: { id } })
|
||||||
return new NextResponse(null, { status: 204 })
|
return new NextResponse(null, { status: 204 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { auth } from '@/lib/auth'
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
|
|
||||||
// One-time migration: flips DEBIT↔CREDIT on all CREDIT_CARD account transactions.
|
|
||||||
// CC CSVs have inverted sign semantics (CREDIT = charge, DEBIT = refund), but we now
|
|
||||||
// normalise them to standard semantics (DEBIT = spend) at upload time. Existing rows
|
|
||||||
// uploaded before that fix need their type flipped.
|
|
||||||
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 ccAccounts = await prisma.account.findMany({
|
|
||||||
where: { userId, type: 'CREDIT_CARD' },
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (ccAccounts.length === 0) {
|
|
||||||
return NextResponse.json({ updated: 0, message: 'No credit card accounts found' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const ccIds = ccAccounts.map((a) => a.id)
|
|
||||||
|
|
||||||
// Flip both directions in one raw statement to avoid stepping on our own update
|
|
||||||
const result = await prisma.$executeRaw`
|
|
||||||
UPDATE "Transaction"
|
|
||||||
SET type = CASE WHEN type = 'DEBIT' THEN 'CREDIT'::"TransactionType"
|
|
||||||
ELSE 'DEBIT'::"TransactionType" END
|
|
||||||
WHERE "accountId" = ANY(${ccIds}::text[])
|
|
||||||
`
|
|
||||||
|
|
||||||
return NextResponse.json({ updated: result })
|
|
||||||
}
|
|
||||||
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 })
|
||||||
|
}
|
||||||
@@ -18,8 +18,50 @@ const bulkSchema = z.discriminatedUnion('action', [
|
|||||||
ids: z.array(z.string()).min(1),
|
ids: z.array(z.string()).min(1),
|
||||||
notes: z.string().max(500),
|
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) {
|
export async function POST(req: Request) {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
@@ -34,14 +76,28 @@ export async function POST(req: Request) {
|
|||||||
// Verify all transaction IDs belong to this user
|
// Verify all transaction IDs belong to this user
|
||||||
const owned = await prisma.transaction.findMany({
|
const owned = await prisma.transaction.findMany({
|
||||||
where: { id: { in: ids }, account: { userId } },
|
where: { id: { in: ids }, account: { userId } },
|
||||||
select: { id: true },
|
select: { id: true, accountId: true, date: true },
|
||||||
})
|
})
|
||||||
if (owned.length !== ids.length) {
|
if (owned.length !== ids.length) {
|
||||||
return NextResponse.json({ error: 'One or more transactions not found' }, { status: 404 })
|
return NextResponse.json({ error: 'One or more transactions not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'delete') {
|
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 } } })
|
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 })
|
return NextResponse.json({ deleted: ids.length })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,11 +114,19 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ updated: ids.length })
|
return NextResponse.json({ updated: ids.length })
|
||||||
}
|
}
|
||||||
|
|
||||||
// addNotes
|
if (action === 'addNotes') {
|
||||||
const { notes } = result.data
|
const { notes } = result.data
|
||||||
await prisma.transaction.updateMany({
|
await prisma.transaction.updateMany({
|
||||||
where: { id: { in: ids } },
|
where: { id: { in: ids } },
|
||||||
data: { notes: notes || null },
|
data: { notes: notes || null },
|
||||||
})
|
})
|
||||||
return NextResponse.json({ updated: ids.length })
|
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,22 +74,34 @@ export async function POST(req: Request) {
|
|||||||
config = result.data
|
config = result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawNormalized = normalizeRows(allRows, accountId, config)
|
// 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 }
|
||||||
|
}
|
||||||
|
|
||||||
// Credit card CSVs have inverted sign semantics: CREDIT = charge, DEBIT = refund.
|
const normalized = normalizeRows(allRows, accountId, config)
|
||||||
// Flip types so the rest of the app can treat DEBIT = spending universally.
|
|
||||||
const normalized = account.type === 'CREDIT_CARD'
|
|
||||||
? rawNormalized.map((r) => ({ ...r, type: r.type === 'CREDIT' ? 'DEBIT' as const : 'CREDIT' as const }))
|
|
||||||
: rawNormalized
|
|
||||||
|
|
||||||
// Apply budget auto-assign rules (first match wins)
|
// Apply transfer rules first, then budget rules (transfer takes priority)
|
||||||
const budgetRules = await prisma.budgetRule.findMany({
|
const [budgetRules, transferRules] = await Promise.all([
|
||||||
|
prisma.budgetRule.findMany({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
select: { pattern: true, budgetId: true },
|
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 rowsWithBudgets = normalized.map((row) => {
|
||||||
const desc = row.description.toLowerCase()
|
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) {
|
for (const rule of budgetRules) {
|
||||||
if (desc.includes(rule.pattern.toLowerCase())) {
|
if (desc.includes(rule.pattern.toLowerCase())) {
|
||||||
return { ...row, budgetId: rule.budgetId }
|
return { ...row, budgetId: rule.budgetId }
|
||||||
@@ -120,26 +132,9 @@ export async function POST(req: Request) {
|
|||||||
type: r.type,
|
type: r.type,
|
||||||
category: r.category ?? null,
|
category: r.category ?? null,
|
||||||
budgetId: r.budgetId,
|
budgetId: r.budgetId,
|
||||||
dedupeHash: r.dedupeHash,
|
|
||||||
})),
|
})),
|
||||||
skipDuplicates: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// For CC accounts, also correct the type on any rows that already existed
|
|
||||||
// (uploaded before the CREDIT↔DEBIT flip was introduced).
|
|
||||||
if (account.type === 'CREDIT_CARD') {
|
|
||||||
const debitHashes = rowsWithBudgets.filter((r) => r.type === 'DEBIT').map((r) => r.dedupeHash)
|
|
||||||
const creditHashes = rowsWithBudgets.filter((r) => r.type === 'CREDIT').map((r) => r.dedupeHash)
|
|
||||||
await Promise.all([
|
|
||||||
debitHashes.length > 0
|
|
||||||
? prisma.transaction.updateMany({ where: { dedupeHash: { in: debitHashes }, type: 'CREDIT' }, data: { type: 'DEBIT' } })
|
|
||||||
: Promise.resolve(),
|
|
||||||
creditHashes.length > 0
|
|
||||||
? prisma.transaction.updateMany({ where: { dedupeHash: { in: creditHashes }, type: 'DEBIT' }, data: { type: 'CREDIT' } })
|
|
||||||
: Promise.resolve(),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
const skippedCount = normalized.length - importedCount
|
const skippedCount = normalized.length - importedCount
|
||||||
|
|
||||||
// Recompute current balance
|
// Recompute current balance
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { AccountType } from '@/generated/prisma/client'
|
import { AccountType } from '@/generated/prisma/client'
|
||||||
|
|
||||||
export function AccountBadge({ type }: { type: AccountType }) {
|
export function AccountBadge({ type }: { type: AccountType }) {
|
||||||
return type === 'BANK' ? (
|
if (type === 'BANK') return <Badge variant="secondary">Bank</Badge>
|
||||||
<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>
|
||||||
<Badge variant="outline">Credit Card</Badge>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ import {
|
|||||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} 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 { AccountBadge } from './AccountBadge'
|
||||||
import { CreateAccountDialog } from './CreateAccountDialog'
|
import { CreateAccountDialog } from './CreateAccountDialog'
|
||||||
|
import { RecordValueDialog } from './RecordValueDialog'
|
||||||
import { formatCents } from '@/lib/utils/currency'
|
import { formatCents } from '@/lib/utils/currency'
|
||||||
|
|
||||||
export function AccountCard({ account }: { account: Account }) {
|
export function AccountCard({ account }: { account: Account }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [recordOpen, setRecordOpen] = useState(false)
|
||||||
|
|
||||||
async function handleToggleActive() {
|
async function handleToggleActive() {
|
||||||
await fetch(`/api/accounts/${account.id}`, {
|
await fetch(`/api/accounts/${account.id}`, {
|
||||||
@@ -56,9 +58,13 @@ export function AccountCard({ account }: { account: Account }) {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => setEditOpen(true)}>
|
<DropdownMenuItem onClick={() => setEditOpen(true)}>
|
||||||
<Pencil className="h-4 w-4 mr-2" />
|
<Pencil className="h-4 w-4 mr-2" />Edit
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{account.type === 'INVESTMENT' && (
|
||||||
|
<DropdownMenuItem onClick={() => setRecordOpen(true)}>
|
||||||
|
<TrendingUp className="h-4 w-4 mr-2" />Record value
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem onClick={handleToggleActive}>
|
<DropdownMenuItem onClick={handleToggleActive}>
|
||||||
{account.isActive
|
{account.isActive
|
||||||
? <><EyeOff className="h-4 w-4 mr-2" />Deactivate</>
|
? <><EyeOff className="h-4 w-4 mr-2" />Deactivate</>
|
||||||
@@ -87,11 +93,15 @@ export function AccountCard({ account }: { account: Account }) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<CreateAccountDialog
|
<CreateAccountDialog open={editOpen} onOpenChange={setEditOpen} account={account} />
|
||||||
open={editOpen}
|
{account.type === 'INVESTMENT' && (
|
||||||
onOpenChange={setEditOpen}
|
<RecordValueDialog
|
||||||
account={account}
|
open={recordOpen}
|
||||||
|
onOpenChange={setRecordOpen}
|
||||||
|
accountId={account.id}
|
||||||
|
accountName={account.name}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export function CreateAccountDialog({ open, onOpenChange, account }: Props) {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="BANK">Bank</SelectItem>
|
<SelectItem value="BANK">Bank</SelectItem>
|
||||||
<SelectItem value="CREDIT_CARD">Credit Card</SelectItem>
|
<SelectItem value="CREDIT_CARD">Credit Card</SelectItem>
|
||||||
|
<SelectItem value="INVESTMENT">Investment</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -80,8 +80,8 @@ export function EditTransactionDialog({ transaction, onOpenChange, budgets }: Pr
|
|||||||
{transaction.description}
|
{transaction.description}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{date} · <span className={transaction.type === 'CREDIT' ? 'text-green-600' : ''}>
|
{date} · <span className={transaction.type === 'CREDIT' ? 'text-green-600' : transaction.type === 'TRANSFER' ? 'text-muted-foreground' : ''}>
|
||||||
{transaction.type === 'CREDIT' ? '+' : '-'}{formatCents(transaction.amountCents)}
|
{transaction.type === 'TRANSFER' ? '⇄ ' : transaction.type === 'CREDIT' ? '+' : '-'}{formatCents(transaction.amountCents)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -101,7 +101,11 @@ export function EditTransactionDialog({ transaction, onOpenChange, budgets }: Pr
|
|||||||
<Label htmlFor="budget">Budget</Label>
|
<Label htmlFor="budget">Budget</Label>
|
||||||
<Select value={budgetId} onValueChange={(v) => setBudgetId(v ?? '')}>
|
<Select value={budgetId} onValueChange={(v) => setBudgetId(v ?? '')}>
|
||||||
<SelectTrigger id="budget">
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">No budget</SelectItem>
|
<SelectItem value="">No budget</SelectItem>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
|
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
@@ -20,17 +20,19 @@ export function TransactionFilters({ accounts }: { accounts: AccountOption[] })
|
|||||||
const sp = (key: string) => searchParams.get(key) ?? ''
|
const sp = (key: string) => searchParams.get(key) ?? ''
|
||||||
|
|
||||||
const [search, setSearch] = useState(sp('search'))
|
const [search, setSearch] = useState(sp('search'))
|
||||||
|
const searchParamsRef = useRef(searchParams)
|
||||||
|
useEffect(() => { searchParamsRef.current = searchParams }, [searchParams])
|
||||||
|
|
||||||
const push = useCallback(
|
const push = useCallback(
|
||||||
(updates: Record<string, string>) => {
|
(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)) {
|
for (const [k, v] of Object.entries(updates)) {
|
||||||
if (v) params.set(k, v); else params.delete(k)
|
if (v) params.set(k, v); else params.delete(k)
|
||||||
}
|
}
|
||||||
params.delete('page')
|
params.delete('page')
|
||||||
router.replace(`${pathname}?${params.toString()}`)
|
router.replace(`${pathname}?${params.toString()}`)
|
||||||
},
|
},
|
||||||
[searchParams, pathname, router],
|
[pathname, router],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Debounce search → URL
|
// Debounce search → URL
|
||||||
@@ -52,7 +54,9 @@ export function TransactionFilters({ accounts }: { accounts: AccountOption[] })
|
|||||||
<Label className="text-xs">Account</Label>
|
<Label className="text-xs">Account</Label>
|
||||||
<Select value={sp('accountId')} onValueChange={(v) => push({ accountId: v ?? '' })}>
|
<Select value={sp('accountId')} onValueChange={(v) => push({ accountId: v ?? '' })}>
|
||||||
<SelectTrigger className="h-8 w-44 text-sm">
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="">All accounts</SelectItem>
|
<SelectItem value="">All accounts</SelectItem>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { usePathname, useSearchParams, useRouter } from 'next/navigation'
|
|||||||
import {
|
import {
|
||||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { Pencil, ChevronLeft, ChevronRight, Trash2, Tag, StickyNote, X } from 'lucide-react'
|
import { Pencil, ChevronLeft, ChevronRight, Trash2, Tag, StickyNote, X, ArrowLeftRight } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
@@ -53,7 +53,7 @@ export function TransactionTable({
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [editing, setEditing] = useState<TransactionRow | null>(null)
|
const [editing, setEditing] = useState<TransactionRow | null>(null)
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
const [bulkDialog, setBulkDialog] = useState<'delete' | 'budget' | 'notes' | null>(null)
|
const [bulkDialog, setBulkDialog] = useState<'delete' | 'budget' | 'notes' | 'transfer' | null>(null)
|
||||||
const [budgetTarget, setBudgetTarget] = useState<string>('__none__')
|
const [budgetTarget, setBudgetTarget] = useState<string>('__none__')
|
||||||
const [notesValue, setNotesValue] = useState('')
|
const [notesValue, setNotesValue] = useState('')
|
||||||
const [working, setWorking] = useState(false)
|
const [working, setWorking] = useState(false)
|
||||||
@@ -125,6 +125,10 @@ export function TransactionTable({
|
|||||||
await bulkPost({ action: 'addNotes', ids: [...selected], notes: notesValue })
|
await bulkPost({ action: 'addNotes', ids: [...selected], notes: notesValue })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleMarkTransfer() {
|
||||||
|
await bulkPost({ action: 'markTransfer', ids: [...selected] })
|
||||||
|
}
|
||||||
|
|
||||||
if (transactions.length === 0 && !someSelected) {
|
if (transactions.length === 0 && !someSelected) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||||
@@ -156,6 +160,13 @@ export function TransactionTable({
|
|||||||
>
|
>
|
||||||
<Tag className="h-3.5 w-3.5 mr-1.5" />Budget
|
<Tag className="h-3.5 w-3.5 mr-1.5" />Budget
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setBulkDialog('transfer')}
|
||||||
|
>
|
||||||
|
<ArrowLeftRight className="h-3.5 w-3.5 mr-1.5" />Transfer
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -195,6 +206,7 @@ export function TransactionTable({
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{transactions.map((tx) => {
|
{transactions.map((tx) => {
|
||||||
const isCredit = tx.type === 'CREDIT'
|
const isCredit = tx.type === 'CREDIT'
|
||||||
|
const isTransfer = tx.type === 'TRANSFER'
|
||||||
const isSelected = selected.has(tx.id)
|
const isSelected = selected.has(tx.id)
|
||||||
const dateStr = new Date(tx.date).toLocaleDateString('en-US', {
|
const dateStr = new Date(tx.date).toLocaleDateString('en-US', {
|
||||||
month: 'short', day: 'numeric', year: 'numeric',
|
month: 'short', day: 'numeric', year: 'numeric',
|
||||||
@@ -225,8 +237,8 @@ export function TransactionTable({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
<TableCell className="text-right tabular-nums font-medium">
|
<TableCell className="text-right tabular-nums font-medium">
|
||||||
<span className={cn('text-sm', isCredit ? 'text-green-600' : '')}>
|
<span className={cn('text-sm', isTransfer ? 'text-muted-foreground' : isCredit ? 'text-green-600' : '')}>
|
||||||
{isCredit ? '+' : '-'}{formatCents(tx.amountCents)}
|
{isTransfer ? '⇄ ' : isCredit ? '+' : '-'}{formatCents(tx.amountCents)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
@@ -356,6 +368,25 @@ export function TransactionTable({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 */}
|
{/* Add notes dialog */}
|
||||||
<Dialog open={bulkDialog === 'notes'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
|
<Dialog open={bulkDialog === 'notes'} onOpenChange={(o) => { if (!o) setBulkDialog(null) }}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm">
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ export const bankProfiles: BankProfile[] = [
|
|||||||
dateColumn: 'Trans. Date',
|
dateColumn: 'Trans. Date',
|
||||||
descriptionColumn: 'Description',
|
descriptionColumn: 'Description',
|
||||||
amountColumn: 'Amount',
|
amountColumn: 'Amount',
|
||||||
invertAmountSign: false, // positive = DEBIT (charge), negative = CREDIT (payment)
|
invertAmountSign: true, // negative = DEBIT (charge), positive = CREDIT (payment/refund)
|
||||||
categoryColumn: 'Category',
|
categoryColumn: 'Category',
|
||||||
detectColumns: ['Trans. Date', 'Post Date', 'Description', 'Amount', 'Category'],
|
detectColumns: ['Trans. Date', 'Post Date', 'Description', 'Amount', 'Category'],
|
||||||
},
|
},
|
||||||
@@ -52,7 +52,7 @@ export const bankProfiles: BankProfile[] = [
|
|||||||
dateColumn: 'Date',
|
dateColumn: 'Date',
|
||||||
descriptionColumn: 'Description',
|
descriptionColumn: 'Description',
|
||||||
amountColumn: 'Amount',
|
amountColumn: 'Amount',
|
||||||
invertAmountSign: true, // negative = DEBIT, positive = CREDIT
|
invertAmountSign: true, // negative = DEBIT (withdrawal), positive = CREDIT (deposit)
|
||||||
detectColumns: ['Date', 'Description', 'Amount', 'Split', 'Tags'],
|
detectColumns: ['Date', 'Description', 'Amount', 'Split', 'Tags'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -63,7 +63,7 @@ export const bankProfiles: BankProfile[] = [
|
|||||||
dateColumn: 'Run Date',
|
dateColumn: 'Run Date',
|
||||||
descriptionColumn: 'Description',
|
descriptionColumn: 'Description',
|
||||||
amountColumn: 'Amount($)',
|
amountColumn: 'Amount($)',
|
||||||
invertAmountSign: true, // negative = DEBIT (purchase), positive = CREDIT
|
invertAmountSign: true, // negative = DEBIT (purchase/withdrawal), positive = CREDIT (deposit/dividend)
|
||||||
detectColumns: ['Run Date', 'Description', 'Amount($)'],
|
detectColumns: ['Run Date', 'Description', 'Amount($)'],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { createHash } from 'crypto'
|
|
||||||
import type { NormalizerConfig } from './bank-profiles'
|
import type { NormalizerConfig } from './bank-profiles'
|
||||||
|
|
||||||
export interface NormalizedRow {
|
export interface NormalizedRow {
|
||||||
@@ -7,7 +6,6 @@ export interface NormalizedRow {
|
|||||||
amountCents: number
|
amountCents: number
|
||||||
type: 'DEBIT' | 'CREDIT'
|
type: 'DEBIT' | 'CREDIT'
|
||||||
category?: string
|
category?: string
|
||||||
dedupeHash: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCents(raw: string): number {
|
export function parseCents(raw: string): number {
|
||||||
@@ -51,18 +49,6 @@ function strategyB(
|
|||||||
return { amountCents: Math.abs(parseCents(creditRaw)), type: 'CREDIT' }
|
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(
|
export function normalizeRows(
|
||||||
rows: Record<string, string>[],
|
rows: Record<string, string>[],
|
||||||
accountId: string,
|
accountId: string,
|
||||||
@@ -100,7 +86,6 @@ export function normalizeRows(
|
|||||||
amountCents,
|
amountCents,
|
||||||
type,
|
type,
|
||||||
category: rawCategory || undefined,
|
category: rawCategory || undefined,
|
||||||
dedupeHash: dedupeHash(accountId, date, description, amountCents),
|
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// skip unparseable rows
|
// skip unparseable rows
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from 'zod'
|
|||||||
export const createAccountSchema = z.object({
|
export const createAccountSchema = z.object({
|
||||||
name: z.string().min(1, 'Name is required').max(100),
|
name: z.string().min(1, 'Name is required').max(100),
|
||||||
institution: z.string().max(100).optional(),
|
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'),
|
currency: z.string().length(3).default('USD'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const transactionQuerySchema = z.object({
|
|||||||
accountId: z.string().optional(),
|
accountId: z.string().optional(),
|
||||||
dateFrom: z.string().optional(),
|
dateFrom: z.string().optional(),
|
||||||
dateTo: z.string().optional(),
|
dateTo: z.string().optional(),
|
||||||
type: z.enum(['DEBIT', 'CREDIT']).optional(),
|
type: z.enum(['DEBIT', 'CREDIT', 'TRANSFER']).optional(),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
budgetId: z.string().optional(),
|
budgetId: z.string().optional(),
|
||||||
page: z.coerce.number().min(1).default(1),
|
page: z.coerce.number().min(1).default(1),
|
||||||
|
|||||||
Reference in New Issue
Block a user