36 Commits

Author SHA1 Message Date
siddharthd ef73a9cea0 fix(payment): participantId stuck as empty string when participants load async 2026-03-14 21:33:25 +11:00
siddharthd d53d3106f2 fix(shared): tag filter SQL precedence, balance cards filter by tag 2026-03-14 21:30:33 +11:00
siddharthd 02ac136e19 fix(payment): crash on open due to amount.toFixed on numeric string
feat(shared): tag filter on shared transactions list
2026-03-14 21:27:08 +11:00
siddharthd 084b8764e3 feat(transactions): Payment button to record existing transaction as debt payment 2026-03-14 21:20:25 +11:00
siddharthd 281f0d3782 fix(shared): show full description and notes in split transactions table 2026-03-14 21:11:34 +11:00
siddharthd 85e7801407 feat(shared): replace settle buttons with payment ledger
- New split_payments table records actual payments between participants
- Balance = total split obligations - total payments (splits never marked settled)
- Record Payment modal per participant: direction toggle, amount pre-filled with balance, date, notes
- Payment history inline on each balance card with +/- display and delete
- Per-transaction Settle button removed; Action column removed from shared table
- Splits always show the true cost breakdown regardless of payment state
2026-03-14 21:09:00 +11:00
siddharthd 5206388958 feat(filters): smart query bar with amount operators and multi-select dropdowns
- Query bar parses >500, >=500, <500, <=500, 500-1500 into amount_min/max filters
- Parsed tokens shown as dismissable chips below the query bar
- Category, Bank, Tag, Type filters upgraded from single-select to multi-select
- MultiSelect dropdown component with checkbox list and active-state border
- Backend: TransactionFilters uses string[] for categories/bank_names/tag_ids/transaction_types
- SQL: ANY($n::text[]) / ANY($n::int[]) for array filters
2026-03-14 20:39:28 +11:00
siddharthd 8076d1a949 docs: update README and add CLAUDE.md for finance app 2026-03-14 20:06:37 +11:00
siddharthd aeaca84cc7 feat(edit-transaction): edit modal with notes, inline tags, and split management
- New EditTransactionModal with scrollable body (sticky header/footer)
- Statement transactions: read-only core fields; manual transactions: editable date/amount/description
- Override fields for all: merchant, category, type, notes (textarea)
- InlineTags sub-component: add/remove tags without dropdown clipping issues
- Live split display via useTransactionSplits, opens SplitModal for editing
- PATCH /api/transactions/:id extended for description/amount/transaction_date (manual only)
- Transactions page: edit button per row, notes shown below description in italic
2026-03-14 20:06:32 +11:00
siddharthd 278e57354c feat(insights): analytics drill-down, fee tracking, and category improvements
- Monthly spend chart with category breakdown drill-down
- Merchant frequency and spend analytics with per-merchant history
- Subscription detection and recurring charge tracking
- Fee and interest analytics endpoint
- Expanded category list with formatCategory display helper
2026-03-14 20:06:24 +11:00
siddharthd 9f90d8726f feat(rules): apply_split rules with run history and revert
- POST /api/rules/apply — run all enabled rules against unmatched transactions
- POST /api/rules/apply/:id — apply a single rule by id
- DELETE /api/rules/apply/:id — revert a rule run (remove applied splits)
- Rules page: show run history with revert button, apply individual rules
2026-03-14 20:06:19 +11:00
siddharthd 859043f5a5 feat(shared): bidirectional split balance, credit direction, and multi-user view
- Rewrite participant balance to UNION both directions (they owe me + I owe them)
- Credits/refunds subtract from owed amount for correct net balance
- Allow secondary users to see transactions split with them
- Add participant balance cards with colour-coded owe direction
- Add inline AddParticipantForm with name + optional email
2026-03-14 20:06:13 +11:00
siddharthd fc22a61a43 feat(transactions): manual transaction support and multi-owner query infrastructure
- Add POST /api/transactions to create manual transactions (statement_id=NULL, owner_id set directly)
- Queries switch from JOIN to LEFT JOIN statements so manual transactions are visible
- COALESCE(t.owner_id, s.owner_id) throughout for owner resolution
- Add "Manual" bank filter option in getTransactions
- Search extended to include merchant_normalized override
- Split data fetched via lateral subquery on every transaction row
- getParticipantBalances rewritten as UNION for bidirectional net balances
  (credits/refunds negate, split from either side of the relationship)
