Compare commits
9 Commits
python
...
7954af1837
| Author | SHA1 | Date | |
|---|---|---|---|
| 7954af1837 | |||
| 2a4522a09a | |||
|
|
60fc0cd8df | ||
|
|
4809edf73a | ||
|
|
4a0f036d01 | ||
|
|
0014db3aea | ||
|
|
73e8f51936 | ||
|
|
344a4b8a46 | ||
|
|
dc45f489a6 |
23
CLAUDE.md
23
CLAUDE.md
@@ -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
11
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<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)}
|
||||
className="flex-1"
|
||||
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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
11
src/components/layout/ThemeProvider.tsx
Normal file
11
src/components/layout/ThemeProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user