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:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
@@ -17,6 +18,7 @@ services:
|
||||
|
||||
app:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "0.0.0.0:3000:3000"
|
||||
environment:
|
||||
|
||||
@@ -16,11 +16,13 @@ model User {
|
||||
accounts Account[]
|
||||
budgets Budget[]
|
||||
budgetRules BudgetRule[]
|
||||
transferRules TransferRule[]
|
||||
}
|
||||
|
||||
enum AccountType {
|
||||
BANK
|
||||
CREDIT_CARD
|
||||
INVESTMENT
|
||||
}
|
||||
|
||||
model Account {
|
||||
@@ -44,6 +46,7 @@ model Account {
|
||||
enum TransactionType {
|
||||
DEBIT
|
||||
CREDIT
|
||||
TRANSFER
|
||||
}
|
||||
|
||||
model Transaction {
|
||||
@@ -60,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])
|
||||
@@ -99,10 +100,20 @@ model BudgetRule {
|
||||
@@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 {
|
||||
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
|
||||
|
||||
@@ -27,14 +27,7 @@ export default async function BudgetsPage({ searchParams }: { searchParams: Sear
|
||||
}),
|
||||
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
|
||||
SELECT t."budgetId",
|
||||
COALESCE(SUM(
|
||||
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
|
||||
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}
|
||||
|
||||
@@ -32,6 +32,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
|
||||
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
|
||||
@@ -42,14 +43,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
|
||||
}),
|
||||
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
|
||||
SELECT t."budgetId",
|
||||
COALESCE(SUM(
|
||||
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
|
||||
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}
|
||||
@@ -75,7 +69,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
|
||||
|
||||
if (isCurrentMonth) {
|
||||
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 },
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
@@ -87,7 +81,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
|
||||
FROM "BalanceSnapshot" bs
|
||||
JOIN "Account" a ON bs."accountId" = a.id
|
||||
WHERE a."userId" = ${userId}
|
||||
AND a.type = 'BANK'
|
||||
AND a.type IN ('BANK', 'INVESTMENT')
|
||||
AND bs.year = ${year}
|
||||
AND bs.month = ${month}
|
||||
`
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
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 })
|
||||
@@ -34,14 +76,28 @@ export async function POST(req: Request) {
|
||||
// Verify all transaction IDs belong to this user
|
||||
const owned = await prisma.transaction.findMany({
|
||||
where: { id: { in: ids }, account: { userId } },
|
||||
select: { id: true },
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -58,7 +114,7 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ updated: ids.length })
|
||||
}
|
||||
|
||||
// addNotes
|
||||
if (action === 'addNotes') {
|
||||
const { notes } = result.data
|
||||
await prisma.transaction.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
@@ -66,3 +122,11 @@ export async function POST(req: Request) {
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
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.
|
||||
// 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
|
||||
const normalized = normalizeRows(allRows, accountId, config)
|
||||
|
||||
// Apply budget auto-assign rules (first match wins)
|
||||
const budgetRules = await prisma.budgetRule.findMany({
|
||||
// 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 }
|
||||
@@ -120,26 +132,9 @@ export async function POST(req: Request) {
|
||||
type: r.type,
|
||||
category: r.category ?? null,
|
||||
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
|
||||
|
||||
// Recompute current balance
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { usePathname, useSearchParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
@@ -53,7 +53,7 @@ export function TransactionTable({
|
||||
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' | null>(null)
|
||||
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)
|
||||
@@ -125,6 +125,10 @@ export function TransactionTable({
|
||||
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">
|
||||
@@ -156,6 +160,13 @@ export function TransactionTable({
|
||||
>
|
||||
<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"
|
||||
@@ -195,6 +206,7 @@ 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',
|
||||
@@ -225,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">
|
||||
@@ -356,6 +368,25 @@ export function TransactionTable({
|
||||
</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">
|
||||
|
||||
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',
|
||||
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'],
|
||||
},
|
||||
@@ -52,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'],
|
||||
},
|
||||
{
|
||||
@@ -63,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 {
|
||||
@@ -7,7 +6,6 @@ export interface NormalizedRow {
|
||||
amountCents: number
|
||||
type: 'DEBIT' | 'CREDIT'
|
||||
category?: string
|
||||
dedupeHash: string
|
||||
}
|
||||
|
||||
export function parseCents(raw: string): number {
|
||||
@@ -51,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,
|
||||
@@ -100,7 +86,6 @@ export function normalizeRows(
|
||||
amountCents,
|
||||
type,
|
||||
category: rawCategory || undefined,
|
||||
dedupeHash: dedupeHash(accountId, date, description, amountCents),
|
||||
})
|
||||
} 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'),
|
||||
})
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user