Compare commits

...

9 Commits

Author SHA1 Message Date
7954af1837 Merge branch 'main' of https://git.jerick.xyz/jerick/finance-app 2026-08-04 11:53:00 -04:00
2a4522a09a Capital One Account Changes 2026-08-04 11:52:01 -04:00
jerick
60fc0cd8df Update package-lock.json for next-themes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:11:26 +00:00
jerick
4809edf73a Add dark mode toggle via next-themes
Uses next-themes with system default. Toggle button in sidebar
switches between light/dark and persists to localStorage.
CSS variables for dark mode were already defined in globals.css.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:10:22 +00:00
jerick
4a0f036d01 Add duplicate pattern check when adding budget rules
Client side catches it immediately (case-insensitive match against
existing rules) and shows an inline error. API also rejects with 409
as the authoritative guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:01:45 +00:00
jerick
0014db3aea Make budget rules list scrollable when it overflows
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:57:14 +00:00
jerick
73e8f51936 Fix transaction pagination resetting to page 1 on Next click
The search debounce useEffect had `push` in its deps. Since useRouter()
returns a new instance each render, `push` was recreated on every
navigation, re-triggering the effect which called push({ search })
after 400ms — always deleting the page param and snapping back to 1.

Replace the useEffect debounce with a ref-based timer in the handler
so the debounce only fires when the user actually types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:46:54 +00:00
jerick
344a4b8a46 Fix Discover CC invertAmountSign: positive CSV amount = DEBIT (charge)
The prior fix had it backwards — invertAmountSign: true made positive
amounts CREDIT, so charges were stored with the wrong type. Discover CC
positive amounts are charges (money out), which should be DEBIT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:33:43 +00:00
jerick
dc45f489a6 Fix budget spend showing negative when TRANSFER transactions assigned
TRANSFER type fell into the ELSE branch of the spend CASE expression,
treating transfers as negative spend like refunds. Explicitly handle
each type: DEBIT=positive, CREDIT=negative, TRANSFER=0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:25:42 +00:00
13 changed files with 126 additions and 45 deletions

View File