- getSharedTransactions: remove my_share_percent from SELECT (fixes GROUP BY error),
  WHERE rewritten as two distinct cases (owner with others split vs participant on others' txn)
- getTransactions: OR EXISTS condition so split participants see shared transactions
- add-transaction-modal component for creating manual transactions with splits
- 0008_my_share_percent migration adds my_share_percent to transaction_overrides
2026-03-14 20:04:00 +11:00
siddharthd 0985c38be8 fix(rules): save-as-rule uses full merchant name with equals, not first description word 2026-03-11 12:46:16 +11:00
siddharthd af4c64bba7 feat(splits): save split as rule from split modal
- Checkbox in split modal: 'Also save as rule for <merchant>'
- Creates a rule with apply_split action storing the participant shares
- Rules engine now handles apply_split: deletes existing splits and re-applies
- Bulk split mode hides the checkbox (rule wouldn't make sense for ad-hoc bulk)
2026-03-11 12:40:03 +11:00
siddharthd a8743ba7df feat(transactions): save-as-rule prompt after merchant/category edit
After changing a merchant name or category inline, a toast-style prompt
appears offering to save it as a rule. Shows a preview of the condition
and action before saving. Dismissable without creating a rule.
2026-03-11 12:05:35 +11:00
siddharthd 7b3fd4b65f fix(merchants): net spend accounting for refunds/credits
- Merchant totals now show net spend (gross debits minus refunds)
- Refund count and amount shown in profile drawer and table
- Scatter plot Y-axis uses net_spend, X-axis uses debit_count
- Per-merchant transaction history includes refunds (shown as negative)
- Monthly trend chart reflects net spend per month
2026-03-10 00:43:58 +11:00
siddharthd dd11019fdf fix(transactions): editable transaction type, fee/interest counted as spend, fees category
- TypeBadge is now clickable — opens inline select to change debit/credit/fee/interest/etc.
- PATCH /api/transactions/[id] now accepts transaction_type, updates transactions table directly
- Analytics monthly query includes fee and interest types as spend (not just debit)
- fee and interest amounts show red in transaction list (same as debit)
- Add fees category to taxonomy
2026-03-10 00:24:42 +11:00
siddharthd 714c5a9b25 feat(merchants): scatter plot, merchant profiles, and per-merchant transaction history
- /merchants page with spend-vs-frequency scatter chart (click to open profile)
- Merchant profile drawer: stats, monthly trend line, full transaction history
- /api/analytics/merchants: split-adjusted merchant aggregates + monthly trends
- /api/analytics/merchants/[merchant]: per-merchant transaction list
- Add Merchants nav item to sidebar
2026-03-10 00:05:48 +11:00
siddharthd 2a10450c3e feat(analytics): replace charts with category trend lines, Pareto chart, and cumulative spend
- Category spend trend lines (top 8 categories, 6-month view) replacing stacked bar chart
- Pareto chart showing 80/20 spend concentration with cumulative % line
- Cumulative spend chart tracking actual vs typical monthly pace
- Fix: add amount_aud to TransactionRow interface
2026-03-09 23:59:07 +11:00
siddharthd e72d3ad9e5 feat(categories): add home_goods and home_maintenance categories 2026-03-09 23:37:28 +11:00
siddharthd a7461ff83b docs: replace boilerplate README with full data model and architecture reference 2026-03-09 23:12:20 +11:00
siddharthd c1d031511a feat(insights): committed/discretionary chart, recurring charge detection, fees & interest audit 2026-03-09 23:04:52 +11:00
siddharthd 7379437cc3 feat(statements): add bank/type/owner/year filters and row numbering 2026-03-09 22:27:21 +11:00
siddharthd 8bd7d77a8a fix(statements): owner assignment dropdown, fix Wise CC false positive, remove amount label
- Add owner <select> dropdown per row using useUpdateStatement + useParticipants
- Detect CC by statement_type.includes('card') instead of credit_limit/payment_due_date
  (Wise multi-currency account had payment_due_date set but is not a CC)
- Amount: remove 'due'/'balance' label; color green for positive bank balances, red for CC/overdraft
- Add statement_type to StatementRow type
2026-03-09 21:05:34 +11:00
siddharthd f90ba332bd feat(statements): table layout + statement-scoped transaction view
- Statements page: replace card grid with compact table showing bank,
  account, period, due date, currency, amount (due for CC / balance for
  bank), transaction count, and View button
- Transactions page: wrap in Suspense, read statement_id from URL search
  params on load; show a dismissible indigo banner with bank name and
  billing period when filtering by statement; × Clear filter removes it
2026-03-09 12:03:39 +11:00
siddharthd e3aa17acdd fix(analytics): cast tx.amount to Number before formatting (PG returns string) 2026-03-08 21:06:14 +11:00
siddharthd 1eff0f9337 fix(analytics): use React.Fragment with key for expandable category rows 2026-03-08 21:03:53 +11:00
siddharthd 3cf67f6e2a feat(analytics): stacked category chart, savings rate line, expandable rows
- Replace grouped cashflow BarChart with ComposedChart: expense categories
  as colour-coded stacked bars + amber savings-rate % line on right Y-axis
- Add category colour legend below chart (matches stacked bars)
- Horizontal category bar chart now uses per-category colours
- Breakdown table: click any category row to expand/collapse individual
  transactions; each transaction has an inline category dropdown that
  calls PATCH /api/transactions/:id → transaction_overrides, then
  invalidates analytics query so totals update immediately
2026-03-08 20:53:55 +11:00
siddharthd 90d8db4abe chore: add migration 0007 for amount_aud + exchange_rate_to_aud columns 2026-03-08 19:16:51 +11:00
siddharthd d1a0eedf03 feat(analytics): cashflow view with income/investment/net — split-adjusted + multi-currency
- Add 'investment' category (shares, ETFs, super)
- Analytics API: separate income, investment, expense queries; use amount_aud for FX-aware sums
- Analytics page: cashflow summary (income/expenses/invested/net cash), grouped bar chart,
  income + invested rows in 6-month trend table
- MonthlyAnalytics interface: add income, investments, net fields to totals
- DB: amount_aud + exchange_rate_to_aud columns added and backfilled (in prior migration)
2026-03-08 19:15:20 +11:00
siddharthd 5dbeb0cb87 chore: commit previously untracked runtime files (splits, auth, participants, shared) 2026-03-08 18:00:46 +11:00
siddharthd 30a7857d13 feat(analytics): replace budget page with spending analytics + split-adjusted amounts
- Rename 'Budget' → 'Analytics' in sidebar
- Rewrite /budget page: summary cards, recharts bar charts (monthly trend + category breakdown), 6-month trend table
- Fix analytics API to count only user's share for split transactions (CASE WHEN ts.share_percent IS NOT NULL THEN amount * share_percent / 100 ELSE amount END)
- Install recharts
2026-03-08 17:58:33 +11:00
siddharthd 1e79ada6d8 feat(finance): implement Shared Expenses page
Show split transactions with per-participant balance cards and settle buttons.
2026-03-08 17:24:04 +11:00
siddharthd be85822cc7 merge: Phase 5 (Rules Engine) + Phase 6 (Budget & Analytics)
Resolve additive conflicts in schema.prisma and hooks.ts — both models and all hooks retained.
2026-03-08 17:09:57 +11:00
siddharthd d455738732 feat(finance): Phase 6 — Budget & Analytics
Add monthly budgets per category with spend-vs-budget dashboard and 6-month trend table.
Includes upsert budget API, monthly analytics endpoint, inline budget editing, and route auth fixes.
2026-03-08 16:57:33 +11:00
46 changed files with 6427 additions and 260 deletions
+118
View File
@@ -0,0 +1,118 @@
# CLAUDE.md
Guidance for Claude Code when working in this repository.
## Project Overview
Personal finance tracker. Bank statements are ingested via an N8N workflow (in the smarthome repo at `docker/automation/workflows/cc-statement-processor-paperless.json`) that sends PDFs to Gemini 2.5 Flash for extraction, then inserts into PostgreSQL.
- **App**: Next.js 16 App Router, TypeScript, Tailwind CSS
- **DB**: PostgreSQL container `postgres-personal`, database `personal`, user `personal`
- **Auth**: `X-Forwarded-User` header (email) set by Traefik → `participants.email`. In dev/fallback: participant id=1 ("Me")
- **Runs at**: port 3000 inside container, exposed on host port 4100, proxied at `https://finance.bosecamp.com`
## Common Commands
```bash
# Build and deploy (from smarthome repo root)
docker compose --env-file docker/common.env --env-file docker/finance/.env \
-f docker/finance/docker-compose.yml up -d --build
# IMPORTANT: docker restart does NOT pick up a new image — always use the compose command above
# DB access
docker exec postgres-personal psql -U personal -d personal
# View logs
docker logs finance -f
```
## Architecture
### Key Files
| File | Purpose |
|------|---------|
| `src/lib/db.ts` | `queryRaw<T>()` — the only DB query function; uses `pg` directly |
| `src/lib/queries.ts` | All SQL query functions (no ORM); import `queryRaw` from `@/lib/db` |
| `src/lib/hooks.ts` | TanStack Query hooks for all API calls |
| `src/lib/auth.ts` | `getCurrentUser()` — reads `X-Forwarded-User` header |
| `src/lib/categories.ts` | Canonical category list (`CATEGORIES` array + `formatCategory()`) |
| `src/app/api/*/route.ts` | API route handlers |
| `src/components/` | Shared UI components |
### Data Flow
- All queries in `src/lib/queries.ts` use raw SQL via `queryRaw` from `src/lib/db.ts`
- API routes call query functions and return `NextResponse.json()`
- Frontend uses hooks from `src/lib/hooks.ts` (TanStack Query) — never fetches directly
- Auth is always checked first in every API route: `const user = await getCurrentUser(req)`
### Owner Scoping
All data is scoped by `owner_id`. The effective owner of a transaction is:
```sql
COALESCE(t.owner_id, s.owner_id)
```
- Statement-linked transactions: owner comes from `statements.owner_id`
- Manual transactions: `statement_id IS NULL`, owner stored directly in `transactions.owner_id`
The effective merchant and category always prefer overrides:
```sql
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) -- merchant
COALESCE(o.category_override, t.category) -- category
```
## Database
```bash
# Schema inspection
docker exec postgres-personal psql -U personal -d personal -c "\d transactions"
# Apply a migration SQL file
docker exec postgres-personal psql -U personal -d personal < prisma/migrations/<name>/migration.sql
```
### Key Tables
- `statements` — one row per billing period per bank account
- `transactions` — line items; `statement_id` is nullable (NULL = manual entry)
- `transaction_overrides` — user corrections to AI-extracted data (category, merchant, notes)
- `transaction_splits` — shared expense tracking (participant, share_percent, settled)
- `transaction_tags` — many-to-many join to `tags`
- `rules` — auto-categorisation rules (JSONB conditions + actions)
- `participants` — people; `id=1` is "Me" (the primary user)
- `account_owner_mappings` — persists bank+account → owner assignments
### Rules System
Conditions are AND-evaluated. Fields: `merchant_normalized`, `description`, `category`, `bank_name`, `amount`, `transaction_type`. Operators: `contains`, `equals`, `starts_with`, `gt`, `lt`, `not_equals`. Actions: `set_category`, `set_merchant`, `add_tag_ids`, `apply_split`.
`contains` and `equals` operators are case-insensitive (both sides `.toLowerCase()`).
## Development Patterns
### Adding a new API route
1. Create `src/app/api/<resource>/route.ts`
2. Always call `getCurrentUser(req)` first; return 403 if null
3. Write SQL in `src/lib/queries.ts` using `queryRaw`
4. Add a TanStack Query hook in `src/lib/hooks.ts`
### Adding a new condition field to rules
Two files only:
- `src/app/api/rules/apply/route.ts` — add to `Condition.field` union, `TxFields` interface, and `evaluateCondition()` switch
- `src/app/rules/page.tsx` — add to `FIELDS` array; add special rendering if needed (e.g. enum dropdown for `transaction_type`)
### Modifying queries
- All JOINs to `statements` must be `LEFT JOIN` (manual transactions have no statement)
- Owner filter pattern: `WHERE COALESCE(t.owner_id, s.owner_id) = $1`
- Bank name pattern: `COALESCE(s.bank_name, 'Manual') as bank_name`
## Known Gaps / TODOs
See `README.md`**Known Gaps / TODOs** for full details.
**Payment provider tracking**: `merchant_normalized` currently conflates payment provider (PayPal, Afterpay, Zip) with the actual merchant. Plan: add `payment_provider` column, update Gemini prompt to extract it separately, backfill from `merchant_name` patterns, surface in UI filters.
+313 -22
View File
@@ -1,36 +1,327 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Finance App
## Getting Started
Personal finance tracker built on Next.js 16 (App Router), PostgreSQL, and Prisma. Bank statements are ingested automatically from Paperless-NGX via an N8N workflow that uses Gemini to extract structured data from PDF statements.
First, run the development server:
## Stack
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
- **Frontend**: Next.js 16 App Router, TypeScript, Tailwind CSS, Recharts
- **Backend**: Next.js API routes, raw PostgreSQL via `pg` + `@prisma/adapter-pg`
- **Database**: PostgreSQL (`postgres-personal` container)
- **Auth**: `X-Forwarded-User` header (email) set by Traefik forward-auth → mapped to `participants.email`
- **Ingestion**: N8N workflow → Gemini 2.5 Flash (PDF parsing) → PostgreSQL
---
## Data Model
### `statements`
The top-level document, one row per billing period per account.
| Column | Type | Description |
|--------|------|-------------|
| `id` | int | Primary key |
| `bank_name` | text | Normalised bank name (e.g. "American Express") |
| `card_name` | text | Product name (e.g. "Rewards Travel Adventures") |
| `account_number` | text | Account/card number (spaces stripped) |
| `account_type` | text | Raw account type string from statement |
| `statement_type` | text | Normalised type: `Credit Card`, `Business Card`, `multi-currency account`, etc. |
| `account_holder_name` | text | Name on the account if extracted |
| `billing_start_date` | date | Period start |
| `billing_end_date` | date | Period end — used as the deduplication anchor |
| `opening_balance` | numeric | Balance at start of period |
| `closing_balance` | numeric | Balance at end of period |
| `total_credits` | numeric | Sum of all credits in period |
| `total_debits` | numeric | Sum of all debits in period |
| `total_amount_due` | numeric | Amount due (credit cards) |
| `minimum_amount_due` | numeric | Minimum payment due (credit cards) |
| `payment_due_date` | date | Payment due date (credit cards) |
| `credit_limit` | numeric | Credit limit (credit cards) |
| `available_credit` | numeric | Available credit at statement date |
| `interest_charged` | numeric | Interest charged this period (from statement summary) |
| `fees_charged` | numeric | Fees charged this period (from statement summary) |
| `currency` | text | Statement currency (e.g. `AUD`, `USD`) |
| `exchange_rate_to_aud` | numeric | FX rate at ingestion time (live from open.er-api.com) |
| `owner_id` | int FK → `participants` | Which person owns this statement |
| `paperless_doc_id` | int | Paperless-NGX document ID — deduplication key |
| `tier_used` | text | AI model used for extraction (e.g. `gemini-2.5-flash`) |
| `event_created` | bool | Whether a Google Calendar reminder was created for payment due date |
**Deduplication**: unique index on `(bank_name, account_number, billing_end_date)` prevents re-ingestion of the same period. `paperless_doc_id` has a separate unique index for Paperless-linked documents.
**Credit card detection**: `statement_type ILIKE '%card%'`
---
### `transactions`
One row per line item within a statement. Cascade-deleted when the parent statement is deleted.
| Column | Type | Description |
|--------|------|-------------|
| `id` | int | Primary key |
| `statement_id` | int FK → `statements` (nullable) | Parent statement; NULL for manually-entered transactions |
| `owner_id` | int FK → `participants` (nullable) | Owner for manual transactions (no statement); statement-linked transactions derive owner from `statements.owner_id` |
| `transaction_date` | date | Date of transaction |
| `description` | text | Raw description from the statement |
| `amount` | numeric | Original amount in statement currency |
| `amount_aud` | numeric | AUD-converted amount (= amount if already AUD) |
| `transaction_type` | text | `debit`, `credit`, `payment`, `refund`, `fee`, `interest`, `transfer` |
| `merchant_name` | text | Raw merchant name extracted by Gemini |
| `merchant_normalized` | text | Cleaned/normalised merchant name (Gemini) |
| `location` | text | Location if present on statement |
| `foreign_currency_amount` | numeric | Original foreign amount if this was an FX transaction |
| `foreign_currency_code` | text | Foreign currency code (e.g. `USD`) |
| `category` | text | AI-assigned category (see category taxonomy below) |
| `row_index` | int | Position in statement — used for deduplication |
**Deduplication**: unique index on `(statement_id, transaction_date, description, amount, row_index)`.
**Analytics**: all spend queries use `amount_aud` for cross-currency consistency. Split-adjusted queries apply `amount_aud * share_percent / 100` where a split exists for the current user.
---
### `transaction_overrides`
User corrections to AI-extracted data. Stored separately to preserve the original extraction.
| Column | Type | Description |
|--------|------|-------------|
| `transaction_id` | int FK → `transactions` (unique) | One override per transaction |
| `merchant_normalized` | text | User-corrected merchant name |
| `category_override` | text | User-corrected category |
| `notes` | text | Free-text notes |
All analytics queries use `COALESCE(o.category_override, t.category)` and `COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name)` to prefer overrides over AI values.
---
### `transaction_splits`
Shared expense tracking — records that a transaction was split between participants.
| Column | Type | Description |
|--------|------|-------------|
| `transaction_id` | int FK → `transactions` | The transaction being split |
| `participant_id` | int FK → `participants` | Who shares in this transaction |
| `share_percent` | numeric(5,2) | Their percentage (1100) |
| `settled` | bool | Whether this share has been settled |
| `settled_at` | timestamptz | When it was settled |
A transaction can be split across multiple participants. The statement owner's own share is implicit (`100 - SUM(other shares)`). Analytics queries LEFT JOIN `transaction_splits` on `participant_id = current_user.id` — if no split row exists, the full amount belongs to the owner.
---
### `transaction_tags`
Many-to-many join between transactions and tags.
| Column | Type |
|--------|------|
| `transaction_id` | int FK → `transactions` |
| `tag_id` | int FK → `tags` |
---
### `tags`
User-defined coloured labels for ad-hoc transaction grouping beyond the fixed category taxonomy.
| Column | Type | Description |
|--------|------|-------------|
| `id` | int | Primary key |
| `name` | text (unique) | Tag name |
| `color` | text | Hex colour (default `#6366f1`) |
---
### `participants`
People who own statements or share expenses.
| Column | Type | Description |
|--------|------|-------------|
| `id` | int | Primary key |
| `name` | text (unique) | Display name |
| `email` | text (unique) | Login identity — matched against `X-Forwarded-User` header |
---
### `account_owner_mappings`
Persists `(bank, account_number) → owner` assignments so future ingestion auto-assigns the correct owner without manual intervention.
| Column | Type | Description |
|--------|------|-------------|
| `bank_name` | text | |
| `account_number` | text | |
| `owner_id` | int FK → `participants` | |
Written when a user reassigns a statement owner in the UI. Consulted by the N8N workflow on every new statement insert.
---
### `rules`
Saved auto-categorisation rules. Applied in bulk via the Rules page.
| Column | Type | Description |
|--------|------|-------------|
| `owner_id` | int FK → `participants` | Rule belongs to this user |
| `name` | text | Rule label |
| `conditions` | jsonb | Array of `{field, operator, value}` — AND logic |
| `actions` | jsonb | `{set_category, add_tag_ids, set_merchant}` |
| `enabled` | bool | |
| `priority` | int | Higher priority rules run first |
**Condition fields**: `merchant_normalized`, `description`, `category`, `bank_name`, `amount`, `transaction_type`
**Condition operators**: `contains`, `equals`, `starts_with`, `gt`, `lt`, `not_equals`
**Actions**: `set_category`, `set_merchant`, `add_tag_ids`, `apply_split`
---
### `budgets`
Monthly spend targets per category. Stored but currently unused in the UI (replaced by the analytics/insights views).
| Column | Type | Description |
|--------|------|-------------|
| `owner_id` | int FK → `participants` | |
| `category` | text | Category name |
| `month` | date | Always first of month (e.g. `2026-03-01`) |
| `amount_limit` | numeric | Spend target for that category/month |
---
## Category Taxonomy
Fixed set defined in `src/lib/categories.ts`. Applied by Gemini at ingestion and overridable by the user or rules engine:
`groceries` · `dining` · `transport` · `fuel` · `shopping` · `utilities` · `entertainment` · `travel` · `health` · `insurance` · `subscriptions` · `cash_advance` · `government` · `education` · `rent` · `home_goods` · `home_maintenance` · `transfers` · `income` · `investment` · `personal_care` · `pets` · `gifts` · `charity` · `other`
- **home_goods** — items purchased for the house (appliances, furniture, kitchenware, electronics)
- **home_maintenance** — services on the property (cleaning, mowing, repairs)
**Committed spend** (Insights page): `rent`, `utilities`, `insurance`, `subscriptions`
**Excluded from spend analytics**: `transfers`, `investment`
---
## API Routes
All routes require authentication via `X-Forwarded-User` header (set by Traefik). Responses are always scoped to the authenticated user's `owner_id`.
| Method | Route | Description |
|--------|-------|-------------|
| GET | `/api/statements` | All statements for current user |
| GET / PATCH | `/api/statements/[id]` | Get statement; PATCH to reassign owner (also writes `account_owner_mappings`) |
| GET | `/api/transactions` | Paginated transactions with filters: `from`, `to`, `category`, `merchant`, `statement_id`, `search`, `sort`, `dir` |
| GET / PATCH | `/api/transactions/[id]` | Get transaction; PATCH to upsert override (category, merchant, notes) |
| GET / POST | `/api/transactions/[id]/splits` | List or create splits on a transaction |
| GET / POST | `/api/transactions/[id]/tags` | List or apply tags to a transaction |
| POST | `/api/transactions/bulk` | Bulk update category/merchant across multiple transactions |
| GET | `/api/analytics/monthly` | Split-adjusted monthly spend by category + income + investments. Params: `months` (124, default 6) |
| GET | `/api/analytics/subscriptions` | Recurring charge detection — merchants with ≥3 occurrences at consistent intervals |
| GET | `/api/analytics/fees` | Fees and interest from statement summaries + individual fee/interest transactions |
| GET | `/api/shared-transactions` | Transactions that have active splits |
| POST | `/api/splits/settle` | Mark a split as settled |
| GET / POST | `/api/participants` | List participants; POST to create (with optional `email`) |
| GET | `/api/participants/[id]/balance` | Net balance owed by/to a specific participant |
| GET | `/api/participants/balances` | All participant balances |
| GET / POST | `/api/rules` | List or create rules |
| PATCH / DELETE | `/api/rules/[id]` | Update or delete a rule |
| POST | `/api/rules/apply` | Run all enabled rules against all transactions; returns `{matched, transactions_affected}` |
| GET / POST | `/api/budgets` | List budgets for a month (`?month=YYYY-MM`); upsert budget |
| DELETE | `/api/budgets/[id]` | Delete a budget |
| GET | `/api/merchants` | Merchant name autocomplete suggestions |
| GET | `/api/me` | Current user info derived from `X-Forwarded-User` header |
| GET / POST | `/api/tags` | List or create tags |
| PATCH / DELETE | `/api/tags/[id]` | Update or delete a tag |
---
## Ingestion Pipeline
```
Paperless-NGX
└─ documents tagged "Bank Statement" + "Credit Card" (without "cc-processor")
N8N workflow — polls every 5 minutes (workflow ID: FysADdFwEtwONQl4)
├─ Duplicate check: SELECT WHERE paperless_doc_id = <id>
│ └─ Already processed → skip, mark in Paperless
├─ Download PDF binary from Paperless API
├─ Gemini 2.5 Flash — PDF → structured JSON
│ responseSchema: { summary: {...}, transactions: [...] }
│ timeout: 180s, retryOnFail: 3×, delay: 30s
├─ Parse & normalise
│ account_number: strip spaces
│ bank_name: title-case
│ FX rate: fetch live from open.er-api.com if non-AUD
├─ Statement exists? (bank + account + billing_end_date)
│ └─ Duplicate → skip, mark in Paperless
├─ New bank? → Slack approval gate (human confirms before insert)
├─ Lookup account_owner_mappings → resolve owner_id (default: 1 = "Me")
├─ INSERT statements + transactions
├─ Google Calendar reminder for payment_due_date (credit cards)
└─ Paperless: PATCH document to add "cc-processor" tag
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
N8N workflow JSON: `docker/automation/workflows/cc-statement-processor-paperless.json` in the smarthome repo.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
---
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Schema Migrations
## Learn More
Located in `prisma/migrations/`. Applied manually against the running container:
To learn more about Next.js, take a look at the following resources:
```bash
docker exec postgres-personal psql -U personal -d personal \
< prisma/migrations/<migration>/migration.sql
```
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
| Migration | What it adds |
|-----------|-------------|
| `0001_init` | `statements`, `transactions`, `participants` |
| `0002_splits` | `transaction_splits` |
| `0003_owner_segregation` | `owner_id` on statements, `account_owner_mappings`, `email` on participants |
| `0004_tags` | `tags`, `transaction_tags` |
| `0005_rules` | `rules` |
| `0006_budgets` | `budgets` |
| `0007_cashflow` | `amount_aud`, `exchange_rate_to_aud` on transactions; `exchange_rate_to_aud` on statements |
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
> `paperless_doc_id` on statements and the `uq_statements_paperless_doc_id` index were added directly (not tracked in a migration file).
> `owner_id` on transactions and `statement_id` made nullable were applied directly (March 2026) to support manual transaction entry without a fake statement.
## Deploy on Vercel
---
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
## Known Gaps / TODOs
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
### Payment Provider tracking
Currently `merchant_normalized` conflates the *payment provider* with the *merchant*. Transactions processed through PayPal, Afterpay, Zip, Alipay, etc. end up with the provider as the merchant when the real merchant can't be recovered.
**What's been done so far:**
- PayPal entries that embed the merchant name (e.g. `PAYPAL *BUNNINGSGRO`) were cleaned up — the real merchant was extracted during the March 2026 consolidation pass.
- Pure PayPal/Afterpay/Zip entries where the merchant is unrecoverable were left as-is.
- A one-time SQL consolidation pass normalised ~50 merchant name variant groups (March 2026).
**Remaining work:**
1. **DB migration**: `ALTER TABLE transactions ADD COLUMN payment_provider text` and same on `transaction_overrides`.
2. **Gemini prompt**: add `payment_provider` to the `responseSchema` so the AI extracts it separately (`"PayPal"`, `"Afterpay"`, `"Zip"`, `null`, etc.) — the raw bank description usually contains enough signal.
3. **Backfill**: for existing transactions, derive `payment_provider` from `merchant_name` patterns (`PAYPAL *`, `AFTERPAY`, `ZIP/ZIPPAY`, `BPAY`).
4. **App**: surface `payment_provider` as a filter/column in the transactions view; exclude payment providers from merchant analytics so they don't inflate the merchant list.
---
## Deployment
Runs as a Docker container alongside the rest of the home lab stack. Build and deploy:
```bash
# From smarthome repo root
docker compose --env-file docker/common.env --env-file docker/finance/.env \
-f docker/finance/docker-compose.yml up -d --build
```
The container uses Next.js standalone output. `@prisma/adapter-pg` and `pg` are listed in `serverExternalPackages` in `next.config.ts` to ensure they are included in the standalone bundle.
+395 -2
View File
@@ -15,7 +15,8 @@
"pg": "^8.20.0",
"prisma": "^7.4.2",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"recharts": "^3.8.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -1490,6 +1491,42 @@
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1503,6 +1540,12 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1820,6 +1863,69 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1882,6 +1988,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
@@ -2987,6 +3099,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3056,6 +3177,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -3135,6 +3377,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3482,6 +3730,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -3935,6 +4193,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
@@ -4509,6 +4773,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -4551,6 +4825,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -6386,9 +6669,31 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -6402,6 +6707,51 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/recharts": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6461,6 +6811,12 @@
"url": "https://github.com/sponsors/remeda"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -7104,6 +7460,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
@@ -7450,6 +7812,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/valibot": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
@@ -7464,6 +7835,28 @@
}
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+2 -1
View File
@@ -16,7 +16,8 @@
"pg": "^8.20.0",
"prisma": "^7.4.2",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"recharts": "^3.8.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS participants (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
INSERT INTO participants (name) VALUES ('Me') ON CONFLICT DO NOTHING;
CREATE TABLE IF NOT EXISTS transaction_splits (
id SERIAL PRIMARY KEY,
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
participant_id INTEGER NOT NULL REFERENCES participants(id) ON DELETE CASCADE,
share_percent NUMERIC(5,2) NOT NULL CHECK (share_percent > 0 AND share_percent <= 100),
settled BOOLEAN DEFAULT FALSE,
settled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (transaction_id, participant_id)
);
CREATE INDEX IF NOT EXISTS idx_splits_txn ON transaction_splits(transaction_id);
CREATE INDEX IF NOT EXISTS idx_splits_participant ON transaction_splits(participant_id);
@@ -0,0 +1,17 @@
-- Add email to participants for OAuth identity mapping
ALTER TABLE participants ADD COLUMN IF NOT EXISTS email TEXT UNIQUE;
-- Add owner_id and account_holder_name to statements
ALTER TABLE statements ADD COLUMN IF NOT EXISTS owner_id INTEGER NOT NULL DEFAULT 1 REFERENCES participants(id);
ALTER TABLE statements ADD COLUMN IF NOT EXISTS account_holder_name TEXT;
CREATE INDEX IF NOT EXISTS idx_statements_owner_id ON statements(owner_id);
-- Auto-assignment mapping table: (bank_name, account_number) -> owner
CREATE TABLE IF NOT EXISTS account_owner_mappings (
id SERIAL PRIMARY KEY,
bank_name TEXT NOT NULL,
account_number TEXT NOT NULL,
owner_id INTEGER NOT NULL REFERENCES participants(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(bank_name, account_number)
);
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS budgets (
id SERIAL PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES participants(id),
category TEXT NOT NULL,
month DATE NOT NULL,
amount_limit NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(owner_id, category, month)
);
CREATE INDEX IF NOT EXISTS idx_budgets_owner_month ON budgets(owner_id, month);
@@ -0,0 +1,7 @@
-- Add FX conversion support
ALTER TABLE statements ADD COLUMN IF NOT EXISTS exchange_rate_to_aud NUMERIC(10,6);
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS amount_aud NUMERIC(12,2);
-- Backfill: all existing data is AUD
UPDATE transactions SET amount_aud = amount WHERE amount_aud IS NULL;
UPDATE statements SET exchange_rate_to_aud = 1.000000 WHERE exchange_rate_to_aud IS NULL;
@@ -0,0 +1 @@
ALTER TABLE "transaction_overrides" ADD COLUMN "my_share_percent" DECIMAL(5,2);
@@ -0,0 +1,13 @@
CREATE TABLE split_payments (
id SERIAL PRIMARY KEY,
from_participant_id INTEGER NOT NULL REFERENCES participants(id),
to_participant_id INTEGER NOT NULL REFERENCES participants(id),
amount DECIMAL(10,2) NOT NULL CHECK (amount > 0),
payment_date DATE NOT NULL,
notes TEXT,
linked_transaction_id INTEGER REFERENCES transactions(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_split_payments_from ON split_payments(from_participant_id);
CREATE INDEX idx_split_payments_to ON split_payments(to_participant_id);
+16
View File
@@ -13,6 +13,7 @@ model transaction_overrides {
merchant_normalized String?
category_override String?
notes String?
my_share_percent Decimal? @db.Decimal(5, 2)
updated_at DateTime @default(now()) @updatedAt
}
@@ -23,6 +24,8 @@ model participants {
created_at DateTime @default(now())
splits transaction_splits[]
account_owner_mappings account_owner_mappings[]
payments_sent split_payments[] @relation("payments_from")
payments_received split_payments[] @relation("payments_to")
}
model account_owner_mappings {
@@ -49,6 +52,19 @@ model transaction_splits {
@@unique([transaction_id, participant_id])
}
model split_payments {
id Int @id @default(autoincrement())
from_participant_id Int
to_participant_id Int
amount Decimal @db.Decimal(10, 2)
payment_date DateTime @db.Date
notes String?
linked_transaction_id Int?
created_at DateTime @default(now())
from_participant participants @relation("payments_from", fields: [from_participant_id], references: [id])
to_participant participants @relation("payments_to", fields: [to_participant_id], references: [id])
}
model tags {
id Int @id @default(autoincrement())
name String @unique
+76
View File
@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
// Statement-level fees and interest (aggregated by Gemini from the PDF)
const stmtRows = await queryRaw<{
bank_name: string;
fees: string;
interest: string;
}>(
`SELECT
bank_name,
SUM(COALESCE(fees_charged, 0))::numeric(12,2) AS fees,
SUM(COALESCE(interest_charged, 0))::numeric(12,2) AS interest
FROM statements
WHERE owner_id = $1
GROUP BY bank_name
HAVING SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0)) > 0
ORDER BY (SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0))) DESC`,
[user.id]
);
// Transaction-level fee and interest line items (split-adjusted)
const txnRows = await queryRaw<{
id: number;
transaction_date: string;
description: string;
merchant_name: string | null;
transaction_type: string;
my_amount: string;
bank_name: string;
}>(
`SELECT
t.id,
t.transaction_date,
t.description,
t.merchant_name,
t.transaction_type,
CASE
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100
ELSE COALESCE(t.amount_aud, t.amount)
END::numeric(12,2) AS my_amount,
s.bank_name
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
JOIN statements s ON s.id = t.statement_id
WHERE s.owner_id = $1
AND t.transaction_type IN ('fee', 'interest')
ORDER BY t.transaction_date DESC`,
[user.id]
);
const by_bank = stmtRows.map((r) => ({
bank_name: r.bank_name,
fees: Number(r.fees),
interest: Number(r.interest),
total: Number(r.fees) + Number(r.interest),
}));
const transactions = txnRows.map((r) => ({
...r,
my_amount: Number(r.my_amount),
}));
// Totals from statement-level data (more complete — Gemini reads the statement summary)
const total_fees = by_bank.reduce((s, r) => s + r.fees, 0);
const total_interest = by_bank.reduce((s, r) => s + r.interest, 0);
return NextResponse.json({ by_bank, transactions, total_fees, total_interest });
}
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ merchant: string }> }
) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { merchant } = await params;
const decoded = decodeURIComponent(merchant);
const transactions = await queryRaw<{
id: number;
transaction_date: string;
description: string;
amount: number;
amount_aud: number | null;
my_amount: number;
transaction_type: string;
category: string;
bank_name: string;
statement_id: number;
}>(`
SELECT
t.id,
t.transaction_date::text,
t.description,
t.amount,
t.amount_aud,
CASE
WHEN t.transaction_type IN ('refund', 'credit') THEN
-(CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END)
ELSE
(CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END)
END::numeric(10,2) as my_amount,
t.transaction_type,
COALESCE(o.category_override, t.category) as category,
s.bank_name,
t.statement_id
FROM transactions t
JOIN statements s ON s.id = t.statement_id
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
WHERE s.owner_id = $1
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = $2
ORDER BY t.transaction_date DESC
LIMIT 500
`, [user.id, decoded]);
return NextResponse.json({ transactions });
}
+121
View File
@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
// Split-adjusted amount helper (positive for spend, negative for refunds)
const MY_AMOUNT = `CASE WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100 WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100 ELSE COALESCE(t.amount_aud, t.amount) END`;
const SPEND_EXPR = `
CASE
WHEN t.transaction_type IN ('refund', 'credit') THEN -(${MY_AMOUNT})
ELSE (${MY_AMOUNT})
END
`;
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(req.url);
const months = Math.min(24, Math.max(1, Number(searchParams.get("months") || "12")));
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - months);
const fromDate = cutoff.toISOString().slice(0, 10);
// Merchant aggregates — net spend (debits + fees - refunds/credits)
const rows = await queryRaw<{
merchant: string;
category: string;
debit_count: number;
refund_count: number;
gross_spend: number;
total_refunds: number;
net_spend: number;
avg_debit: number;
first_seen: string;
last_seen: string;
months_active: number;
}>(`
SELECT
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) as merchant,
MODE() WITHIN GROUP (ORDER BY COALESCE(o.category_override, t.category)) as category,
COUNT(*) FILTER (WHERE t.transaction_type IN ('debit', 'fee', 'interest'))::int as debit_count,
COUNT(*) FILTER (WHERE t.transaction_type IN ('refund', 'credit'))::int as refund_count,
COALESCE(SUM(
CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN
${MY_AMOUNT}
ELSE 0 END
), 0)::numeric(12,2) as gross_spend,
COALESCE(SUM(
CASE WHEN t.transaction_type IN ('refund', 'credit') THEN
${MY_AMOUNT}
ELSE 0 END
), 0)::numeric(12,2) as total_refunds,
SUM(${SPEND_EXPR})::numeric(12,2) as net_spend,
AVG(
CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN
${MY_AMOUNT}
END
)::numeric(10,2) as avg_debit,
MIN(t.transaction_date)::text as first_seen,
MAX(t.transaction_date)::text as last_seen,
COUNT(DISTINCT TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM'))::int as months_active
FROM transactions t
JOIN statements s ON s.id = t.statement_id
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
WHERE s.owner_id = $1
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
AND t.transaction_date >= $2
AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment')
GROUP BY 1
HAVING SUM(${SPEND_EXPR}) > 0
ORDER BY net_spend DESC
LIMIT 200
`, [user.id, fromDate]);
// Monthly net trend per merchant (top 50 by net spend)
const topMerchants = rows.slice(0, 50).map((r) => r.merchant);
interface TrendRow { merchant: string; month: string; total: number }
let trendRows: TrendRow[] = [];
if (topMerchants.length > 0) {
trendRows = await queryRaw<TrendRow>(`
SELECT
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) as merchant,
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
SUM(${SPEND_EXPR})::numeric(10,2) as total
FROM transactions t
JOIN statements s ON s.id = t.statement_id
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
WHERE s.owner_id = $1
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
AND t.transaction_date >= $2
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = ANY($3)
AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment')
GROUP BY 1, 2
ORDER BY 1, 2
`, [user.id, fromDate, topMerchants]);
}
const trendByMerchant: Record<string, Record<string, number>> = {};
for (const tr of trendRows) {
if (!trendByMerchant[tr.merchant]) trendByMerchant[tr.merchant] = {};
trendByMerchant[tr.merchant][tr.month] = Number(tr.total);
}
const merchants = rows.map((r) => ({
...r,
debit_count: Number(r.debit_count),
refund_count: Number(r.refund_count),
gross_spend: Number(r.gross_spend),
total_refunds: Number(r.total_refunds),
net_spend: Number(r.net_spend),
avg_debit: Number(r.avg_debit),
months_active: Number(r.months_active),
monthly_trend: trendByMerchant[r.merchant] || {},
}));
return NextResponse.json({ merchants, months });
}
+148
View File
@@ -0,0 +1,148 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { searchParams } = new URL(req.url);
const monthCount = Math.min(Math.max(Number(searchParams.get("months") || "6"), 1), 24);
const now = new Date();
const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const startDate = new Date(now.getFullYear(), now.getMonth() - monthCount + 1, 1);
const startStr = startDate.toISOString().slice(0, 10);
const endStr = endDate.toISOString().slice(0, 10);
// Expenses: debits excluding transfers and investments, split-adjusted
const spendRows = await queryRaw<{
month: string;
category: string;
total_spent: number;
transaction_count: number;
}>(
`SELECT
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
COALESCE(o.category_override, t.category) as category,
SUM(
CASE
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100
ELSE COALESCE(t.amount_aud, t.amount)
END
)::numeric(12,2) as total_spent,
COUNT(*)::int as transaction_count
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
JOIN statements s ON s.id = t.statement_id
WHERE s.owner_id = $1
AND t.transaction_type IN ('debit', 'fee', 'interest')
AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment')
AND t.transaction_date >= $2
AND t.transaction_date < $3
GROUP BY 1, 2
ORDER BY 1 DESC, total_spent DESC`,
[user.id, startStr, endStr]
);
// Income: credits/payments categorised as income
const incomeRows = await queryRaw<{
month: string;
total_income: number;
transaction_count: number;
}>(
`SELECT
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
SUM(COALESCE(t.amount_aud, t.amount))::numeric(12,2) as total_income,
COUNT(*)::int as transaction_count
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
WHERE s.owner_id = $1
AND t.transaction_type IN ('credit', 'payment')
AND COALESCE(o.category_override, t.category) = 'income'
AND t.transaction_date >= $2
AND t.transaction_date < $3
GROUP BY 1
ORDER BY 1 DESC`,
[user.id, startStr, endStr]
);
// Investments: any transaction categorised as investment
const investmentRows = await queryRaw<{
month: string;
total_invested: number;
transaction_count: number;
}>(
`SELECT
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
SUM(COALESCE(t.amount_aud, t.amount))::numeric(12,2) as total_invested,
COUNT(*)::int as transaction_count
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
WHERE s.owner_id = $1
AND COALESCE(o.category_override, t.category) = 'investment'
AND t.transaction_date >= $2
AND t.transaction_date < $3
GROUP BY 1
ORDER BY 1 DESC`,
[user.id, startStr, endStr]
);
// Build month list (most recent first)
const months: string[] = [];
for (let i = monthCount - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
}
months.reverse();
const spendMap = new Map<string, number>();
const countMap = new Map<string, number>();
const incomeMap = new Map<string, number>();
const investMap = new Map<string, number>();
for (const r of spendRows) {
spendMap.set(`${r.category}:${r.month}`, Number(r.total_spent));
countMap.set(`${r.category}:${r.month}`, r.transaction_count);
}
for (const r of incomeRows) incomeMap.set(r.month, Number(r.total_income));
for (const r of investmentRows) investMap.set(r.month, Number(r.total_invested));
const allCategories = new Set<string>();
for (const r of spendRows) allCategories.add(r.category);
const rows = Array.from(allCategories)
.sort()
.map((cat) => {
const spent: Record<string, number> = {};
const txCount: Record<string, number> = {};
for (const m of months) {
const s = spendMap.get(`${cat}:${m}`);
const c = countMap.get(`${cat}:${m}`);
if (s !== undefined) spent[m] = s;
if (c !== undefined) txCount[m] = c;
}
return { category: cat, spent, txCount };
});
const totals: Record<string, { spent: number; income: number; investments: number; net: number }> = {};
for (const m of months) {
let spent = 0;
for (const row of rows) spent += row.spent[m] || 0;
const income = incomeMap.get(m) || 0;
const investments = investMap.get(m) || 0;
totals[m] = {
spent: Math.round(spent * 100) / 100,
income: Math.round(income * 100) / 100,
investments: Math.round(investments * 100) / 100,
net: Math.round((income - spent - investments) * 100) / 100,
};
}
return NextResponse.json({ months, rows, income: Object.fromEntries(incomeMap), investments: Object.fromEntries(investMap), totals });
}
@@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const rows = await queryRaw<{
merchant: string;
category: string;
occurrences: number;
avg_amount: string;
first_seen: string;
last_seen: string;
total_paid: string;
median_interval: string;
frequency: string | null;
}>(
`WITH merchant_txns AS (
SELECT
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) AS merchant,
COALESCE(o.category_override, t.category) AS category,
t.transaction_date,
CASE
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
WHEN o.my_share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * o.my_share_percent / 100
ELSE COALESCE(t.amount_aud, t.amount)
END AS my_amount
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
JOIN statements s ON s.id = t.statement_id
WHERE s.owner_id = $1
AND t.transaction_type IN ('debit', 'fee')
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) IS NOT NULL
),
merchant_with_lag AS (
SELECT
merchant,
category,
transaction_date,
my_amount,
LAG(transaction_date) OVER (PARTITION BY merchant ORDER BY transaction_date) AS prev_date
FROM merchant_txns
),
merchant_stats AS (
SELECT
merchant,
MODE() WITHIN GROUP (ORDER BY category) AS category,
COUNT(*) + 1 AS occurrences,
AVG(my_amount)::numeric(12,2) AS avg_amount,
MIN(transaction_date) AS first_seen,
MAX(transaction_date) AS last_seen,
SUM(my_amount)::numeric(12,2) AS total_paid,
PERCENTILE_CONT(0.5) WITHIN GROUP (
ORDER BY (transaction_date - prev_date)::int
) AS median_interval,
STDDEV((transaction_date - prev_date)::int) AS stddev_interval
FROM merchant_with_lag
WHERE prev_date IS NOT NULL
GROUP BY merchant
HAVING COUNT(*) >= 2
),
classified AS (
SELECT *,
CASE
WHEN median_interval BETWEEN 6 AND 8 THEN 'weekly'
WHEN median_interval BETWEEN 13 AND 16 THEN 'fortnightly'
WHEN median_interval BETWEEN 27 AND 35 THEN 'monthly'
WHEN median_interval BETWEEN 85 AND 95 THEN 'quarterly'
WHEN median_interval BETWEEN 350 AND 380 THEN 'annual'
ELSE NULL
END AS frequency
FROM merchant_stats
WHERE stddev_interval < median_interval * 0.4
AND (
median_interval BETWEEN 6 AND 8 OR
median_interval BETWEEN 13 AND 16 OR
median_interval BETWEEN 27 AND 35 OR
median_interval BETWEEN 85 AND 95 OR
median_interval BETWEEN 350 AND 380
)
)
SELECT merchant, category, occurrences, avg_amount, first_seen, last_seen, total_paid,
median_interval::numeric(8,1), frequency
FROM classified
WHERE frequency IS NOT NULL
ORDER BY
CASE frequency
WHEN 'weekly' THEN avg_amount * 4.33
WHEN 'fortnightly' THEN avg_amount * 2.17
WHEN 'monthly' THEN avg_amount
WHEN 'quarterly' THEN avg_amount / 3
WHEN 'annual' THEN avg_amount / 12
END DESC NULLS LAST`,
[user.id]
);
const today = new Date();
const subscriptions = rows.map((r) => {
const lastSeen = new Date(r.last_seen);
const daysSinceLast = Math.floor((today.getTime() - lastSeen.getTime()) / 86400000);
const medianInterval = Number(r.median_interval);
const is_active = daysSinceLast < medianInterval * 1.5;
const avg = Number(r.avg_amount);
const monthly_equiv =
r.frequency === "weekly" ? avg * 4.33 :
r.frequency === "fortnightly" ? avg * 2.17 :
r.frequency === "quarterly" ? avg / 3 :
r.frequency === "annual" ? avg / 12 :
avg;
return {
merchant: r.merchant,
category: r.category,
frequency: r.frequency,
avg_amount: avg,
monthly_equiv: Math.round(monthly_equiv * 100) / 100,
first_seen: r.first_seen,
last_seen: r.last_seen,
occurrences: r.occurrences,
total_paid: Number(r.total_paid),
is_active,
};
});
const total_monthly_equiv = subscriptions
.filter((s) => s.is_active)
.reduce((sum, s) => sum + s.monthly_equiv, 0);
return NextResponse.json({ subscriptions, total_monthly_equiv: Math.round(total_monthly_equiv * 100) / 100 });
}
+18
View File
@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { id } = await params;
const existing = await queryRaw<{ id: number }>(
`SELECT id FROM budgets WHERE id = $1 AND owner_id = $2`,
[Number(id), user.id]
);
if (!existing.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
await queryRaw(`DELETE FROM budgets WHERE id = $1`, [Number(id)]);
return NextResponse.json({ ok: true });
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { searchParams } = new URL(req.url);
const month = searchParams.get("month");
let monthDate: string;
if (month) {
monthDate = month.length === 7 ? `${month}-01` : month;
} else {
const now = new Date();
monthDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
}
const rows = await queryRaw<{ id: number; category: string; month: string; amount_limit: number }>(
`SELECT id, category, month::text, amount_limit::numeric FROM budgets WHERE owner_id = $1 AND month = $2::date`,
[user.id, monthDate]
);
return NextResponse.json(rows);
}
export async function POST(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { category, month, amount_limit } = await req.json();
if (!category || !month || amount_limit === undefined) {
return NextResponse.json({ error: "category, month, and amount_limit required" }, { status: 400 });
}
const monthDate = month.length === 7 ? `${month}-01` : month;
const rows = await queryRaw<{ id: number; category: string; month: string; amount_limit: number }>(
`INSERT INTO budgets (owner_id, category, month, amount_limit)
VALUES ($1, $2, $3::date, $4)
ON CONFLICT (owner_id, category, month) DO UPDATE SET amount_limit = $4, updated_at = NOW()
RETURNING id, category, month::text, amount_limit::numeric`,
[user.id, category, monthDate, amount_limit]
);
return NextResponse.json(rows[0], { status: 201 });
}
+8
View File
@@ -0,0 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
return NextResponse.json(user);
}
+1 -1
View File
@@ -7,7 +7,7 @@ export async function GET(req: NextRequest) {
if (type === "banks") {
const banks = await getBankNames();
return NextResponse.json(banks.map((b) => b.bank_name));
return NextResponse.json(banks);
}
if (!search) return NextResponse.json([]);
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRaw } from "@/lib/db";
interface BalanceRow {
participant_id: number;
name: string;
total_owed: number;
transaction_count: number;
}
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const rows = await queryRaw<BalanceRow>(
`SELECT ts.participant_id, p.name,
SUM(t.amount * ts.share_percent / 100)::numeric(12,2) as total_owed,
COUNT(*)::int as transaction_count
FROM transaction_splits ts
JOIN transactions t ON t.id = ts.transaction_id
JOIN participants p ON p.id = ts.participant_id
WHERE ts.participant_id = $1 AND ts.settled = false
GROUP BY ts.participant_id, p.name`,
[Number(id)]
);
return NextResponse.json(
rows[0] ?? { participant_id: Number(id), total_owed: 0, transaction_count: 0 }
);
}
@@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { getParticipantBalances } from "@/lib/queries";
import { getCurrentUser } from "@/lib/auth";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const tagParam = req.nextUrl.searchParams.get("tag_ids");
const tagIds = tagParam ? tagParam.split(",").map(Number).filter(Boolean) : undefined;
const balances = await getParticipantBalances(user.id, tagIds);
return NextResponse.json(balances);
}
+21
View File
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { queryRaw } from "@/lib/db";
export async function GET() {
const participants = await prisma.participants.findMany({
orderBy: { name: "asc" },
});
return NextResponse.json(participants);
}
export async function POST(req: NextRequest) {
const { name, email } = await req.json();
if (!name?.trim()) {
return NextResponse.json({ error: "name required" }, { status: 400 });
}
const participant = await prisma.participants.create({
data: { name: name.trim(), email: email?.trim() || null },
});
return NextResponse.json(participant, { status: 201 });
}
@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
interface SnapshotEntry {
transaction_id: number;
had_override: boolean;
prev_category_override: string | null;
prev_merchant_normalized: string | null;
prev_tag_ids: number[];
prev_splits: { participant_id: number; share_percent: number; settled: boolean }[];
}
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { id } = await params;
const runId = Number(id);
const rows = await queryRaw<{
id: number;
owner_id: number;
reverted_at: string | null;
snapshot: unknown;
}>(
`SELECT id, owner_id, reverted_at, snapshot FROM rule_apply_runs WHERE id = $1`,
[runId]
);
if (!rows.length) return NextResponse.json({ error: "Run not found" }, { status: 404 });
const run = rows[0];
if (run.owner_id !== user.id) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
if (run.reverted_at) return NextResponse.json({ error: "Already reverted" }, { status: 409 });
const snapshot = (typeof run.snapshot === "string"
? JSON.parse(run.snapshot)
: run.snapshot) as SnapshotEntry[];
for (const entry of snapshot) {
const txId = entry.transaction_id;
// Restore overrides
if (entry.had_override) {
await queryRaw(
`INSERT INTO transaction_overrides (transaction_id, category_override, merchant_normalized)
VALUES ($1, $2, $3)
ON CONFLICT (transaction_id) DO UPDATE SET
category_override = $2,
merchant_normalized = $3,
updated_at = NOW()`,
[txId, entry.prev_category_override, entry.prev_merchant_normalized]
);
} else {
// No override existed before — remove any that were created
await queryRaw(
`DELETE FROM transaction_overrides WHERE transaction_id = $1
AND category_override IS NULL AND merchant_normalized IS NULL`,
[txId]
);
// If override row exists but was only partially set by this run, clear those fields
await queryRaw(
`UPDATE transaction_overrides SET
category_override = NULL,
merchant_normalized = NULL,
updated_at = NOW()
WHERE transaction_id = $1`,
[txId]
);
}
// Restore tags: remove any that weren't there before, don't touch pre-existing ones
const prevTagIds = entry.prev_tag_ids;
if (prevTagIds.length > 0) {
await queryRaw(
`DELETE FROM transaction_tags WHERE transaction_id = $1 AND tag_id != ALL($2::int[])`,
[txId, prevTagIds]
);
} else {
// No tags existed before — remove all tags (they were all added by this run)
// Note: this only removes tags on transactions that matched this run
await queryRaw(`DELETE FROM transaction_tags WHERE transaction_id = $1`, [txId]);
}
// Restore splits
await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [txId]);
for (const s of entry.prev_splits) {
await queryRaw(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
[txId, s.participant_id, s.share_percent, s.settled]
);
}
}
await queryRaw(
`UPDATE rule_apply_runs SET reverted_at = NOW() WHERE id = $1`,
[runId]
);
return NextResponse.json({ reverted: snapshot.length });
}
+127 -12
View File
@@ -4,15 +4,30 @@ import { queryRaw } from "@/lib/db";
import { getTransactions } from "@/lib/queries";
interface Condition {
field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount";
field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount" | "transaction_type";
operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals";
value: string;
}
interface SplitEntry {
participant_id: number;
share_percent: number;
}
interface Actions {
set_category?: string;
add_tag_ids?: number[];
set_merchant?: string;
apply_split?: SplitEntry[];
}
interface SnapshotEntry {
transaction_id: number;
had_override: boolean;
prev_category_override: string | null;
prev_merchant_normalized: string | null;
prev_tag_ids: number[];
prev_splits: { participant_id: number; share_percent: number; settled: boolean }[];
}
interface TxFields {
@@ -21,6 +36,7 @@ interface TxFields {
description: string;
bank_name: string;
amount: number;
transaction_type: string;
}
function evaluateCondition(cond: Condition, tx: TxFields): boolean {
@@ -42,6 +58,7 @@ function evaluateCondition(cond: Condition, tx: TxFields): boolean {
case "description": fieldVal = tx.description || ""; break;
case "category": fieldVal = tx.effective_category || ""; break;
case "bank_name": fieldVal = tx.bank_name || ""; break;
case "transaction_type": fieldVal = tx.transaction_type || ""; break;
default: return false;
}
@@ -56,6 +73,26 @@ function evaluateCondition(cond: Condition, tx: TxFields): boolean {
}
}
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const runs = await queryRaw<{
id: number;
applied_at: string;
split_from: string | null;
matched: number;
transactions_affected: number;
reverted_at: string | null;
}>(
`SELECT id, applied_at, split_from, matched, transactions_affected, reverted_at
FROM rule_apply_runs WHERE owner_id = $1 ORDER BY applied_at DESC LIMIT 20`,
[user.id]
);
return NextResponse.json(runs);
}
export async function POST(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
@@ -67,22 +104,81 @@ export async function POST(req: NextRequest) {
if (!rules.length) return NextResponse.json({ matched: 0, transactions_affected: 0 });
const body = await req.json().catch(() => ({})) as { splitFrom?: string | null };
const splitFrom = body.splitFrom || null;
const { data: transactions } = await getTransactions(user.id, { limit: 100000, offset: 0 });
// --- Pre-pass: find all transactions that will match any rule ---
const parsedRules = rules.map((r) => ({
conditions: (typeof r.conditions === "string" ? JSON.parse(r.conditions) : r.conditions) as Condition[],
actions: (typeof r.actions === "string" ? JSON.parse(r.actions) : r.actions) as Actions,
}));
const matchedIds = new Set<number>();
for (const tx of transactions) {
for (const { conditions } of parsedRules) {
if (conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx))) {
matchedIds.add(tx.id);
break;
}
}
}
// --- Capture before-state for all matched transactions (batched) ---
const snapshot: SnapshotEntry[] = [];
if (matchedIds.size > 0) {
const ids = Array.from(matchedIds);
const idList = ids.join(",");
const overrides = await queryRaw<{ transaction_id: number; category_override: string | null; merchant_normalized: string | null }>(
`SELECT transaction_id, category_override, merchant_normalized FROM transaction_overrides WHERE transaction_id = ANY($1::int[])`,
[ids]
);
const overrideMap = new Map(overrides.map((o) => [o.transaction_id, o]));
const tagRows = await queryRaw<{ transaction_id: number; tag_id: number }>(
`SELECT transaction_id, tag_id FROM transaction_tags WHERE transaction_id = ANY($1::int[])`,
[ids]
);
const tagMap = new Map<number, number[]>();
for (const row of tagRows) {
if (!tagMap.has(row.transaction_id)) tagMap.set(row.transaction_id, []);
tagMap.get(row.transaction_id)!.push(row.tag_id);
}
const splitRows = await queryRaw<{ transaction_id: number; participant_id: number; share_percent: number; settled: boolean }>(
`SELECT transaction_id, participant_id, share_percent, settled FROM transaction_splits WHERE transaction_id = ANY($1::int[])`,
[ids]
);
const splitMap = new Map<number, { participant_id: number; share_percent: number; settled: boolean }[]>();
for (const row of splitRows) {
if (!splitMap.has(row.transaction_id)) splitMap.set(row.transaction_id, []);
splitMap.get(row.transaction_id)!.push({ participant_id: row.participant_id, share_percent: row.share_percent, settled: row.settled });
}
for (const id of ids) {
const ov = overrideMap.get(id);
snapshot.push({
transaction_id: id,
had_override: !!ov,
prev_category_override: ov?.category_override ?? null,
prev_merchant_normalized: ov?.merchant_normalized ?? null,
prev_tag_ids: tagMap.get(id) ?? [],
prev_splits: splitMap.get(id) ?? [],
});
}
void idList; // suppress unused warning
}
// --- Apply rules ---
let matched = 0;
const affectedIds = new Set<number>();
for (const rule of rules) {
const conditions = (typeof rule.conditions === "string"
? JSON.parse(rule.conditions)
: rule.conditions) as Condition[];
const actions = (typeof rule.actions === "string"
? JSON.parse(rule.actions)
: rule.actions) as Actions;
for (const { conditions, actions } of parsedRules) {
for (const tx of transactions) {
const allMatch =
conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx));
const allMatch = conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx));
if (!allMatch) continue;
matched++;
@@ -108,8 +204,27 @@ export async function POST(req: NextRequest) {
);
}
}
if (actions.apply_split?.length) {
if (splitFrom && tx.transaction_date < splitFrom) continue;
await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [tx.id]);
for (const s of actions.apply_split) {
await queryRaw(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[tx.id, s.participant_id, s.share_percent]
);
}
}
}
}
return NextResponse.json({ matched, transactions_affected: affectedIds.size });
// --- Save run record ---
const run = await queryRaw<{ id: number }>(
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
[user.id, splitFrom, matched, affectedIds.size, JSON.stringify(snapshot)]
);
return NextResponse.json({ id: run[0].id, matched, transactions_affected: affectedIds.size });
}
+13
View File
@@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { getSharedTransactions } from "@/lib/queries";
import { getCurrentUser } from "@/lib/auth";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const tagParam = req.nextUrl.searchParams.get("tag_ids");
const tagIds = tagParam ? tagParam.split(",").map(Number).filter(Boolean) : undefined;
const transactions = await getSharedTransactions(user.id, tagIds);
return NextResponse.json(transactions);
}
+88
View File
@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
import { prisma } from "@/lib/db";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const sp = req.nextUrl.searchParams;
const participantId = sp.get("participant_id");
// Return payment history between current user and a participant
const rows = await queryRaw<{
id: number;
from_participant_id: number;
from_name: string;
to_participant_id: number;
to_name: string;
amount: number;
payment_date: string;
notes: string | null;
linked_transaction_id: number | null;
created_at: string;
}>(
`SELECT sp.id, sp.from_participant_id, pf.name as from_name,
sp.to_participant_id, pt.name as to_name,
sp.amount, sp.payment_date, sp.notes,
sp.linked_transaction_id, sp.created_at
FROM split_payments sp
JOIN participants pf ON pf.id = sp.from_participant_id
JOIN participants pt ON pt.id = sp.to_participant_id
WHERE (sp.from_participant_id = $1 OR sp.to_participant_id = $1)
AND (sp.from_participant_id = $2 OR sp.to_participant_id = $2)
ORDER BY sp.payment_date DESC, sp.created_at DESC`,
[user.id, participantId ? Number(participantId) : user.id]
);
return NextResponse.json(rows);
}
export async function POST(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const body = await req.json() as {
from_participant_id: number;
to_participant_id: number;
amount: number;
payment_date: string;
notes?: string;
linked_transaction_id?: number;
};
const { from_participant_id, to_participant_id, amount, payment_date, notes, linked_transaction_id } = body;
if (!from_participant_id || !to_participant_id || !amount || !payment_date) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
if (amount <= 0) {
return NextResponse.json({ error: "Amount must be positive" }, { status: 400 });
}
const payment = await prisma.split_payments.create({
data: {
from_participant_id,
to_participant_id,
amount,
payment_date: new Date(payment_date),
notes: notes || null,
linked_transaction_id: linked_transaction_id || null,
},
});
return NextResponse.json(payment);
}
export async function DELETE(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const sp = req.nextUrl.searchParams;
const id = Number(sp.get("id"));
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
await prisma.split_payments.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function POST(req: NextRequest) {
const body = await req.json();
const { participant_id, split_ids } = body as {
participant_id?: number;
split_ids?: number[];
};
const now = new Date();
if (participant_id) {
const result = await prisma.transaction_splits.updateMany({
where: { participant_id, settled: false },
data: { settled: true, settled_at: now },
});
return NextResponse.json({ settled: result.count });
}
if (split_ids?.length) {
const result = await prisma.transaction_splits.updateMany({
where: { id: { in: split_ids }, settled: false },
data: { settled: true, settled_at: now },
});
return NextResponse.json({ settled: result.count });
}
return NextResponse.json({ error: "participant_id or split_ids required" }, { status: 400 });
}
+55 -1
View File
@@ -1,6 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { getTransactionById } from "@/lib/queries";
import { prisma } from "@/lib/db";
import { queryRaw } from "@/lib/db";
const VALID_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
export async function GET(
_req: NextRequest,
@@ -20,16 +23,66 @@ export async function PATCH(
const transactionId = Number(id);
const body = await req.json();
const { category, merchant_normalized, notes } = body as {
const { category, merchant_normalized, notes, transaction_type, my_share_percent, description, amount, transaction_date } = body as {
category?: string;
merchant_normalized?: string;
notes?: string;
transaction_type?: string;
my_share_percent?: number | null;
description?: string;
amount?: number;
transaction_date?: string;
};
if (my_share_percent !== undefined && my_share_percent !== null) {
if (typeof my_share_percent !== "number" || my_share_percent <= 0 || my_share_percent > 100) {
return NextResponse.json({ error: "my_share_percent must be between 1 and 100" }, { status: 400 });
}
}
// Direct field edits — only allowed for manual transactions (statement_id IS NULL)
const directFields = [description, amount, transaction_date].filter((v) => v !== undefined);
if (directFields.length > 0) {
const txRows = await queryRaw<{ statement_id: number | null }>(
`SELECT statement_id FROM transactions WHERE id = $1`,
[transactionId]
);
if (!txRows[0]?.statement_id) {
const setClauses: string[] = [];
const params: unknown[] = [];
let idx = 1;
if (description !== undefined) { setClauses.push(`description = $${idx++}`); params.push(description); }
if (amount !== undefined) { setClauses.push(`amount = $${idx++}`); params.push(amount); }
if (transaction_date !== undefined) { setClauses.push(`transaction_date = $${idx++}`); params.push(transaction_date); }
if (setClauses.length) {
params.push(transactionId);
await queryRaw(`UPDATE transactions SET ${setClauses.join(", ")} WHERE id = $${idx}`, params);
}
}
}
// transaction_type is a direct correction on the transactions table
if (transaction_type !== undefined) {
if (!VALID_TYPES.includes(transaction_type)) {
return NextResponse.json({ error: "Invalid transaction_type" }, { status: 400 });
}
await queryRaw(
`UPDATE transactions SET transaction_type = $1 WHERE id = $2`,
[transaction_type, transactionId]
);
}
// category/merchant/notes/my_share_percent go through the overrides table
const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined || my_share_percent !== undefined;
if (!hasOverride) {
return NextResponse.json({ ok: true });
}
const data: Record<string, unknown> = { updated_at: new Date() };
if (category !== undefined) data.category_override = category;
if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized;
if (notes !== undefined) data.notes = notes;
if (my_share_percent !== undefined) data.my_share_percent = my_share_percent;
const override = await prisma.transaction_overrides.upsert({
where: { transaction_id: transactionId },
@@ -39,6 +92,7 @@ export async function PATCH(
category_override: category || null,
merchant_normalized: merchant_normalized || null,
notes: notes || null,
my_share_percent: my_share_percent != null ? String(my_share_percent) : null,
},
});
@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { queryRaw } from "@/lib/db";
interface SplitInput {
participant_id: number;
share_percent: number;
}
interface SplitRow {
id: number;
transaction_id: number;
participant_id: number;
name: string;
share_percent: number;
settled: boolean;
settled_at: string | null;
created_at: string;
}
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const splits = await queryRaw<SplitRow>(
`SELECT ts.*, p.name
FROM transaction_splits ts
JOIN participants p ON p.id = ts.participant_id
WHERE ts.transaction_id = $1
ORDER BY p.name`,
[Number(id)]
);
return NextResponse.json(splits);
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const transactionId = Number(id);
const { splits } = (await req.json()) as { splits: SplitInput[] };
if (!splits || !Array.isArray(splits) || splits.length === 0) {
return NextResponse.json({ error: "splits array required" }, { status: 400 });
}
const total = splits.reduce((sum, s) => sum + Number(s.share_percent), 0);
if (Math.abs(total - 100) > 0.01) {
return NextResponse.json(
{ error: `Shares must sum to 100%, got ${total}%` },
{ status: 400 }
);
}
// Replace all splits for this transaction atomically
await prisma.$transaction([
prisma.transaction_splits.deleteMany({ where: { transaction_id: transactionId } }),
...splits.map((s) =>
prisma.transaction_splits.create({
data: {
transaction_id: transactionId,
participant_id: s.participant_id,
share_percent: s.share_percent,
},
})
),
]);
const result = await queryRaw<SplitRow>(
`SELECT ts.*, p.name FROM transaction_splits ts
JOIN participants p ON p.id = ts.participant_id
WHERE ts.transaction_id = $1 ORDER BY p.name`,
[transactionId]
);
return NextResponse.json(result);
}
+59 -3
View File
@@ -1,25 +1,81 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { getTransactions } from "@/lib/queries";
import { queryRaw } from "@/lib/db";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const sp = req.nextUrl.searchParams;
const parseArr = (key: string) => { const v = sp.get(key); return v ? v.split(",").filter(Boolean) : undefined; };
const result = await getTransactions(user.id, {
from: sp.get("from") || undefined,
to: sp.get("to") || undefined,
category: sp.get("category") || undefined,
bank_name: sp.get("bank_name") || undefined,
categories: parseArr("categories"),
bank_names: parseArr("bank_names"),
tag_ids: parseArr("tag_ids"),
transaction_types: parseArr("transaction_types"),
search: sp.get("search") || undefined,
statement_id: sp.get("statement_id") || undefined,
tag_id: sp.get("tag_id") || undefined,
sort_by: sp.get("sort_by") || undefined,
sort_dir: sp.get("sort_dir") || undefined,
limit: sp.get("limit") ? Number(sp.get("limit")) : undefined,
offset: sp.get("offset") ? Number(sp.get("offset")) : undefined,
amount_min: sp.get("amount_min") ? Number(sp.get("amount_min")) : undefined,
amount_max: sp.get("amount_max") ? Number(sp.get("amount_max")) : undefined,
});
return NextResponse.json(result);
}
export async function POST(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const body = await req.json() as {
date: string;
description: string;
amount: number;
transaction_type?: string;
merchant_normalized?: string;
category?: string;
splits?: { participant_id: number; share_percent: number }[];
};
if (!body.date || !body.description || body.amount == null) {
return NextResponse.json({ error: "date, description, amount are required" }, { status: 400 });
}
// Insert manual transaction with no statement (statement_id = NULL, owner_id set directly)
const txRows = await queryRaw<{ id: number }>(
`INSERT INTO transactions (statement_id, owner_id, transaction_date, description, amount, transaction_type, merchant_normalized, category, row_index)
VALUES (NULL, $1, $2, $3, $4, $5, $6, $7, (
SELECT COALESCE(MAX(row_index), -1) + 1 FROM transactions WHERE owner_id = $1 AND statement_id IS NULL
))
RETURNING id`,
[
user.id,
body.date,
body.description,
body.amount,
body.transaction_type || "debit",
body.merchant_normalized || null,
body.category || null,
]
);
const transactionId = txRows[0].id;
// Insert splits if provided
if (body.splits?.length) {
for (const s of body.splits) {
await queryRaw(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[transactionId, s.participant_id, s.share_percent]
);
}
}
return NextResponse.json({ id: transactionId }, { status: 201 });
}
+631 -4
View File
@@ -1,8 +1,635 @@
export default function BudgetPage() {
"use client";
import { useState, Fragment, useMemo } from "react";
import {
ComposedChart,
LineChart,
Bar,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
ReferenceLine,
Legend,
} from "recharts";
import { useQueryClient } from "@tanstack/react-query";
import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks";
import { formatCategory, CATEGORIES } from "@/lib/categories";
function currentMonthStr(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
function prevMonth(m: string): string {
const [year, month] = m.split("-").map(Number);
const d = new Date(year, month - 2, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
function nextMonth(m: string): string {
const [year, month] = m.split("-").map(Number);
const d = new Date(year, month, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
function formatMonth(m: string): string {
const [year, month] = m.split("-");
return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "long", year: "numeric" });
}
function formatShortMonth(m: string): string {
const [year, month] = m.split("-");
return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "short" });
}
function fmt(n: number): string { return `$${n.toFixed(0)}`; }
function fmtExact(n: number): string { return `$${n.toFixed(2)}`; }
function deltaColor(n: number): string {
if (n > 0) return "text-red-400";
if (n < 0) return "text-emerald-400";
return "";
}
const TOOLTIP_STYLE = { background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12 };
const CATEGORY_COLORS: Record<string, string> = {
groceries: "#22c55e",
dining: "#f97316",
transport: "#06b6d4",
fuel: "#eab308",
shopping: "#ec4899",
utilities: "#8b5cf6",
entertainment: "#f43f5e",
travel: "#0ea5e9",
health: "#10b981",
insurance: "#64748b",
subscriptions: "#a78bfa",
cash_advance: "#dc2626",
government: "#78716c",
education: "#3b82f6",
rent: "#d97706",
transfers: "#6b7280",
income: "#34d399",
investment: "#818cf8",
personal_care: "#fb7185",
pets: "#86efac",
gifts: "#fcd34d",
charity: "#a3e635",
home_goods: "#67e8f9",
home_maintenance: "#c084fc",
other: "#71717a",
};
// ─── Tooltips ────────────────────────────────────────────────────────────────
function TrendTooltip({ active, payload, label }: { active?: boolean; payload?: { dataKey: string; value: number; stroke: string }[]; label?: string }) {
if (!active || !payload?.length) return null;
const items = payload.filter((p) => p.value > 0.01).sort((a, b) => b.value - a.value);
if (!items.length) return null;
return (
<div>
<h2 className="text-xl font-semibold mb-4">Budget</h2>
<p className="text-zinc-500">Coming soon - monthly budgets and analytics.</p>
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs min-w-40">
<p className="text-zinc-400 mb-2 font-medium">{label}</p>
{items.map((p) => (
<div key={p.dataKey} className="flex items-center gap-2 mb-0.5">
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: p.stroke }} />
<span className="text-zinc-400">{formatCategory(p.dataKey.replace("cat_", ""))}:</span>
<span className="text-zinc-100 tabular-nums ml-auto pl-2">{fmtExact(p.value)}</span>
</div>
))}
</div>
);
}
function ParetoTooltip({ active, payload }: { active?: boolean; payload?: { payload: { category: string; spent: number; pct: number; cumulative: number } }[] }) {
if (!active || !payload?.length) return null;
const d = payload[0].payload;
return (
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs">
<p className="font-medium text-zinc-300 mb-1">{formatCategory(d.category)}</p>
<div className="flex justify-between gap-4"><span className="text-zinc-400">Spend</span><span>{fmtExact(d.spent)}</span></div>
<div className="flex justify-between gap-4"><span className="text-zinc-400">Share</span><span>{d.pct}%</span></div>
<div className="flex justify-between gap-4"><span className="text-zinc-400">Cumulative</span><span>{d.cumulative}%</span></div>
</div>
);
}
function CumulativeTooltip({ active, payload, label }: { active?: boolean; payload?: { dataKey: string; value: number; stroke: string }[]; label?: number }) {
if (!active || !payload?.length) return null;
return (
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs">
<p className="text-zinc-400 mb-1 font-medium">Day {label}</p>
{payload.map((p) => p.value != null && (
<div key={p.dataKey} className="flex items-center gap-2 mb-0.5">
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: p.stroke }} />
<span className="text-zinc-400">{p.dataKey === "actual" ? "Actual" : "Typical pace"}:</span>
<span className="text-zinc-100 tabular-nums ml-auto pl-2">{fmtExact(p.value)}</span>
</div>
))}
</div>
);
}
// ─── CategoryPanel ────────────────────────────────────────────────────────────
function CategoryPanel({ category, selectedMonth }: { category: string; selectedMonth: string }) {
const qc = useQueryClient();
const updateTx = useUpdateTransaction();
const from = `${selectedMonth}-01`;
const [year, month] = selectedMonth.split("-").map(Number);
const nextDate = new Date(year, month, 1);
const to = `${nextDate.getFullYear()}-${String(nextDate.getMonth() + 1).padStart(2, "0")}-01`;
const { data, isLoading } = useTransactions({ categories: category ? [category] : [], from, to, limit: 200 });
const txns = data?.data || [];
return (
<tr>
<td colSpan={4} className="px-0 pb-2 bg-zinc-950/60 border-b border-zinc-800">
{isLoading ? (
<p className="text-xs text-zinc-500 px-6 py-2">Loading...</p>
) : txns.length === 0 ? (
<p className="text-xs text-zinc-600 px-6 py-2">No transactions</p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="text-zinc-600">
<th className="text-left px-6 py-1 font-normal w-24">Date</th>
<th className="text-left px-2 py-1 font-normal">Description</th>
<th className="text-right px-2 py-1 font-normal w-24">Amount</th>
<th className="text-right px-4 py-1 font-normal w-36">Category</th>
</tr>
</thead>
<tbody>
{txns.map((tx) => (
<tr key={tx.id} className="border-t border-zinc-800/30 hover:bg-zinc-800/20">
<td className="px-6 py-1.5 text-zinc-500 tabular-nums">{tx.transaction_date.slice(5).replace("-", "/")}</td>
<td className="px-2 py-1.5 text-zinc-300 max-w-xs truncate">{tx.effective_merchant || tx.description}</td>
<td className="px-2 py-1.5 text-right tabular-nums text-zinc-300">{fmtExact(Number(tx.amount))}</td>
<td className="px-4 py-1.5 text-right">
<select
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
defaultValue={tx.effective_category}
onChange={(e) =>
updateTx.mutate({ id: tx.id, category: e.target.value }, {
onSuccess: () => qc.invalidateQueries({ queryKey: ["analytics"] }),
})
}
>
{CATEGORIES.map((cat) => (
<option key={cat} value={cat}>{formatCategory(cat)}</option>
))}
</select>
</td>
</tr>
))}
</tbody>
</table>
)}
</td>
</tr>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function AnalyticsPage() {
const [selectedMonth, setSelectedMonth] = useState(currentMonthStr);
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
const { data: analytics, isLoading } = useMonthlyAnalytics(6);
// Cumulative chart: fetch this month's transactions
const smFrom = `${selectedMonth}-01`;
const [smYear, smMonth] = selectedMonth.split("-").map(Number);
const smNextDate = new Date(smYear, smMonth, 1);
const smTo = `${smNextDate.getFullYear()}-${String(smNextDate.getMonth() + 1).padStart(2, "0")}-01`;
const { data: monthTxData } = useTransactions({ from: smFrom, to: smTo, limit: 1000 });
const months = useMemo(() => analytics ? [...analytics.months].reverse() : [], [analytics]);
// Category rows for selected month
const categoryRows = useMemo(() => {
if (!analytics) return [];
return analytics.rows
.filter((r) => (r.spent[selectedMonth] || 0) > 0)
.map((r) => ({ category: r.category, spent: r.spent[selectedMonth] || 0, txCount: r.txCount[selectedMonth] || 0 }))
.sort((a, b) => b.spent - a.spent);
}, [analytics, selectedMonth]);
// Trend line data — top 8 categories by total 6-month spend
const trendData = useMemo(() => {
if (!analytics) return { data: [], categories: [] };
const categoryTotals = analytics.rows
.map((r) => ({ category: r.category, total: months.reduce((s, m) => s + (r.spent[m] || 0), 0) }))
.sort((a, b) => b.total - a.total)
.slice(0, 8)
.map((r) => r.category);
const data = months.map((m) => {
const entry: Record<string, unknown> = { month: m, label: formatShortMonth(m) };
for (const cat of categoryTotals) {
const row = analytics.rows.find((r) => r.category === cat);
entry[`cat_${cat}`] = row?.spent[m] || 0;
}
return entry;
});
return { data, categories: categoryTotals };
}, [analytics, months]);
// Pareto chart data
const paretoData = useMemo(() => {
const total = categoryRows.reduce((s, r) => s + r.spent, 0);
let running = 0;
return categoryRows.map((r) => {
running += r.spent;
return {
category: r.category,
spent: r.spent,
pct: total > 0 ? Math.round((r.spent / total) * 1000) / 10 : 0,
cumulative: total > 0 ? Math.round((running / total) * 1000) / 10 : 0,
};
});
}, [categoryRows]);
// Cumulative spend chart data
const cumulativeData = useMemo(() => {
const daysInMonth = new Date(smYear, smMonth, 0).getDate();
const isCurrentMonth = selectedMonth === currentMonthStr();
const today = new Date();
const lastDay = isCurrentMonth ? today.getDate() : daysInMonth;
// Daily spend from transactions
const daily: Record<number, number> = {};
(monthTxData?.data ?? [])
.filter((tx) => tx.transaction_type === "debit" && !["transfers", "investment"].includes(tx.effective_category))
.forEach((tx) => {
const day = new Date(tx.transaction_date).getDate();
daily[day] = (daily[day] || 0) + Number(tx.amount_aud ?? tx.amount);
});
// Typical pace: avg of prior months (straight-line ramp)
const priorMonths = analytics?.months.filter((m) => m !== selectedMonth) ?? [];
const priorAvg = priorMonths.length > 0
? priorMonths.reduce((s, m) => s + (analytics?.totals[m]?.spent || 0), 0) / priorMonths.length
: 0;
let cum = 0;
return Array.from({ length: daysInMonth }, (_, i) => {
const day = i + 1;
if (day <= lastDay) cum += daily[day] || 0;
return {
day,
actual: day <= lastDay ? Math.round(cum * 100) / 100 : null,
typical: Math.round((priorAvg * day / daysInMonth) * 100) / 100,
};
});
}, [monthTxData, analytics, selectedMonth, smYear, smMonth]);
if (isLoading || !analytics) {
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold">Analytics</h2>
<p className="text-zinc-500 text-sm">Loading...</p>
</div>
);
}
const totals = analytics.totals[selectedMonth] ?? { spent: 0, income: 0, investments: 0, net: 0 };
const lastTotals = analytics.totals[prevMonth(selectedMonth)] ?? { spent: 0, income: 0, investments: 0, net: 0 };
const spentDelta = totals.spent - lastTotals.spent;
const largestCategory = categoryRows[0];
const hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0);
const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) > 0);
// Pareto: find where cumulative crosses 80%
const pareto80idx = paretoData.findIndex((r) => r.cumulative >= 80);
return (
<div className="space-y-6">
{/* Header + month selector */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Analytics</h2>
<div className="flex items-center gap-3">
<button onClick={() => setSelectedMonth(prevMonth(selectedMonth))} className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"></button>
<span className="text-sm font-medium min-w-36 text-center">{formatMonth(selectedMonth)}</span>
<button onClick={() => setSelectedMonth(nextMonth(selectedMonth))} className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"></button>
</div>
</div>
{/* Summary cards */}
<div className={`grid gap-4 ${hasIncome ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-3"}`}>
{hasIncome && (
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">Income</p>
<p className="text-2xl font-semibold text-emerald-400">{fmtExact(totals.income)}</p>
<p className="text-xs text-zinc-500 mt-1">received</p>
</div>
)}
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">Expenses</p>
<p className="text-2xl font-semibold">{fmtExact(totals.spent)}</p>
<p className={`text-xs mt-1 ${deltaColor(spentDelta)}`}>
{spentDelta === 0 || lastTotals.spent === 0
? <span className="text-zinc-500">split-adjusted</span>
: `${spentDelta > 0 ? "+" : ""}${fmtExact(spentDelta)} vs ${formatShortMonth(prevMonth(selectedMonth))}`}
</p>
</div>
{(hasInvestments || totals.investments > 0) && (
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">Invested</p>
<p className="text-2xl font-semibold text-indigo-400">{fmtExact(totals.investments)}</p>
<p className="text-xs text-zinc-500 mt-1">shares / ETFs</p>
</div>
)}
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">{hasIncome ? "Net Cash" : "Largest Category"}</p>
{hasIncome ? (
<>
<p className={`text-2xl font-semibold ${totals.net >= 0 ? "text-emerald-400" : "text-red-400"}`}>
{totals.net >= 0 ? "+" : ""}{fmtExact(totals.net)}
</p>
<p className="text-xs text-zinc-500 mt-1">income expenses invested</p>
</>
) : largestCategory ? (
<>
<p className="text-2xl font-semibold">{fmtExact(largestCategory.spent)}</p>
<p className="text-xs text-zinc-400 mt-1">{formatCategory(largestCategory.category)}</p>
</>
) : (
<p className="text-2xl font-semibold text-zinc-600"></p>
)}
</div>
</div>
{/* 1. Category trend lines */}
{trendData.categories.length > 0 && (
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<h3 className="text-sm font-medium mb-4">Category Trends 6 Months</h3>
<ResponsiveContainer width="100%" height={240}>
<LineChart
data={trendData.data}
margin={{ top: 4, right: 8, bottom: 0, left: 8 }}
onClick={(d) => {
const month = (d as any)?.activePayload?.[0]?.payload?.month as string | undefined;
if (month) setSelectedMonth(month);
}}
style={{ cursor: "pointer" }}
>
<XAxis dataKey="label" tick={{ fill: "#71717a", fontSize: 12 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${v}`} width={52} />
<Tooltip content={<TrendTooltip />} cursor={{ stroke: "rgba(255,255,255,0.08)", strokeWidth: 1 }} />
{trendData.categories.map((cat) => (
<Line
key={cat}
dataKey={`cat_${cat}`}
name={cat}
stroke={CATEGORY_COLORS[cat] || "#71717a"}
strokeWidth={selectedMonth && trendData.data.some((d) => (d as any).month === selectedMonth) ? 2 : 2}
dot={{ fill: CATEGORY_COLORS[cat] || "#71717a", r: 3 }}
activeDot={{ r: 5 }}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-3 pt-3 border-t border-zinc-800">
{trendData.categories.map((cat) => (
<span key={cat} className="flex items-center gap-1 text-xs text-zinc-500">
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[cat] || "#71717a" }} />
{formatCategory(cat)}
</span>
))}
{analytics.rows.length > 8 && (
<span className="text-xs text-zinc-600">+ {analytics.rows.length - 8} more in table below</span>
)}
</div>
</div>
)}
{/* 2. Pareto chart */}
{paretoData.length > 0 && (
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium">Spend Concentration {formatMonth(selectedMonth)}</h3>
{pareto80idx >= 0 && (
<span className="text-xs text-zinc-500">
Top {pareto80idx + 1} categor{pareto80idx === 0 ? "y" : "ies"} = 80% of spend
</span>
)}
</div>
<ResponsiveContainer width="100%" height={220}>
<ComposedChart data={paretoData} margin={{ top: 4, right: 48, bottom: 0, left: 8 }}>
<XAxis
dataKey="category"
tick={{ fill: "#71717a", fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={formatCategory}
/>
<YAxis
yAxisId="left"
tick={{ fill: "#71717a", fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `$${v}`}
width={52}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fill: "#71717a", fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${v}%`}
domain={[0, 100]}
width={36}
/>
<Tooltip content={<ParetoTooltip />} cursor={{ fill: "rgba(255,255,255,0.04)" }} />
<ReferenceLine yAxisId="right" y={80} stroke="#52525b" strokeDasharray="4 2" label={{ value: "80%", fill: "#71717a", fontSize: 10, position: "right" }} />
<Bar yAxisId="left" dataKey="spent" radius={[3, 3, 0, 0]} maxBarSize={40}>
{paretoData.map((entry, i) => (
<Cell
key={entry.category}
fill={i <= pareto80idx ? (CATEGORY_COLORS[entry.category] || "#6366f1") : "#3f3f46"}
/>
))}
</Bar>
<Line
yAxisId="right"
dataKey="cumulative"
stroke="#fbbf24"
strokeWidth={2}
dot={{ fill: "#fbbf24", r: 3 }}
activeDot={{ r: 5 }}
/>
</ComposedChart>
</ResponsiveContainer>
<div className="flex items-center gap-4 mt-2 justify-end">
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-0.5 bg-amber-400 inline-block" />Cumulative %</span>
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm bg-zinc-600 inline-block" />Below 80% threshold</span>
</div>
</div>
)}
{/* 3. Cumulative spend this month */}
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium">Cumulative Spend {formatMonth(selectedMonth)}</h3>
<span className="text-xs text-zinc-500">vs avg monthly pace</span>
</div>
<ResponsiveContainer width="100%" height={180}>
<LineChart data={cumulativeData} margin={{ top: 4, right: 8, bottom: 0, left: 8 }}>
<XAxis dataKey="day" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `${v}`} interval={4} />
<YAxis tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(1)}k`} width={44} />
<Tooltip content={<CumulativeTooltip />} cursor={{ stroke: "rgba(255,255,255,0.08)", strokeWidth: 1 }} />
<Line dataKey="typical" stroke="#3f3f46" strokeWidth={1.5} strokeDasharray="4 3" dot={false} name="typical" />
<Line dataKey="actual" stroke="#6366f1" strokeWidth={2} dot={false} activeDot={{ r: 4 }} connectNulls={false} name="actual" />
</LineChart>
</ResponsiveContainer>
<div className="flex items-center gap-4 mt-2 justify-end">
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-4 h-0.5 bg-indigo-500 inline-block" />This month</span>
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-4 h-0.5 bg-zinc-600 inline-block" style={{ borderTop: "1px dashed #52525b", display: "inline-block" }} />Typical pace</span>
</div>
</div>
{/* Category breakdown table — expandable rows */}
{categoryRows.length > 0 && (
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-zinc-800">
<h3 className="text-sm font-medium">Spending Breakdown {formatMonth(selectedMonth)}</h3>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Category</th>
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">Spent</th>
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium"># Txns</th>
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">% of Total</th>
</tr>
</thead>
<tbody>
{categoryRows.map(({ category, spent, txCount }) => {
const isExpanded = expandedCategory === category;
return (
<Fragment key={category}>
<tr
className={`border-b border-zinc-800/50 cursor-pointer select-none transition-colors ${isExpanded ? "bg-zinc-800/40" : "hover:bg-zinc-800/30"}`}
onClick={() => setExpandedCategory(isExpanded ? null : category)}
>
<td className="px-4 py-2.5 font-medium">
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[category] || "#71717a" }} />
{formatCategory(category)}
<span className="text-zinc-600 text-xs ml-1">{isExpanded ? "▲" : "▼"}</span>
</span>
</td>
<td className="px-4 py-2.5 text-right tabular-nums">{fmtExact(spent)}</td>
<td className="px-4 py-2.5 text-right text-zinc-400">{txCount}</td>
<td className="px-4 py-2.5 text-right text-zinc-400 tabular-nums">
{totals.spent > 0 ? ((spent / totals.spent) * 100).toFixed(1) : "0.0"}%
</td>
</tr>
{isExpanded && <CategoryPanel category={category} selectedMonth={selectedMonth} />}
</Fragment>
);
})}
</tbody>
</table>
</div>
)}
{/* 6-month trend table */}
{analytics.months.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-zinc-400 mb-3">6-Month Trend</h3>
<div className="overflow-x-auto rounded-xl border border-zinc-700">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900">
<th className="text-left px-3 py-2 text-zinc-500 font-medium sticky left-0 bg-zinc-900 min-w-32">Category</th>
{analytics.months.map((m) => (
<th
key={m}
className={`text-right px-3 py-2 font-medium whitespace-nowrap cursor-pointer hover:text-zinc-300 ${m === selectedMonth ? "text-indigo-400" : "text-zinc-500"}`}
onClick={() => setSelectedMonth(m)}
>
{formatShortMonth(m)}
</th>
))}
</tr>
</thead>
<tbody>
{analytics.rows.map((row) => (
<tr key={row.category} className="border-b border-zinc-800/40 hover:bg-zinc-900/30">
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950">
<span className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[row.category] || "#71717a" }} />
{formatCategory(row.category)}
</span>
</td>
{analytics.months.map((m) => {
const spent = row.spent[m];
return (
<td key={m} className={`px-3 py-2 text-right tabular-nums ${spent === undefined ? "text-zinc-700" : "text-zinc-300"} ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}>
{spent !== undefined ? fmt(spent) : "—"}
</td>
);
})}
</tr>
))}
{hasIncome && (
<tr className="border-b border-zinc-800/40">
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950 text-emerald-600">Income</td>
{analytics.months.map((m) => {
const inc = analytics.income[m];
return (
<td key={m} className={`px-3 py-2 text-right tabular-nums text-emerald-500 ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}>
{inc ? fmt(inc) : "—"}
</td>
);
})}
</tr>
)}
{hasInvestments && (
<tr className="border-b border-zinc-800/40">
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950 text-indigo-500">Invested</td>
{analytics.months.map((m) => {
const inv = analytics.investments[m];
return (
<td key={m} className={`px-3 py-2 text-right tabular-nums text-indigo-400 ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}>
{inv ? fmt(inv) : "—"}
</td>
);
})}
</tr>
)}
<tr className="border-t-2 border-zinc-700 font-semibold bg-zinc-900/50">
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Expenses</td>
{analytics.months.map((m) => {
const t = analytics.totals[m];
return (
<td key={m} className={`px-3 py-2 text-right tabular-nums ${m === selectedMonth ? "bg-zinc-800/30 text-indigo-300" : ""}`}>
{fmt(t?.spent || 0)}
</td>
);
})}
</tr>
{hasIncome && (
<tr className="font-semibold bg-zinc-900/50">
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Net Cash</td>
{analytics.months.map((m) => {
const t = analytics.totals[m];
const net = t?.net || 0;
return (
<td key={m} className={`px-3 py-2 text-right tabular-nums ${net >= 0 ? "text-emerald-400" : "text-red-400"} ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}>
{net >= 0 ? "+" : ""}{fmt(net)}
</td>
);
})}
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
+499
View File
@@ -0,0 +1,499 @@
"use client";
import { useMemo, useState } from "react";
import {
ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
} from "recharts";
import { useMonthlyAnalytics, useSubscriptions, useFees, useTransactions, useUpdateTransaction } from "@/lib/hooks";
import { CATEGORIES, REGULAR_CATEGORIES, formatCategory } from "@/lib/categories";
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
function fmt(n: number) {
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", maximumFractionDigits: 0 }).format(n);
}
function fmtTx(amount: number, type: string) {
const formatted = new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", minimumFractionDigits: 2 }).format(amount);
return SPEND_TYPES.has(type) ? formatted : `+${formatted}`;
}
function fmtExact(n: number) {
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", minimumFractionDigits: 2 }).format(n);
}
function fmtDate(d: string) {
return new Date(d).toLocaleDateString("en-AU", { month: "short", year: "numeric" });
}
function trend(values: number[]): { pct: number; dir: "up" | "down" | "flat" } {
if (values.length < 2) return { pct: 0, dir: "flat" };
const recent = values.slice(-3).reduce((a, b) => a + b, 0) / 3;
const prior = values.slice(0, 3).reduce((a, b) => a + b, 0) / 3;
if (prior === 0) return { pct: 0, dir: "flat" };
const pct = Math.round(((recent - prior) / prior) * 100);
return { pct: Math.abs(pct), dir: pct > 2 ? "up" : pct < -2 ? "down" : "flat" };
}
const FREQ_LABEL: Record<string, string> = {
weekly: "Weekly",
fortnightly: "Fortnightly",
monthly: "Monthly",
quarterly: "Quarterly",
annual: "Annual",
};
// ─── Section wrapper ────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-8">
<h3 className="text-base font-semibold text-zinc-200 mb-3">{title}</h3>
{children}
</div>
);
}
// ─── Custom tooltip ──────────────────────────────────────────────────
function RegularTooltip({ active, payload, label }: any) {
if (!active || !payload?.length) return null;
const regular = payload.find((p: any) => p.dataKey === "regular")?.value ?? 0;
const occasional = payload.find((p: any) => p.dataKey === "occasional")?.value ?? 0;
return (
<div className="bg-zinc-900 border border-zinc-700 rounded px-3 py-2 text-xs space-y-1">
<div className="font-medium text-zinc-300 mb-1">{label}</div>
<div className="flex justify-between gap-4"><span className="text-indigo-400">Regular</span><span>{fmt(regular)}</span></div>
<div className="flex justify-between gap-4"><span className="text-zinc-400">Occasional</span><span>{fmt(occasional)}</span></div>
<div className="flex justify-between gap-4 border-t border-zinc-700 pt-1"><span className="text-zinc-500">Total</span><span>{fmt(regular + occasional)}</span></div>
</div>
);
}
// ─── Drill-down row ──────────────────────────────────────────────────
function DrillDownRow({
category,
from,
to,
}: {
category: string;
from: string;
to: string;
}) {
const { data, isLoading } = useTransactions({ categories: category ? [category] : [], from, to, limit: 200 });
const updateTx = useUpdateTransaction();
if (isLoading) {
return (
<tr>
<td colSpan={2} className="px-4 py-3 text-xs text-zinc-500">Loading...</td>
</tr>
);
}
const txns = data?.data ?? [];
return (
<tr>
<td colSpan={2} className="px-0 py-0">
<div className="bg-zinc-950 border-t border-zinc-800/50">
{txns.length === 0 ? (
<p className="px-6 py-3 text-xs text-zinc-600">No transactions found.</p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-6 py-2 text-zinc-600 font-medium">Date</th>
<th className="text-left px-4 py-2 text-zinc-600 font-medium">Merchant</th>
<th className="text-right px-4 py-2 text-zinc-600 font-medium">My share</th>
<th className="text-left px-4 py-2 text-zinc-600 font-medium">Category</th>
<th className="text-right px-4 py-2 text-zinc-600 font-medium">% mine</th>
</tr>
</thead>
<tbody>
{txns.map((t) => {
const sharePct = t.my_share_percent ?? 100;
const effectiveAmt = t.amount * sharePct / 100;
const isDebit = SPEND_TYPES.has(t.transaction_type);
return (
<tr key={t.id} className="border-b border-zinc-800/30 hover:bg-zinc-900/30">
<td className="px-6 py-2 text-zinc-500 whitespace-nowrap">
{new Date(t.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })}
</td>
<td className="px-4 py-2 text-zinc-300">{t.merchant_name || t.description}</td>
<td className={`px-4 py-2 text-right tabular-nums ${isDebit ? "text-zinc-200" : "text-green-400"}`}>
{fmtTx(effectiveAmt, t.transaction_type)}
{sharePct < 100 && (
<span className="text-zinc-600 ml-1 line-through">{fmtExact(t.amount)}</span>
)}
</td>
<td className="px-4 py-2">
<select
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500"
defaultValue={t.effective_category ?? "other"}
onChange={(e) => updateTx.mutate({ id: t.id, category: e.target.value })}
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{formatCategory(c)}</option>
))}
</select>
</td>
<td className="px-4 py-2 text-right">
<select
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500"
defaultValue={t.my_share_percent ?? 100}
onChange={(e) => {
const val = Number(e.target.value);
updateTx.mutate({ id: t.id, my_share_percent: val === 100 ? null : val });
}}
>
<option value={100}>100%</option>
<option value={75}>75%</option>
<option value={67}>67%</option>
<option value={50}>50%</option>
<option value={33}>33%</option>
<option value={25}>25%</option>
</select>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</td>
</tr>
);
}
// ─── Monthly Spend Breakdown ─────────────────────────────────────────
function MonthlyBreakdown({ analytics }: { analytics: NonNullable<ReturnType<typeof useMonthlyAnalytics>["data"]> }) {
// analytics.months is newest-first; show last 6
const months = useMemo(() => analytics.months.slice(0, 6), [analytics.months]);
const [selectedMonth, setSelectedMonth] = useState<string>(months[0] ?? "");
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
// Reset expanded when month changes
const handleSelectMonth = (m: string) => {
setSelectedMonth(m);
setExpandedCategory(null);
};
const from = selectedMonth + "-01";
const lastDay = new Date(parseInt(selectedMonth.slice(0, 4)), parseInt(selectedMonth.slice(5, 7)), 0).getDate();
const to = selectedMonth + "-" + String(lastDay).padStart(2, "0");
const categoryData = useMemo(() => {
return analytics.rows
.map((row) => ({ category: row.category, amount: Number(row.spent[selectedMonth] ?? 0) }))
.filter((r) => r.amount > 0)
.sort((a, b) => b.amount - a.amount);
}, [analytics.rows, selectedMonth]);
const regularRows = categoryData.filter((r) => REGULAR_CATEGORIES.has(r.category as any));
const occasionalRows = categoryData.filter((r) => !REGULAR_CATEGORIES.has(r.category as any));
const regularTotal = regularRows.reduce((s, r) => s + r.amount, 0);
const occasionalTotal = occasionalRows.reduce((s, r) => s + r.amount, 0);
function monthLabel(m: string) {
const [year, month] = m.split("-");
return new Date(parseInt(year), parseInt(month) - 1).toLocaleDateString("en-AU", { month: "short", year: "2-digit" });
}
function renderRows(rows: typeof categoryData, dotClass: string) {
return rows.map((row) => (
<>
<tr
key={row.category}
className="border-b border-zinc-800/50 hover:bg-zinc-800/20 cursor-pointer transition-colors"
onClick={() => setExpandedCategory(expandedCategory === row.category ? null : row.category)}
>
<td className="px-4 py-2.5">
<span className="flex items-center gap-2 text-sm text-zinc-300">
<span className={`w-2 h-2 rounded-full inline-block flex-shrink-0 ${dotClass}`} />
{formatCategory(row.category)}
<span className="text-zinc-600 text-xs">{expandedCategory === row.category ? "▲" : "▼"}</span>
</span>
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-sm text-zinc-200">{fmt(row.amount)}</td>
</tr>
{expandedCategory === row.category && (
<DrillDownRow
key={`${row.category}-drill`}
category={row.category}
from={from}
to={to}
/>
)}
</>
));
}
return (
<div>
{/* Month tabs */}
<div className="flex gap-1 mb-3 flex-wrap">
{months.map((m) => (
<button
key={m}
onClick={() => handleSelectMonth(m)}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
m === selectedMonth
? "bg-indigo-600 text-white"
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
}`}
>
{monthLabel(m)}
</button>
))}
</div>
<div className="border border-zinc-700 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900">
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Category</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Spend</th>
</tr>
</thead>
<tbody>
{regularRows.length > 0 && (
<>
<tr className="bg-zinc-900/30">
<td className="px-4 py-1.5 text-xs text-indigo-400 font-medium tracking-wide uppercase">Regular</td>
<td className="px-4 py-1.5 text-right text-xs text-indigo-400 font-medium">{fmt(regularTotal)}</td>
</tr>
{renderRows(regularRows, "bg-indigo-500")}
</>
)}
{occasionalRows.length > 0 && (
<>
<tr className="bg-zinc-900/30">
<td className="px-4 py-1.5 text-xs text-zinc-500 font-medium tracking-wide uppercase">Occasional</td>
<td className="px-4 py-1.5 text-right text-xs text-zinc-500 font-medium">{fmt(occasionalTotal)}</td>
</tr>
{renderRows(occasionalRows, "bg-zinc-500")}
</>
)}
{categoryData.length === 0 && (
<tr>
<td colSpan={2} className="px-4 py-6 text-center text-xs text-zinc-600">No spend data for this month.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
// ─── Main page ────────────────────────────────────────────────────────
export default function InsightsPage() {
const { data: analytics } = useMonthlyAnalytics(12);
const { data: analytics6 } = useMonthlyAnalytics(6);
const { data: subData } = useSubscriptions();
const { data: feesData } = useFees();
// Build regular/occasional chart data
const chartData = useMemo(() => {
if (!analytics) return [];
return [...analytics.months].reverse().map((month) => {
let regular = 0;
let occasional = 0;
for (const row of analytics.rows) {
const spend = Number(row.spent[month] ?? 0);
if (REGULAR_CATEGORIES.has(row.category as any)) regular += spend;
else occasional += spend;
}
return {
month: month.slice(5) + "/" + month.slice(2, 4),
regular: Math.round(regular),
occasional: Math.round(occasional),
total: Math.round(regular + occasional),
};
});
}, [analytics]);
const regularValues = chartData.map((d) => d.regular);
const regularTrend = trend(regularValues);
const avgRegular = regularValues.length
? Math.round(regularValues.reduce((a, b) => a + b, 0) / regularValues.length)
: 0;
const latestRegular = regularValues[regularValues.length - 1] ?? 0;
const activeSubscriptions = subData?.subscriptions.filter((s) => s.is_active) ?? [];
const inactiveSubscriptions = subData?.subscriptions.filter((s) => !s.is_active) ?? [];
return (
<div className="max-w-4xl">
<h2 className="text-xl font-semibold mb-6">Insights</h2>
{/* ── 1. Regular vs Occasional ── */}
<Section title="Regular vs Occasional Spend">
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
<div className="text-xs text-zinc-500 mb-1">This month regular spend</div>
<div className="text-xl font-semibold text-indigo-400">{fmt(latestRegular)}</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
<div className="text-xs text-zinc-500 mb-1">12-month avg regular</div>
<div className="text-xl font-semibold text-zinc-200">{fmt(avgRegular)}</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
<div className="text-xs text-zinc-500 mb-1">Trend (first 3 vs last 3 mo)</div>
<div className={`text-xl font-semibold ${regularTrend.dir === "up" ? "text-red-400" : regularTrend.dir === "down" ? "text-green-400" : "text-zinc-400"}`}>
{regularTrend.dir === "up" ? "↑" : regularTrend.dir === "down" ? "↓" : "→"} {regularTrend.pct}%
</div>
</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
<ResponsiveContainer width="100%" height={220}>
<ComposedChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<XAxis dataKey="month" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} width={44} />
<Tooltip content={<RegularTooltip />} />
<Bar dataKey="regular" stackId="a" fill="#6366f1" name="Regular" radius={[0, 0, 0, 0]} />
<Bar dataKey="occasional" stackId="a" fill="#3f3f46" name="Occasional" radius={[3, 3, 0, 0]} />
<Line type="monotone" dataKey="regular" stroke="#818cf8" strokeWidth={2} dot={false} strokeDasharray="4 2" />
</ComposedChart>
</ResponsiveContainer>
<div className="flex gap-4 mt-2 justify-end">
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm bg-indigo-500 inline-block" />Regular (groceries, dining, transport)</span>
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm bg-zinc-600 inline-block" />Occasional</span>
</div>
</div>
</Section>
{/* ── 2. Monthly Spend Breakdown ── */}
<Section title="Monthly Spend Breakdown">
{!analytics6 ? (
<p className="text-zinc-500 text-sm">Loading...</p>
) : (
<MonthlyBreakdown analytics={analytics6} />
)}
</Section>
{/* ── 3. Recurring Charges ── */}
<Section title="Recurring Charges">
{!subData ? (
<p className="text-zinc-500 text-sm">Loading...</p>
) : subData.subscriptions.length === 0 ? (
<p className="text-zinc-500 text-sm">No recurring patterns detected yet more transaction history needed.</p>
) : (
<>
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-zinc-500">{activeSubscriptions.length} active · {inactiveSubscriptions.length} inactive</span>
<span className="text-sm font-medium text-indigo-400">{fmtExact(subData.total_monthly_equiv)}<span className="text-xs text-zinc-500 font-normal ml-1">/ month committed</span></span>
</div>
<div className="border border-zinc-700 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900">
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Merchant</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Category</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Frequency</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">My $/mo equiv</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Avg charge</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Since</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Total paid</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Count</th>
</tr>
</thead>
<tbody>
{[...activeSubscriptions, ...inactiveSubscriptions].map((s) => (
<tr key={s.merchant} className={`border-b border-zinc-800/50 ${s.is_active ? "hover:bg-zinc-800/20" : "opacity-40"} transition-colors`}>
<td className="px-4 py-3 font-medium">{s.merchant}</td>
<td className="px-4 py-3 text-zinc-400 text-xs">{formatCategory(s.category ?? "other")}</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${s.is_active ? "bg-indigo-900/40 text-indigo-300" : "bg-zinc-800 text-zinc-500"}`}>
{FREQ_LABEL[s.frequency] ?? s.frequency}
</span>
</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-200">{fmtExact(s.monthly_equiv)}</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{fmtExact(s.avg_amount)}</td>
<td className="px-4 py-3 text-right text-zinc-500 text-xs whitespace-nowrap">{fmtDate(s.first_seen)}</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{fmtExact(s.total_paid)}</td>
<td className="px-4 py-3 text-right text-zinc-500">{s.occurrences}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</Section>
{/* ── 4. Fees & Interest ── */}
<Section title="Fees & Interest">
{!feesData ? (
<p className="text-zinc-500 text-sm">Loading...</p>
) : feesData.by_bank.length === 0 && feesData.transactions.length === 0 ? (
<p className="text-zinc-500 text-sm">No fees or interest recorded across your statements.</p>
) : (
<div className="space-y-4">
{feesData.by_bank.length > 0 && (
<div className="border border-zinc-700 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900">
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Bank</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Fees</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Interest</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Total</th>
</tr>
</thead>
<tbody>
{feesData.by_bank.map((r) => (
<tr key={r.bank_name} className="border-b border-zinc-800/50">
<td className="px-4 py-3 font-medium">{r.bank_name}</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{r.fees > 0 ? fmtExact(r.fees) : <span className="text-zinc-700"></span>}</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{r.interest > 0 ? fmtExact(r.interest) : <span className="text-zinc-700"></span>}</td>
<td className="px-4 py-3 text-right tabular-nums text-red-400 font-medium">{fmtExact(r.total)}</td>
</tr>
))}
<tr className="bg-zinc-900/50">
<td className="px-4 py-2.5 text-xs text-zinc-500 font-medium">Total</td>
<td className="px-4 py-2.5 text-right tabular-nums text-xs text-zinc-400">{fmtExact(feesData.total_fees)}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-xs text-zinc-400">{fmtExact(feesData.total_interest)}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-red-400 font-medium">{fmtExact(feesData.total_fees + feesData.total_interest)}</td>
</tr>
</tbody>
</table>
</div>
)}
{feesData.transactions.length > 0 && (
<div>
<p className="text-xs text-zinc-500 mb-2">Individual fee / interest transactions</p>
<div className="border border-zinc-700 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900">
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Date</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Bank</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Description</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Type</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Amount</th>
</tr>
</thead>
<tbody>
{feesData.transactions.map((t) => (
<tr key={t.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/20">
<td className="px-4 py-2.5 text-zinc-500 text-xs whitespace-nowrap">
{new Date(t.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" })}
</td>
<td className="px-4 py-2.5 text-zinc-400 text-xs">{t.bank_name}</td>
<td className="px-4 py-2.5 text-zinc-300">{t.description}</td>
<td className="px-4 py-2.5">
<span className={`text-xs px-2 py-0.5 rounded-full ${t.transaction_type === "interest" ? "bg-orange-900/40 text-orange-300" : "bg-zinc-800 text-zinc-400"}`}>
{t.transaction_type}
</span>
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-red-400">{fmtExact(t.my_amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</Section>
</div>
);
}
+448
View File
@@ -0,0 +1,448 @@
"use client";
import { useState, useMemo } from "react";
import {
ScatterChart,
Scatter,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
LineChart,
Line,
CartesianGrid,
} from "recharts";
import { useMerchants, useMerchantTransactions, MerchantRow } from "@/lib/hooks";
import { formatCategory } from "@/lib/categories";
function fmt(n: number) {
return new Intl.NumberFormat("en-AU", {
style: "currency",
currency: "AUD",
maximumFractionDigits: 0,
}).format(n);
}
function fmtExact(n: number) {
return new Intl.NumberFormat("en-AU", {
style: "currency",
currency: "AUD",
minimumFractionDigits: 2,
}).format(n);
}
function fmtDate(d: string) {
return new Date(d + "T00:00:00").toLocaleDateString("en-AU", {
day: "numeric",
month: "short",
year: "numeric",
});
}
const CATEGORY_COLORS: Record<string, string> = {
groceries: "#4ade80",
dining: "#fb923c",
transport: "#60a5fa",
fuel: "#facc15",
shopping: "#f472b6",
utilities: "#a78bfa",
entertainment: "#34d399",
travel: "#38bdf8",
health: "#f87171",
insurance: "#94a3b8",
subscriptions: "#c084fc",
government: "#6b7280",
education: "#fbbf24",
rent: "#e879f9",
home_goods: "#67e8f9",
home_maintenance: "#c084fc",
personal_care: "#fb7185",
pets: "#a3e635",
gifts: "#f9a8d4",
charity: "#6ee7b7",
other: "#71717a",
};
// Custom scatter tooltip
function ScatterTooltip({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload: MerchantRow }>;
}) {
if (!active || !payload?.length) return null;
const d = payload[0].payload;
return (
<div className="bg-zinc-800 border border-zinc-700 rounded-lg p-3 text-sm shadow-xl max-w-48">
<p className="font-semibold text-white truncate">{d.merchant}</p>
<p className="text-zinc-400 text-xs mt-0.5">{formatCategory(d.category)}</p>
<div className="mt-2 space-y-1 text-zinc-300">
<p>{fmt(d.net_spend)} net</p>
{d.refund_count > 0 && (
<p className="text-emerald-400 text-xs">{d.refund_count} refund(s) {fmt(d.total_refunds)}</p>
)}
<p>{d.debit_count}× transactions</p>
<p>{fmtExact(d.avg_debit)} avg</p>
</div>
</div>
);
}
// Quadrant labels
function QuadrantLabels({ medianX, medianY }: { medianX: number; medianY: number }) {
return (
<div className="absolute inset-0 pointer-events-none select-none">
<div className="absolute top-2 right-4 text-xs text-zinc-600 text-right">
high cost · frequent
</div>
<div className="absolute top-2 left-16 text-xs text-zinc-600">
high cost · rare
</div>
<div className="absolute bottom-8 right-4 text-xs text-zinc-600 text-right">
low cost · frequent
</div>
<div className="absolute bottom-8 left-16 text-xs text-zinc-600">
low cost · rare
</div>
</div>
);
}
// Merchant profile drawer
function MerchantProfile({
merchant,
onClose,
}: {
merchant: MerchantRow;
onClose: () => void;
}) {
const { data } = useMerchantTransactions(merchant.merchant);
const transactions = data?.transactions ?? [];
// Build trend chart data from monthly_trend
const trendData = useMemo(() => {
const months = Object.keys(merchant.monthly_trend).sort();
return months.map((m) => ({
month: m,
label: new Date(m + "-01").toLocaleDateString("en-AU", { month: "short", year: "2-digit" }),
amount: merchant.monthly_trend[m],
}));
}, [merchant.monthly_trend]);
const color = CATEGORY_COLORS[merchant.category] || "#6366f1";
return (
<div className="fixed inset-0 z-50 flex justify-end">
{/* backdrop */}
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
{/* drawer */}
<div className="relative w-full max-w-lg bg-zinc-900 border-l border-zinc-700 h-full overflow-y-auto flex flex-col shadow-2xl">
{/* Header */}
<div className="flex items-start justify-between p-5 border-b border-zinc-800">
<div className="flex-1 min-w-0 pr-4">
<h2 className="text-lg font-semibold text-white truncate">{merchant.merchant}</h2>
<span
className="inline-block mt-1 px-2 py-0.5 rounded text-xs font-medium"
style={{ background: color + "33", color }}
>
{formatCategory(merchant.category)}
</span>
</div>
<button
onClick={onClose}
className="text-zinc-400 hover:text-white text-xl leading-none mt-0.5"
>
×
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-3 p-5 border-b border-zinc-800">
<div className="bg-zinc-800 rounded-lg p-3 text-center">
<p className="text-xs text-zinc-400 mb-1">Net Spent</p>
<p className="text-white font-semibold">{fmt(merchant.net_spend)}</p>
</div>
<div className="bg-zinc-800 rounded-lg p-3 text-center">
<p className="text-xs text-zinc-400 mb-1">Avg per Visit</p>
<p className="text-white font-semibold">{fmtExact(merchant.avg_debit)}</p>
</div>
<div className="bg-zinc-800 rounded-lg p-3 text-center">
<p className="text-xs text-zinc-400 mb-1">Visits</p>
<p className="text-white font-semibold">{merchant.debit_count}</p>
</div>
</div>
{/* Refund callout — only show if there are refunds */}
{merchant.refund_count > 0 && (
<div className="mx-5 mt-3 px-3 py-2 rounded-lg bg-emerald-900/20 border border-emerald-800/40 flex items-center justify-between text-sm">
<span className="text-emerald-400">
{merchant.refund_count} refund{merchant.refund_count > 1 ? "s" : ""}
</span>
<span className="text-zinc-300">
<span className="text-zinc-500 mr-2">gross {fmt(merchant.gross_spend)} refunds</span>
<span className="text-emerald-400 font-medium">{fmt(merchant.total_refunds)}</span>
</span>
</div>
)}
{/* Trend chart */}
{trendData.length > 1 && (
<div className="p-5 border-b border-zinc-800">
<p className="text-sm font-medium text-zinc-300 mb-3">Monthly Spend</p>
<ResponsiveContainer width="100%" height={120}>
<LineChart data={trendData}>
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
<XAxis
dataKey="label"
tick={{ fill: "#71717a", fontSize: 10 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fill: "#71717a", fontSize: 10 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `$${Math.round(v)}`}
width={45}
/>
<Tooltip
contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: "8px" }}
labelStyle={{ color: "#a1a1aa" }}
/>
<Line
type="monotone"
dataKey="amount"
stroke={color}
strokeWidth={2}
dot={{ fill: color, r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
{/* Transaction history */}
<div className="p-5 flex-1">
<p className="text-sm font-medium text-zinc-300 mb-3">
Transactions{" "}
<span className="text-zinc-500 font-normal">({transactions.length})</span>
</p>
{transactions.length === 0 ? (
<p className="text-zinc-500 text-sm">Loading</p>
) : (
<div className="space-y-1">
{transactions.map((tx) => (
<div
key={tx.id}
className="flex items-center justify-between py-2 border-b border-zinc-800 text-sm"
>
<div className="flex-1 min-w-0 pr-3">
<p className="text-zinc-300 truncate">{tx.description}</p>
<p className="text-zinc-500 text-xs mt-0.5">
{fmtDate(tx.transaction_date)} · {tx.bank_name}
</p>
</div>
<p className="text-white font-medium whitespace-nowrap">
{fmtExact(tx.my_amount)}
</p>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
export default function MerchantsPage() {
const [months, setMonths] = useState(12);
const [selected, setSelected] = useState<MerchantRow | null>(null);
const [search, setSearch] = useState("");
const { data, isLoading } = useMerchants(months);
const merchants = data?.merchants ?? [];
// Stats for median lines
const { medianX, medianY } = useMemo(() => {
if (merchants.length === 0) return { medianX: 0, medianY: 0 };
const counts = [...merchants].sort((a, b) => a.debit_count - b.debit_count);
const spends = [...merchants].sort((a, b) => a.net_spend - b.net_spend);
const mid = Math.floor(merchants.length / 2);
return {
medianX: counts[mid]?.debit_count ?? 0,
medianY: spends[mid]?.net_spend ?? 0,
};
}, [merchants]);
// Filtered merchants for the table
const filtered = useMemo(() => {
if (!search.trim()) return merchants;
const q = search.toLowerCase();
return merchants.filter((m) => m.merchant.toLowerCase().includes(q));
}, [merchants, search]);
// Top merchants for the scatter (max 150 for performance)
const scatterData = useMemo(() => merchants.slice(0, 150), [merchants]);
if (isLoading) {
return (
<div className="p-8 text-zinc-400">Loading merchant data</div>
);
}
return (
<div className="p-6 max-w-6xl space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-white">Merchants</h1>
<p className="text-zinc-400 text-sm mt-0.5">{merchants.length} merchants · last {months} months</p>
</div>
<select
value={months}
onChange={(e) => setMonths(Number(e.target.value))}
className="bg-zinc-800 border border-zinc-700 rounded-md text-sm text-zinc-200 px-3 py-1.5"
>
<option value={3}>3 months</option>
<option value={6}>6 months</option>
<option value={12}>12 months</option>
<option value={24}>24 months</option>
</select>
</div>
{/* Scatter plot */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<h2 className="text-sm font-medium text-zinc-300 mb-1">Spend vs Frequency</h2>
<p className="text-xs text-zinc-500 mb-4">
Each dot = one merchant. Click to open profile.
</p>
<div className="relative">
<QuadrantLabels medianX={medianX} medianY={medianY} />
<ResponsiveContainer width="100%" height={360}>
<ScatterChart margin={{ top: 10, right: 20, bottom: 20, left: 10 }}>
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
<XAxis
dataKey="debit_count"
name="Transactions"
type="number"
tick={{ fill: "#71717a", fontSize: 11 }}
axisLine={false}
tickLine={false}
label={{ value: "Transaction Count", position: "insideBottom", offset: -10, fill: "#52525b", fontSize: 11 }}
/>
<YAxis
dataKey="net_spend"
name="Net Spend"
type="number"
tick={{ fill: "#71717a", fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `$${Math.round(v / 1000)}k`}
width={48}
label={{ value: "Total Spend", angle: -90, position: "insideLeft", offset: 10, fill: "#52525b", fontSize: 11 }}
/>
<Tooltip content={<ScatterTooltip />} cursor={{ strokeDasharray: "3 3", stroke: "#52525b" }} />
<Scatter
data={scatterData}
onClick={(d) => setSelected(d as unknown as MerchantRow)}
style={{ cursor: "pointer" }}
>
{scatterData.map((entry, idx) => (
<Cell
key={idx}
fill={CATEGORY_COLORS[entry.category] || "#6366f1"}
fillOpacity={0.75}
stroke={selected?.merchant === entry.merchant ? "#fff" : "transparent"}
strokeWidth={2}
/>
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</div>
{/* Category legend */}
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1">
{Object.entries(CATEGORY_COLORS)
.filter(([cat]) => merchants.some((m) => m.category === cat))
.slice(0, 12)
.map(([cat, color]) => (
<span key={cat} className="flex items-center gap-1.5 text-xs text-zinc-400">
<span className="w-2 h-2 rounded-full inline-block" style={{ background: color }} />
{formatCategory(cat)}
</span>
))}
</div>
</div>
{/* Merchant table */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
<div className="flex items-center justify-between p-4 border-b border-zinc-800">
<h2 className="text-sm font-medium text-zinc-300">All Merchants</h2>
<input
type="text"
placeholder="Search…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="bg-zinc-800 border border-zinc-700 rounded-md text-sm text-zinc-200 placeholder-zinc-500 px-3 py-1.5 w-48"
/>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-zinc-500 text-xs border-b border-zinc-800">
<th className="text-left px-4 py-2 font-medium">Merchant</th>
<th className="text-left px-4 py-2 font-medium">Category</th>
<th className="text-right px-4 py-2 font-medium">Total</th>
<th className="text-right px-4 py-2 font-medium">Count</th>
<th className="text-right px-4 py-2 font-medium">Avg</th>
<th className="text-right px-4 py-2 font-medium hidden sm:table-cell">Last Seen</th>
</tr>
</thead>
<tbody>
{filtered.map((m) => {
const color = CATEGORY_COLORS[m.category] || "#6366f1";
return (
<tr
key={m.merchant}
onClick={() => setSelected(m)}
className="border-b border-zinc-800 hover:bg-zinc-800 cursor-pointer transition-colors"
>
<td className="px-4 py-2.5 text-zinc-200 max-w-[200px]">
<span className="truncate block">{m.merchant}</span>
</td>
<td className="px-4 py-2.5">
<span
className="px-1.5 py-0.5 rounded text-xs"
style={{ background: color + "22", color }}
>
{formatCategory(m.category)}
</span>
</td>
<td className="px-4 py-2.5 text-right text-white font-medium">
{fmt(m.net_spend)}
{m.refund_count > 0 && (
<span className="ml-1.5 text-emerald-400 text-xs">{m.refund_count}</span>
)}
</td>
<td className="px-4 py-2.5 text-right text-zinc-400">{m.debit_count}</td>
<td className="px-4 py-2.5 text-right text-zinc-400">
{fmtExact(m.avg_debit)}
</td>
<td className="px-4 py-2.5 text-right text-zinc-500 text-xs hidden sm:table-cell">
{fmtDate(m.last_seen)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Merchant profile drawer */}
{selected && (
<MerchantProfile merchant={selected} onClose={() => setSelected(null)} />
)}
</div>
);
}
+205 -31
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useTags } from "@/lib/hooks";
import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useRuleRuns, useRevertRuleRun, useTags, useParticipants } from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories";
const FIELDS = [
@@ -10,6 +10,7 @@ const FIELDS = [
{ value: "category", label: "Category" },
{ value: "bank_name", label: "Bank" },
{ value: "amount", label: "Amount" },
{ value: "transaction_type", label: "Transaction Type" },
] as const;
const TEXT_OPS = [
@@ -24,18 +25,24 @@ const AMOUNT_OPS = [
{ value: "gt", label: ">" },
{ value: "lt", label: "<" },
];
const ENUM_OPS = [
{ value: "equals", label: "equals" },
{ value: "not_equals", label: "not equals" },
];
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
type Condition = { field: string; operator: string; value: string };
type Actions = { set_category?: string; add_tag_ids?: number[]; set_merchant?: string };
type SplitEntry = { participant_id: number; share_percent: number };
type Actions = { set_category?: string; add_tag_ids?: number[]; set_merchant?: string; apply_split?: SplitEntry[] };
function humanCondition(c: Condition): string {
const fieldLabel = FIELDS.find((f) => f.value === c.field)?.label || c.field;
const ops = [...TEXT_OPS, ...AMOUNT_OPS];
const ops = [...TEXT_OPS, ...AMOUNT_OPS, ...ENUM_OPS];
const opText = ops.find((o) => o.value === c.operator)?.label || c.operator;
return `${fieldLabel} ${opText} "${c.value}"`;
}
function humanAction(a: Actions, tagNames: Map<number, string>): string {
function humanAction(a: Actions, tagNames: Map<number, string>, participantNames: Map<number, string>): string {
const parts: string[] = [];
if (a.set_category) parts.push(`set category: ${formatCategory(a.set_category)}`);
if (a.set_merchant) parts.push(`set merchant: ${a.set_merchant}`);
@@ -43,26 +50,62 @@ function humanAction(a: Actions, tagNames: Map<number, string>): string {
const names = a.add_tag_ids.map((id) => tagNames.get(id) || `tag#${id}`).join(", ");
parts.push(`add tags: ${names}`);
}
if (a.apply_split?.length) {
const splits = a.apply_split.map((s) => `${participantNames.get(s.participant_id) || `#${s.participant_id}`} ${s.share_percent}%`).join(", ");
parts.push(`split: ${splits}`);
}
return parts.length ? "→ " + parts.join(", ") : "(no actions)";
}
const EMPTY_ACTIONS: Actions = {};
export default function RulesPage() {
const { data: rules = [], isLoading } = useRules();
const { data: tags = [] } = useTags();
const { data: participants = [] } = useParticipants();
const createRule = useCreateRule();
const updateRule = useUpdateRule();
const deleteRule = useDeleteRule();
const applyRules = useApplyRules();
const { data: runs = [] } = useRuleRuns();
const revertRun = useRevertRuleRun();
const tagNames = new Map(tags.map((t) => [t.id, t.name]));
const participantNames = new Map(participants.map((p) => [p.id, p.name]));
const [applyFrom, setApplyFrom] = useState("2026-01-08");
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null);
const [name, setName] = useState("");
const [conditions, setConditions] = useState<Condition[]>([]);
const [actions, setActions] = useState<Actions>({});
const [actions, setActions] = useState<Actions>(EMPTY_ACTIONS);
const [priority, setPriority] = useState(0);
function openNewForm() {
setEditingId(null);
setName("");
setConditions([]);
setActions(EMPTY_ACTIONS);
setPriority(0);
setShowForm(true);
}
function openEditForm(rule: { id: number; name: string; conditions: Condition[]; actions: Actions; priority: number }) {
setEditingId(rule.id);
setName(rule.name);
setConditions(Array.isArray(rule.conditions) ? rule.conditions : []);
setActions(rule.actions && typeof rule.actions === "object" ? rule.actions : EMPTY_ACTIONS);
setPriority(rule.priority);
setShowForm(true);
window.scrollTo({ top: 0, behavior: "smooth" });
}
function closeForm() {
setShowForm(false);
setEditingId(null);
}
function addCondition() {
setConditions([...conditions, { field: "merchant_normalized", operator: "contains", value: "" }]);
}
@@ -75,26 +118,54 @@ export default function RulesPage() {
setConditions(conditions.filter((_, idx) => idx !== i));
}
function addSplitEntry() {
if (!participants.length) return;
const existing = actions.apply_split || [];
setActions({ ...actions, apply_split: [...existing, { participant_id: participants[0].id, share_percent: 0 }] });
}
function updateSplitEntry(i: number, patch: Partial<SplitEntry>) {
const entries = (actions.apply_split || []).map((s, idx) => (idx === i ? { ...s, ...patch } : s));
setActions({ ...actions, apply_split: entries });
}
function removeSplitEntry(i: number) {
const entries = (actions.apply_split || []).filter((_, idx) => idx !== i);
setActions({ ...actions, apply_split: entries.length ? entries : undefined });
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
await createRule.mutateAsync({ name, conditions, actions, enabled: true, priority });
setName("");
setConditions([]);
setActions({});
setPriority(0);
setShowForm(false);
const payload = { name, conditions, actions, enabled: true, priority };
if (editingId !== null) {
await updateRule.mutateAsync({ id: editingId, ...payload });
} else {
await createRule.mutateAsync(payload);
}
closeForm();
}
async function handleApply() {
const result = await applyRules.mutateAsync();
const result = await applyRules.mutateAsync(applyFrom || undefined);
setApplyResult(result);
}
const splitTotal = (actions.apply_split || []).reduce((sum, s) => sum + (s.share_percent || 0), 0);
const isPending = editingId !== null ? updateRule.isPending : createRule.isPending;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Rules</h2>
<div className="flex gap-2">
<div className="flex gap-2 items-center">
<label className="text-xs text-zinc-500 whitespace-nowrap">Splits from</label>
<input
type="date"
value={applyFrom}
onChange={(e) => setApplyFrom(e.target.value)}
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
title="Split rules only apply to transactions on or after this date. Category/merchant/tag rules apply to all transactions."
/>
<button
onClick={handleApply}
disabled={applyRules.isPending}
@@ -103,7 +174,7 @@ export default function RulesPage() {
{applyRules.isPending ? "Applying..." : "Apply All Rules"}
</button>
<button
onClick={() => setShowForm(!showForm)}
onClick={showForm ? closeForm : openNewForm}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium"
>
{showForm ? "Cancel" : "New Rule"}
@@ -123,7 +194,7 @@ export default function RulesPage() {
{showForm && (
<form onSubmit={handleSubmit} className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 space-y-4">
<h3 className="font-semibold text-sm text-zinc-300">New Rule</h3>
<h3 className="font-semibold text-sm text-zinc-300">{editingId !== null ? "Edit Rule" : "New Rule"}</h3>
<div>
<label className="block text-xs text-zinc-500 mb-1">Rule Name</label>
@@ -145,17 +216,20 @@ export default function RulesPage() {
</div>
{conditions.map((cond, i) => {
const isAmount = cond.field === "amount";
const ops = isAmount ? AMOUNT_OPS : TEXT_OPS;
const isEnum = cond.field === "transaction_type";
const ops = isAmount ? AMOUNT_OPS : isEnum ? ENUM_OPS : TEXT_OPS;
return (
<div key={i} className="flex gap-2 mb-2 items-center">
<select
value={cond.field}
onChange={(e) =>
updateCondition(i, {
field: e.target.value,
operator: e.target.value === "amount" ? "equals" : "contains",
})
}
onChange={(e) => {
const newField = e.target.value;
const patch: Partial<Condition> = { field: newField };
if (newField === "amount") { patch.operator = "equals"; patch.value = ""; }
else if (newField === "transaction_type") { patch.operator = "equals"; patch.value = "debit"; }
else { patch.operator = "contains"; patch.value = ""; }
updateCondition(i, patch);
}}
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{FIELDS.map((f) => (
@@ -175,12 +249,24 @@ export default function RulesPage() {
</option>
))}
</select>
<input
value={cond.value}
onChange={(e) => updateCondition(i, { value: e.target.value })}
placeholder="value"
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
{isEnum ? (
<select
value={cond.value}
onChange={(e) => updateCondition(i, { value: e.target.value })}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{TRANSACTION_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
) : (
<input
value={cond.value}
onChange={(e) => updateCondition(i, { value: e.target.value })}
placeholder="value"
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
)}
<button
type="button"
onClick={() => removeCondition(i)}
@@ -252,6 +338,56 @@ export default function RulesPage() {
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs text-zinc-500">
Apply Split (optional)
{(actions.apply_split?.length ?? 0) > 0 && (
<span className={`ml-2 ${splitTotal === 100 ? "text-emerald-400" : "text-amber-400"}`}>
{splitTotal}% total
</span>
)}
</label>
{participants.length > 0 && (
<button type="button" onClick={addSplitEntry} className="text-xs text-indigo-400 hover:text-indigo-300">
+ Add participant
</button>
)}
</div>
{(actions.apply_split || []).map((entry, i) => (
<div key={i} className="flex gap-2 mb-2 items-center">
<select
value={entry.participant_id}
onChange={(e) => updateSplitEntry(i, { participant_id: Number(e.target.value) })}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{participants.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<input
type="number"
min={0}
max={100}
value={entry.share_percent}
onChange={(e) => updateSplitEntry(i, { share_percent: Number(e.target.value) })}
className="w-20 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
<span className="text-xs text-zinc-500">%</span>
<button
type="button"
onClick={() => removeSplitEntry(i)}
className="text-zinc-500 hover:text-red-400 text-lg leading-none px-1"
>
×
</button>
</div>
))}
{participants.length === 0 && (
<p className="text-xs text-zinc-600">No participants created yet.</p>
)}
</div>
<div className="flex items-end gap-4">
<div>
<label className="block text-xs text-zinc-500 mb-1">Priority</label>
@@ -264,15 +400,47 @@ export default function RulesPage() {
</div>
<button
type="submit"
disabled={createRule.isPending}
disabled={isPending}
className="px-6 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
{createRule.isPending ? "Creating..." : "Create Rule"}
{isPending ? "Saving..." : editingId !== null ? "Save Changes" : "Create Rule"}
</button>
</div>
</form>
)}
{runs.length > 0 && (
<div>
<h3 className="text-sm font-medium text-zinc-400 mb-2">Apply History</h3>
<div className="space-y-2">
{runs.map((run) => (
<div key={run.id} className={`flex items-center justify-between px-4 py-2.5 rounded-lg border text-sm ${run.reverted_at ? "bg-zinc-900/40 border-zinc-800 opacity-60" : "bg-zinc-900 border-zinc-700"}`}>
<div className="flex items-center gap-4">
<span className="text-zinc-300">{new Date(run.applied_at).toLocaleString()}</span>
<span className="text-zinc-500">{run.matched} matches · {run.transactions_affected} transactions</span>
{run.split_from && <span className="text-zinc-600 text-xs">splits from {run.split_from}</span>}
</div>
{run.reverted_at ? (
<span className="text-xs text-zinc-500">reverted {new Date(run.reverted_at).toLocaleString()}</span>
) : (
<button
onClick={() => {
if (confirm("Revert this run? This will restore all affected transactions to their state before the rules were applied.")) {
revertRun.mutate(run.id);
}
}}
disabled={revertRun.isPending}
className="text-xs text-amber-400 hover:text-amber-300 disabled:opacity-50"
>
Revert
</button>
)}
</div>
))}
</div>
</div>
)}
{isLoading ? (
<p className="text-zinc-500 text-sm">Loading rules...</p>
) : rules.length === 0 ? (
@@ -294,7 +462,7 @@ export default function RulesPage() {
<p className="text-xs text-zinc-400">
{conds.length > 0 ? conds.map(humanCondition).join(" AND ") : "(matches all)"}
</p>
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames)}</p>
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames, participantNames)}</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<button
@@ -309,6 +477,12 @@ export default function RulesPage() {
}`}
/>
</button>
<button
onClick={() => openEditForm({ id: rule.id, name: rule.name, conditions: conds as Condition[], actions: acts, priority: rule.priority })}
className="text-zinc-400 hover:text-white text-sm"
>
Edit
</button>
<button
onClick={() => {
if (confirm("Delete this rule?")) deleteRule.mutate(rule.id);
+400 -4
View File
@@ -1,8 +1,404 @@
export default function SharedPage() {
"use client";
import { useState, useRef, useEffect } from "react";
import {
useSharedTransactions,
useParticipantBalances,
useCreateParticipant,
useRecordPayment,
usePaymentHistory,
useDeletePayment,
useCurrentUser,
useTags,
type SplitPayment,
} from "@/lib/hooks";
import type { SharedTransactionRow } from "@/lib/queries";
function formatDate(d: string) {
return new Date(d).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" });
}
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
function formatAmount(n: number, type?: string) {
const formatted = `$${Number(n).toFixed(2)}`;
return type && !SPEND_TYPES.has(type) ? `+${formatted}` : formatted;
}
// ── Tag multi-select ──────────────────────────────────────────────────────────
function TagFilter({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) {
const { data: tags = [] } = useTags();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handler(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
if (!tags.length) return null;
const toggle = (id: string) =>
onChange(value.includes(id) ? value.filter((x) => x !== id) : [...value, id]);
const label = value.length === 0 ? "All Tags"
: value.length === 1 ? (tags.find((t) => String(t.id) === value[0])?.name ?? "1 tag")
: `${value.length} tags`;
return (
<div>
<h2 className="text-xl font-semibold mb-4">Shared Expenses</h2>
<p className="text-zinc-500">Coming soon - track shared expenses and splits.</p>
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`border rounded px-3 py-1.5 text-sm flex items-center gap-2 min-w-[120px] bg-zinc-900 ${value.length > 0 ? "border-indigo-500 text-white" : "border-zinc-700 text-zinc-400"}`}
>
<span className="flex-1 text-left">{label}</span>
<span className="text-zinc-500 text-xs"></span>
</button>
{open && (
<div className="absolute top-full mt-1 z-20 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl min-w-[160px] max-h-56 overflow-y-auto">
{tags.map((t) => (
<label key={t.id} className="flex items-center gap-2 px-3 py-1.5 hover:bg-zinc-800 cursor-pointer text-sm">
<input type="checkbox" checked={value.includes(String(t.id))} onChange={() => toggle(String(t.id))}
className="accent-indigo-500 flex-shrink-0" />
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: t.color }} />
{t.name}
</label>
))}
</div>
)}
</div>
);
}
// ── Add Participant ───────────────────────────────────────────────────────────
function AddParticipantForm({ onDone }: { onDone: () => void }) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [error, setError] = useState("");
const create = useCreateParticipant();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (!name.trim()) { setError("Name is required"); return; }
try {
await create.mutateAsync({ name: name.trim(), email: email.trim() || undefined });
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create");
}
}
return (
<form onSubmit={handleSubmit} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4 space-y-3">
<p className="text-sm font-medium">Add Participant</p>
<div className="flex gap-2">
<input type="text" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-zinc-500" />
<input type="email" placeholder="Email (optional)" value={email} onChange={(e) => setEmail(e.target.value)}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-zinc-500" />
<button type="submit" disabled={create.isPending}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium">
{create.isPending ? "Adding..." : "Add"}
</button>
<button type="button" onClick={onDone}
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded-lg text-sm">
Cancel
</button>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
</form>
);
}
// ── Record Payment modal ──────────────────────────────────────────────────────
function RecordPaymentModal({
participant,
currentUserId,
currentBalance,
onClose,
}: {
participant: { id: number; name: string };
currentUserId: number;
currentBalance: number; // positive = they owe me, negative = I owe them
onClose: () => void;
}) {
const record = useRecordPayment();
const theyOweMe = currentBalance > 0;
// Default direction matches the debt direction
const [amount, setAmount] = useState(Math.abs(currentBalance).toFixed(2));
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
const [notes, setNotes] = useState("");
// direction: "received" = they paid me, "sent" = I paid them
const [direction, setDirection] = useState<"received" | "sent">(theyOweMe ? "received" : "sent");
const [error, setError] = useState("");
async function handleSave() {
setError("");
const amt = parseFloat(amount);
if (!amt || amt <= 0) { setError("Enter a valid amount"); return; }
try {
await record.mutateAsync({
from_participant_id: direction === "received" ? participant.id : currentUserId,
to_participant_id: direction === "received" ? currentUserId : participant.id,
amount: amt,
payment_date: date,
notes: notes || undefined,
});
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to record payment");
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div className="bg-zinc-900 border border-zinc-700 rounded-xl w-full max-w-sm mx-4 shadow-2xl p-6 space-y-4"
onClick={(e) => e.stopPropagation()}>
<h3 className="font-semibold text-sm text-zinc-300">Record Payment</h3>
{/* Direction toggle */}
<div className="flex rounded-lg overflow-hidden border border-zinc-700 text-sm">
<button
type="button"
onClick={() => setDirection("received")}
className={`flex-1 py-1.5 transition-colors ${direction === "received" ? "bg-emerald-700 text-white" : "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"}`}
>
{participant.name} paid me
</button>
<button
type="button"
onClick={() => setDirection("sent")}
className={`flex-1 py-1.5 transition-colors ${direction === "sent" ? "bg-blue-700 text-white" : "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"}`}
>
I paid {participant.name}
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Amount</label>
<div className="relative">
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500 text-sm">$</span>
<input type="number" step="0.01" min="0.01" value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm pl-6" />
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Date</label>
<input type="date" value={date} onChange={(e) => setDate(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" />
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Notes (optional)</label>
<input value={notes} onChange={(e) => setNotes(e.target.value)}
placeholder="e.g. Bank transfer, cash"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" />
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-2">
<button type="button" onClick={onClose}
className="flex-1 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
Cancel
</button>
<button type="button" onClick={handleSave} disabled={record.isPending}
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium">
{record.isPending ? "Saving…" : "Record"}
</button>
</div>
</div>
</div>
);
}
// ── Payment history inline ────────────────────────────────────────────────────
function PaymentHistory({ participantId, currentUserId }: { participantId: number; currentUserId: number }) {
const { data: payments = [], isLoading } = usePaymentHistory(participantId);
const deletePayment = useDeletePayment();
if (isLoading) return <p className="text-xs text-zinc-600 mt-2">Loading payments</p>;
if (payments.length === 0) return <p className="text-xs text-zinc-600 italic mt-2">No payments recorded</p>;
return (
<div className="mt-3 space-y-1.5">
<p className="text-xs text-zinc-500 font-medium">Payment history</p>
{payments.map((p: SplitPayment) => {
const theyPaidMe = p.to_participant_id === currentUserId;
return (
<div key={p.id} className="flex items-center gap-2 text-xs">
<span className={`font-mono font-medium ${theyPaidMe ? "text-emerald-400" : "text-blue-400"}`}>
{theyPaidMe ? "+" : "-"}${Number(p.amount).toFixed(2)}
</span>
<span className="text-zinc-500">{formatDate(p.payment_date)}</span>
{p.notes && <span className="text-zinc-600 truncate flex-1">{p.notes}</span>}
<button
onClick={() => deletePayment.mutate(p.id)}
className="text-zinc-600 hover:text-red-400 leading-none ml-auto flex-shrink-0"
title="Delete payment"
>
×
</button>
</div>
);
})}
</div>
);
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function SharedPage() {
const [tagIds, setTagIds] = useState<string[]>([]);
const { data: transactions = [], isLoading: txLoading } = useSharedTransactions(tagIds);
const { data: balances = [], isLoading: balLoading } = useParticipantBalances(tagIds);
const { data: me } = useCurrentUser();
const [addingParticipant, setAddingParticipant] = useState(false);
const [paymentModal, setPaymentModal] = useState<{ id: number; name: string; balance: number } | null>(null);
const [showHistory, setShowHistory] = useState<number | null>(null);
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold">Shared Expenses</h2>
<div className="flex items-center gap-2 ml-auto">
<TagFilter value={tagIds} onChange={setTagIds} />
{!addingParticipant && (
<button onClick={() => setAddingParticipant(true)}
className="text-sm px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg whitespace-nowrap">
+ Add Participant
</button>
)}
</div>
</div>
{addingParticipant && <AddParticipantForm onDone={() => setAddingParticipant(false)} />}
{/* Balance cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{balLoading ? (
<p className="text-zinc-500 text-sm col-span-3">Loading balances...</p>
) : balances.length === 0 ? (
<p className="text-zinc-500 text-sm col-span-3">No participants yet.</p>
) : (
balances.map((b) => {
const theyOweMe = b.total_owed > 0;
const net = Math.abs(b.total_owed);
const settled = net < 0.005;
return (
<div key={b.id} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<div className="flex items-start justify-between mb-3">
<div>
<p className="font-medium">{b.name}</p>
<p className="text-xs text-zinc-500">
{settled ? "all square" : theyOweMe ? `owes you` : "you owe"}
</p>
</div>
<div className="text-right">
<p className={`text-lg font-semibold ${settled ? "text-zinc-500" : theyOweMe ? "text-amber-400" : "text-blue-400"}`}>
${net.toFixed(2)}
</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setPaymentModal({ id: b.id, name: b.name, balance: b.total_owed })}
className="flex-1 py-1.5 text-xs font-medium bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg"
>
Record Payment
</button>
<button
onClick={() => setShowHistory(showHistory === b.id ? null : b.id)}
className={`px-3 py-1.5 text-xs rounded-lg ${showHistory === b.id ? "bg-zinc-700 text-white" : "bg-zinc-800 text-zinc-500 hover:text-zinc-300"}`}
>
History
</button>
</div>
{showHistory === b.id && me && (
<PaymentHistory participantId={b.id} currentUserId={me.id} />
)}
</div>
);
})
)}
</div>
{/* Transaction list */}
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-zinc-800">
<h3 className="text-sm font-medium">Split Transactions</h3>
</div>
{txLoading ? (
<p className="text-zinc-500 text-sm px-4 py-6">Loading...</p>
) : transactions.length === 0 ? (
<p className="text-zinc-500 text-sm px-4 py-6">
No split transactions yet. Use the Split button on any transaction.
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Date</th>
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Description</th>
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">Amount</th>
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Splits</th>
</tr>
</thead>
<tbody>
{(transactions as SharedTransactionRow[]).map((tx) => {
const splits = Array.isArray(tx.splits) ? tx.splits : [];
return (
<tr key={tx.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">{formatDate(tx.transaction_date)}</td>
<td className="px-4 py-3 max-w-xs">
<p className="font-medium break-words">{tx.effective_merchant || tx.description}</p>
{tx.effective_merchant && (
<p className="text-xs text-zinc-500 break-words">{tx.description}</p>
)}
{tx.notes && (
<p className="text-xs text-zinc-500 italic mt-0.5 break-words">{tx.notes}</p>
)}
</td>
<td className={`px-4 py-3 text-right font-medium tabular-nums ${SPEND_TYPES.has(tx.transaction_type) ? "" : "text-green-400"}`}>
{formatAmount(tx.amount, tx.transaction_type)}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{splits.map((s) => (
<span key={s.participant_id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-300">
{s.name} {s.share_percent}%
</span>
))}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* Payment modal */}
{paymentModal && me && (
<RecordPaymentModal
participant={{ id: paymentModal.id, name: paymentModal.name }}
currentUserId={me.id}
currentBalance={paymentModal.balance}
onClose={() => setPaymentModal(null)}
/>
)}
</div>
);
}
+202 -48
View File
@@ -1,10 +1,11 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import { useStatements } from "@/lib/hooks";
import { useStatements, useParticipants, useUpdateStatement } from "@/lib/hooks";
function formatDate(d: string | null) {
if (!d) return "-";
if (!d) return "";
return new Date(d).toLocaleDateString("en-AU", {
day: "2-digit",
month: "short",
@@ -12,64 +13,217 @@ function formatDate(d: string | null) {
});
}
function formatCurrency(amount: number | null, currency = "AUD") {
if (amount === null || amount === undefined) return "-";
function formatPeriod(start: string | null, end: string | null) {
if (!start && !end) return "";
const fmt = (d: string) =>
new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "2-digit" });
if (!start) return `until ${fmt(end!)}`;
if (!end) return `from ${fmt(start)}`;
return `${fmt(start)} ${fmt(end)}`;
}
function formatAmount(n: number | null): string {
if (n === null || n === undefined) return "—";
return new Intl.NumberFormat("en-AU", {
style: "currency",
currency,
}).format(amount);
currency: "AUD",
minimumFractionDigits: 2,
}).format(Number(n));
}
const selectCls =
"bg-zinc-900 border border-zinc-700 rounded text-xs px-2 py-1.5 text-zinc-300 cursor-pointer hover:border-zinc-600 focus:outline-none focus:border-indigo-500";
export default function StatementsPage() {
const { data: statements, isLoading } = useStatements();
const { data: participants } = useParticipants();
const updateStatement = useUpdateStatement();
const [bankFilter, setBankFilter] = useState("");
const [typeFilter, setTypeFilter] = useState<"all" | "card" | "bank">("all");
const [ownerFilter, setOwnerFilter] = useState("");
const [yearFilter, setYearFilter] = useState("");
const banks = useMemo(
() => [...new Set((statements ?? []).map((s) => s.bank_name))].sort(),
[statements]
);
const years = useMemo(
() =>
[
...new Set(
(statements ?? [])
.map((s) => s.billing_end_date?.slice(0, 4))
.filter(Boolean) as string[]
),
].sort((a, b) => b.localeCompare(a)),
[statements]
);
const filtered = useMemo(() => {
if (!statements) return [];
return statements.filter((s) => {
const isCard = s.statement_type?.toLowerCase().includes("card") ?? false;
if (bankFilter && s.bank_name !== bankFilter) return false;
if (typeFilter === "card" && !isCard) return false;
if (typeFilter === "bank" && isCard) return false;
if (ownerFilter && String(s.owner_id) !== ownerFilter) return false;
if (yearFilter && s.billing_end_date?.slice(0, 4) !== yearFilter) return false;
return true;
});
}, [statements, bankFilter, typeFilter, ownerFilter, yearFilter]);
const hasFilters = bankFilter || typeFilter !== "all" || ownerFilter || yearFilter;
return (
<div>
<h2 className="text-xl font-semibold mb-4">Statements</h2>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Statements</h2>
{!isLoading && statements && (
<span className="text-xs text-zinc-500">
{hasFilters ? `${filtered.length} of ${statements.length}` : statements.length} statements
</span>
)}
</div>
{/* Filters */}
{!isLoading && statements && (
<div className="flex flex-wrap gap-2 mb-4">
<select value={bankFilter} onChange={(e) => setBankFilter(e.target.value)} className={selectCls}>
<option value="">All banks</option>
{banks.map((b) => (
<option key={b} value={b}>{b}</option>
))}
</select>
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value as typeof typeFilter)} className={selectCls}>
<option value="all">All types</option>
<option value="card">Credit card</option>
<option value="bank">Bank account</option>
</select>
{participants && participants.length > 1 && (
<select value={ownerFilter} onChange={(e) => setOwnerFilter(e.target.value)} className={selectCls}>
<option value="">All owners</option>
{participants.map((p) => (
<option key={p.id} value={String(p.id)}>{p.name}</option>
))}
</select>
)}
<select value={yearFilter} onChange={(e) => setYearFilter(e.target.value)} className={selectCls}>
<option value="">All years</option>
{years.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
{hasFilters && (
<button
onClick={() => { setBankFilter(""); setTypeFilter("all"); setOwnerFilter(""); setYearFilter(""); }}
className="text-xs text-zinc-500 hover:text-zinc-300 px-2 py-1.5 transition-colors"
>
× Clear
</button>
)}
</div>
)}
{isLoading ? (
<p className="text-zinc-500">Loading...</p>
) : !statements?.length ? (
<p className="text-zinc-500">No statements found</p>
<p className="text-zinc-500 text-sm">Loading...</p>
) : !filtered.length ? (
<p className="text-zinc-500 text-sm">{hasFilters ? "No statements match filters" : "No statements found"}</p>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{statements.map((s) => (
<div
key={s.id}
className="border border-zinc-800 rounded-lg p-4 bg-zinc-900/50 hover:border-zinc-700 transition-colors"
>
<div className="flex items-center justify-between mb-2">
<h3 className="font-medium">{s.bank_name}</h3>
<span className="text-xs text-zinc-500">{s.currency}</span>
</div>
{s.card_name && (
<p className="text-sm text-zinc-400 mb-2">{s.card_name}</p>
)}
<div className="text-sm text-zinc-400 space-y-1">
<p>Account: {s.account_number}</p>
<p>
Period: {formatDate(s.billing_start_date)} - {formatDate(s.billing_end_date)}
</p>
<p>Due: {formatDate(s.payment_due_date)}</p>
</div>
<div className="mt-3 pt-3 border-t border-zinc-800 flex items-center justify-between">
<div>
<p className="text-lg font-semibold text-red-400">
{formatCurrency(s.total_amount_due, s.currency)}
</p>
<p className="text-xs text-zinc-500">
{s.transaction_count} transactions
</p>
</div>
<Link
href={`/transactions?statement_id=${s.id}`}
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded text-sm transition-colors"
>
View
</Link>
</div>
</div>
))}
<div className="border border-zinc-700 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900">
<th className="text-left px-3 py-2.5 text-xs text-zinc-600 font-medium w-8">#</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Bank</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Account</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Period</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Due / End</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Ccy</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Amount</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Txns</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Owner</th>
<th className="px-4 py-2.5"></th>
</tr>
</thead>
<tbody>
{filtered.map((s, idx) => {
const isCreditCard = s.statement_type?.toLowerCase().includes("card") ?? false;
const displayAmount = isCreditCard ? s.total_amount_due : s.closing_balance;
const amount = Number(displayAmount);
const amountColor = isCreditCard
? "text-red-400"
: amount >= 0
? "text-green-400"
: "text-red-400";
return (
<tr key={s.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/20 transition-colors">
<td className="px-3 py-3 text-xs text-zinc-600 tabular-nums">{idx + 1}</td>
<td className="px-4 py-3">
<div className="font-medium truncate max-w-[180px]" title={s.bank_name}>
{s.bank_name}
</div>
{s.card_name && (
<div className="text-xs text-zinc-500 truncate max-w-[180px]">{s.card_name}</div>
)}
</td>
<td className="px-4 py-3 text-zinc-400 font-mono text-xs">
{s.account_number}
</td>
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
{formatPeriod(s.billing_start_date, s.billing_end_date)}
</td>
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
{isCreditCard ? formatDate(s.payment_due_date) : formatDate(s.billing_end_date)}
</td>
<td className="px-4 py-3 text-zinc-500 text-xs">
{s.currency}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{displayAmount !== null && displayAmount !== undefined ? (
<span className={amountColor}>{formatAmount(displayAmount)}</span>
) : (
<span className="text-zinc-600"></span>
)}
</td>
<td className="px-4 py-3 text-right text-zinc-500">
{s.transaction_count}
</td>
<td className="px-4 py-3">
{participants?.length ? (
<select
value={s.owner_id ?? ""}
onChange={(e) =>
updateStatement.mutate({ id: s.id, owner_id: Number(e.target.value) })
}
className="bg-zinc-800 border border-zinc-700 rounded text-xs px-2 py-1 text-zinc-300 cursor-pointer hover:border-zinc-600 focus:outline-none focus:border-indigo-500"
>
{participants.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
) : (
<span className="text-zinc-600 text-xs">{s.owner_name}</span>
)}
</td>
<td className="px-4 py-3">
<Link
href={`/transactions?statement_id=${s.id}`}
className="px-3 py-1 bg-zinc-800 hover:bg-zinc-700 rounded text-xs transition-colors whitespace-nowrap"
>
View
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
+611 -64
View File
@@ -1,10 +1,14 @@
"use client";
import { useState, useCallback } from "react";
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags } from "@/lib/hooks";
import { useState, useCallback, useRef, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule, useParticipants, useRecordPayment, useCurrentUser } from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories";
import { SplitModal } from "@/components/split-modal";
import { TagPicker } from "@/components/tag-picker";
import { AddTransactionModal } from "@/components/add-transaction-modal";
import { EditTransactionModal } from "@/components/edit-transaction-modal";
import type { TransactionRow } from "@/lib/queries";
function formatDate(d: string) {
return new Date(d).toLocaleDateString("en-AU", {
@@ -14,30 +18,135 @@ function formatDate(d: string) {
});
}
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
function formatAmount(amount: number, type: string) {
const formatted = new Intl.NumberFormat("en-AU", {
style: "currency",
currency: "AUD",
}).format(amount);
return type === "debit" ? formatted : `+${formatted}`;
return SPEND_TYPES.has(type) ? formatted : `+${formatted}`;
}
const TYPE_COLORS: Record<string, string> = {
debit: "bg-red-900/30 text-red-400",
credit: "bg-green-900/30 text-green-400",
payment: "bg-blue-900/30 text-blue-400",
refund: "bg-emerald-900/30 text-emerald-400",
fee: "bg-yellow-900/30 text-yellow-400",
interest: "bg-orange-900/30 text-orange-400",
transfer: "bg-zinc-800 text-zinc-400",
};
const TYPE_OPTIONS = [
"debit", "credit", "payment", "refund", "fee", "interest", "transfer",
].map((t) => ({ value: t, label: t }));
function TypeBadge({ type }: { type: string }) {
const colors: Record<string, string> = {
debit: "bg-red-900/30 text-red-400",
credit: "bg-green-900/30 text-green-400",
payment: "bg-blue-900/30 text-blue-400",
refund: "bg-emerald-900/30 text-emerald-400",
fee: "bg-yellow-900/30 text-yellow-400",
interest: "bg-orange-900/30 text-orange-400",
};
return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${colors[type] || "bg-zinc-800 text-zinc-400"}`}>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${TYPE_COLORS[type] || "bg-zinc-800 text-zinc-400"}`}>
{type}
</span>
);
}
function EditableTypeBadge({ type, onSave }: { type: string; onSave: (t: string) => void }) {
const [editing, setEditing] = useState(false);
if (!editing) {
return (
<button onClick={() => setEditing(true)} title="Click to change type">
<TypeBadge type={type} />
</button>
);
}
return (
<select
autoFocus
defaultValue={type}
onBlur={(e) => { onSave(e.target.value); setEditing(false); }}
onChange={(e) => { onSave(e.target.value); setEditing(false); }}
className="bg-zinc-800 border border-zinc-600 rounded px-1 py-0.5 text-xs"
>
{TYPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
}
// Prompt shown after a merchant/category edit — offers to save it as a rule
function SaveAsRulePrompt({
tx,
field,
newValue,
onDone,
}: {
tx: { id: number; effective_merchant: string; description: string; bank_name: string };
field: "category" | "merchant";
newValue: string;
onDone: () => void;
}) {
const createRule = useCreateRule();
const [saving, setSaving] = useState(false);
// Build a sensible default rule from the transaction context.
// Prefer merchant_normalized (full name, exact match) over a partial description word.
const hasMerchant = !!tx.effective_merchant;
const conditionField = hasMerchant ? "merchant_normalized" : "description";
const conditionOperator = hasMerchant ? "equals" : "contains";
const conditionValue = hasMerchant ? tx.effective_merchant : tx.description;
const defaultName =
field === "category"
? `${conditionValue}${formatCategory(newValue)}`
: `Rename ${conditionValue}${newValue}`;
const conditions = [{ field: conditionField, operator: conditionOperator, value: conditionValue }];
const actions =
field === "category"
? { set_category: newValue }
: { set_merchant: newValue };
async function save() {
setSaving(true);
await createRule.mutateAsync({ name: defaultName, conditions, actions, enabled: true, priority: 0 });
onDone();
}
return (
<div className="fixed bottom-4 right-4 z-50 bg-zinc-800 border border-zinc-600 rounded-xl shadow-2xl p-4 w-80 text-sm">
<p className="text-zinc-200 font-medium mb-1">Save as rule?</p>
<p className="text-zinc-400 text-xs mb-3">
Automatically apply this {field === "category" ? "category" : "merchant name"} to future matching transactions.
</p>
<div className="bg-zinc-900 rounded-lg px-3 py-2 text-xs text-zinc-300 mb-3 space-y-1">
<p><span className="text-zinc-500">If</span> {conditionField === "merchant_normalized" ? "merchant" : "description"} {conditionOperator} <span className="text-white">"{conditionValue}"</span></p>
<p>
<span className="text-zinc-500">Then</span>{" "}
{field === "category"
? <>set category <span className="text-white">{formatCategory(newValue)}</span></>
: <>set merchant <span className="text-white">{newValue}</span></>}
</p>
</div>
<div className="flex gap-2">
<button
onClick={save}
disabled={saving}
className="flex-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white rounded-lg py-1.5 font-medium transition-colors"
>
{saving ? "Saving…" : "Save rule"}
</button>
<button
onClick={onDone}
className="flex-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-200 rounded-lg py-1.5 transition-colors"
>
Dismiss
</button>
</div>
</div>
);
}
function InlineEdit({
value,
onSave,
@@ -97,27 +206,318 @@ function InlineEdit({
);
}
// ── Mark as Payment modal ─────────────────────────────────────────────────────
function MarkAsPaymentModal({
transaction,
onClose,
}: {
transaction: TransactionRow;
onClose: () => void;
}) {
const { data: participants = [] } = useParticipants();
const { data: me } = useCurrentUser();
const record = useRecordPayment();
const others = participants.filter((p) => p.name !== "Me");
const [participantId, setParticipantId] = useState<number | "">(others[0]?.id ?? "");
useEffect(() => {
if (participantId === "" && others.length > 0) {
setParticipantId(others[0].id);
}
}, [others]);
// For credits/refunds the default direction is "they paid me"
const [direction, setDirection] = useState<"received" | "sent">(
SPEND_TYPES.has(transaction.transaction_type) ? "sent" : "received"
);
const [amount, setAmount] = useState(Number(transaction.amount).toFixed(2));
const [date, setDate] = useState(transaction.transaction_date.slice(0, 10));
const [notes, setNotes] = useState("");
const [error, setError] = useState("");
const selectedParticipant = others.find((p) => p.id === participantId);
async function handleSave() {
setError("");
if (!participantId || !me) { setError("Select a participant"); return; }
const amt = parseFloat(amount);
if (!amt || amt <= 0) { setError("Enter a valid amount"); return; }
try {
await record.mutateAsync({
from_participant_id: direction === "received" ? participantId : me.id,
to_participant_id: direction === "received" ? me.id : participantId,
amount: amt,
payment_date: date,
notes: notes || undefined,
linked_transaction_id: transaction.id,
});
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to record");
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div
className="bg-zinc-900 border border-zinc-700 rounded-xl w-full max-w-sm mx-4 shadow-2xl p-6 space-y-4"
onClick={(e) => e.stopPropagation()}
>
<div>
<h3 className="font-semibold text-sm text-zinc-300">Record as Debt Payment</h3>
<p className="text-xs text-zinc-500 mt-0.5 truncate">{transaction.description}</p>
</div>
{/* Participant */}
<div>
<label className="block text-xs text-zinc-500 mb-1">Participant</label>
<select
value={participantId}
onChange={(e) => setParticipantId(Number(e.target.value))}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{others.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{/* Direction */}
<div className="flex rounded-lg overflow-hidden border border-zinc-700 text-sm">
<button
type="button"
onClick={() => setDirection("received")}
className={`flex-1 py-1.5 transition-colors ${direction === "received" ? "bg-emerald-700 text-white" : "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"}`}
>
{selectedParticipant?.name ?? "They"} paid me
</button>
<button
type="button"
onClick={() => setDirection("sent")}
className={`flex-1 py-1.5 transition-colors ${direction === "sent" ? "bg-blue-700 text-white" : "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"}`}
>
I paid {selectedParticipant?.name ?? "them"}
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Amount</label>
<div className="relative">
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500 text-sm">$</span>
<input
type="number" step="0.01" min="0.01" value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm pl-6"
/>
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Date</label>
<input
type="date" value={date} onChange={(e) => setDate(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Notes (optional)</label>
<input
value={notes} onChange={(e) => setNotes(e.target.value)}
placeholder="e.g. bank transfer reference"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-2">
<button type="button" onClick={onClose}
className="flex-1 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
Cancel
</button>
<button type="button" onClick={handleSave} disabled={record.isPending}
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium">
{record.isPending ? "Saving…" : "Record Payment"}
</button>
</div>
</div>
</div>
);
}
// ── Query bar parser ──────────────────────────────────────────────────────────
interface QueryToken { key: string; label: string }
interface ParsedQuery { text: string; amountMin?: number; amountMax?: number; tokens: QueryToken[] }
function parseQuery(input: string): ParsedQuery {
let text = input;
let amountMin: number | undefined;
let amountMax: number | undefined;
const tokens: QueryToken[] = [];
// Range shorthand: 500-1500
text = text.replace(/\b(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\b/g, (_, a, b) => {
amountMin = parseFloat(a);
amountMax = parseFloat(b);
tokens.push({ key: "range", label: `$${a}$${b}` });
return "";
});
// Operators: >=500 <=500 >500 <500
text = text.replace(/(>=|<=|>|<)\s*(\d+(?:\.\d+)?)/g, (_, op, num) => {
const val = parseFloat(num);
if (op === ">") amountMin = val + 0.005;
else if (op === ">=") amountMin = val;
else if (op === "<") amountMax = Math.max(0, val - 0.005);
else if (op === "<=") amountMax = val;
const display = op === ">=" ? "≥" : op === "<=" ? "≤" : op;
tokens.push({ key: `amt_${op}`, label: `${display} $${num}` });
return "";
});
return { text: text.trim(), amountMin, amountMax, tokens };
}
// ── MultiSelect dropdown ──────────────────────────────────────────────────────
function MultiSelect({
options,
value,
onChange,
placeholder,
}: {
options: { value: string; label: string }[];
value: string[];
onChange: (v: string[]) => void;
placeholder: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handler(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const toggle = (v: string) =>
onChange(value.includes(v) ? value.filter((x) => x !== v) : [...value, v]);
const label =
value.length === 0
? placeholder
: value.length === 1
? (options.find((o) => o.value === value[0])?.label ?? value[0])
: `${value.length} selected`;
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`bg-zinc-900 border rounded px-3 py-1.5 text-sm flex items-center gap-2 min-w-[140px] whitespace-nowrap ${
value.length > 0 ? "border-indigo-500 text-white" : "border-zinc-700 text-zinc-400"
}`}
>
<span className="flex-1 text-left truncate">{label}</span>
<span className="text-zinc-500 text-xs"></span>
</button>
{open && (
<div className="absolute top-full mt-1 z-20 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl min-w-[160px] max-h-64 overflow-y-auto">
{options.map((o) => (
<label
key={o.value}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-zinc-800 cursor-pointer text-sm"
>
<input
type="checkbox"
checked={value.includes(o.value)}
onChange={() => toggle(o.value)}
className="accent-indigo-500 flex-shrink-0"
/>
{o.label}
</label>
))}
</div>
)}
</div>
);
}
export default function TransactionsPage() {
return (
<Suspense fallback={<p className="text-zinc-500 text-sm">Loading...</p>}>
<TransactionsContent />
</Suspense>
);
}
function TransactionsContent() {
const searchParams = useSearchParams();
const initialStatementId = searchParams.get("statement_id") || "";
const [filters, setFilters] = useState({
from: "",
to: "",
category: "",
bank_name: "",
categories: [] as string[],
bank_names: [] as string[],
tag_ids: [] as string[],
transaction_types: [] as string[],
search: "",
tag_id: "",
statement_id: initialStatementId,
sort_by: "transaction_date",
sort_dir: "desc",
limit: 50,
offset: 0,
amount_min: undefined as number | undefined,
amount_max: undefined as number | undefined,
});
const [queryInput, setQueryInput] = useState("");
const [queryTokens, setQueryTokens] = useState<QueryToken[]>([]);
function handleQueryChange(val: string) {
setQueryInput(val);
const parsed = parseQuery(val);
setQueryTokens(parsed.tokens);
setFilters((f) => ({
...f,
search: parsed.text,
amount_min: parsed.amountMin,
amount_max: parsed.amountMax,
offset: 0,
}));
}
function clearQueryToken(key: string) {
// Rebuild input without the token's contribution by re-running parse on cleared input
// Simplest: just clear the whole query bar
const next = queryInput
.replace(/(>=|<=|>|<)\s*\d+(?:\.\d+)?/g, "")
.replace(/\b\d+(?:\.\d+)?\s*-\s*\d+(?:\.\d+)?\b/g, "")
.trim();
handleQueryChange(next);
}
const [selected, setSelected] = useState<Set<number>>(new Set());
const [bulkCategory, setBulkCategory] = useState("");
const [bulkTagId, setBulkTagId] = useState("");
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string } | null>(null);
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string; merchant?: string } | null>(null);
const [addModal, setAddModal] = useState<{ prefill?: Parameters<typeof AddTransactionModal>[0]["prefill"]; title?: string } | null>(null);
const [editModal, setEditModal] = useState<TransactionRow | null>(null);
const [paymentModal, setPaymentModal] = useState<TransactionRow | null>(null);
const [rulePrompt, setRulePrompt] = useState<{
tx: { id: number; effective_merchant: string; description: string; bank_name: string };
field: "category" | "merchant";
newValue: string;
} | null>(null);
const { data, isLoading } = useTransactions(filters);
const { data: banks } = useBanks();
const { data: tags } = useTags();
const { data: statementInfo } = useStatement(parseInt(filters.statement_id) || 0);
const updateTxn = useUpdateTransaction();
const bulkAction = useBulkAction();
@@ -159,17 +559,70 @@ export default function TransactionsPage() {
return (
<div>
<h2 className="text-xl font-semibold mb-4">Transactions</h2>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Transactions</h2>
<button
onClick={() => setAddModal({})}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium"
>
+ Add Transaction
</button>
</div>
{/* Statement context banner */}
{filters.statement_id && statementInfo && (
<div className="flex items-center gap-3 mb-4 px-3 py-2 bg-indigo-950/40 border border-indigo-800/50 rounded-lg text-sm">
<span className="text-indigo-300 font-medium">{statementInfo.bank_name}</span>
{statementInfo.billing_start_date && statementInfo.billing_end_date && (
<span className="text-zinc-400">
{new Date(statementInfo.billing_start_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })}
{" "}
{new Date(statementInfo.billing_end_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" })}
</span>
)}
<span className="text-zinc-500 text-xs">{statementInfo.transaction_count} transactions</span>
<button
onClick={() => setFilters((f) => ({ ...f, statement_id: "", offset: 0 }))}
className="ml-auto text-zinc-500 hover:text-zinc-200 text-xs px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
>
× Clear filter
</button>
</div>
)}
{/* Filter bar */}
<div className="flex flex-wrap gap-3 mb-4">
<input
type="text"
placeholder="Search..."
value={filters.search}
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm w-48"
/>
<div className="flex flex-wrap gap-3 mb-2">
{/* Smart query bar */}
<div className="flex flex-col gap-1">
<input
type="text"
value={queryInput}
onChange={(e) => handleQueryChange(e.target.value)}
placeholder="Search… or >500 <=1500 200-800"
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm w-64 font-mono placeholder:font-sans placeholder:text-zinc-600"
/>
{queryTokens.length > 0 && (
<div className="flex flex-wrap gap-1">
{queryTokens.map((tok) => (
<span
key={tok.key}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-indigo-900/50 border border-indigo-700/50 text-indigo-300"
>
{tok.label}
<button
type="button"
onClick={() => clearQueryToken(tok.key)}
className="text-indigo-400 hover:text-white leading-none"
>
×
</button>
</span>
))}
</div>
)}
</div>
{/* Date range */}
<input
type="date"
value={filters.from}
@@ -182,36 +635,32 @@ export default function TransactionsPage() {
onChange={(e) => setFilters((f) => ({ ...f, to: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
/>
<select
value={filters.category}
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
>
<option value="">All Categories</option>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{formatCategory(c)}</option>
))}
</select>
<select
value={filters.bank_name}
onChange={(e) => setFilters((f) => ({ ...f, bank_name: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
>
<option value="">All Banks</option>
{banks?.map((b) => (
<option key={b} value={b}>{b}</option>
))}
</select>
<select
value={filters.tag_id}
onChange={(e) => setFilters((f) => ({ ...f, tag_id: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
>
<option value="">All Tags</option>
{tags?.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
{/* Multi-select dropdowns */}
<MultiSelect
options={CATEGORIES.map((c) => ({ value: c, label: formatCategory(c) }))}
value={filters.categories}
onChange={(v) => setFilters((f) => ({ ...f, categories: v, offset: 0 }))}
placeholder="All Categories"
/>
<MultiSelect
options={(banks ?? []).map((b) => ({ value: b, label: b }))}
value={filters.bank_names}
onChange={(v) => setFilters((f) => ({ ...f, bank_names: v, offset: 0 }))}
placeholder="All Banks"
/>
<MultiSelect
options={(tags ?? []).map((t) => ({ value: String(t.id), label: t.name }))}
value={filters.tag_ids}
onChange={(v) => setFilters((f) => ({ ...f, tag_ids: v, offset: 0 }))}
placeholder="All Tags"
/>
<MultiSelect
options={TYPE_OPTIONS}
value={filters.transaction_types}
onChange={(v) => setFilters((f) => ({ ...f, transaction_types: v, offset: 0 }))}
placeholder="All Types"
/>
</div>
{/* Bulk action bar */}
@@ -338,12 +787,20 @@ export default function TransactionsPage() {
/>
</td>
<td className="p-2 whitespace-nowrap">{formatDate(t.transaction_date)}</td>
<td className="p-2 max-w-xs truncate" title={t.description}>{t.description}</td>
<td className="p-2 max-w-xs">
<p className="truncate" title={t.description}>{t.description}</p>
{t.notes && (
<p className="truncate text-xs text-zinc-500 italic mt-0.5" title={t.notes}>{t.notes}</p>
)}
</td>
<td className="p-2 max-w-[150px]">
<div className="relative">
<InlineEdit
value={t.effective_merchant || ""}
onSave={(val) => updateTxn.mutate({ id: t.id, merchant_normalized: val })}
onSave={(val) => {
updateTxn.mutate({ id: t.id, merchant_normalized: val });
setRulePrompt({ tx: t, field: "merchant", newValue: val });
}}
/>
{t.merchant_override && (
<span className="absolute -left-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 bg-blue-500 rounded-full" title="Manually overridden" />
@@ -351,16 +808,24 @@ export default function TransactionsPage() {
</div>
</td>
<td className={`p-2 text-right whitespace-nowrap font-mono ${
t.transaction_type === "debit" ? "text-red-400" : "text-green-400"
SPEND_TYPES.has(t.transaction_type) ? "text-red-400" : "text-green-400"
}`}>
{formatAmount(t.amount, t.transaction_type)}
</td>
<td className="p-2"><TypeBadge type={t.transaction_type} /></td>
<td className="p-2">
<EditableTypeBadge
type={t.transaction_type}
onSave={(val) => updateTxn.mutate({ id: t.id, transaction_type: val })}
/>
</td>
<td className="p-2 max-w-[140px]">
<div className="relative">
<InlineEdit
value={t.effective_category}
onSave={(val) => updateTxn.mutate({ id: t.id, category: val })}
onSave={(val) => {
updateTxn.mutate({ id: t.id, category: val });
setRulePrompt({ tx: t, field: "category", newValue: val });
}}
type="select"
options={categoryOptions}
/>
@@ -384,13 +849,63 @@ export default function TransactionsPage() {
<TagPicker transactionId={t.id} currentTags={t.tags ?? []} />
</div>
</td>
<td className="p-2">
<td className="p-2 whitespace-nowrap">
<div className="flex items-center gap-1 flex-wrap">
{t.splits?.filter((s) => s.name !== "Me").map((s) => (
<span
key={s.participant_id}
className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${
s.settled ? "bg-zinc-800 text-zinc-500" : "bg-amber-900/40 text-amber-300"
}`}
title={`${s.name}: ${s.share_percent}%${s.settled ? " (settled)" : ""}`}
>
{s.name} {s.share_percent}%
</span>
))}
<button
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, merchant: t.effective_merchant || undefined, transactionIds: undefined })}
className={`text-xs px-2 py-0.5 rounded transition-colors ${
t.splits?.some((s) => s.name !== "Me")
? "text-amber-400 hover:text-amber-200 hover:bg-zinc-800"
: "text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800"
}`}
title="Split this transaction"
>
Split
</button>
</div>
<button
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, transactionIds: undefined })}
onClick={() => setEditModal(t)}
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
title="Split this transaction"
title="Edit this transaction"
>
Split
Edit
</button>
{!SPEND_TYPES.has(t.transaction_type) && (
<button
onClick={() => setPaymentModal(t)}
className="text-xs text-emerald-600 hover:text-emerald-400 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
title="Record as debt payment"
>
Payment
</button>
)}
<button
onClick={() => setAddModal({
title: "Duplicate Transaction",
prefill: {
date: new Date().toISOString().slice(0, 10),
description: t.description,
amount: t.amount,
transaction_type: t.transaction_type,
merchant_normalized: t.effective_merchant || undefined,
category: t.effective_category || undefined,
},
})}
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
title="Duplicate this transaction"
>
Dupe
</button>
</td>
</tr>
@@ -407,10 +922,42 @@ export default function TransactionsPage() {
transactionIds={splitModal.transactionIds}
amount={splitModal.amount}
description={splitModal.description}
merchant={splitModal.merchant}
onClose={() => { setSplitModal(null); if (splitModal.transactionIds) setSelected(new Set()); }}
/>
)}
{addModal && (
<AddTransactionModal
prefill={addModal.prefill}
title={addModal.title}
onClose={() => setAddModal(null)}
/>
)}
{editModal && (
<EditTransactionModal
transaction={editModal}
onClose={() => setEditModal(null)}
/>
)}
{paymentModal && (
<MarkAsPaymentModal
transaction={paymentModal}
onClose={() => setPaymentModal(null)}
/>
)}
{rulePrompt && (
<SaveAsRulePrompt
tx={rulePrompt.tx}
field={rulePrompt.field}
newValue={rulePrompt.newValue}
onDone={() => setRulePrompt(null)}
/>
)}
{/* Pagination */}
{data && data.total > filters.limit && (
<div className="flex items-center justify-between mt-4">
+215
View File
@@ -0,0 +1,215 @@
"use client";
import { useState } from "react";
import { useCreateTransaction, useParticipants } from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories";
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
interface Prefill {
date?: string;
description?: string;
amount?: number;
transaction_type?: string;
merchant_normalized?: string;
category?: string;
splits?: { participant_id: number; share_percent: number }[];
}
export function AddTransactionModal({
prefill,
title,
onClose,
}: {
prefill?: Prefill;
title?: string;
onClose: () => void;
}) {
const createTransaction = useCreateTransaction();
const { data: participants = [] } = useParticipants();
const [date, setDate] = useState(prefill?.date ?? new Date().toISOString().slice(0, 10));
const [description, setDescription] = useState(prefill?.description ?? "");
const [amount, setAmount] = useState(prefill?.amount != null ? String(prefill.amount) : "");
const [type, setType] = useState(prefill?.transaction_type ?? "debit");
const [merchant, setMerchant] = useState(prefill?.merchant_normalized ?? "");
const [category, setCategory] = useState(prefill?.category ?? "");
const [splits, setSplits] = useState<{ participant_id: number; share_percent: number }[]>(
prefill?.splits ?? []
);
function addSplit() {
if (!participants.length) return;
setSplits([...splits, { participant_id: participants[0].id, share_percent: 50 }]);
}
function updateSplit(i: number, patch: Partial<{ participant_id: number; share_percent: number }>) {
setSplits(splits.map((s, idx) => (idx === i ? { ...s, ...patch } : s)));
}
function removeSplit(i: number) {
setSplits(splits.filter((_, idx) => idx !== i));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
await createTransaction.mutateAsync({
date,
description,
amount: parseFloat(amount),
transaction_type: type,
merchant_normalized: merchant || undefined,
category: category || undefined,
splits: splits.length ? splits : undefined,
});
onClose();
}
const splitTotal = splits.reduce((s, e) => s + (e.share_percent || 0), 0);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div
className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 w-full max-w-md shadow-2xl space-y-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="font-semibold text-sm text-zinc-300">{title ?? "Add Transaction"}</h3>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Date</label>
<input
type="date"
required
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Amount</label>
<input
type="number"
step="0.01"
required
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Description</label>
<input
required
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="e.g. Coles Wyndham Vale"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Type</label>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{TRANSACTION_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Category</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
<option value=""> none </option>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{formatCategory(c)}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Merchant (optional)</label>
<input
value={merchant}
onChange={(e) => setMerchant(e.target.value)}
placeholder="Normalized merchant name"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
{/* Splits */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-zinc-500">
Splits (optional)
{splits.length > 0 && (
<span className={`ml-2 ${splitTotal === 100 ? "text-emerald-400" : "text-amber-400"}`}>
{splitTotal}%
</span>
)}
</label>
{participants.length > 0 && (
<button type="button" onClick={addSplit} className="text-xs text-indigo-400 hover:text-indigo-300">
+ Add
</button>
)}
</div>
{splits.map((s, i) => (
<div key={i} className="flex gap-2 mb-1.5 items-center">
<select
value={s.participant_id}
onChange={(e) => updateSplit(i, { participant_id: Number(e.target.value) })}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{participants.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<input
type="number"
min={0}
max={100}
value={s.share_percent}
onChange={(e) => updateSplit(i, { share_percent: Number(e.target.value) })}
className="w-16 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
<span className="text-xs text-zinc-500">%</span>
<button type="button" onClick={() => removeSplit(i)} className="text-zinc-500 hover:text-red-400 text-lg leading-none">×</button>
</div>
))}
</div>
<div className="flex gap-2 pt-1">
<button
type="submit"
disabled={createTransaction.isPending}
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
{createTransaction.isPending ? "Saving..." : "Save Transaction"}
</button>
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
+332
View File
@@ -0,0 +1,332 @@
"use client";
import { useState } from "react";
import {
useUpdateTransaction,
useTags,
useAddTransactionTag,
useRemoveTransactionTag,
useTransactionSplits,
} from "@/lib/hooks";
import { SplitModal } from "./split-modal";
import { CATEGORIES, formatCategory } from "@/lib/categories";
import type { TransactionRow, TagRow } from "@/lib/queries";
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
function formatAmount(amount: number, type: string) {
const formatted = `$${Number(amount).toFixed(2)}`;
return SPEND_TYPES.has(type) ? formatted : `+${formatted}`;
}
function InlineTags({ transactionId, initialTags }: { transactionId: number; initialTags: TagRow[] }) {
const { data: allTags = [] } = useTags();
const addTag = useAddTransactionTag();
const removeTag = useRemoveTransactionTag();
const [tags, setTags] = useState<TagRow[]>(initialTags);
const [showPicker, setShowPicker] = useState(false);
const available = allTags.filter((t) => !tags.find((ct) => ct.id === t.id));
return (
<div>
<div className="flex flex-wrap gap-1 items-center">
{tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium text-white"
style={{ backgroundColor: tag.color + "99" }}
>
{tag.name}
<button
type="button"
onClick={() => {
removeTag.mutate({ transactionId, tagId: tag.id });
setTags((prev) => prev.filter((t) => t.id !== tag.id));
}}
className="ml-0.5 text-white/60 hover:text-white leading-none"
>
×
</button>
</span>
))}
{available.length > 0 && (
<button
type="button"
onClick={() => setShowPicker((v) => !v)}
className="text-xs text-zinc-500 hover:text-zinc-300 px-1.5 py-0.5 rounded hover:bg-zinc-800"
>
+ Add tag
</button>
)}
</div>
{showPicker && (
<div className="mt-1.5 flex flex-wrap gap-1">
{available.map((tag) => (
<button
key={tag.id}
type="button"
onClick={() => {
addTag.mutate({ transactionId, tagId: tag.id });
setTags((prev) => [...prev, tag]);
setShowPicker(false);
}}
className="px-2 py-0.5 rounded text-xs font-medium text-white hover:brightness-125"
style={{ backgroundColor: tag.color + "66" }}
>
{tag.name}
</button>
))}
</div>
)}
</div>
);
}
export function EditTransactionModal({
transaction,
onClose,
}: {
transaction: TransactionRow;
onClose: () => void;
}) {
const isManual = !transaction.statement_id;
const updateTxn = useUpdateTransaction();
// Editable override fields
const [merchant, setMerchant] = useState(transaction.merchant_override ?? transaction.merchant_normalized ?? "");
const [category, setCategory] = useState(transaction.effective_category ?? "");
const [type, setType] = useState(transaction.transaction_type);
const [notes, setNotes] = useState(transaction.notes ?? "");
// Manual-only direct fields
const [date, setDate] = useState(transaction.transaction_date?.slice(0, 10) ?? "");
const [description, setDescription] = useState(transaction.description);
const [amount, setAmount] = useState(String(transaction.amount));
// Splits — live via hook so they refresh after SplitModal saves
const { data: liveSplits = [] } = useTransactionSplits(transaction.id);
const [showSplitModal, setShowSplitModal] = useState(false);
const [error, setError] = useState("");
async function handleSave() {
setError("");
try {
const patch: Parameters<typeof updateTxn.mutateAsync>[0] = { id: transaction.id };
// Override fields (always)
if (merchant !== (transaction.merchant_override ?? transaction.merchant_normalized ?? ""))
patch.merchant_normalized = merchant;
if (category !== (transaction.effective_category ?? ""))
patch.category = category;
if (type !== transaction.transaction_type)
patch.transaction_type = type;
if (notes !== (transaction.notes ?? ""))
patch.notes = notes;
// Direct fields (manual only)
if (isManual) {
if (date !== transaction.transaction_date?.slice(0, 10))
patch.transaction_date = date;
if (description !== transaction.description)
patch.description = description;
if (parseFloat(amount) !== transaction.amount)
patch.amount = parseFloat(amount);
}
await updateTxn.mutateAsync(patch);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save");
}
}
return (
<>
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60" onClick={onClose}>
<div
className="bg-zinc-900 border border-zinc-700 rounded-xl w-full max-w-lg mx-4 shadow-2xl flex flex-col max-h-[90vh]"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-6 pt-5 pb-4 border-b border-zinc-800">
<h3 className="font-semibold text-sm text-zinc-300">Edit Transaction</h3>
<p className="text-xs text-zinc-500 mt-0.5">{transaction.bank_name}</p>
</div>
<div className="overflow-y-auto flex-1 px-6 py-4 space-y-5">
{/* Core fields — read-only for statement, editable for manual */}
{isManual ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Date</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Amount</label>
<input
type="number"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Description</label>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
</div>
) : (
<div className="bg-zinc-800/50 rounded-lg px-3 py-2.5 space-y-1">
<p className="text-sm font-medium">{transaction.description}</p>
<p className={`text-sm font-mono ${SPEND_TYPES.has(transaction.transaction_type) ? "text-red-400" : "text-green-400"}`}>
{formatAmount(transaction.amount, transaction.transaction_type)}
</p>
<p className="text-xs text-zinc-500">
{new Date(transaction.transaction_date).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" })}
</p>
</div>
)}
{/* Override fields */}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Type</label>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{TRANSACTION_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Category</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
<option value=""> none </option>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{formatCategory(c)}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Merchant</label>
<input
value={merchant}
onChange={(e) => setMerchant(e.target.value)}
placeholder="Normalized merchant name"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
placeholder="Additional context about this transaction…"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm resize-none"
/>
</div>
</div>
{/* Tags */}
<div>
<p className="text-xs text-zinc-500 mb-1.5">Tags</p>
<InlineTags transactionId={transaction.id} initialTags={transaction.tags ?? []} />
</div>
{/* Splits */}
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-xs text-zinc-500">Splits</p>
<button
type="button"
onClick={() => setShowSplitModal(true)}
className="text-xs text-blue-400 hover:text-blue-300"
>
{liveSplits.length > 0 ? "Edit splits" : "Add split"}
</button>
</div>
{liveSplits.length > 0 ? (
<div className="flex flex-wrap gap-1">
{liveSplits.map((s: { participant_id: number; name: string; share_percent: number; settled: boolean }) => (
<span
key={s.participant_id}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${
s.settled ? "bg-zinc-800 text-zinc-500" : "bg-amber-900/40 text-amber-300"
}`}
>
{s.name} {s.share_percent}%
{s.settled && <span className="text-emerald-500"></span>}
</span>
))}
</div>
) : (
<p className="text-xs text-zinc-600 italic">No splits</p>
)}
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-zinc-800 flex gap-2">
{error && <p className="text-red-400 text-xs flex-1 self-center">{error}</p>}
<div className="flex gap-2 ml-auto">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={updateTxn.isPending}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
{updateTxn.isPending ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
</div>
{showSplitModal && (
<SplitModal
transactionId={transaction.id}
amount={transaction.amount}
description={transaction.description}
merchant={transaction.effective_merchant || undefined}
onClose={() => setShowSplitModal(false)}
/>
)}
</>
);
}
+13 -1
View File
@@ -7,7 +7,9 @@ const NAV_ITEMS = [
{ href: "/transactions", label: "Transactions", icon: "receipt" },
{ href: "/statements", label: "Statements", icon: "file-text" },
{ href: "/shared", label: "Shared", icon: "users" },
{ href: "/budget", label: "Budget", icon: "bar-chart" },
{ href: "/budget", label: "Analytics", icon: "bar-chart" },
{ href: "/insights", label: "Insights", icon: "lightbulb" },
{ href: "/merchants", label: "Merchants", icon: "store" },
{ href: "/tags", label: "Tags", icon: "tag" },
{ href: "/rules", label: "Rules", icon: "settings" },
];
@@ -44,6 +46,16 @@ const ICONS: Record<string, React.ReactNode> = {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
lightbulb: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m1.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
),
store: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
),
};
export function Sidebar() {
+228
View File
@@ -0,0 +1,228 @@
"use client";
import { useState, useEffect } from "react";
import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction, useCreateRule } from "@/lib/hooks";
interface Split {
participant_id: number;
share_percent: number;
}
interface Props {
transactionId?: number;
transactionIds?: number[];
amount?: number;
description: string;
merchant?: string;
onClose: () => void;
}
export function SplitModal({ transactionId, transactionIds, amount, description, merchant, onClose }: Props) {
const isBulk = !!transactionIds && transactionIds.length > 0;
const singleId = transactionId ?? 0;
const { data: participants } = useParticipants();
const { data: existingSplits } = useTransactionSplits(isBulk ? 0 : singleId);
const setSplits = useSetSplits();
const bulkAction = useBulkAction();
const createRule = useCreateRule();
const [splits, setSplitsState] = useState<Split[]>([]);
const [error, setError] = useState("");
const [saveAsRule, setSaveAsRule] = useState(false);
const [ruleSaved, setRuleSaved] = useState(false);
// Initialise: bulk always defaults to 100% Me; single loads existing splits
useEffect(() => {
if (!participants || participants.length === 0) return;
const me = participants.find((p) => p.name === "Me");
if (isBulk) {
if (me) setSplitsState([{ participant_id: me.id, share_percent: 100 }]);
} else if (existingSplits && existingSplits.length > 0) {
setSplitsState(
existingSplits.map((s: { participant_id: number; share_percent: number }) => ({
participant_id: s.participant_id,
share_percent: Number(s.share_percent),
}))
);
} else if (me) {
setSplitsState([{ participant_id: me.id, share_percent: 100 }]);
}
}, [existingSplits, participants, isBulk]);
const total = splits.reduce((sum, s) => sum + s.share_percent, 0);
const toggleParticipant = (id: number) => {
setSplitsState((prev) => {
const exists = prev.find((s) => s.participant_id === id);
if (exists) {
return prev.filter((s) => s.participant_id !== id);
}
// Add with equal split
const count = prev.length + 1;
const equal = Math.floor(100 / count);
const remainder = 100 - equal * count;
return [
...prev.map((s, i) => ({ ...s, share_percent: equal + (i === 0 ? remainder : 0) })),
{ participant_id: id, share_percent: equal },
];
});
};
const updateShare = (id: number, value: number) => {
setSplitsState((prev) =>
prev.map((s) => (s.participant_id === id ? { ...s, share_percent: value } : s))
);
};
const splitEvenly = () => {
if (splits.length === 0) return;
const each = Math.floor(100 / splits.length);
const remainder = 100 - each * splits.length;
setSplitsState((prev) =>
prev.map((s, i) => ({ ...s, share_percent: each + (i === 0 ? remainder : 0) }))
);
};
const isPending = isBulk ? bulkAction.isPending : setSplits.isPending;
const handleSave = async () => {
setError("");
if (Math.abs(total - 100) > 0.01) {
setError(`Shares must sum to 100% (currently ${total.toFixed(1)}%)`);
return;
}
try {
if (isBulk) {
await bulkAction.mutateAsync({ action: "split", ids: transactionIds!, splits });
} else {
await setSplits.mutateAsync({ transactionId: singleId, splits });
}
if (saveAsRule && !isBulk) {
const matchValue = merchant || (description.split(" ")[0] ?? description);
await createRule.mutateAsync({
name: `Split: ${merchant || description}`,
conditions: [{ field: "merchant_normalized", operator: "contains", value: matchValue }],
actions: { apply_split: splits },
enabled: true,
priority: 0,
});
setRuleSaved(true);
setTimeout(onClose, 1200);
} else {
onClose();
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save splits");
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<div
className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 w-full max-w-md mx-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold mb-1">
{isBulk ? `Split ${transactionIds!.length} Transactions` : "Split Transaction"}
</h3>
<p className="text-sm text-zinc-400 mb-4 truncate">{description}</p>
{!isBulk && amount !== undefined && (
<p className="text-2xl font-mono font-semibold mb-6">
${Number(amount).toFixed(2)}
</p>
)}
{/* Participant toggles */}
<div className="space-y-3 mb-4">
{participants?.map((p) => {
const split = splits.find((s) => s.participant_id === p.id);
const active = !!split;
return (
<div key={p.id} className="flex items-center gap-3">
<button
onClick={() => toggleParticipant(p.id)}
className={`w-8 h-8 rounded-full text-sm font-medium flex-shrink-0 transition-colors ${
active
? "bg-blue-600 text-white"
: "bg-zinc-800 text-zinc-500 hover:bg-zinc-700"
}`}
>
{p.name.charAt(0).toUpperCase()}
</button>
<span className="flex-1 text-sm">{p.name}</span>
{active && (
<div className="flex items-center gap-2">
<input
type="range"
min={1}
max={99}
value={split.share_percent}
onChange={(e) => updateShare(p.id, Number(e.target.value))}
className="w-24 accent-blue-600"
/>
<span className="w-12 text-right text-sm font-mono">
{split.share_percent}%
</span>
{!isBulk && amount !== undefined && (
<span className="w-20 text-right text-sm text-zinc-400 font-mono">
${((amount * split.share_percent) / 100).toFixed(2)}
</span>
)}
</div>
)}
</div>
);
})}
</div>
{/* Total indicator */}
<div className="flex items-center justify-between mb-4 text-sm">
<span className={`font-mono ${Math.abs(total - 100) > 0.01 ? "text-red-400" : "text-green-400"}`}>
Total: {total.toFixed(1)}%
</span>
<button
onClick={splitEvenly}
className="text-blue-400 hover:text-blue-300 text-xs"
>
Split evenly
</button>
</div>
{error && <p className="text-red-400 text-sm mb-3">{error}</p>}
{ruleSaved && (
<p className="text-green-400 text-sm mb-3">Rule saved future matching transactions will be split the same way.</p>
)}
{!isBulk && (
<label className="flex items-center gap-2 text-sm text-zinc-400 mb-3 cursor-pointer select-none">
<input
type="checkbox"
checked={saveAsRule}
onChange={(e) => setSaveAsRule(e.target.checked)}
className="accent-blue-500"
/>
Also save as rule for <span className="text-zinc-200 font-medium">{merchant || description.split(" ")[0]}</span>
</label>
)}
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-sm transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isPending || Math.abs(total - 100) > 0.01}
className="flex-1 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
>
{isPending ? "Saving..." : "Save splits"}
</button>
</div>
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest } from "next/server";
import { queryRaw } from "./db";
export interface CurrentUser {
id: number;
name: string;
email: string;
}
export async function getCurrentUser(req: NextRequest): Promise<CurrentUser | null> {
const email = req.headers.get("x-forwarded-user");
// Dev fallback: no Traefik header → use participant id=1
if (!email) {
if (process.env.NODE_ENV === "development") {
const rows = await queryRaw<CurrentUser>(`SELECT id, name, email FROM participants WHERE id = 1`);
return rows[0] || null;
}
return null;
}
const rows = await queryRaw<CurrentUser>(
`SELECT id, name, COALESCE(email, '') as email FROM participants WHERE email = $1`,
[email]
);
return rows[0] || null;
}
+10
View File
@@ -1,3 +1,9 @@
export const REGULAR_CATEGORIES = new Set([
"rent", "utilities", "insurance", "subscriptions",
"groceries", "dining", "transport", "fuel",
"health", "personal_care", "government", "charity", "pets",
] as const);
export const CATEGORIES = [
"groceries",
"dining",
@@ -14,12 +20,16 @@ export const CATEGORIES = [
"government",
"education",
"rent",
"home_goods",
"home_maintenance",
"transfers",
"income",
"investment",
"personal_care",
"pets",
"gifts",
"charity",
"fees",
"other",
] as const;
+324 -18
View File
@@ -14,21 +14,29 @@ interface TransactionsResponse {
interface TransactionFilters {
from?: string;
to?: string;
category?: string;
bank_name?: string;
categories?: string[];
bank_names?: string[];
tag_ids?: string[];
transaction_types?: string[];
search?: string;
statement_id?: string;
tag_id?: string;
sort_by?: string;
sort_dir?: string;
limit?: number;
offset?: number;
amount_min?: number;
amount_max?: number;
}
function buildParams(filters: TransactionFilters): string {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, val]) => {
if (val !== undefined && val !== "") params.set(key, String(val));
if (val === undefined || val === "") return;
if (Array.isArray(val)) {
if (val.length > 0) params.set(key, val.join(","));
} else {
params.set(key, String(val));
}
});
return params.toString();
}
@@ -83,6 +91,35 @@ export function useBanks() {
});
}
export function useCreateTransaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: {
date: string;
description: string;
amount: number;
transaction_type?: string;
merchant_normalized?: string;
category?: string;
splits?: { participant_id: number; share_percent: number }[];
}) => {
const res = await fetch("/api/transactions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error((await res.json()).error || "Failed to create transaction");
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["splits"] });
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
qc.invalidateQueries({ queryKey: ["participant-balances"] });
},
});
}
export function useUpdateTransaction() {
const qc = useQueryClient();
return useMutation({
@@ -94,6 +131,11 @@ export function useUpdateTransaction() {
category?: string;
merchant_normalized?: string;
notes?: string;
transaction_type?: string;
my_share_percent?: number | null;
description?: string;
amount?: number;
transaction_date?: string;
}) => {
const res = await fetch(`/api/transactions/${id}`, {
method: "PATCH",
@@ -105,6 +147,7 @@ export function useUpdateTransaction() {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["transaction"] });
qc.invalidateQueries({ queryKey: ["analytics"] });
},
});
}
@@ -155,21 +198,23 @@ export function useParticipants() {
});
}
export function useParticipantBalances() {
export function useParticipantBalances(tagIds?: string[]) {
return useQuery<{ id: number; name: string; total_owed: number; unsettled_count: number }[]>({
queryKey: ["participant-balances"],
queryKey: ["participant-balances", tagIds],
queryFn: async () => {
const res = await fetch("/api/participants/balances");
const params = tagIds?.length ? `?tag_ids=${tagIds.join(",")}` : "";
const res = await fetch(`/api/participants/balances${params}`);
return res.json();
},
});
}
export function useSharedTransactions() {
export function useSharedTransactions(tagIds?: string[]) {
return useQuery({
queryKey: ["shared-transactions"],
queryKey: ["shared-transactions", tagIds],
queryFn: async () => {
const res = await fetch("/api/shared-transactions");
const params = tagIds?.length ? `?tag_ids=${tagIds.join(",")}` : "";
const res = await fetch(`/api/shared-transactions${params}`);
return res.json();
},
});
@@ -214,11 +259,43 @@ export function useSetSplits() {
});
}
export function useSettleSplits() {
export interface SplitPayment {
id: number;
from_participant_id: number;
from_name: string;
to_participant_id: number;
to_name: string;
amount: number;
payment_date: string;
notes: string | null;
linked_transaction_id: number | null;
created_at: string;
}
export function usePaymentHistory(participantId: number | null) {
return useQuery<SplitPayment[]>({
queryKey: ["split-payments", participantId],
queryFn: async () => {
if (!participantId) return [];
const res = await fetch(`/api/split-payments?participant_id=${participantId}`);
return res.json();
},
enabled: !!participantId,
});
}
export function useRecordPayment() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: { participant_id?: number; split_ids?: number[] }) => {
const res = await fetch("/api/splits/settle", {
mutationFn: async (body: {
from_participant_id: number;
to_participant_id: number;
amount: number;
payment_date: string;
notes?: string;
linked_transaction_id?: number;
}) => {
const res = await fetch("/api/split-payments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -226,8 +303,22 @@ export function useSettleSplits() {
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
qc.invalidateQueries({ queryKey: ["participant-balances"] });
qc.invalidateQueries({ queryKey: ["split-payments"] });
},
});
}
export function useDeletePayment() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const res = await fetch(`/api/split-payments?id=${id}`, { method: "DELETE" });
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["participant-balances"] });
qc.invalidateQueries({ queryKey: ["split-payments"] });
},
});
}
@@ -359,7 +450,7 @@ export interface RuleRow {
id: number;
name: string;
conditions: { field: string; operator: string; value: string }[];
actions: { set_category?: string; add_tag_ids?: number[]; set_merchant?: string };
actions: { set_category?: string; add_tag_ids?: number[]; set_merchant?: string; apply_split?: { participant_id: number; share_percent: number }[] };
enabled: boolean;
priority: number;
created_at: string;
@@ -420,14 +511,229 @@ export function useDeleteRule() {
export function useApplyRules() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => {
const res = await fetch("/api/rules/apply", { method: "POST" });
mutationFn: async (splitFrom?: string) => {
const res = await fetch("/api/rules/apply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ splitFrom: splitFrom || null }),
});
if (!res.ok) throw new Error("Failed to apply rules");
return res.json() as Promise<{ matched: number; transactions_affected: number }>;
return res.json() as Promise<{ id: number; matched: number; transactions_affected: number }>;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["rules"] });
qc.invalidateQueries({ queryKey: ["rule-runs"] });
},
});
}
export interface RuleRun {
id: number;
applied_at: string;
split_from: string | null;
matched: number;
transactions_affected: number;
reverted_at: string | null;
}
export function useRuleRuns() {
return useQuery({
queryKey: ["rule-runs"],
queryFn: async () => {
const res = await fetch("/api/rules/apply");
if (!res.ok) throw new Error("Failed to fetch rule runs");
return res.json() as Promise<RuleRun[]>;
},
});
}
export function useRevertRuleRun() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (runId: number) => {
const res = await fetch(`/api/rules/apply/${runId}/revert`, { method: "POST" });
if (!res.ok) throw new Error("Failed to revert run");
return res.json() as Promise<{ reverted: number }>;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["rule-runs"] });
},
});
}
// --- Budgets & Analytics ---
export interface BudgetRow {
id: number;
category: string;
month: string;
amount_limit: number;
}
export interface MonthlyAnalyticsRow {
category: string;
spent: Record<string, number>;
budget: Record<string, number>;
txCount: Record<string, number>;
}
export interface MonthlyAnalytics {
months: string[];
rows: MonthlyAnalyticsRow[];
income: Record<string, number>;
investments: Record<string, number>;
totals: Record<string, { spent: number; income: number; investments: number; net: number }>;
}
export function useBudgets(month: string) {
return useQuery<BudgetRow[]>({
queryKey: ["budgets", month],
queryFn: async () => {
const res = await fetch(`/api/budgets?month=${month}`);
return res.json();
},
});
}
export function useUpsertBudget() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { category: string; month: string; amount_limit: number }) => {
const res = await fetch("/api/budgets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed to save budget");
return res.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
});
}
export function useDeleteBudget() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
await fetch(`/api/budgets/${id}`, { method: "DELETE" });
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
});
}
export function useMonthlyAnalytics(months?: number) {
const m = months || 6;
return useQuery<MonthlyAnalytics>({
queryKey: ["analytics", "monthly", m],
queryFn: async () => {
const res = await fetch(`/api/analytics/monthly?months=${m}`);
return res.json();
},
});
}
export interface SubscriptionRow {
merchant: string;
category: string;
frequency: string;
avg_amount: number;
monthly_equiv: number;
first_seen: string;
last_seen: string;
occurrences: number;
total_paid: number;
is_active: boolean;
}
export function useSubscriptions() {
return useQuery<{ subscriptions: SubscriptionRow[]; total_monthly_equiv: number }>({
queryKey: ["analytics", "subscriptions"],
queryFn: async () => {
const res = await fetch("/api/analytics/subscriptions");
return res.json();
},
});
}
export interface FeeBankRow {
bank_name: string;
fees: number;
interest: number;
total: number;
}
export interface FeeTxnRow {
id: number;
transaction_date: string;
description: string;
merchant_name: string | null;
transaction_type: string;
my_amount: number;
bank_name: string;
}
export function useFees() {
return useQuery<{
by_bank: FeeBankRow[];
transactions: FeeTxnRow[];
total_fees: number;
total_interest: number;
}>({
queryKey: ["analytics", "fees"],
queryFn: async () => {
const res = await fetch("/api/analytics/fees");
return res.json();
},
});
}
export interface MerchantRow {
merchant: string;
category: string;
debit_count: number;
refund_count: number;
gross_spend: number;
total_refunds: number;
net_spend: number;
avg_debit: number;
first_seen: string;
last_seen: string;
months_active: number;
monthly_trend: Record<string, number>;
}
export function useMerchants(months = 12) {
return useQuery<{ merchants: MerchantRow[]; months: number }>({
queryKey: ["analytics", "merchants", months],
queryFn: async () => {
const res = await fetch(`/api/analytics/merchants?months=${months}`);
return res.json();
},
});
}
export interface MerchantTxnRow {
id: number;
transaction_date: string;
description: string;
amount: number;
amount_aud: number | null;
my_amount: number;
transaction_type: string;
category: string;
bank_name: string;
statement_id: number;
}
export function useMerchantTransactions(merchant: string | null) {
return useQuery<{ transactions: MerchantTxnRow[] }>({
queryKey: ["analytics", "merchant-txns", merchant],
queryFn: async () => {
const res = await fetch(`/api/analytics/merchants/${encodeURIComponent(merchant!)}`);
return res.json();
},
enabled: !!merchant,
});
}
+144 -48
View File
@@ -8,10 +8,11 @@ export interface TagRow {
export interface TransactionRow {
id: number;
statement_id: number;
statement_id: number | null;
transaction_date: string;
description: string;
amount: number;
amount_aud: number | null;
transaction_type: string;
merchant_name: string | null;
merchant_normalized: string | null;
@@ -25,14 +26,17 @@ export interface TransactionRow {
category_override: string | null;
merchant_override: string | null;
notes: string | null;
my_share_percent: number | null;
effective_category: string;
effective_merchant: string;
// statement context
// statement context (null for manual transactions)
bank_name: string;
owner_id: number;
owner_name: string;
// tags
tags: TagRow[];
// splits
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
}
export interface StatementRow {
@@ -55,6 +59,7 @@ export interface StatementRow {
fees_charged: number | null;
credit_limit: number | null;
currency: string;
statement_type: string | null;
tier_used: string | null;
owner_id: number;
owner_name: string;
@@ -65,19 +70,22 @@ export interface StatementRow {
interface TransactionFilters {
from?: string;
to?: string;
category?: string;
bank_name?: string;
categories?: string[];
bank_names?: string[];
tag_ids?: string[];
transaction_types?: string[];
search?: string;
statement_id?: string;
tag_id?: string;
sort_by?: string;
sort_dir?: string;
limit?: number;
offset?: number;
amount_min?: number;
amount_max?: number;
}
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
const conditions: string[] = [`s.owner_id = $1`];
const conditions: string[] = [`(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`];
const params: unknown[] = [ownerId];
let paramIdx = 2;
@@ -89,16 +97,33 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
conditions.push(`t.transaction_date <= $${paramIdx++}`);
params.push(filters.to);
}
if (filters.category) {
conditions.push(`COALESCE(o.category_override, t.category) = $${paramIdx++}`);
params.push(filters.category);
if (filters.categories?.length) {
conditions.push(`COALESCE(o.category_override, t.category) = ANY($${paramIdx++}::text[])`);
params.push(filters.categories);
}
if (filters.bank_name) {
conditions.push(`s.bank_name = $${paramIdx++}`);
params.push(filters.bank_name);
if (filters.bank_names?.length) {
const hasManual = filters.bank_names.includes("Manual");
const bankList = filters.bank_names.filter((b) => b !== "Manual");
if (hasManual && bankList.length > 0) {
conditions.push(`(t.statement_id IS NULL OR s.bank_name = ANY($${paramIdx++}::text[]))`);
params.push(bankList);
} else if (hasManual) {
conditions.push(`t.statement_id IS NULL`);
} else {
conditions.push(`s.bank_name = ANY($${paramIdx++}::text[])`);
params.push(bankList);
}
}
if (filters.tag_ids?.length) {
conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = ANY($${paramIdx++}::int[]))`);
params.push(filters.tag_ids.map(Number));
}
if (filters.transaction_types?.length) {
conditions.push(`t.transaction_type = ANY($${paramIdx++}::text[])`);
params.push(filters.transaction_types);
}
if (filters.search) {
conditions.push(`(t.description ILIKE $${paramIdx} OR t.merchant_name ILIKE $${paramIdx})`);
conditions.push(`(t.description ILIKE $${paramIdx} OR t.merchant_name ILIKE $${paramIdx} OR COALESCE(o.merchant_normalized, t.merchant_normalized) ILIKE $${paramIdx})`);
params.push(`%${filters.search}%`);
paramIdx++;
}
@@ -106,9 +131,13 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
conditions.push(`t.statement_id = $${paramIdx++}`);
params.push(Number(filters.statement_id));
}
if (filters.tag_id) {
conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = $${paramIdx++})`);
params.push(Number(filters.tag_id));
if (filters.amount_min !== undefined) {
conditions.push(`t.amount >= $${paramIdx++}`);
params.push(filters.amount_min);
}
if (filters.amount_max !== undefined) {
conditions.push(`t.amount <= $${paramIdx++}`);
params.push(filters.amount_max);
}
const where = `WHERE ${conditions.join(" AND ")}`;
@@ -122,7 +151,7 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
SELECT COUNT(*)::int as total
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
LEFT JOIN statements s ON s.id = t.statement_id
${where}
`;
const countResult = await queryRaw<{ total: number }>(countSql, params);
@@ -130,32 +159,41 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
const dataSql = `
SELECT t.*,
o.category_override, o.merchant_normalized as merchant_override, o.notes,
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
COALESCE(o.category_override, t.category) as effective_category,
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
s.bank_name, s.owner_id,
COALESCE(s.bank_name, 'Manual') as bank_name,
COALESCE(t.owner_id, s.owner_id) as owner_id,
p.name as owner_name,
txn_tags.tags
txn_tags.tags,
txn_splits.splits
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p ON p.id = s.owner_id
LEFT JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
LEFT JOIN LATERAL (
SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags
FROM transaction_tags tt
JOIN tags tg ON tg.id = tt.tag_id
WHERE tt.transaction_id = t.id
) txn_tags ON true
LEFT JOIN LATERAL (
SELECT COALESCE(json_agg(json_build_object('participant_id', ts.participant_id, 'name', sp.name, 'share_percent', ts.share_percent, 'settled', ts.settled) ORDER BY sp.name), '[]'::json) as splits
FROM transaction_splits ts
JOIN participants sp ON sp.id = ts.participant_id
WHERE ts.transaction_id = t.id
) txn_splits ON true
${where}
ORDER BY ${sortCol} ${sortDir}, t.row_index ASC
LIMIT $${paramIdx++} OFFSET $${paramIdx++}
`;
params.push(limit, offset);
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[] }>(dataSql, params);
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[]; splits: string | TransactionRow["splits"] }>(dataSql, params);
const data = raw.map((r) => ({
...r,
tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []),
splits: typeof r.splits === "string" ? JSON.parse(r.splits) : (r.splits ?? []),
})) as TransactionRow[];
return { data, total, limit, offset };
@@ -164,15 +202,16 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
export async function getTransactionById(id: number) {
const sql = `
SELECT t.*,
o.category_override, o.merchant_normalized as merchant_override, o.notes,
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
COALESCE(o.category_override, t.category) as effective_category,
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
s.bank_name, s.owner_id,
COALESCE(s.bank_name, 'Manual') as bank_name,
COALESCE(t.owner_id, s.owner_id) as owner_id,
p.name as owner_name
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p ON p.id = s.owner_id
LEFT JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
WHERE t.id = $1
`;
const rows = await queryRaw<TransactionRow>(sql, [id]);
@@ -218,8 +257,13 @@ export async function getMerchantSuggestions(search: string) {
}
export async function getBankNames() {
const sql = `SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`;
return queryRaw<{ bank_name: string }>(sql);
const [bankRows, manualCount] = await Promise.all([
queryRaw<{ bank_name: string }>(`SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`),
queryRaw<{ count: number }>(`SELECT COUNT(*)::int as count FROM transactions WHERE statement_id IS NULL`),
]);
const banks = bankRows.map((r) => r.bank_name);
if (manualCount[0]?.count > 0) banks.push("Manual");
return banks;
}
export interface ParticipantBalance {
@@ -229,19 +273,59 @@ export interface ParticipantBalance {
unsettled_count: number;
}
export async function getParticipantBalances(ownerId: number) {
export async function getParticipantBalances(ownerId: number, tagIds?: number[]) {
const params: unknown[] = [ownerId];
let tagFilter = "";
if (tagIds?.length) {
params.push(tagIds);
tagFilter = `AND EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id = ANY($2::int[]))`;
}
return queryRaw<ParticipantBalance>(`
SELECT p.id, p.name,
COALESCE(SUM(CASE WHEN ts.settled = false THEN t.amount * ts.share_percent / 100 ELSE 0 END), 0)::numeric(12,2) as total_owed,
COUNT(CASE WHEN ts.settled = false THEN 1 END)::int as unsettled_count
COALESCE(SUM(splits.signed_amount), 0)::numeric(12,2)
- COALESCE(payments.net_paid, 0)::numeric(12,2) AS total_owed,
COALESCE(SUM(splits.split_count), 0)::int AS unsettled_count
FROM participants p
LEFT JOIN transaction_splits ts ON ts.participant_id = p.id
LEFT JOIN transactions t ON t.id = ts.transaction_id
LEFT JOIN statements s ON s.id = t.statement_id
WHERE (s.owner_id = $1 OR s.id IS NULL)
GROUP BY p.id, p.name
LEFT JOIN (
-- They owe me: their splits on transactions I own
SELECT ts.participant_id AS pid,
(CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN t.amount ELSE -t.amount END) * ts.share_percent / 100 AS signed_amount,
1 AS split_count
FROM transaction_splits ts
JOIN transactions t ON t.id = ts.transaction_id
LEFT JOIN statements s ON s.id = t.statement_id
WHERE COALESCE(t.owner_id, s.owner_id) = $1 AND ts.participant_id != $1
${tagFilter}
UNION ALL
-- I owe them: my splits on transactions they own
SELECT COALESCE(t.owner_id, s.owner_id) AS pid,
-((CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN t.amount ELSE -t.amount END) * ts.share_percent / 100) AS signed_amount,
0 AS split_count
FROM transaction_splits ts
JOIN transactions t ON t.id = ts.transaction_id
LEFT JOIN statements s ON s.id = t.statement_id
WHERE ts.participant_id = $1 AND COALESCE(t.owner_id, s.owner_id) != $1
${tagFilter}
) splits ON splits.pid = p.id
-- Net payments always unfiltered (payments are against total debt, not per-tag)
LEFT JOIN (
SELECT
CASE WHEN sp.from_participant_id != $1 THEN sp.from_participant_id ELSE sp.to_participant_id END AS pid,
SUM(CASE WHEN sp.to_participant_id = $1 THEN sp.amount ELSE -sp.amount END) AS net_paid
FROM split_payments sp
WHERE sp.from_participant_id = $1 OR sp.to_participant_id = $1
GROUP BY pid
) payments ON payments.pid = p.id
WHERE p.id != $1
GROUP BY p.id, p.name, payments.net_paid
ORDER BY p.name
`, [ownerId]);
`, params);
}
export interface SharedTransactionRow extends TransactionRow {
@@ -259,13 +343,21 @@ export async function getTags() {
`);
}
export async function getSharedTransactions(ownerId: number) {
export async function getSharedTransactions(ownerId: number, tagIds?: number[]) {
const params: unknown[] = [ownerId];
let tagClause = "";
if (tagIds?.length) {
params.push(tagIds);
tagClause = `AND EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id = ANY($2::int[]))`;
}
const rows = await queryRaw<TransactionRow & { split_data: string }>(`
SELECT t.*,
o.category_override, o.merchant_normalized as merchant_override, o.notes,
COALESCE(o.category_override, t.category) as effective_category,
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
s.bank_name, s.owner_id,
COALESCE(s.bank_name, 'Manual') as bank_name,
COALESCE(t.owner_id, s.owner_id) as owner_id,
p_owner.name as owner_name,
json_agg(json_build_object(
'split_id', ts.id,
@@ -278,17 +370,21 @@ export async function getSharedTransactions(ownerId: number) {
JOIN transaction_splits ts ON ts.transaction_id = t.id
JOIN participants p ON p.id = ts.participant_id
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p_owner ON p_owner.id = s.owner_id
WHERE s.owner_id = $1
AND EXISTS (
SELECT 1 FROM transaction_splits ts2
JOIN participants p2 ON p2.id = ts2.participant_id
WHERE ts2.transaction_id = t.id AND p2.name != 'Me'
LEFT JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p_owner ON p_owner.id = COALESCE(t.owner_id, s.owner_id)
WHERE (
(
COALESCE(t.owner_id, s.owner_id) = $1
AND EXISTS (SELECT 1 FROM transaction_splits ts2 WHERE ts2.transaction_id = t.id AND ts2.participant_id != $1)
) OR (
COALESCE(t.owner_id, s.owner_id) != $1
AND EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1)
)
)
${tagClause}
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name
ORDER BY t.transaction_date DESC
`, [ownerId]);
`, params);
return rows.map((r) => ({
...r,