@@ -187,20 +187,21 @@ model BalanceSnapshot {
All 4 user banks with exact column mappings. Implement in `src/lib/csv/bank-profiles.ts`.
### Discover High Yield Savings → `BANK`
- **Strategy**: B (split debit/credit columns)
- **Strategy**: C (single unsigned amount column + type column)
- `date` ← `Transaction Date`
- `description` ← `Transaction Description`
- `debitAmount` ← `Debit`
- `creditAmount` `Credit`
- `amount` ← `Transaction Amount`
- `type` ← `Transaction Type` — value is literally `Debit` or `Credit`
- `balance` ← `Balance` (reference only)
- Ignore: `Transaction Type`
- Ignore: `Account Number`
### Discover Credit Card → `CREDIT_CARD`
- **Strategy**: A (single signed column)
- `date` ← `Trans. Date`
- **Strategy**: B (split debit/credit columns)
- `date` ← `Transaction Date`
- `description` ← `Description`
- `amount` ← `Amount` — **positive = DEBIT (charge), negative = CREDIT (payment/refund)**
- Ignore: `Post Date`, `Category`
- `debitAmount` ← `Debit`
- `creditAmount` ← `Credit`
- Ignore: `Posted Date`, `Card No.`, `Category`
### Huntington Checking → `BANK`
- **Strategy**: A (single signed column)
@@ -224,10 +225,14 @@ All 4 user banks with exact column mappings. Implement in `src/lib/csv/bank-prof
// Sign convention varies per bank; each profile has invertAmountSign: boolean
function parseStrategyA(raw: string, invert: boolean): { amountCents: number; type: TransactionType }
// Strategy B — separate debit/credit columns (Discover Savings)
// Strategy B — separate debit/credit columns (Discover Credit Card)
// The non-empty/non-zero column determines type and amount
function parseStrategyB(debitRaw: string, creditRaw: string): { amountCents: number; type: TransactionType }
// Strategy C — single unsigned amount column + separate type column (Discover Savings)
// The type column's literal value ("Debit" / "Credit") determines type
function parseStrategyC(amountRaw: string, typeRaw: string): { amountCents: number; type: TransactionType }
// All amounts via:
function parseCents(raw: string): number {
return Math.round(parseFloat(raw.replace(/[$,\s]/g, '')) * 100)

11
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"lucide-react": "^1.8.0",
"next": "16.2.4",
"next-auth": "^5.0.0-beta.31",
"next-themes": "^0.4.6",
"papaparse": "^5.5.3",
"pg": "^8.20.0",
"react": "19.2.4",
@@ -8831,6 +8832,16 @@
}
}
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",

View File

@@ -18,6 +18,7 @@
"lucide-react": "^1.8.0",
"next": "16.2.4",
"next-auth": "^5.0.0-beta.31",
"next-themes": "^0.4.6",
"papaparse": "^5.5.3",
"pg": "^8.20.0",
"react": "19.2.4",

View File

@@ -27,7 +27,7 @@ export default async function BudgetsPage({ searchParams }: { searchParams: Sear
}),
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId",
COALESCE(SUM(CASE 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" WHEN t.type = 'CREDIT' THEN -t."amountCents" ELSE 0 END), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}

View File

@@ -43,7 +43,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
}),
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId",
COALESCE(SUM(CASE 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" WHEN t.type = 'CREDIT' THEN -t."amountCents" ELSE 0 END), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}

View File

@@ -29,6 +29,11 @@ export async function POST(req: Request) {
const budget = await prisma.budget.findFirst({ where: { id: budgetId, userId: session.user.id } })
if (!budget) return NextResponse.json({ error: 'Budget not found' }, { status: 404 })
const existing = await prisma.budgetRule.findFirst({
where: { budgetId, pattern: { equals: pattern, mode: 'insensitive' } },
})
if (existing) return NextResponse.json({ error: 'Duplicate rule pattern' }, { status: 409 })
const rule = await prisma.budgetRule.create({
data: { userId: session.user.id, budgetId, pattern },
select: { id: true, budgetId: true, pattern: true },

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/layout/ThemeProvider";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -27,7 +28,9 @@ export default function RootLayout({
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col">
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}

View File

@@ -24,10 +24,14 @@ export function BudgetRulesDialog({ open, onOpenChange, budgetId, budgetName, ru
const router = useRouter()
const [pattern, setPattern] = useState('')
const [adding, setAdding] = useState(false)
const [dupError, setDupError] = useState(false)
async function handleAdd(e: React.FormEvent) {
e.preventDefault()
if (!pattern.trim()) return
const isDup = rules.some((r) => r.pattern.toLowerCase() === pattern.trim().toLowerCase())
if (isDup) { setDupError(true); return }
setDupError(false)
setAdding(true)
await fetch('/api/budget-rules', {
method: 'POST',
@@ -36,6 +40,7 @@ export function BudgetRulesDialog({ open, onOpenChange, budgetId, budgetName, ru
})
setPattern('')
setAdding(false)
setDupError(false)
router.refresh()
}
@@ -59,7 +64,7 @@ export function BudgetRulesDialog({ open, onOpenChange, budgetId, budgetName, ru
{rules.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">No rules yet.</p>
) : (
<ul className="space-y-1.5">
<ul className="space-y-1.5 max-h-64 overflow-y-auto pr-1">
{rules.map((rule) => (
<li
key={rule.id}
@@ -78,17 +83,22 @@ export function BudgetRulesDialog({ open, onOpenChange, budgetId, budgetName, ru
</ul>
)}
<form onSubmit={handleAdd} className="flex gap-2">
<Input
placeholder="e.g. Amazon, Netflix, Starbucks"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
className="flex-1"
/>
<Button type="submit" disabled={adding || !pattern.trim()} size="sm">
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
<form onSubmit={handleAdd} className="space-y-1.5">
<div className="flex gap-2">
<Input
placeholder="e.g. Amazon, Netflix, Starbucks"
value={pattern}
onChange={(e) => { setPattern(e.target.value); setDupError(false) }}
className={dupError ? 'flex-1 border-destructive focus-visible:ring-destructive' : 'flex-1'}
/>
<Button type="submit" disabled={adding || !pattern.trim()} size="sm">
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
{dupError && (
<p className="text-xs text-destructive">That pattern already exists for this budget.</p>
)}
</form>
</DialogContent>
</Dialog>

View File

@@ -3,10 +3,11 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { useTheme } from 'next-themes'
import { cn } from '@/lib/utils'
import {
LayoutDashboard, CreditCard, ArrowLeftRight,
Upload, PiggyBank, TrendingUp, LogOut,
Upload, PiggyBank, TrendingUp, LogOut, Sun, Moon,
} from 'lucide-react'
import { Separator } from '@/components/ui/separator'
@@ -21,6 +22,7 @@ const navItems = [
export function Sidebar() {
const pathname = usePathname()
const { resolvedTheme, setTheme } = useTheme()
return (
<aside className="flex w-56 shrink-0 flex-col border-r bg-card">
@@ -46,7 +48,15 @@ export function Sidebar() {
))}
</nav>
<Separator />
<div className="p-2">
<div className="p-2 space-y-1">
<button
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
{resolvedTheme === 'dark'
? <><Sun className="h-4 w-4 shrink-0" />Light mode</>
: <><Moon className="h-4 w-4 shrink-0" />Dark mode</>}
</button>
<button
onClick={() => signOut({ callbackUrl: '/login' })}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"

View File

@@ -0,0 +1,11 @@
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
{children}
</NextThemesProvider>
)
}

View File

@@ -35,11 +35,15 @@ export function TransactionFilters({ accounts }: { accounts: AccountOption[] })
[pathname, router],
)
// Debounce search → URL
useEffect(() => {
const t = setTimeout(() => push({ search }), 400)
return () => clearTimeout(t)
}, [search, push])
const pushRef = useRef(push)
useEffect(() => { pushRef.current = push }, [push])
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
function handleSearchChange(value: string) {
setSearch(value)
if (searchTimer.current) clearTimeout(searchTimer.current)
searchTimer.current = setTimeout(() => pushRef.current({ search: value }), 400)
}
function reset() {
setSearch('')
@@ -107,7 +111,7 @@ export function TransactionFilters({ accounts }: { accounts: AccountOption[] })
className="h-8 text-sm"
placeholder="Search descriptions…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onChange={(e) => handleSearchChange(e.target.value)}
/>
</div>

View File

@@ -1,4 +1,4 @@
export type ParseStrategy = 'A' | 'B'
export type ParseStrategy = 'A' | 'B' | 'C'
export interface NormalizerConfig {
strategy: ParseStrategy
@@ -11,6 +11,8 @@ export interface NormalizerConfig {
// Strategy B
debitColumn?: string
creditColumn?: string
// Strategy C
typeColumn?: string
}
export interface BankProfile extends NormalizerConfig {
@@ -25,24 +27,24 @@ export const bankProfiles: BankProfile[] = [
id: 'discover-savings',
name: 'Discover High Yield Savings',
accountType: 'BANK',
strategy: 'B',
strategy: 'C',
dateColumn: 'Transaction Date',
descriptionColumn: 'Transaction Description',
debitColumn: 'Debit',
creditColumn: 'Credit',
detectColumns: ['Transaction Date', 'Transaction Description', 'Debit', 'Credit', 'Balance'],
amountColumn: 'Transaction Amount',
typeColumn: 'Transaction Type',
detectColumns: ['Transaction Date', 'Transaction Description', 'Transaction Type', 'Transaction Amount', 'Balance'],
},
{
id: 'discover-cc',
name: 'Discover Credit Card',
accountType: 'CREDIT_CARD',
strategy: 'A',
dateColumn: 'Trans. Date',
strategy: 'B',
dateColumn: 'Transaction Date',
descriptionColumn: 'Description',
amountColumn: 'Amount',
invertAmountSign: true, // negative = DEBIT (charge), positive = CREDIT (payment/refund)
invertAmountSign: false, // positive = DEBIT (charge), negative = CREDIT (payment/refund)
categoryColumn: 'Category',
detectColumns: ['Trans. Date', 'Post Date', 'Description', 'Amount', 'Category'],
detectColumns: ['Transaction Date', 'Posted Date', 'Card No.', 'Description', 'Category', 'Debit', 'Credit'],
},
{
id: 'huntington-checking',

View File

@@ -16,10 +16,14 @@ export function parseCents(raw: string): number {
function parseDate(raw: string): Date {
const trimmed = raw.trim()
// MM/DD/YYYY
if (/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(trimmed)) {
const [m, d, y] = trimmed.split('/')
return new Date(parseInt(y), parseInt(m) - 1, parseInt(d))
// MM/DD/YYYY or MM/DD/YY
const m = /^(\d{1,2})\/(\d{1,2})\/(\d{2}|\d{4})$/.exec(trimmed)
if (m) {
const month = parseInt(m[1])
const day = parseInt(m[2])
let year = parseInt(m[3])
if (year < 100) year += 2000
return new Date(year, month - 1, day)
}
const date = new Date(trimmed)
if (!isNaN(date.getTime())) return date
@@ -49,6 +53,17 @@ function strategyB(
return { amountCents: Math.abs(parseCents(creditRaw)), type: 'CREDIT' }
}
function strategyC(
amountRaw: string,
typeRaw: string,
): { amountCents: number; type: 'DEBIT' | 'CREDIT' } {
const cents = Math.abs(parseCents(amountRaw))
const typeNormalized = typeRaw.trim().toUpperCase()
if (typeNormalized === 'CREDIT') return { amountCents: cents, type: 'CREDIT' }
if (typeNormalized === 'DEBIT') return { amountCents: cents, type: 'DEBIT' }
throw new Error(`Unknown transaction type: ${typeRaw}`)
}
export function normalizeRows(
rows: Record<string, string>[],
accountId: string,
@@ -73,6 +88,10 @@ export function normalizeRows(
const r = strategyB(row[config.debitColumn] ?? '', row[config.creditColumn] ?? '')
amountCents = r.amountCents
type = r.type
} else if (config.strategy === 'C' && config.amountColumn && config.typeColumn) {
const r = strategyC(row[config.amountColumn] ?? '0', row[config.typeColumn] ?? '')
amountCents = r.amountCents
type = r.type
} else {
continue
}