Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d06088fe34 | ||
|
|
c465742635 | ||
|
|
3f04cbd5e7 | ||
|
|
ba87ff86e7 | ||
|
|
4b7a7e5d9c | ||
|
|
3778bfe836 | ||
|
|
cc852e7c6f | ||
|
|
31a8177958 | ||
|
|
030490efa3 | ||
|
|
d6b4ec84f6 | ||
|
|
ab00f8c592 | ||
|
|
02495e4173 | ||
|
|
315bc06d0d | ||
|
|
76db9dddb4 | ||
|
|
25ef504574 | ||
|
|
e0b0fc91e0 | ||
|
|
856e1a51ab | ||
|
|
831d30669b | ||
|
|
99af10f9ea | ||
|
|
0b40924af0 | ||
|
|
48ec151c15 | ||
|
|
b706973fb5 | ||
|
|
710e089c4f | ||
|
|
b2a8a56c7b | ||
|
|
d384cc96f6 | ||
|
|
5df164e9a5 | ||
|
|
b8cd1b0f89 | ||
|
|
ce67e38d77 | ||
|
|
b8296b6e29 | ||
|
|
0c1f88ed9c | ||
|
|
4a49add277 | ||
|
|
07b8c1ef16 | ||
|
|
1b561af9e9 | ||
|
|
1296555f17 | ||
|
|
7491e70a15 | ||
|
|
0a1f6b48a2 | ||
|
|
ef73a9cea0 | ||
|
|
d53d3106f2 | ||
|
|
02ac136e19 | ||
|
|
084b8764e3 | ||
|
|
281f0d3782 | ||
|
|
85e7801407 | ||
|
|
5206388958 | ||
|
|
8076d1a949 | ||
|
|
aeaca84cc7 | ||
|
|
278e57354c | ||
|
|
9f90d8726f | ||
|
|
859043f5a5 | ||
|
|
fc22a61a43 | ||
|
|
0985c38be8 | ||
|
|
af4c64bba7 | ||
|
|
a8743ba7df | ||
|
|
7b3fd4b65f | ||
|
|
dd11019fdf | ||
|
|
714c5a9b25 | ||
|
|
2a10450c3e | ||
|
|
e72d3ad9e5 | ||
|
|
a7461ff83b | ||
|
|
c1d031511a | ||
|
|
7379437cc3 | ||
|
|
8bd7d77a8a | ||
|
|
f90ba332bd | ||
|
|
e3aa17acdd | ||
|
|
1eff0f9337 | ||
|
|
3cf67f6e2a | ||
|
|
90d8db4abe | ||
|
|
d1a0eedf03 | ||
|
|
5dbeb0cb87 | ||
|
|
30a7857d13 | ||
|
|
1e79ada6d8 | ||
|
|
be85822cc7 | ||
|
|
d455738732 |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx next:*)",
|
||||
"Read(//mnt/m2cache/appdata/smarthome/**)",
|
||||
"Bash(docker compose:*)",
|
||||
"Bash(curl -s http://localhost:4100/ -I)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(docker port:*)",
|
||||
"Bash(docker inspect:*)",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(k, v['IPAddress']\\) for k,v in d.items\\(\\)]\")",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(node_modules/.bin/vitest:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(xargs ls:*)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(docker exec postgres-personal:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#ENC[AES256_GCM,data:8Y6694wDaHKDf03nhurNnRYhbMv4TXBNI7HJnh0I4jM0tq1W4iq0NLgQ4W0wDx/cVat0szyrq/tAYsUj+LuRMMdHEY3Fcdp51MT2KtGXEa9kw/Wp,iv:jJTLMVvpbW7cKOGWV6x2JuAqkx6QgOKlw4K9NXA9fa4=,tag:9DhD7Zm9BtJPPH+mXjzZuQ==,type:comment]
|
||||
#ENC[AES256_GCM,data:HMMVGfE8jPTsDyz9y0d+vfyRaf0B3segW04TFPIZDtJBlDD/RUMbv3KylOm0e+iBkJQFAIeAgBrvxr9+myD7XwYWJtIjVYrcgktHlcC8GoH4cotGDQni58uhMknlF33TdOVxTxs=,iv:4FH4Qbe4LMdUiIRLhhr+0Dnp7tT85EwQ0mcHqfz3T+Y=,tag:DT4muDzq/cKeGhgriX+Rbw==,type:comment]
|
||||
#ENC[AES256_GCM,data:L39MkIKSEcj9C/aXvlz9zPDvR0Awgp4OYVXYX0ctIuwCKq0qmbApFv+fj6YbcnS7vlWttlDcyypnF8KbJe+0bDFRLN+MBzmWjMUhjKPz8eYe+v6gp9Mz,iv:1H88VnLGt6P81KP3e3lonvpL/OhtBCardjhPT0hCOag=,tag:jJmHguBHWp0Kl3Je0bEG3w==,type:comment]
|
||||
#ENC[AES256_GCM,data:BBpTe55+cpatCSGS4g7YaWb0PRS4QjbrUE+mG2CzVmtS63s5onN22swVYtQw/NaMcv8qkrR2imww/vHnH/Z98IuJpAZIy8CXTMGNzXvFU82LNFIcb2QPZ1bWHHWLJfj7EHoDRgCBvuDkO8cAORgmI1U3m1ekZe82,iv:fI7a+edL5LpLVt+2vD75YwcxzHaO/Hz6OfPylSCTYEs=,tag:leDqySg/m9G8vlddrhwWhg==,type:comment]
|
||||
#ENC[AES256_GCM,data:hKjWUHaA7rYY/VjDprmcQ++mphIBzPyFJVylFGyDEfRu4pSoFN3ZGD3B0H8P2wiN8IarrJz4p6hQzfrB248lEojxKBoUmOU1Dxegv1qhiNO0liIQnZWTtKrenz4zzOdb3sw=,iv:CfydB3duDyuuSphNz5OuZQMbWpJBW0u1Os94KeclgXw=,tag:PAn6S9DCeizHcaxCvS+jdg==,type:comment]
|
||||
#ENC[AES256_GCM,data:hVUROHUoWJuBofyc4qdzTC1+PrecP8jG/1i/xddsTxthNwhYBiEcvq2aJhfXggbNSC1+PJ8AGg2sCrHL3NfD7KBAX33GgdDx0JAeHsyfsHN9hc583cjeVQDlOmocHGrBkGFTOi1g,iv:uiVFAPKXCQhTUFUSj0yPaX3l+eDmhsrwRrPQkTaksig=,tag:pyW76cx0dPErUWXoxW95+A==,type:comment]
|
||||
#ENC[AES256_GCM,data:BOynLXwT+rKrTd1dxnVjveABiJaA94Zl2UQ/yPg5It+cbUNUpJ/GMlrR5OLJQMkw5OD+EkeEMqieY0KYTAualEKOoaIG6T+c0zZAjYNrmWBZYrPuOtpLk4Bzwuel0agrFAs3xqvmsk/OuktQNyvmXMgUpwnzD0U=,iv:WZn4CCL0tzzdorI0zl/mqE+/MMkNtkIyo1SBHpboFw4=,tag:kY/+mg9qyLAxC27UwnXacA==,type:comment]
|
||||
#ENC[AES256_GCM,data:KzC0cK7daY++mskeHmBEjlmDvmC2oncQ18mxwB1tGS0hp2X7lOOYgMYnRwGZRy5Gd0ymfJF8M52GDIV7VGKsOFT2Rt+T3EcCJ8ng1Q1EJpTPk1139g27,iv:ZTg+sbmSuX3oJrf6AWkWFwV2eS+D7j0RNP57d6TGh6A=,tag:bTtnmShFZPxNi9hzI3PVrQ==,type:comment]
|
||||
DATABASE_URL=ENC[AES256_GCM,data:fnfh3OD0nX7xmdCy74/8uoDxu7JNsplPbJeZ/YUrwzrr5BrdJx5+CUbIc/BDx1I1+sId0tu5G2rx1GiPJUKSVw9fKMLrP2yXk8EUjAKcTl1ozOEAxVsW6S/0fqjdBheK2iLgomcQ6LLhT2CHBUadmPNoOEpuYGRY16akPotafWGBGxZiVmg9zD0DZwgxY0ASJ6NQKn5Fup1+FAkSuLPV5Ok1evDecVol9La0bp3pVT6kX0bWQlP8SrxOpznZ2XQbHjXU6xNXJvaA2mVeP6qjCDxJbXX8BB+I52l1ZR5mQ7RZSpossbwT+q6yXGmxUVKDP54oqq7eMasNQ4AkpWrgTSSivogBATuGQyVvBUEeymRWfPRayliGMoEbiITO3Lf6/Z+u+xcAWDUoThJZ3LemScC/RgRN/PG3Dnx1b/uF0piFRDF59xNela515QlQqUDiwoxAdO7hhY1l/gH/VwZEvp4GYJgMqTIP+85Ta2y0Ph4z9clSSHOc/dkAnknRyQDCpbtDSR+DCppzQ9OgUzPK7Popqa1k/yO6Oo+1OTpov3I18Yyh+Vy00kYx9rR2AAp5C32K2rYkdYrEiK3erIBhKNPrLrYRiT/2LReArMAxpUk1iM9uDPnx/JEhISJRlD+PWcF1R8i8CiBc+OhTFOlhKykEUdpXBv4eBzqB0tSwsEnT/zJzMLvqiVoYX0fnGlS61Lg06ZIRohzQLZAU6A+m0rOOFmRkHS6oZbkOnRxf8c5QfjVUq5WxmPNEFWHsIi2LsbjkDLk9yoDLIXflO+G2sP6JB/CWvCXAK++5MiS0G6ijNBEi+kjjHokE/Snpaq+Uw7JynAFKJ0QVMT7YP9YGfVJu2cYIvEkpWtz8,iv:D2HI2gwY9R7jXFsClkV7qKDDxvBips8BVAGOPKLNZCk=,tag:cY8AgAkGXJQZpbUsQBiRxQ==,type:str]
|
||||
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkSGtocXNOZXU0NUZSZlBY\nYmpMbnJVbVVSV2xnWVdwUVBObWtIb05raW1jCkdJRzUyTlBGSjFISks3OHN0clJY\ncmluR0o0aGlXU3VOaVVUc0t5RWk4NVUKLS0tIHdZUVZrd3JxUmpnbWJkUzZBaHRx\nZ2lKK0NZT0RjU1J0andQMVRPMGdoL2sKlgWx9xKOabP5q4cmHPVVD7xuwn6/OV6V\nZR6MXV07XzLfUl3G5NMeOBI4e6s9y+xZGSoDWeWB194euASTlyiwwA==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_0__map_recipient=age1tw6wsyxgxa465cc0wx32u7xuw5675pyz35cuzey4huz69hc54v4qp0pvgn
|
||||
sops_lastmodified=2026-05-15T09:13:11Z
|
||||
sops_mac=ENC[AES256_GCM,data:BhA6Tbo2bNOYzuoYBd/3nYp9CrZx7Nspl6Us1hpUIXR/EEOLRUCofqbVyDzU+2CNQMNivPYyltQ4nRdapDQwKWlZjTEufSkRKNwafr8eexN3Dwo1j3Njw9bK26ytG/d1Dm3jXOKogRRUZYsQqtHZBmZwuF4JvL49x+ODQ0Hco1o=,iv:6cIK8//MbBoiPSsxJnp7oKiz04B1hxa4vK0QFEBzHP4=,tag:TT5b8KSO+UUZJ/hOJqkMpw==,type:str]
|
||||
sops_unencrypted_suffix=_unencrypted
|
||||
sops_version=3.9.4
|
||||
@@ -0,0 +1,22 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install
|
||||
run: npm ci
|
||||
# Advisory until the pre-existing lint debt is cleared (2026-07-19:
|
||||
# ~20 errors across budget/insights/shared pages) — then make blocking.
|
||||
- name: Lint (advisory)
|
||||
run: npm run lint
|
||||
continue-on-error: true
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
@@ -32,6 +32,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.sops
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -41,3 +42,6 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
# Raw statement exports — real financial data, never commit
|
||||
dump/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
creation_rules:
|
||||
- path_regex: \.env$
|
||||
age: age1tw6wsyxgxa465cc0wx32u7xuw5675pyz35cuzey4huz69hc54v4qp0pvgn
|
||||
@@ -0,0 +1,397 @@
|
||||
# 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
|
||||
|
||||
**Deployment is push-to-deploy via Komodo** (since 2026-07-19): pushing to `main` on
|
||||
Gitea triggers the `deploy-finance` Procedure, which runs DeployStack `--build` on the
|
||||
`finance` stack (files_on_host over `docker/finance/` in the smarthome repo). Just
|
||||
commit and push — no manual deploy needed.
|
||||
|
||||
```bash
|
||||
# Manual fallback only (from smarthome repo root), e.g. if Komodo is down
|
||||
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 — push to main (or 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); `reconciled_with_id` links a manual tx to its matched statement tx; `payment_method` (migration 0016) is `card | cash | bank_transfer | other`, NULL = unknown
|
||||
|
||||
### Cash and reconciliation
|
||||
|
||||
`payment_method = 'cash'` excludes a transaction from reconciliation via the
|
||||
`notCash()` fragment in `queries.ts`. Cash never appears on a statement, so
|
||||
without it a cash entry sits in the pending queue forever being offered matches
|
||||
within 3 days and 1% on amount — and accepting one is silently destructive:
|
||||
reconciled manual rows are filtered out of every query, so the cash spend
|
||||
disappears while the card transaction it matched claims to be that same spend.
|
||||
|
||||
Only cash is excluded. Bank transfers *do* appear on a statement now that
|
||||
transaction accounts are imported, and NULL means unknown — both stay
|
||||
candidates, preserving the behaviour of every pre-existing row.
|
||||
|
||||
ATM withdrawals stay categorised as spend rather than `transfers`. Treating them
|
||||
as transfers only works if every cash purchase is logged; with partial logging
|
||||
it silently deletes the unlogged remainder from spend totals.
|
||||
- `transaction_overrides` — user corrections to AI-extracted data (category, merchant, notes)
|
||||
- `transaction_splits` — shared expense tracking (participant, share_percent, settled)
|
||||
- `split_payments` — recorded cash settlements between participants
|
||||
- `transaction_tags` — many-to-many join to `tags`
|
||||
- `rules` — auto-categorisation rules (JSONB conditions + actions)
|
||||
- `rule_apply_runs` — audit log of bulk rule-apply runs with full snapshot for revert
|
||||
- `expense_metadata` — enrichment from email receipts; `transaction_id` nullable until reconciled
|
||||
- `participants` — people; `id=1` is "Me" (the primary user)
|
||||
- `account_owner_mappings` — persists bank+account → owner assignments
|
||||
|
||||
### Shared expenses and settlement — read before touching
|
||||
|
||||
The model is under active redesign. See `docs/shared-expenses-design.md` for the
|
||||
proposal and what is already decided. Three traps:
|
||||
|
||||
**`transaction_splits.settled` is dead data.** It is `false` on every row. Its
|
||||
only writer was `/api/splits/settle`, removed in `3f04cbd` because nothing called
|
||||
it and one request could mark all of a participant's splits settled. Do not build
|
||||
on this flag until settlement contexts exist.
|
||||
|
||||
**`getParticipantBalances` computes `splits − payments` and is correct.** Do not
|
||||
"fix" it to exclude settled splits — the payments that settled them are still
|
||||
subtracted, so you would double-count. The two settlement models (running tab vs
|
||||
per-split flag) must not be mixed.
|
||||
|
||||
**Settlement cannot be attributed per trip.** `split_payments` records only
|
||||
from/to/amount/date. Any per-trip settled/unsettled figure is fabricated; the
|
||||
trip view used to show one and always reported 100% unsettled. Trips show share
|
||||
only, and point at `/shared` for real balances.
|
||||
|
||||
Also: settlements already exist twice. Four of eight `split_payments` match an
|
||||
offset-account credit exactly on amount and date, with `linked_transaction_id`
|
||||
populated on only one. And Sonu's loan contributions (`…emi` in the offset
|
||||
account, 39 rows, $37,980.24) are categorised `transfers`, indistinguishable from
|
||||
ordinary internal transfers.
|
||||
|
||||
**Partial split coverage inside a category is usually correct, not a gap.** Only
|
||||
*shared* items are split. `utilities` sits at 69% yours because Globird, OVO, GWW
|
||||
and home telecoms are split while Telstra, Vodafone, Optus and JB Hi-Fi Mobile
|
||||
are personal. `subscriptions` is 91% because Uber One, Amazon Prime and OnePass
|
||||
are shared while Claude, OpenAI, Anthropic, OpenRouter, You.com, LinkedIn, Xero,
|
||||
Billdu, Spotify and Patreon are not. `fees` and `charity` are 100% yours and
|
||||
correct. Check the merchants before concluding a rule was never applied — a
|
||||
category-level ratio that "looks wrong" usually is not.
|
||||
|
||||
Splits exist in this app from **2026-01-09** only; earlier splits lived in
|
||||
SplitMyExpenses. So a trailing-12-month per-person series splices six months of
|
||||
gross onto six months of net. Use Feb–Jun 2026 for anything per-person.
|
||||
|
||||
### The shared loan
|
||||
|
||||
The loan is a **separate ledger**, not a shared expense and not a settlement
|
||||
context — a contribution must never be able to settle a dinner. Sonu's obligation
|
||||
is a fixed 50% of the repayment; actual contributions vary, and the difference is
|
||||
a tracked receivable ($4,000.00 over 2025-07 → 2026-06).
|
||||
|
||||
Do not derive the share from actual payments. During her leave the obligation did
|
||||
not change, only the payment did — a percentage-of-actual model would silently
|
||||
redefine her share as 30% and make the shortfall vanish.
|
||||
|
||||
Loan interest reconciles exactly: `repayments − interest − fees = balance
|
||||
reduction`. It stays categorised `loan_interest` and counts as spend — over 12
|
||||
months $63,500 of cash left and debt fell $44,127.36, and the $16,523.64
|
||||
difference bought nothing. Excluding it would leave the balance sheet unable to
|
||||
reconcile cash out against equity gained.
|
||||
|
||||
**The repayment is voluntarily above contracted, and the gap is the largest
|
||||
flexible cost in the whole picture.** Contracted is $1,190.54/fortnight
|
||||
($2,579.50/mo annualised); the actual direct debit is $2,500.00/fortnight
|
||||
($5,416.67/mo). That is $2,837.17/mo of overpayment, and it is *not* sunk — it
|
||||
shows up as `statements.redraw_available`, which grew $62,387.17 → $81,017.42
|
||||
across the two most recent loan statements. Sonu returned to $1,250/fortnight in
|
||||
July 2026 after the reduced $750 period during her leave.
|
||||
|
||||
Treat the repayment as two figures whenever asking "what does this cost me":
|
||||
the contracted floor and the actual. `scheduled_repayment` holds the actual
|
||||
($2,500), not the contracted minimum — the contracted figure is not in the DB at
|
||||
all. See `docs/expense-baseline.md`.
|
||||
|
||||
### Import Date (`created_at`)
|
||||
|
||||
`transactions.created_at` is the import timestamp (DB default `now()`). In the transactions and shared views, the "Imported" column shows:
|
||||
- For statement transactions: when the statement was processed by N8N
|
||||
- For reconciled transactions: the `created_at` of the original manual/CSV transaction (via `LEFT JOIN transactions src ON src.reconciled_with_id = t.id`) — so the original import date is preserved post-reconciliation
|
||||
|
||||
Use `created_at` (not `transaction_date`) to answer "what was added since the last settlement?". Sort by `created_at` is supported server-side in `getTransactions` and client-side in the shared view.
|
||||
|
||||
### 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()`).
|
||||
|
||||
**A rule with zero conditions matches every transaction.** Both apply paths use
|
||||
`conditions.length === 0 || conditions.every(...)`. Rule 43 "Home 50/50 Sonu" has
|
||||
no conditions and a 50/50 split action — applying it blindly would split all
|
||||
~3,700 transactions with another participant. That is what `manual_only` is for:
|
||||
those rules are excluded from bulk runs and fire from the transactions page
|
||||
against a hand-picked selection.
|
||||
|
||||
### Previewing a rule before applying it
|
||||
|
||||
`GET /api/rules/[id]/matches` is a dry run — it writes nothing and returns only
|
||||
the transactions a rule would actually *change*, with already-correct rows
|
||||
summarised as a count. The Preview button on the rules page uses it.
|
||||
|
||||
Apply then goes through `POST /api/transactions/bulk` with `action: "apply_rule"`
|
||||
and **explicit transaction ids**, not the conditions. That is the safety
|
||||
property: a rule whose conditions are too broad cannot reach further than what
|
||||
the preview showed and the user ticked.
|
||||
|
||||
Prefer this over auto-applying rules on ingestion. It fails safe, works
|
||||
retroactively, and tells you which rules are consistent enough to automate later.
|
||||
|
||||
### Rule apply history
|
||||
|
||||
`rule_apply_runs` snapshots the before-state so a run can be reverted, and since
|
||||
migration 0017 also records `rule_id`, `rule_name` and `source`
|
||||
(`all` | `rule` | `selection`). `rule_name` is denormalised deliberately and
|
||||
there is no FK to `rules` — history must stay readable after a rule is renamed or
|
||||
deleted, and deleting a rule must not cascade away the audit trail.
|
||||
|
||||
`GET /api/rules/runs/[id]` diffs that snapshot against current values. Rows
|
||||
changed by something else since the run are flagged, because reverting restores
|
||||
the pre-run value and discards the later edit.
|
||||
|
||||
### Trusting extracted statement data
|
||||
|
||||
**Balance assertions are the check that works.** `getStatements` computes
|
||||
`opening + movement − closing`; the statements page flags any statement that does
|
||||
not reconcile. Sign depends on what the balance means — on a credit card or loan
|
||||
it is what you owe, so spending increases it; on a transaction or offset account
|
||||
it is what you hold. 11 pre-existing statements currently fail, ~$4,177
|
||||
unexplained, including two adjacent ANZ statements off by exactly ±$230.38 (a
|
||||
transaction filed against the wrong one).
|
||||
|
||||
**Do not derive `opening_balance` from `closing − movement`.** It is an accounting
|
||||
identity, so every statement would reconcile and the check would go permanently
|
||||
green. A null that reads "unverified" is worth more than a number that is right
|
||||
by construction. For the same reason, do not add a totals assertion comparing
|
||||
`total_debits` to the summed rows — those totals are now *computed* from the rows
|
||||
(see the N8N `Parse Gemini Result` node), so that check can never fail.
|
||||
|
||||
**Gemini invents summary fields the statement does not print.** Wise PDFs show
|
||||
only a closing balance; asked for an opening balance anyway, the model produced
|
||||
11,277.08 against a truth of 0.00, and on another statement read the running
|
||||
balance of the oldest row. Every transaction was extracted perfectly in both
|
||||
cases — verified row for row against the CSV exports. When a balance assertion
|
||||
fails, suspect the summary before the transactions.
|
||||
|
||||
**Gemini drops rows silently on long tables.** `finishReason` was `STOP`, not
|
||||
`MAX_TOKENS`, so raising `maxOutputTokens` does not help. This did *not* actually
|
||||
occur on the Wise imports (that was the summary bug above), but it is why an
|
||||
empty statement must not throw: a document that errors never gets tagged, so it
|
||||
is re-fetched every poll forever and blocks everything behind it in the queue
|
||||
(`ordering=-created`, `page_size=1`).
|
||||
|
||||
**FX is per transaction date**, via Frankfurter (ECB daily, free, no key), with
|
||||
weekends resolving to the prior publication. A single spot rate across a 15-month
|
||||
statement is wrong by up to 20%. Wise's own rates are more accurate in principle
|
||||
but differ by only 0.05% and exist on 44 of 194 rows, so mixing bases is not
|
||||
worth it.
|
||||
|
||||
**When comparing CSV exports to extracted data, order by full timestamp
|
||||
including milliseconds.** Two of one statement's rows are 1ms apart; dropping the
|
||||
fraction reversed them and produced a bogus opening balance.
|
||||
|
||||
## 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`
|
||||
|
||||
Analytics queries must import the fragments from `src/lib/analytics-sql.ts`
|
||||
(`STATEMENTS_JOIN`, `OWNER_SCOPE`, `EFFECTIVE_CATEGORY`, `EXCLUDE_NON_SPEND`)
|
||||
rather than hand-rolling them. Two failure modes they exist to prevent:
|
||||
|
||||
- An `INNER JOIN statements` + `WHERE s.owner_id = $1` silently drops every
|
||||
manual/CSV transaction (`statement_id IS NULL`).
|
||||
- Spend must exclude the `transfers` and `investment` categories. Once bank
|
||||
statements are imported alongside card statements, a credit-card payment
|
||||
appears twice — as a debit leaving the bank account and as the underlying
|
||||
purchases on the card statement. Excluding `transfers` is what nets it out.
|
||||
Use the `EXCLUDE_NON_SPEND` fragment: a bare `category NOT IN (...)` evaluates
|
||||
to NULL for uncategorised rows and drops them from totals.
|
||||
|
||||
### Statement types
|
||||
|
||||
`statements.statement_type` is constrained to `credit_card | transaction |
|
||||
savings | loan | offset | investment | other`. Migration 0013 added a
|
||||
`normalize_statement_type()` SQL function plus a BEFORE INSERT/UPDATE trigger, so
|
||||
the N8N workflow can keep sending raw free text (`'ACCESS ADVANTAGE'`, `'Business
|
||||
Card'`) and the DB normalises it on write. The raw extracted value is preserved in
|
||||
`account_type`.
|
||||
|
||||
The TypeScript mirror is `src/lib/statement-types.ts` — keep the list, the SQL
|
||||
function, and the CHECK constraint in sync when adding a type.
|
||||
|
||||
### Loans
|
||||
|
||||
A loan repayment is **not** an expense. It is part principal (equity, a
|
||||
balance-sheet move) and part interest (the only part that is spend). Migration
|
||||
0014 adds:
|
||||
|
||||
- `transactions.principal_amount` / `interest_amount` — populated only when the
|
||||
lender itemises the split on the repayment row itself
|
||||
- `statements.interest_rate`, `scheduled_repayment`, `repayment_frequency`,
|
||||
`redraw_available`, `loan_term_months`
|
||||
|
||||
Two statement shapes, both handled:
|
||||
|
||||
1. **Separate rows** (the common Australian case) — the loan statement lists
|
||||
repayments and "Interest Charged" separately. `transaction_type` alone is
|
||||
enough: `interest` rows count as spend, `payment` rows don't. No split columns
|
||||
needed.
|
||||
2. **Itemised repayment row** — some lenders print principal and interest on the
|
||||
repayment line. That row is typed `payment`, so it would be skipped entirely
|
||||
and its interest lost. The `SPEND_ROWS` / `SPEND_BASE` fragments in
|
||||
`analytics-sql.ts` handle it: a row with a non-null `interest_amount` counts
|
||||
as spend, valued at `interest_amount` rather than `amount`.
|
||||
|
||||
The N8N `Parse Gemini Result` node only accepts a split when both parts are
|
||||
present *and* they sum to the row amount (±2c) — a half-extracted split would
|
||||
silently misreport spend, so it is discarded rather than trusted.
|
||||
|
||||
Loan interest uses the `loan_interest` category; principal repayments use
|
||||
`investment` (excluded from spend, surfaced on the investments line in monthly
|
||||
analytics).
|
||||
|
||||
### Prisma
|
||||
|
||||
The schema at `prisma/schema.prisma` covers all tables. The generated client (gitignored) must be regenerated after schema changes:
|
||||
```bash
|
||||
cd /mnt/m2cache/appdata/finance-app && npx prisma generate
|
||||
```
|
||||
Docker builds run `npx prisma generate` automatically. Do not commit `src/generated/prisma/` — it is gitignored.
|
||||
|
||||
## Agent / MCP Access
|
||||
|
||||
Agents read this DB through the read-only `postgres-personal` MCP server (lives in the
|
||||
`personal-agent-gateway` repo, not here): `agent_ro` role, SELECT-only, SQLGlot guardrail,
|
||||
100-row cap, every call audited to `mcp_query_log`. See `docs/agent-access.md` for the tool
|
||||
list, the five analysis views, and per-client setup (Claude Code, Codex, Hermes).
|
||||
|
||||
Two things to remember when changing the schema: the agent views are created by
|
||||
`smarthome/personal-agent/migrations/006_agent_read_role_views.sql` (not Prisma) and read
|
||||
`transactions`/`statements`/`expense_metadata` columns directly — rename a column and they
|
||||
break or go stale. And the views are **not** owner-scoped and do **not** merge
|
||||
`transaction_overrides`, so agent numbers can differ from the UI.
|
||||
|
||||
## 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.
|
||||
|
||||
### Open as of 2026-07-26
|
||||
|
||||
- **Shared expenses redesign** — `docs/shared-expenses-design.md`. Phase 0 done;
|
||||
Phases 1–4 unbuilt. Deliberately paused to live with the current behaviour
|
||||
before committing to a model designed in one session.
|
||||
- **Expense baseline / emergency reserve** — `docs/expense-baseline.md`. One-off
|
||||
analysis, nothing built. Records four data corrections the raw numbers need
|
||||
(misfiled Raiz/super/brokerage debits, `other` credits read as negative spend,
|
||||
`government` conflating ATO with rates/rego, `fees` being mostly annual) and
|
||||
why only Feb–Jun 2026 is trustworthy for per-person figures.
|
||||
- **11 statements fail the balance assertion**, ~$4,177 unexplained. Predates
|
||||
this work. One ANZ statement is off by exactly $0.50, traced to a misread digit
|
||||
in fee rows ($5.00 vs $5.50).
|
||||
- **28 Up Bank debits are categorised `other`** ($3,760.74). Up only categorised
|
||||
16 of 88 rows. The `Payee` field is populated throughout, so merchant rules plus
|
||||
the rule preview should clear most of it.
|
||||
- **Up item sales are categorised `income`** ($4,238.04 across 19 credits — iPad,
|
||||
drone, camera). Correct in that they are excluded from spend, but it mixes
|
||||
asset disposals into the income line alongside salary.
|
||||
- **`payment_method` is not shown in the transactions list** — settable on create
|
||||
and edit only. Worth a column or filter if cash becomes routine.
|
||||
- **Raw statement exports live in `dump/`**, gitignored since `31a8177`. They were
|
||||
committed by accident in `030490e` and remain in that commit's history; the repo
|
||||
has no GitHub remote, so exposure is limited to the local Gitea. Purging history
|
||||
was offered and not actioned.
|
||||
@@ -1,36 +1,409 @@
|
||||
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 |
|
||||
| `reconciled_with_id` | int FK → `transactions` (nullable) | Links a manually-entered transaction to its matching statement transaction after reconciliation |
|
||||
| `created_at` | timestamptz | When the row was inserted — the "import date". For reconciled transactions the UI shows the original manual/CSV `created_at`, not the statement's |
|
||||
|
||||
**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 (1–100) |
|
||||
| `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`
|
||||
|
||||
---
|
||||
|
||||
### `split_payments`
|
||||
Records of actual cash settlements between participants.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `from_participant_id` | int FK → `participants` | Who paid |
|
||||
| `to_participant_id` | int FK → `participants` | Who received |
|
||||
| `amount` | numeric | Amount settled |
|
||||
| `payment_date` | date | Date of settlement |
|
||||
| `notes` | text | Optional note (e.g. "bank transfer") |
|
||||
| `linked_transaction_id` | int FK → `transactions` (nullable) | If the payment was itself a transaction |
|
||||
|
||||
---
|
||||
|
||||
### `expense_metadata`
|
||||
Enrichment records for non-statement expenses (email receipts, manual entries). Linked to a `transaction` if one exists; otherwise a standalone record awaiting reconciliation.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `transaction_id` | int FK → `transactions` (unique, nullable) | Linked transaction; NULL until reconciled |
|
||||
| `source` | text | Origin: `email`, `manual` |
|
||||
| `paperless_doc_id` | int | Paperless-NGX document ID |
|
||||
| `payment_method` | text | `credit_card`, `debit_card`, `paypal`, `afterpay`, `cash`, etc. |
|
||||
| `payment_method_detail` | text | Card last-4 or provider detail |
|
||||
| `order_reference` | text | Order/confirmation number |
|
||||
| `line_items` | jsonb | Array of `{description, qty, unit_price, total}` |
|
||||
| `merchant_normalized` | text | Canonical merchant for matching |
|
||||
| `amount` / `transaction_date` | numeric / date | Used for reconciliation matching when `transaction_id IS NULL` |
|
||||
| `extraction_model` | text | AI model used (`gemini-2.5-flash`) |
|
||||
|
||||
Partial index on `(merchant_normalized, transaction_date) WHERE transaction_id IS NULL` powers reconciliation queries.
|
||||
|
||||
---
|
||||
|
||||
### `rule_apply_runs`
|
||||
Audit log of bulk rule-apply operations. Each run captures which transactions were affected and a full snapshot for revert support.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `owner_id` | int FK → `participants` | |
|
||||
| `applied_at` | timestamptz | When the run executed |
|
||||
| `split_from` | date | Optional date filter used for this run |
|
||||
| `matched` | int | Number of rules matched |
|
||||
| `transactions_affected` | int | Number of transactions changed |
|
||||
| `reverted_at` | timestamptz | Set when run was reverted |
|
||||
| `snapshot` | jsonb | Pre-run state of all affected transactions |
|
||||
|
||||
---
|
||||
|
||||
### `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. Filters: `from`, `to`, `categories`, `bank_names`, `tag_ids`, `transaction_types`, `search`, `statement_id`, `amount_min`, `amount_max`, `has_split` (`yes`/`no`). Sort: `sort_by` (`transaction_date`\|`amount`\|`created_at`), `sort_dir` (`asc`\|`desc`) |
|
||||
| POST | `/api/transactions` | Create a manual transaction (no statement) |
|
||||
| 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 |
|
||||
| POST | `/api/transactions/reconcile` | Link manual transactions to statement transactions; copies overrides, tags, splits across |
|
||||
| GET | `/api/analytics/monthly` | Split-adjusted monthly spend by category + income + investments. Params: `months` (1–24, 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 with active splits; sorted client-side by date/imported/amount in the UI |
|
||||
| POST | `/api/splits/settle` | Mark a split as settled |
|
||||
| GET / POST | `/api/split-payments` | List or record cash settlements between participants |
|
||||
| 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 |
|
||||
| `0008_my_share_percent` | `my_share_percent` on `transaction_overrides` |
|
||||
| `0009_split_payments` | `split_payments` |
|
||||
| `0010_csv_import_reconcile` | `reconciled_with_id`, CSV import support |
|
||||
| `0011_trips` | `trips`, `trip_id` on `transaction_overrides` (catch-up — was applied directly) |
|
||||
|
||||
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.
|
||||
> `reconciled_with_id` on transactions, `expense_metadata`, `rule_apply_runs`, `split_payments` were added directly and are covered by the Prisma schema but lack individual migration files.
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## Agent Access (MCP)
|
||||
|
||||
LLM agents read this data through the read-only `postgres-personal` MCP server (7 typed
|
||||
finance tools + guarded `adhoc_query`, `agent_ro` role, 100-row cap, `mcp_query_log`
|
||||
audit). It's a stdio server, so any MCP client can use it — Hermes, Claude Code, Codex,
|
||||
Claude Desktop — locally via `docker exec` or remotely over SSH.
|
||||
|
||||
**→ [docs/agent-access.md](docs/agent-access.md)** — architecture, tool + view reference,
|
||||
per-client setup, and what changing the schema means for the agent views.
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
Runs as a Docker container alongside the rest of the home lab stack.
|
||||
|
||||
**Push-to-deploy (default, since 2026-07-19)**: pushing to `main` on Gitea fires a
|
||||
webhook to Komodo's `deploy-finance` Procedure, which redeploys the `finance` stack
|
||||
with `--build`. CI (Gitea Actions) runs lint + unit tests on the same push.
|
||||
|
||||
**Manual fallback** if Komodo is unavailable:
|
||||
|
||||
```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 is only reachable via Traefik (`https://finance.bosecamp.com`, forward-auth
|
||||
sets `X-Forwarded-User`) plus a `127.0.0.1`-bound host port for on-box debugging — the
|
||||
app trusts the `X-Forwarded-User` header, so it must never be directly reachable from
|
||||
the LAN.
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# Agent Access — querying the finance data from an LLM agent
|
||||
|
||||
How an AI agent (Hermes, Claude Code, Codex, Claude Desktop, …) reads the finance
|
||||
database. **Read-only, audited, row-capped.** Agents never write to `personal`.
|
||||
|
||||
The read path is an MCP server — `postgres-personal` — that lives in the
|
||||
[`personal-agent-gateway`](ssh://git@localhost:2222/siddharthd/personal-agent-gateway.git)
|
||||
repo at `mcp-servers/postgres-personal/`. This app owns the data; that repo owns the
|
||||
server. Keep both in mind when changing the schema (see [Schema changes](#schema-changes-that-affect-agents)).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
agent client (Hermes / Claude Code / Codex)
|
||||
│ stdio (JSON-RPC, MCP)
|
||||
▼
|
||||
server.py ── guardrails.validate_select (SQLGlot: single SELECT, LIMIT ≤ 100)
|
||||
├── PG_DSN_RO → role agent_ro (SELECT only, statement_timeout 5s) → queries
|
||||
└── PG_DSN_LOG → role agent_log (INSERT into mcp_query_log only) → audit
|
||||
```
|
||||
|
||||
Both roles, the audit table, and the five analysis views are created by
|
||||
`smarthome/personal-agent/migrations/006_agent_read_role_views.sql` — **not** by this
|
||||
repo's Prisma migrations. Passwords are supplied at apply time from Infisical
|
||||
(`homelab-w-ae9 / prod / /agent-gateway`), never stored in the SQL file.
|
||||
|
||||
Defence in depth, in order:
|
||||
|
||||
1. `agent_ro` has no write grants at all — the DB refuses writes regardless of the SQL.
|
||||
2. `validate_select` parses every SQL string (typed tools and `adhoc_query` alike) and
|
||||
rejects anything that isn't one plain `SELECT`, plus `pg_sleep`/`pg_read_file`/
|
||||
`dblink`/`lo_import`/`lo_export`. It rewrites `LIMIT` to ≤ 100.
|
||||
3. Third-party text columns (`description`, `location`, `order_reference`, `line_items`)
|
||||
come back wrapped in `<<untrusted-data>>…<</untrusted-data>>` so the runtime's system
|
||||
prompt can treat statement/receipt text as data, never instructions.
|
||||
4. Every call — accepted or rejected — is inserted into `mcp_query_log`
|
||||
(`tool, args, sql, row_count, duration_ms, error`).
|
||||
|
||||
Audit review:
|
||||
|
||||
```bash
|
||||
docker exec postgres-personal psql -U personal -d personal -c \
|
||||
"SELECT at, tool, row_count, duration_ms, error FROM mcp_query_log ORDER BY at DESC LIMIT 20"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | What it answers |
|
||||
|---|---|
|
||||
| `query_statements` | Statements by bank / account number / `billing_end_date` range |
|
||||
| `query_transactions` | Transaction search: merchant, category, date range, amount range, description substring |
|
||||
| `get_spending_summary` | Outflow for one month (`YYYY-MM`) or date range, grouped by category or merchant |
|
||||
| `get_spending_comparison` | Two months side by side, sorted by absolute delta — what drove the change |
|
||||
| `get_upcoming_payments` | Statements with `payment_due_date` in the next N days (1–90, default 14) |
|
||||
| `get_recurring_spend` | Recurring-payment candidates (heuristic — expect false positives) |
|
||||
| `adhoc_query` | One read-only `SELECT` when the typed tools can't express the question |
|
||||
|
||||
Views available to `adhoc_query` (all `SELECT`-granted to `agent_ro`):
|
||||
|
||||
| Relation | Grain |
|
||||
|---|---|
|
||||
| `transaction_search` | Flat per-transaction surface: transaction ⟕ statement identity ⟕ `expense_metadata`. The right default for search. |
|
||||
| `merchant_monthly_spend` | month × merchant → txn_count, total_spend, avg_amount |
|
||||
| `category_monthly_spend` | month × category → txn_count, total_spend |
|
||||
| `cashflow_monthly` | month → total_outflow, total_inflow, net, txn_count |
|
||||
| `recurring_candidates` | merchant × amount bucket → occurrences, cadence, last_seen, next_expected |
|
||||
|
||||
Base tables (`transactions`, `statements`, `transaction_splits`, …) are also readable —
|
||||
see [README → Data Model](../README.md#data-model).
|
||||
|
||||
Semantics baked into the views:
|
||||
|
||||
- outflows = `transaction_type IN ('debit','fee','interest')`; inflows = `('payment','credit','refund')`
|
||||
- amount prefers `amount_aud` (FX-normalised) over `amount`
|
||||
- merchant prefers `merchant_normalized` → `merchant_name` → first 60 chars of `description`
|
||||
|
||||
### Two caveats worth knowing
|
||||
|
||||
- **No owner scoping.** Unlike the app's API routes (`COALESCE(t.owner_id, s.owner_id) = $1`),
|
||||
the agent views expose *all* owners' rows. Fine while the only consumer is the household's
|
||||
own assistant; it must change before any agent is exposed to a second person.
|
||||
- **No `transaction_overrides` merge.** The views read `t.category` / `t.merchant_normalized`
|
||||
directly, so manual corrections made in the UI are not reflected. The app's own queries use
|
||||
`COALESCE(o.category_override, t.category)`. Numbers from an agent can therefore differ
|
||||
slightly from the same figure in the UI.
|
||||
|
||||
---
|
||||
|
||||
## Connecting a client
|
||||
|
||||
The server speaks **stdio MCP**. There is no network listener, so a client connects by
|
||||
*executing* it. The easiest correct way is to exec it inside the `hermes` container: the
|
||||
repo is bind-mounted there (`/mnt/user/projects/personal-agent-gateway` → `/opt/data/repo`),
|
||||
the venv and deps already exist, `PG_DSN_RO`/`PG_DSN_LOG` are already in the container env,
|
||||
and `postgres-personal` resolves on `networks_internal`.
|
||||
|
||||
The canonical command, used by every recipe below:
|
||||
|
||||
```bash
|
||||
docker exec -i hermes /opt/data/venvs/pgp/bin/python \
|
||||
/opt/data/repo/mcp-servers/postgres-personal/server.py
|
||||
```
|
||||
|
||||
Smoke-test it before wiring a client in:
|
||||
|
||||
```bash
|
||||
printf '%s\n' \
|
||||
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}' \
|
||||
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
|
||||
| docker exec -i hermes /opt/data/venvs/pgp/bin/python \
|
||||
/opt/data/repo/mcp-servers/postgres-personal/server.py 2>/dev/null | head -2
|
||||
```
|
||||
|
||||
### Claude Code (on the Unraid host)
|
||||
|
||||
```bash
|
||||
claude mcp add postgres-personal -s user -- \
|
||||
docker exec -i hermes /opt/data/venvs/pgp/bin/python \
|
||||
/opt/data/repo/mcp-servers/postgres-personal/server.py
|
||||
```
|
||||
|
||||
Then `/mcp` in a session to confirm the seven tools are listed.
|
||||
|
||||
### Claude Code / Claude Desktop (from a laptop, over SSH)
|
||||
|
||||
Same command, tunnelled — stdio doesn't care what carries it:
|
||||
|
||||
```bash
|
||||
claude mcp add postgres-personal -s user -- \
|
||||
ssh unraid docker exec -i hermes /opt/data/venvs/pgp/bin/python \
|
||||
/opt/data/repo/mcp-servers/postgres-personal/server.py
|
||||
```
|
||||
|
||||
Needs key-based SSH to the host (Tailscale or LAN) and a non-interactive shell. For
|
||||
Claude Desktop, the same `command` / `args` go in `claude_desktop_config.json` under
|
||||
`mcpServers`.
|
||||
|
||||
### Codex CLI
|
||||
|
||||
`~/.codex/config.toml`:
|
||||
|
||||
```toml
|
||||
[mcp_servers.postgres-personal]
|
||||
command = "docker"
|
||||
args = [
|
||||
"exec", "-i", "hermes",
|
||||
"/opt/data/venvs/pgp/bin/python",
|
||||
"/opt/data/repo/mcp-servers/postgres-personal/server.py",
|
||||
]
|
||||
```
|
||||
|
||||
(Prefix `args` with `["unraid", "docker", …]` and set `command = "ssh"` for the remote case.)
|
||||
|
||||
### Hermes
|
||||
|
||||
Already wired — `mcp_servers.postgres-personal` in `/mnt/user/appdata/docker/hermes/config.yaml`
|
||||
(template: `personal-agent-gateway/spike/hermes/config.yaml.example`). It runs the same
|
||||
venv and script directly rather than via `docker exec`, since it *is* the container.
|
||||
|
||||
### Running the server outside `hermes`
|
||||
|
||||
Only needed if `hermes` is down or you want isolation. Two constraints: Python ≥ 3.11 with
|
||||
`mcp`, `psycopg[binary]`, `sqlglot`; and network reach to `postgres-personal`, which is
|
||||
**not** published to the host — it only resolves on the `networks_internal` Docker network.
|
||||
So run it in a container on that network, passing the DSNs:
|
||||
|
||||
```bash
|
||||
docker run -i --rm --network networks_internal \
|
||||
--env-file /mnt/user/appdata/docker/hermes/.env \
|
||||
-v /mnt/user/projects/personal-agent-gateway:/app \
|
||||
personal-agent-gateway python /app/mcp-servers/postgres-personal/server.py
|
||||
```
|
||||
|
||||
A bare host venv would need the container's current bridge IP in the DSN instead of the
|
||||
hostname, which breaks on every recreate — don't.
|
||||
|
||||
### Secrets
|
||||
|
||||
`PG_DSN_RO` and `PG_DSN_LOG` live in `/mnt/user/appdata/docker/hermes/.env` (canonical
|
||||
copies in Infisical `/agent-gateway`). Never paste them into a client config, a
|
||||
`claude mcp add` command line, or this repo — the `docker exec` recipes above inherit them
|
||||
from the container, which is precisely why they're preferred.
|
||||
|
||||
---
|
||||
|
||||
## Direct SQL (humans and one-off scripts)
|
||||
|
||||
For ad-hoc analysis where an agent isn't in the loop, skip MCP:
|
||||
|
||||
```bash
|
||||
docker exec postgres-personal psql -U personal -d personal -c \
|
||||
"SELECT * FROM cashflow_monthly ORDER BY month DESC LIMIT 12"
|
||||
```
|
||||
|
||||
That's the full-privilege `personal` role — no guardrail, no row cap, no audit row. Use
|
||||
`agent_ro` if you want the same safety envelope an agent gets.
|
||||
|
||||
---
|
||||
|
||||
## Making this available more widely
|
||||
|
||||
Today: any MCP client that can run a subprocess can use it (all of the above), and any
|
||||
machine that can SSH to the host can too. That covers Claude Code, Claude Desktop, Codex,
|
||||
and Hermes without a code change.
|
||||
|
||||
What it would take to go further:
|
||||
|
||||
- **Remote clients without SSH** — FastMCP supports `streamable-http`; `mcp.run()` in
|
||||
`server.py` would become `mcp.run(transport="streamable-http")` behind Traefik with
|
||||
forward-auth. Small code change, real security decision: it puts the household's
|
||||
financial history behind a network listener. Not done, deliberately.
|
||||
- **A second user** — requires owner scoping in the views (see caveats above) plus an
|
||||
identity the server can bind to; the DSN carries no user identity today.
|
||||
- **Write access** — out of scope. The write path is the app's API routes with
|
||||
`getCurrentUser()`; `agent_ro` should stay SELECT-only.
|
||||
|
||||
---
|
||||
|
||||
## Schema changes that affect agents
|
||||
|
||||
The views in `006_agent_read_role_views.sql` read `transactions`, `statements`, and
|
||||
`expense_metadata` directly. If you rename or drop a column those views use, re-apply the
|
||||
migration in the smarthome repo — a Prisma migration here won't do it, and the views will
|
||||
either break or silently go stale:
|
||||
|
||||
The file is idempotent (`CREATE OR REPLACE VIEW`, guarded role creation) — re-applying is
|
||||
safe. It needs the `agent.ro_password` / `agent.log_password` settings supplied at apply
|
||||
time from Infisical; follow the apply instructions in the header of the SQL file itself
|
||||
(`/mnt/user/appdata/smarthome/personal-agent/migrations/006_agent_read_role_views.sql`)
|
||||
rather than piping it in blind.
|
||||
|
||||
New tables are readable by `agent_ro` automatically (`ALTER DEFAULT PRIVILEGES`), but new
|
||||
*views* need an explicit `GRANT SELECT … TO agent_ro`.
|
||||
@@ -0,0 +1,211 @@
|
||||
# Monthly expense baseline and emergency reserve
|
||||
|
||||
Analysis run 2026-07-26. **One-off analysis, not a feature** — nothing in the app
|
||||
computes these numbers. Read "Reproducing this" before trusting a restated figure.
|
||||
|
||||
The question: how much should be held in reserve to cover 6–12 months of expenses?
|
||||
|
||||
## Answer
|
||||
|
||||
| Scenario | $/mo | 6 months | 12 months |
|
||||
|---|---:|---:|---:|
|
||||
| Survival — contracted loan repayment, essentials only | 3,040 | 18,250 | 36,500 |
|
||||
| **Realistic — contracted loan, + dining and charity** | **4,140** | **24,800** | **49,600** |
|
||||
| Status quo — keep overpaying the loan, normal life ex-travel | 5,557 | 33,500 | 67,000 |
|
||||
|
||||
Use the middle row. The survival row assumes dining is cut to zero and stays
|
||||
there, which is not a plan anyone executes for six months.
|
||||
|
||||
Against that, liquidity already available (statements 133 and 131, 2026-06-30):
|
||||
|
||||
| | |
|
||||
|---|---:|
|
||||
| Loan redraw | 81,017.42 |
|
||||
| Offset balance | 8,753.00 |
|
||||
| **Accessible** | **89,770.42** |
|
||||
|
||||
That is 3.6× the six-month target and 1.8× the twelve-month one. Redraw grew
|
||||
$62,387.17 → $81,017.42 across the last two loan statements, matching the
|
||||
overpayment rate — the money spent killing the loan faster is still reachable.
|
||||
|
||||
**Caveat on counting redraw as the reserve.** It is available at AMP's
|
||||
discretion, and lenders reduce or freeze it exactly when a borrower looks
|
||||
distressed — which is when it would be needed. The rate also moved 5.54% → 6.29%
|
||||
between the two statements, so redrawn funds cost more than they did. Hold some
|
||||
genuine cash; it does not need to be $50,000.
|
||||
|
||||
## This is *your* outgoings, not household spend
|
||||
|
||||
The app only sees accounts that get imported. Sonu's own spending on the
|
||||
household is invisible to it. Grocery spend reads as ~$300/mo gross on 4–10
|
||||
transactions, which is implausible for a household and is partly explained by her
|
||||
paying from her own account.
|
||||
|
||||
For "how much reserve do **I** need" that blind spot does not matter — your own
|
||||
outgoings is the correct measure. Do not relabel these figures as household
|
||||
totals; they are not, and they would be wrong by an unknown amount.
|
||||
|
||||
## The loan has two floors
|
||||
|
||||
This is the largest single lever and the reason there are three scenarios.
|
||||
|
||||
| | $/fortnight | $/mo annualised | Your 50% |
|
||||
|---|---:|---:|---:|
|
||||
| Contracted minimum | 1,190.54 | 2,579.50 | **1,290** |
|
||||
| Actual direct debit | 2,500.00 | 5,416.67 | **2,708** |
|
||||
| Voluntary overpayment | 1,309.46 | 2,837.17 | 1,419 |
|
||||
|
||||
Dropping to contracted cuts your loan cost by $1,418/mo. Sonu's obligation is a
|
||||
fixed 50% of the repayment (see CLAUDE.md → "The shared loan"), so it falls with
|
||||
it. Her rate returned to $1,250/fortnight in July 2026 after the reduced $750
|
||||
period during her leave.
|
||||
|
||||
## Baseline composition
|
||||
|
||||
Built from **Feb–Jun 2026** — the months where split data is trustworthy — with
|
||||
annual items annualised over 12 rather than divided by the 5-month sample.
|
||||
|
||||
| Essential | $/mo | Note |
|
||||
|---|---:|---|
|
||||
| Loan (contracted, your 50%) | 1,290 | 2,708 at the current actual rate |
|
||||
| Transport | 326 | |
|
||||
| Insurance | 280 | annualised; your 55% |
|
||||
| Utilities | 278 | shared energy/water + personal mobile |
|
||||
| Subscriptions | 204 | shared household + personal/AI |
|
||||
| Groceries | 200 | see the blind-spot note above |
|
||||
| Card + package fees | 175 | annualised — see below |
|
||||
| Health | 167 | |
|
||||
| Rates + rego | 122 | annualised, your share |
|
||||
| **Essential** | **3,042** | |
|
||||
| + dining 675, charity 422 | 4,139 | charity is a Smith Family sponsorship commitment |
|
||||
| + typical shopping | ~4,800 | median 657, **not** the 1,709 mean |
|
||||
|
||||
**Travel is excluded throughout.** At $3,153/mo of your share even post-cutover it
|
||||
would roughly double every figure, and it is the first thing that stops.
|
||||
|
||||
## Four corrections the raw data needed
|
||||
|
||||
Any restatement that skips these will be wrong. None are fixed in the data yet.
|
||||
|
||||
**Micro-investing counted as spend.** Raiz ($9,406 / 27 rows), Vanguard Super
|
||||
($500) and moomoo ($300) sit in `other` as debits — $10,206/yr, ~$850/mo of
|
||||
phantom spend. These belong in `investment`, which is already excluded.
|
||||
|
||||
**Incoming money counted as negative spend.** 17 rows in `other` typed `credit`
|
||||
($5,622 in the window). `SPEND_SIGNED` negates credits so refunds cancel
|
||||
purchases, but these are not refunds — they are money arriving. June 2025 shows
|
||||
*minus* $7,814 of total spend because two Wise credits of ~$16.7k each landed in
|
||||
`other`.
|
||||
|
||||
**`government` is two unrelated things.** $25,554 of ATO payments (one annual
|
||||
bill, routed through Zen B2B and RewardPay to earn points) versus $2,054 council
|
||||
rates and $875 rego. Tax is not a monthly living cost and falls with income
|
||||
anyway; rates and rego are non-negotiable. Splitting them moves this line from
|
||||
$2,411/mo to $244/mo.
|
||||
|
||||
**`fees` is mostly annual.** Of $2,599 post-cutover, $1,750 is an annual card fee
|
||||
and $349 a loan package annual fee. Recurring is ~$175/mo annualised, not the
|
||||
$520/mo the 5-month mean implies.
|
||||
|
||||
## Splits: what is trustworthy and what is not
|
||||
|
||||
Splits exist in this app from **2026-01-09** only; before that they were tracked
|
||||
in SplitMyExpenses. So a trailing-12-month per-person series splices six months
|
||||
of *gross* onto six months of *net* and is not a series at all. Use Feb–Jun 2026.
|
||||
|
||||
**Partial split coverage within a category is usually correct, not a gap.** This
|
||||
was misdiagnosed once during the analysis. Verified composition:
|
||||
|
||||
| Category | Your share | Split | Unsplit |
|
||||
|---|---:|---|---|
|
||||
| utilities | 69% | Globird, OVO, GWW, home telecoms | Telstra, Vodafone, Optus, JB Hi-Fi Mobile |
|
||||
| subscriptions | 91% | Uber One, Amazon Prime, OnePass | Claude, OpenAI, Anthropic, OpenRouter, You.com, LinkedIn, Xero, Billdu, Spotify, Patreon |
|
||||
| fees | 100% | — | credit card fees are personal |
|
||||
| charity | 100% | — | personal commitment |
|
||||
|
||||
Only *shared* utilities and subscriptions are split. Mobile bills, AI
|
||||
subscriptions and card fees are personal and correctly sit at 100%. Do not
|
||||
"fix" these ratios.
|
||||
|
||||
## Known weak spots
|
||||
|
||||
- **`other` is $424–773/mo of your share and unclassified.** The single biggest
|
||||
lever on accuracy. Clearing it with the rule preview
|
||||
(`GET /api/rules/[id]/matches`) improves every other view at the same time.
|
||||
- **Five months is a short sample**, and it contains the Europe trip and the ATO
|
||||
bill. Both are excluded, but they crowd out the ordinary months. The
|
||||
SplitMyExpenses CSVs would stretch it to 18 months — worth having, not
|
||||
blocking, since only an *aggregate ratio per category* is needed, not row
|
||||
matching, so the combined-transaction problem does not bite.
|
||||
- **`/api/analytics/monthly` does not filter `reconciled_with_id IS NOT NULL`.**
|
||||
48 rows are double-counted app-wide. Small, but real, and unrelated to this
|
||||
analysis. The queries below do filter it.
|
||||
|
||||
## Reproducing this
|
||||
|
||||
Working queries are not checked in; they were run ad hoc against
|
||||
`postgres-personal`. The shape that matters:
|
||||
|
||||
```sql
|
||||
-- Per-category monthly distribution, your share, Feb-Jun 2026.
|
||||
-- Mirrors src/lib/analytics-sql.ts, plus the corrections above.
|
||||
WITH s AS (
|
||||
SELECT to_char(t.transaction_date,'YYYY-MM') m,
|
||||
COALESCE(o.category_override, t.category, 'other') cat,
|
||||
SUM((CASE WHEN t.transaction_type IN ('refund','credit') THEN -1 ELSE 1 END)
|
||||
* (CASE WHEN t.interest_amount IS NOT NULL
|
||||
THEN t.interest_amount ELSE COALESCE(t.amount_aud, t.amount) END)
|
||||
* COALESCE(ts.share_percent, o.my_share_percent,
|
||||
100 - COALESCE((SELECT SUM(x.share_percent) FROM transaction_splits x
|
||||
WHERE x.transaction_id = t.id
|
||||
AND x.participant_id <> 1), 0)) / 100)::numeric(12,2) amt
|
||||
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
|
||||
LEFT JOIN statements st ON st.id = t.statement_id
|
||||
WHERE COALESCE(t.owner_id, st.owner_id) = 1
|
||||
AND t.reconciled_with_id IS NULL -- see weak spots
|
||||
AND ((t.transaction_type IN ('debit','fee','interest')
|
||||
OR t.interest_amount IS NOT NULL)
|
||||
OR t.transaction_type IN ('refund','credit'))
|
||||
AND COALESCE(o.category_override, t.category, 'other')
|
||||
NOT IN ('transfers','investment','income')
|
||||
-- correction 1: misfiled investments
|
||||
AND COALESCE(t.merchant_normalized, t.merchant_name, '')
|
||||
!~* '^(Raiz|Vanguard Super|moomoo)'
|
||||
-- correction 2: incoming money miscategorised as 'other' credits
|
||||
AND NOT (COALESCE(o.category_override, t.category, 'other') = 'other'
|
||||
AND t.transaction_type = 'credit')
|
||||
AND t.transaction_date >= '2026-02-01' AND t.transaction_date < '2026-07-01'
|
||||
GROUP BY 1, 2
|
||||
)
|
||||
SELECT cat, count(*) mths, round(avg(amt),0) mean,
|
||||
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY amt)::numeric, 0) median
|
||||
FROM s GROUP BY cat ORDER BY mean DESC;
|
||||
```
|
||||
|
||||
Use the **median** for anything recurring monthly and the **annualised total** for
|
||||
lumpy-but-certain items (insurance, rates, rego, annual card fees). Means are
|
||||
badly skewed here — shopping is 1,709 mean against 657 median.
|
||||
|
||||
Corrections 3 and 4 (`government`, `fees`) are not expressible as filters; they
|
||||
need the category split by merchant, done by hand above.
|
||||
|
||||
## If this becomes a feature
|
||||
|
||||
The blocker is `other`, not the split model — the split data held up under
|
||||
scrutiny. Sequence:
|
||||
|
||||
1. Recategorise Raiz / Vanguard Super / moomoo to `investment` (27 rows,
|
||||
unambiguous) via the rule preview.
|
||||
2. Split `government` so rates and rego separate from ATO. Probably a new
|
||||
category or a tag; `government` conflates a recurring obligation with an
|
||||
annual tax bill.
|
||||
3. Work `other` down with the rule preview.
|
||||
4. Then a baseline panel on Insights, which already carries the
|
||||
Regular/Occasional split this analysis is a stricter version of
|
||||
(`REGULAR_CATEGORIES` in `src/lib/categories.ts`).
|
||||
|
||||
A **liquidity vs baseline** view would be genuinely new rather than a restatement:
|
||||
`statements.redraw_available`, offset closing balance, and the
|
||||
contracted-vs-actual repayment gap are all in the schema and nothing reads them.
|
||||
@@ -0,0 +1,287 @@
|
||||
# Shared expenses, settlement, and the shared loan — design proposal
|
||||
|
||||
Status: **proposal, nothing built**. Written 2026-07-26 for review.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Three questions have no answer in the current model:
|
||||
|
||||
1. Which splits does a settlement payment settle?
|
||||
2. Is the Europe trip settled, separately from the ongoing household tab?
|
||||
3. Whose expense is a $2,500 loan repayment when Sonu funds part of it?
|
||||
|
||||
They look like three problems. They are one: **the app records money moving, and
|
||||
separately records who owes whom, and the two never meet.**
|
||||
|
||||
---
|
||||
|
||||
## What is actually broken
|
||||
|
||||
### Two settlement models, neither finished
|
||||
|
||||
| Model | Where | State |
|
||||
|---|---|---|
|
||||
| Running tab | `split_payments` (from, to, amount, date) | **in use** — 8 payments, $37,881.10 |
|
||||
| Per-split flag | `transaction_splits.settled` | **never used** — all 673 splits are `false` |
|
||||
|
||||
They are honoured inconsistently:
|
||||
|
||||
- `getParticipantBalances` (the shared page) ignores `settled` entirely
|
||||
- `/api/participants/[id]/balance` filters on `settled = false`
|
||||
- `getTripAnalytics` reports settled/unsettled **from the unused flag**
|
||||
|
||||
The third is a live bug. Every trip shows 100% unsettled forever, even though
|
||||
Molina has paid $20,782.79 against $19,556.07 of splits and is square.
|
||||
|
||||
### Settlements exist twice, unlinked
|
||||
|
||||
Four of the eight recorded payments match an offset-account credit exactly:
|
||||
|
||||
| Payment date | From | Amount | Offset transaction |
|
||||
|---|---|---:|---|
|
||||
| 2026-02-02 | Molina | 7,500.00 | `Transfer from - MEGHALEE BOSE mummy Pa…` |
|
||||
| 2026-04-12 | Sonu | 3,779.33 | `Transfer from - MEGHALEE BOSE transfer` |
|
||||
| 2026-04-21 | Molina | 1,685.24 | `Transfer from - MEGHALEE BOSE mummy split` |
|
||||
| 2026-05-16 | Sonu | 4,794.06 | `Transfer from - MEGHALEE BOSE transfer` |
|
||||
|
||||
The same money is a `split_payments` row *and* a `transactions` row.
|
||||
`split_payments.linked_transaction_id` exists but only 1 of 8 rows uses it. So a
|
||||
settlement is bookkeeping that happens to resemble a bank credit, rather than
|
||||
being that credit.
|
||||
|
||||
### The shared loan is invisible
|
||||
|
||||
Sonu's contributions are already in the ledger and unrecognised:
|
||||
|
||||
| Pattern in offset credits | Rows | Total | Meaning |
|
||||
|---|---:|---:|---|
|
||||
| `…emi` | 39 | $37,980.24 | Sonu's loan contribution |
|
||||
| `…mummy…` | 6 | $29,721.24 | Molina's money, forwarded by Sonu |
|
||||
| other Meghalee | 15 | $71,130.27 | Sonu's own settlements |
|
||||
|
||||
All are categorised `transfers` — correct for spend purposes, but it means a
|
||||
loan contribution and an expense settlement are indistinguishable.
|
||||
|
||||
Meanwhile the loan itself, over the 12 imported months:
|
||||
|
||||
| | |
|
||||
|---|---:|
|
||||
| Principal repaid (`investment`, excluded from spend) | $63,500.00 |
|
||||
| Interest charged (`loan_interest`, the only part counted as spend) | $16,523.64 |
|
||||
|
||||
At roughly $25,000/year of `emi` against ~$80,000 of annual repayments, Sonu
|
||||
funds about **31%** — of both the equity being built and the interest being paid.
|
||||
Today 100% of the interest counts as your spend and 100% of the equity as yours.
|
||||
|
||||
---
|
||||
|
||||
## The core problem
|
||||
|
||||
The model conflates two different things:
|
||||
|
||||
- **Money movement** — a credit landed in the offset account
|
||||
- **Obligation** — someone owed someone else, and now owes less
|
||||
|
||||
A settlement is both. A loan contribution is both. Right now movement lives in
|
||||
`transactions` and obligation lives in `transaction_splits` / `split_payments`,
|
||||
with nothing joining them. That is why a payment cannot say what it settles: it
|
||||
was never attached to anything in the first place.
|
||||
|
||||
---
|
||||
|
||||
## Proposed model
|
||||
|
||||
### 1. Settlement contexts
|
||||
|
||||
Splits belong to something that is settled **as a unit**. Payments name which
|
||||
unit they settle. Balance is computed per context, not globally.
|
||||
|
||||
| Context | Splits | Settled by | State |
|
||||
|---|---|---|---|
|
||||
| Household (default) | ongoing | periodic payments | running tab |
|
||||
| Europe 2026 | trip-bound | lump sum | closeable |
|
||||
| Pre-2026 (SplitMyExpenses) | historical | settled elsewhere | **born closed** |
|
||||
|
||||
A closed context still contributes to analytics — you see your true share — but
|
||||
contributes nothing to what anyone owes.
|
||||
|
||||
This answers all three opening questions, and it dissolves the `splitFrom` date
|
||||
cutoff: pre-2026 splits can be applied retroactively **because they are born
|
||||
into a closed context**, so they fix the analytics without creating debt. No date
|
||||
guard needed, no risk of resurrecting settled obligations.
|
||||
|
||||
Mechanically: `settlement_contexts` table; `transaction_splits.context_id`;
|
||||
`split_payments.context_id`. `transaction_splits.settled` becomes derived
|
||||
("is my context closed?") or is dropped.
|
||||
|
||||
### 2. Payments are transactions, not a side table
|
||||
|
||||
A settlement is the offset-account credit. `split_payments` becomes a thin
|
||||
attribution layer over a real transaction rather than a parallel record of it:
|
||||
|
||||
- Populate `linked_transaction_id` on all existing payments where a match exists
|
||||
- On ingestion, an incoming credit that looks like a settlement is *proposed* as
|
||||
one for confirmation, rather than silently becoming `transfers`
|
||||
- A payment with no matching transaction (cash, or an account not imported)
|
||||
stays as a manual row — the model must tolerate that
|
||||
|
||||
### 3. The shared loan — a separate ledger
|
||||
|
||||
Not a settlement context. The loan is a jointly funded asset with its own
|
||||
obligation, and mixing it with expense settlement would let a contribution
|
||||
accidentally settle a dinner.
|
||||
|
||||
- `emi` credits are recognised as **contributions**, not generic transfers
|
||||
- A **contribution schedule** states what is owed per period (50% of the
|
||||
repayment), independent of what was actually paid
|
||||
- The running difference is a **receivable** — currently $4,000.00
|
||||
|
||||
The schedule matters: during Sonu's leave the obligation did not change, only the
|
||||
payment did. A percentage-of-actual model would silently redefine her share as
|
||||
30% and make the shortfall disappear.
|
||||
|
||||
---
|
||||
|
||||
## Migration path
|
||||
|
||||
### Phase 0 — DONE (2026-07-26, commit `3f04cbd`)
|
||||
|
||||
Stop the trip view reporting a settlement breakdown it cannot compute.
|
||||
|
||||
The original plan was "make `getTripAnalytics` and `getParticipantBalances`
|
||||
agree". **That plan was wrong** and reading the code before building is what
|
||||
caught it:
|
||||
|
||||
- `getParticipantBalances` is *not* buggy. It computes `splits − payments`,
|
||||
which is coherent. Excluding settled splits there while still subtracting the
|
||||
payments that settled them would have double-counted and broken a working page.
|
||||
- The real defect was narrower: the trip view showed Settled/Unsettled from
|
||||
`transaction_splits.settled`, which nothing sets. A correct per-trip figure is
|
||||
not computable at all, because `split_payments` has no trip attribution.
|
||||
|
||||
So the fix was **subtractive**: the trip view now shows each participant's share
|
||||
and points at Shared for what is actually owed.
|
||||
|
||||
Also removed `/api/splits/settle` — unreachable from the UI but live on its URL,
|
||||
where one call with `participant_id` would mark every one of that person's splits
|
||||
settled, writing a flag nothing reads.
|
||||
|
||||
`transaction_splits.settled` / `settled_at` still exist and are now pure dead
|
||||
data. Phase 1 either repurposes them ("is my context closed?") or drops them.
|
||||
|
||||
### Phase 1 — settlement contexts
|
||||
|
||||
Add contexts; put every existing split in "Household"; every payment likewise.
|
||||
Balance queries group by context. Touches `queries.ts` (both balance CTEs),
|
||||
`shared/page.tsx`, `trips/[id]/page.tsx`, `split-payments/route.ts`. ~1 day.
|
||||
|
||||
### Phase 2 — link payments to transactions
|
||||
|
||||
Backfill `linked_transaction_id` for the four exact matches; flag the other four
|
||||
for manual linking. On ingestion, propose a matching credit as a settlement
|
||||
rather than silently categorising it `transfers`. ~half a day.
|
||||
|
||||
### Phase 3 — retroactive pre-2026 split
|
||||
|
||||
Create the "Pre-2026" closed context. Apply household split rules into it via the
|
||||
rule preview (`/api/rules/[id]/matches`, built 2026-07-26) — fixes ~$97,627 of
|
||||
the trailing 12 months currently shown as 100% yours. Then delete the `splitFrom`
|
||||
cutoff entirely.
|
||||
|
||||
**Validate the ratio first.** This assumes today's 50/50 held through 2025. The
|
||||
SplitMyExpenses CSVs should be used to *check* that assumption — not to
|
||||
reconcile, since transactions were sometimes combined and exact matching is
|
||||
impossible.
|
||||
|
||||
### Phase 4 — loan ledger
|
||||
|
||||
Contribution schedule, contributions recognised from `emi` credits, running
|
||||
receivable. Independent of contexts — the loan is a separate ledger. ~1–2 days.
|
||||
|
||||
---
|
||||
|
||||
## Decisions taken (2026-07-26)
|
||||
|
||||
### The loan is separate from shared expenses
|
||||
|
||||
Different obligations, different rhythms, different nature: one funds an asset,
|
||||
the other funds consumption. They do not share a settlement context and a
|
||||
contribution is never a settlement.
|
||||
|
||||
### The share is 50/50, fixed — with the shortfall tracked
|
||||
|
||||
Not derived from actual payments, which fluctuate. Sonu's obligation is half the
|
||||
repayment; the difference between obligation and actual is a **receivable**, and
|
||||
it is the interesting number.
|
||||
|
||||
Over 2025-07-01 → 2026-06-30:
|
||||
|
||||
| | |
|
||||
|---|---:|
|
||||
| Repayments | $63,500.00 |
|
||||
| Sonu's 50% obligation | $31,750.00 |
|
||||
| Actually contributed (26 payments) | $27,750.00 |
|
||||
| **Shortfall** | **$4,000.00** |
|
||||
|
||||
She never missed a fortnight; the rate changed:
|
||||
|
||||
| Rate | Payments | Period |
|
||||
|---|---:|---|
|
||||
| $1,250 | 15 | Aug 2025 – Feb 2026 (the correct 50%) |
|
||||
| $1,000 | 3 | Jul 2025 (pre-adjustment) |
|
||||
| $750 | 8 | Mar – Jun 2026 (leave) |
|
||||
|
||||
So the model needs a **contribution schedule** (expected per period) alongside
|
||||
actual contributions, with the running difference as a tracked balance. A flat
|
||||
percentage cannot express "obligation unchanged, payment temporarily reduced,
|
||||
difference owed".
|
||||
|
||||
### Interest: recommended as expense, pending final call
|
||||
|
||||
The mechanics are as described — interest is debited to the loan and repayments
|
||||
pay down the combined balance. Reconciles exactly:
|
||||
|
||||
134: 31,000.00 − 8,553.27 = 22,446.73 = balance reduction
|
||||
133: 32,500.00 − 7,970.37 − 2,849.00 = 21,680.63 = balance reduction
|
||||
|
||||
But mechanics are not the same as economics. Over 12 months $63,500 of cash left
|
||||
and debt fell by $44,127.36. The $16,523.64 difference bought nothing and is not
|
||||
recoverable — that is an expense by definition. Excluding it leaves the balance
|
||||
sheet unable to reconcile cash out against equity gained, and understates annual
|
||||
cost by ~10%.
|
||||
|
||||
The legitimate concern is that interest is **non-discretionary**. The answer to
|
||||
that is a fixed-commitments grouping alongside rent, insurance and utilities —
|
||||
a presentation change, not an exclusion.
|
||||
|
||||
**Recommendation: keep `loan_interest` as spend, add a fixed/discretionary
|
||||
split.** Flagged rather than settled: it is a judgement about what "spend" means
|
||||
in your own reporting.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Does equity need tracking per person?** If Sonu accrues a share of the
|
||||
principal, that is a balance-sheet item the app has no concept of. Probably
|
||||
belongs in the net-worth view rather than here.
|
||||
|
||||
4. **Attribution of forwarded payments.** `mummy` in the description reliably
|
||||
marks Molina's money in all six known cases, but it is a description match on
|
||||
a free-text field. Acceptable as a *suggestion* requiring confirmation; not as
|
||||
an automatic rule.
|
||||
|
||||
5. **Retroactive split ratios.** Applying today's household rules to 2025
|
||||
assumes the arrangement has not changed. The SplitMyExpenses CSVs could give
|
||||
real historical shares, but transactions were sometimes combined, so matching
|
||||
is imperfect. Recommendation: use today's ratios, accept the approximation —
|
||||
the goal is a truer analytics picture, not a restated ledger.
|
||||
|
||||
---
|
||||
|
||||
## What I would not do
|
||||
|
||||
- **Do not** restate history from the SplitMyExpenses CSVs. The combining problem
|
||||
makes exact reconciliation impossible, and the value is low: those balances are
|
||||
settled and will not change.
|
||||
- **Do not** make the loan a shared *expense*. It is a funded asset. Modelling it
|
||||
as a recurring split would put $2,500 a fortnight of principal into spend,
|
||||
which is the error migration 0014 was written to prevent.
|
||||
@@ -0,0 +1,342 @@
|
||||
# Europe 2026 — Expense Report
|
||||
|
||||
**Trip:** 22 days · Mar 21 – Apr 12, 2026
|
||||
**Route:** Rome → Venice → Ortisei (Dolomites) → Varenna (Lake Como) → St Moritz → Lucerne → Paris
|
||||
**Group:** 5 travellers — Siddharth & Meghalee (Melbourne), Molina, Sibnath & Rumna (Delhi)
|
||||
**Data:** 212 tagged transactions across all cards
|
||||
|
||||
---
|
||||
|
||||
## Group Breakdown — AU vs India
|
||||
|
||||
The two groups fly different routes and airlines. Everything else (accommodation, trains, on-trip spending) is shared across all 5 travellers.
|
||||
|
||||
| | AU Group (Siddharth + Meghalee) | India Group (Molina + Sibnath + Rumna) |
|
||||
|---|---:|---:|
|
||||
| Flights | $2,164 | $6,151 |
|
||||
| Share of shared costs | $12,577 | $18,866 |
|
||||
| **Group total** | **$14,741** | **$25,017** |
|
||||
| Per person | $7,371 | $8,339 |
|
||||
|
||||
**Shared costs (accommodation + trains + all on-trip):** $31,443
|
||||
Apportioned 2:3 by headcount — AU group (2 pax) = 40% · India group (3 pax) = 60%
|
||||
|
||||
| Scenario | Amount |
|
||||
|---|---:|
|
||||
| Total trip | $39,757 |
|
||||
| **Excl. all flights** | **$31,443** |
|
||||
| Excl. AU flights only (what India group is responsible for) | $37,593 |
|
||||
| Excl. India flights only (what AU group is responsible for) | $33,607 |
|
||||
|
||||
> Australia group flights: China Eastern MEL→PVG→FCO × 2 ($1,885) + Qatar cancellation fee ($279) = **$2,164**
|
||||
> India group flights: ITA Airways DEL→FCO × 3 (Azwebin, $2,696) + Air India CDG→DEL→GAU × 3 ($3,454) = **$6,151**
|
||||
> India group flights are ~3× the AU group total despite flying a similar number of legs — driven by 3 pax vs 2, plus the ITA outbound on top of Air India return.
|
||||
|
||||
---
|
||||
|
||||
## Overall Summary
|
||||
|
||||
| | AUD |
|
||||
|---|---:|
|
||||
| Gross charged | $42,696.73 |
|
||||
| Refunds received | −$2,939.22 |
|
||||
| **Net total spend** | **$39,757.51** |
|
||||
|
||||
| Phase | Net Spend | % of Total |
|
||||
|---|---:|---:|
|
||||
| Pre-trip bookings | $23,730.02 | 60% |
|
||||
| On-trip (22 days) | $16,027.49 | 40% |
|
||||
|
||||
**On-trip daily average:** $729/day for the group · $146/person/day
|
||||
|
||||
---
|
||||
|
||||
## Refunds & Cancellations
|
||||
|
||||
| Merchant | Charged | Refunded | Net | Detail |
|
||||
|---|---:|---:|---:|---|
|
||||
| Qatar Airways | $2,396.90 | −$2,118.10 | **$278.80** | Cancelled. Replaced with China Eastern MEL→PVG→FCO. |
|
||||
| Luxury Escapes | $3,283.80 | −$820.95 | **$2,462.85** | Partial refund on Paris Adagio Montmartre. |
|
||||
| FreeNow | $43.32 | −$0.17 | $43.15 | Hold reversal. |
|
||||
|
||||
---
|
||||
|
||||
## Pre-Trip Bookings — $23,730 net
|
||||
|
||||
### Flights
|
||||
|
||||
| Carrier | Passengers | Gross | Refund | Net |
|
||||
|---|---|---:|---:|---:|
|
||||
| ITA Airways (via Azwebin) | Delhi group (3) DEL → FCO | $2,696.34 | — | $2,696.34 |
|
||||
| Air India | Delhi group (3) CDG → DEL → GAU (return) | $3,454.32 | — | $3,454.32 |
|
||||
| China Eastern | Siddharth MEL → PVG → FCO | $849.05 | — | $849.05 |
|
||||
| China Eastern | Meghalee MEL → PVG → FCO | $849.05 | — | $849.05 |
|
||||
| China Eastern (ancillary) | Seat/baggage fees | $187.14 | — | $187.14 |
|
||||
| Qatar Airways | Siddharth + Meghalee (cancelled) | $2,396.90 | −$2,118.10 | $278.80 |
|
||||
| **Flights total** | | **$10,432.80** | **−$2,118.10** | **$8,314.70** |
|
||||
|
||||
> ITA Airways (billed as Azwebin Fiumicino) — 3 outbound tickets Delhi → Rome plus 3 sets of ancillary fees, all charged Jan 17.
|
||||
> Qatar was booked first (Jan 17) and then cancelled; refund of $2,118.10 processed Mar 24 (during Rome stay). Net cancellation penalty: **$278.80**. China Eastern replaced it at $1,885.24 total — slightly cheaper.
|
||||
|
||||
### Accommodation
|
||||
|
||||
| Property | Destination | Nights | Booked via | Net Cost |
|
||||
|---|---|---|---|---:|
|
||||
| Fontana di Trevi area (Vrbo HA-XTP24R) | Rome | 4 | Vrbo | $2,221.66 |
|
||||
| Apartment Moro 4 — Rialto | Venice | 3 | Direct | $1,410.11 |
|
||||
| 174A Apartments Murata | Ortisei | 4 | Muse Holiday | $1,815.06 |
|
||||
| In the heart of Varenna (Vrbo HA7B9C9J) | Varenna | 2 | Vrbo | $2,274.46 |
|
||||
| Cozy Central Duplex | St Moritz | 2 | Agoda | $2,560.45 |
|
||||
| Hotel Luzernerhof | Lucerne | 3 | Agoda | $1,930.07 |
|
||||
| Adagio Paris Montmartre | Paris | 4 | Luxury Escapes | $2,745.24 |
|
||||
| **Accommodation total** | | **22 nights** | | **$14,957.05** |
|
||||
|
||||
> Paris: Luxury Escapes $3,283.80 − $820.95 refund + $282.39 extra charge = $2,745.24 net.
|
||||
> **Average cost per night (5 pax):** $680 · **per person per night:** $136
|
||||
|
||||
### Pre-booked Transport
|
||||
|
||||
| Date | Merchant | Route | Amount |
|
||||
|---|---|---|---:|
|
||||
| Jan 26 | Trenitalia (Frecciarossa Business) | Rome → Venice (5 pax) | $468.55 |
|
||||
| Mar 4 | Rail Europe | Various European routes | $372.40 |
|
||||
| | **Total** | | **$840.95** |
|
||||
|
||||
---
|
||||
|
||||
## On-Trip Spending — $16,027 net
|
||||
|
||||
### By Destination
|
||||
|
||||
| Destination | Dates | Net Spend | Txns | Daily Avg |
|
||||
|---|---|---:|---:|---:|
|
||||
| Rome *(inc. transit days Mar 19–20)* | Mar 19–24 | $2,955 | 46 | $493 |
|
||||
| Venice | Mar 25–27 | $960 | 21 | $320 |
|
||||
| Ortisei / Dolomites | Mar 28–31 | $3,595 | 19 | $899 |
|
||||
| Varenna / Lake Como | Apr 1–2 | $863 | 16 | $432 |
|
||||
| St Moritz | Apr 3–4 | $355 | 10 | $178 |
|
||||
| Lucerne | Apr 5–7 | $3,422 | 22 | $1,141 |
|
||||
| Paris | Apr 8–12 | $3,878 | 46 | $970 |
|
||||
| **Total on-trip** | **22 days** | **$16,027** | **180** | **$729** |
|
||||
|
||||
---
|
||||
|
||||
### Rome & Transit (Mar 19–24) — $2,955
|
||||
|
||||
**Mar 19–20 (transit via Shanghai → Milan → Rome):**
|
||||
- Glovo Milan × 4 deliveries: $93 — feeding the group across airport transit days
|
||||
- Shanghai Sunny restaurant: $16 (CNY 78)
|
||||
- Todis Roma groceries: $55 (manual, likely night of arrival)
|
||||
|
||||
**On ground in Rome:**
|
||||
|
||||
| Category | Key Items | Total |
|
||||
|---|---|---:|
|
||||
| Dining | Lione 21 EUR 90 ($148) · Suresh EUR 52 ($85) · Glovo × 4 ($179) · Faro Café ($40) · Bap Vaschette ($29) · various | ~$780 |
|
||||
| Transport | FreeNow × 2 ($43) · Uber × 6 ($151) · ATAC bus ($2) · Radiotaxi ($11) | ~$210 |
|
||||
| Travel | Trenitalia local ($23) · Roma Termini station tickets EUR 300 ($493) · Trenitalia EUR 275 ($452) · Trenitalia EUR 165 ($274) | ~$1,242 |
|
||||
| Entertainment | St Peter's Basilica tickets: $58 | $58 |
|
||||
| Groceries | Todis × 2 ($12) · F.Lli Casali ($17) · SumUp ($12) | $41 |
|
||||
| Shopping | Scudieri International Florence EUR 51: $84 | $84 |
|
||||
| Cash | Mar 21 cash transactions lump sum | $337 |
|
||||
|
||||
> **Florence day trip (Mar 22):** Trenitalia tickets $452 + $274 bought at Roma Termini ($493) and online, plus Scudieri shopping EUR 51.
|
||||
> The two large Trenitalia charges on Mar 22–23 suggest additional train legs beyond Rome–Venice (possibly Florence or Naples day trip and ticket changes).
|
||||
|
||||
---
|
||||
|
||||
### Venice (Mar 25–27) — $960
|
||||
|
||||
| Item | Amount |
|
||||
|---|---:|
|
||||
| Water taxi Ferrovia → accommodation | $79 |
|
||||
| Caffe Rialto Venezia (dinner) | $109 |
|
||||
| Fresh pasta restaurant (Sum Dal) | $96 |
|
||||
| Baci Pasta | $70 |
|
||||
| Dolcemente Salato (pastries/café) | $67 |
|
||||
| Farini Pizza × 3 visits | $92 |
|
||||
| Tvm Rialto Diretti 2 — shopping EUR 125 | $209 |
|
||||
| Muse Holiday extra charge EUR 60 | $100 |
|
||||
| Uber × 2 | $48 |
|
||||
| Despar groceries | $19 |
|
||||
| Gelatoteca Suso + Bar Americano | $17 |
|
||||
| Pasticceria Ballarin + Bar Filovia × 2 | $28 |
|
||||
|
||||
> Grand Canal water taxi arrival ($79) was the planned "wow moment".
|
||||
> Tvm Rialto Diretti 2 (EUR 125) — likely Murano glass or quality Venetian craft souvenirs, the largest single on-trip discretionary purchase outside of gear/activities.
|
||||
|
||||
---
|
||||
|
||||
### Ortisei / Dolomites (Mar 28–31) — $3,595
|
||||
|
||||
The most expensive on-trip leg. Five major items account for $2,857 of it:
|
||||
|
||||
| Item | Amount | Detail |
|
||||
|---|---:|---|
|
||||
| Erich Perathon (gear shop, Val Gardena) | **$1,477** | EUR 880 — outdoor/ski clothing or equipment in Santa Cristina |
|
||||
| Funivie Seceda cable car | **$441** | EUR 236+27 — the centrepiece cable car to 2,519m above Ortisei |
|
||||
| Trenitalia Venice → Bolzano (5 pax) | **$528** | EUR 259.50 + EUR 55 split across two bookings |
|
||||
| Taxi Demez Gregor (into valley) | **$238** | EUR 142 — private taxi from Bolzano to Ortisei (no public option for 5 with bags) |
|
||||
| Funivia Lagazuoi, Cortina | **$222** | EUR 132.50 — Cortina d'Ampezzo day trip cable car |
|
||||
| Despar Dolomiti (groceries) | **$201** | EUR 97.59 + EUR 22.30 across two shops |
|
||||
| Ristorante Meuble Pont, Cortina | **$180** | EUR 107.50 — group dinner on Cortina day trip |
|
||||
| Turonda Pizza Ortisei | $93 | |
|
||||
| Istanbul Kebab + Buffet Bolzano × 2 | $111 | Transit + local meals |
|
||||
| Lago di Braies chocolate stop | $21 | |
|
||||
| Café Adler + Caffe Corso | $34 | |
|
||||
|
||||
---
|
||||
|
||||
### Varenna / Lake Como (Apr 1–2) — $863
|
||||
|
||||
| Item | Amount |
|
||||
|---|---:|
|
||||
| Taxi Demez Gregor (out of valley) | $239 |
|
||||
| Trenitalia (onward journey) | $101 |
|
||||
| Pizza e Sfizi × 2 dinners | $113 |
|
||||
| Mr Panino Bellagio (lunch) | $67 |
|
||||
| Bernasconi Claudia Varenna | $55 |
|
||||
| Varenna 1 shop EUR 55 | $92 |
|
||||
| Valli Mara Bellagio souvenirs | $71 |
|
||||
| Bistro di Corti × 2 | $50 |
|
||||
| Lake Bakery × 2 | $30 |
|
||||
| Ristorante Liberty, Tirano | $44 |
|
||||
| Groceries (Despar, Macelleria × 2) | $49 |
|
||||
| Gelato + bakery | $28 |
|
||||
|
||||
> Bellagio day trip from Varenna was a highlight — lunch at Mr Panino, ice cream at Gilardoni Stefania, souvenirs.
|
||||
> Taxi Demez again for the exit from Val Gardena (charged Apr 1, same driver/company as arrival).
|
||||
|
||||
---
|
||||
|
||||
### St Moritz (Apr 3–4) — $355
|
||||
|
||||
Almost entirely self-catered:
|
||||
|
||||
| Item | Amount |
|
||||
|---|---:|
|
||||
| Coop supermarket (big shop) | $114 |
|
||||
| Pizzaway × 2 orders | $89 |
|
||||
| Bäckerei Bad × 3 visits | $58 |
|
||||
| Coop (additional) | $14 |
|
||||
| Bus und Service | $22 |
|
||||
| Taxi St Moritz CHF 30 | $55 |
|
||||
| SBB WC | $3 |
|
||||
|
||||
> Despite being the highest accommodation cost per night ($1,280/night), almost nothing was spent on-trip. The group self-catered using Coop and relied on the bakery.
|
||||
|
||||
---
|
||||
|
||||
### Lucerne (Apr 5–7) — $3,422
|
||||
|
||||
Three large purchases dominate:
|
||||
|
||||
| Item | Amount | Detail |
|
||||
|---|---:|---|
|
||||
| Rail Europe (last-minute) | **$1,526** | EUR 911.95 — Swiss Travel Pass or multi-city rail tickets; bought while already in Lucerne |
|
||||
| Daytrip.com private tour | **$1,078** | EUR 642 — private guided day tour (description: "Prague" but likely Rhine Falls or day excursion from Lucerne) |
|
||||
| Rigi Bahnen (cog railway) | **$844** | CHF 461.60 — historic Rigi mountain railway, 5 pax round trip (charged Apr 10 but relates to Lucerne stay) |
|
||||
| Hotel Rigi Kaltbad dining | $47 | Lunch at mountain hotel summit |
|
||||
| Rigi Dorfladen shop | $37 | CHF 20 at Rigi summit village |
|
||||
| Uber Eats | $94 | CHF 51.23 |
|
||||
| WAL*TENZ GMBH Zurich | $82 | CHF 45.10 — dining/café in Zurich |
|
||||
| QUZGYXOO-2 | $150 | CHF 82.50 — unidentified Lucerne charge |
|
||||
| GO4T Luzern | $114 | CHF 62.40 — likely a guided activity or boat tour |
|
||||
| Amorino Gelato + Alpineum Café | $51 | |
|
||||
| Juice Paradise bubble tea | $29 | |
|
||||
| SBB toilets × 4 | $11 | CHF 1.50 each — Swiss public toilets |
|
||||
|
||||
> The Rail Europe purchase last-minute cost $1,526 vs booking pre-trip where Swiss Travel Passes are generally cheaper. Worth pre-purchasing on future Swiss trips.
|
||||
|
||||
---
|
||||
|
||||
### Paris (Apr 8–12) — $3,878
|
||||
|
||||
The return leg via Basel with 4 nights in Paris:
|
||||
|
||||
**Transit Basel (Apr 8):**
|
||||
Bahn Mi Pho CHF 69 ($126) + McDonald's CHF 51 ($93) + Weber1s bagels ($66) + Kaffeemacher coffee ($17) + Confiseur Bachmann ($22) + Acento Argentino ($26) = **$350 in Basel** for the group during travel day
|
||||
|
||||
**Paris spending:**
|
||||
|
||||
| Item | Amount | Detail |
|
||||
|---|---:|---|
|
||||
| Viator/TripAdvisor guided tour | $296 | USD 203.70 — skip-the-line or guided Versailles/Paris tour |
|
||||
| Palace of Versailles tickets | $292 | Full group |
|
||||
| Louvre Museum tickets | $277 | Full group |
|
||||
| Adagio Paris hotel extra charge | $282 | EUR 169 — extra night or room upgrade |
|
||||
| FC Soleil restaurant | $207 | EUR 125 — group dinner |
|
||||
| Uber airport transfer (Apr 12) | $126 | EUR 75.98 — departure to CDG |
|
||||
| Deliveroo × 2 | $123 | EUR 52.49 + EUR 21.90 |
|
||||
| Yak and Yeti restaurant | $83 | EUR 49.90 |
|
||||
| Uber Eats | $65 | |
|
||||
| Wild Deer restaurant | $64 | EUR 38.20 |
|
||||
| Uber trips × 5 | $192 | Multiple rides across Paris |
|
||||
| Fric Frac Montmartre | $55 | EUR 33 |
|
||||
| Mitao | $56 | |
|
||||
| Navigo transit pass | $61 | Group metro/bus |
|
||||
| Little Versailles coffeeshop | $36 | |
|
||||
| Pain Pain bakery × 2 | $29 | |
|
||||
| Sarl by Orsel (groceries) × 2 | $35 | |
|
||||
| Various small | $49 | |
|
||||
|
||||
> Museums + guided tour = **$865 on Paris attractions** (Versailles, Louvre, Viator).
|
||||
> Uber was heavily used in Paris — 5+ trips totalling $254 including the CDG airport transfer.
|
||||
|
||||
---
|
||||
|
||||
## Spend by Category (Net)
|
||||
|
||||
| Category | Net Spend | Txns | Notes |
|
||||
|---|---:|---:|---|
|
||||
| Travel | $31,290 | 51 | Flights, accommodation, trains, cable cars |
|
||||
| Dining | $4,241 | 95 | 95 restaurant/café/delivery transactions |
|
||||
| Transport | $2,636 | 35 | Uber, taxis, buses, metro passes |
|
||||
| Shopping | $2,053 | 6 | Erich Perathon $1,477 + Venice/Varenna/Florence |
|
||||
| Entertainment | $698 | 4 | Versailles, Louvre, Basilica, Bellagio souvenirs |
|
||||
| Groceries | $589 | 15 | Despar, Coop, Todis, Macelleria |
|
||||
| Other (net) | −$1,751 | 4 | Qatar refund exceeds "other" debits |
|
||||
| Uncategorised | $42 | 2 | Residual |
|
||||
| **Total** | **$39,757** | **212** | |
|
||||
|
||||
> The negative "Other" figure reflects the Qatar Airways refund ($2,118) offsetting the ITA/Azwebin ancillary fees charged in the same category.
|
||||
|
||||
---
|
||||
|
||||
## Top 15 On-Trip Transactions
|
||||
|
||||
| Date | Merchant | AUD | Category |
|
||||
|---|---|---:|---|
|
||||
| Mar 28 | Erich Perathon, Val Gardena | $1,477 | Shopping — EUR 880 gear |
|
||||
| Apr 6 | Rail Europe | $1,526 | Travel — EUR 912 rail passes |
|
||||
| Apr 7 | Daytrip.com | $1,078 | Transport — EUR 642 private tour |
|
||||
| Apr 10 | Rigi Bahnen | $844 | Travel — CHF 462 mountain railway |
|
||||
| Mar 22 | Roma Termini (train tickets) | $493 | Travel — EUR 300 |
|
||||
| Mar 22 | Trenitalia | $452 | Travel — EUR 275 |
|
||||
| Mar 28 | Trenitalia | $435 | Travel — EUR 260 |
|
||||
| Mar 30 | Funivie Seceda | $396 | Travel — EUR 236 cable car |
|
||||
| Apr 9 | Viator/TripAdvisor | $296 | Travel — USD 204 Paris tour |
|
||||
| Apr 8 | Palace of Versailles | $292 | Entertainment |
|
||||
| Apr 10 | Adagio Paris extra | $282 | Travel |
|
||||
| Apr 8 | Louvre Museum | $277 | Entertainment |
|
||||
| Mar 23 | Trenitalia | $274 | Travel — EUR 165 |
|
||||
| Mar 21 | Cash transactions (Rome) | $337 | Various |
|
||||
| Apr 1 | Taxi Demez Gregor | $239 | Transport — EUR 142 |
|
||||
|
||||
---
|
||||
|
||||
## Key Observations
|
||||
|
||||
**Flight changes cost $278.80.** Qatar was booked first (Jan 17, $2,396.90) and later cancelled; the refund of $2,118.10 came through during the Rome stay on Mar 24. China Eastern (MEL→PVG→FCO) replaced it at $1,885.24 — marginally cheaper but via Shanghai with a longer travel time.
|
||||
|
||||
**60% of spend was locked in before leaving Australia.** Pre-trip bookings (flights for all 5, accommodation across 7 cities, and pre-purchased trains) totalled $23,730 vs $16,027 spent on the ground.
|
||||
|
||||
**Dolomites was the most expensive on-trip leg** despite only 4 days — Erich Perathon EUR 880 gear purchase in Val Gardena accounts for $1,477 alone. Without it, Dolomites would be the cheapest destination per day.
|
||||
|
||||
**Rail spend ballooned in Lucerne.** Rail Europe EUR 912 ($1,526) bought last-minute in Lucerne was the single largest on-trip travel cost. Swiss Travel Passes are considerably cheaper when purchased outside Switzerland before travel.
|
||||
|
||||
**Dining: 95 transactions, $4,241 net.** The group averaged $193/day on food across 22 days ($39/person/day). Glovo delivery was the primary dining mode during transit days and in Rome (10+ orders). Paris had the most sit-down restaurant spending — FC Soleil EUR 125, Yak and Yeti EUR 50, plus multiple delivery orders.
|
||||
|
||||
**St Moritz: highest accommodation cost, lowest on-trip spend.** Agoda charged $2,560 for 2 nights but the group spent only $355 on-trip — almost entirely groceries and the local bakery.
|
||||
|
||||
**Paris Uber spend: $254 across 6+ trips** including a $126 CDG airport transfer (EUR 76). Combined with 2 Deliveroo orders and Uber Eats, rideshare/delivery accounted for ~$380 of Paris on-trip costs.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
Generated
+1644
-7
File diff suppressed because it is too large
Load Diff
+12
-3
@@ -6,7 +6,12 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"test:watch": "vitest --config vitest.config.ts",
|
||||
"test:setup": "bash scripts/setup-test-db.sh",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"test:all": "npm test && npm run test:integration"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.4.2",
|
||||
@@ -16,7 +21,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",
|
||||
@@ -24,9 +30,12 @@
|
||||
"@types/pg": "^8.18.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE transactions ADD COLUMN reconciled_with_id INTEGER REFERENCES transactions(id) ON DELETE SET NULL;
|
||||
CREATE INDEX idx_transactions_reconciled ON transactions(reconciled_with_id) WHERE reconciled_with_id IS NOT NULL;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Trips: group transactions (via transaction_overrides.trip_id) into named
|
||||
-- trips with analytics. Catch-up migration — this DDL was applied directly to
|
||||
-- the live DB when the feature shipped; idempotent so re-running is safe.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trips (
|
||||
id SERIAL PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL REFERENCES participants(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
start_date DATE,
|
||||
end_date DATE,
|
||||
color VARCHAR(20) NOT NULL DEFAULT '#6366f1',
|
||||
archived BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE transaction_overrides
|
||||
ADD COLUMN IF NOT EXISTS trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tx_overrides_trip_id
|
||||
ON transaction_overrides (trip_id) WHERE trip_id IS NOT NULL;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Manual-only rules ("quick actions"): a rule flagged manual_only is never
|
||||
-- picked up by the bulk apply-all run. It exists to be fired by hand against a
|
||||
-- selection of transactions from the transactions page, so its conditions are
|
||||
-- irrelevant — the selection is the condition.
|
||||
|
||||
ALTER TABLE rules
|
||||
ADD COLUMN IF NOT EXISTS manual_only BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rules_manual_only
|
||||
ON rules (owner_id, manual_only) WHERE manual_only = true;
|
||||
@@ -0,0 +1,99 @@
|
||||
-- Normalise statements.statement_type to a fixed vocabulary.
|
||||
--
|
||||
-- Before this migration the column held whatever free text Gemini put in
|
||||
-- `account_type` ('Credit Card', 'credit card', 'credit_card', 'Business Card',
|
||||
-- 'ACCESS ADVANTAGE', 'multi-currency account', ...). The UI coped only by
|
||||
-- doing `.includes("card")`, which breaks as soon as bank and loan statements
|
||||
-- arrive.
|
||||
--
|
||||
-- The raw extracted text is preserved in `account_type` — this only touches
|
||||
-- `statement_type`. A BEFORE trigger normalises on write, so the N8N ingestion
|
||||
-- workflow keeps working unchanged while it still sends free text.
|
||||
--
|
||||
-- Idempotent: safe to re-run.
|
||||
|
||||
CREATE OR REPLACE FUNCTION normalize_statement_type(raw TEXT)
|
||||
RETURNS TEXT AS $$
|
||||
DECLARE
|
||||
v TEXT := lower(trim(coalesce(raw, '')));
|
||||
BEGIN
|
||||
IF v = '' THEN
|
||||
RETURN 'other';
|
||||
END IF;
|
||||
|
||||
-- Already canonical
|
||||
IF v IN ('credit_card', 'transaction', 'savings', 'loan', 'offset', 'investment', 'other') THEN
|
||||
RETURN v;
|
||||
END IF;
|
||||
|
||||
-- Offset before loan: "Mortgage Offset" is an offset account, not a loan.
|
||||
IF v LIKE '%offset%' THEN
|
||||
RETURN 'offset';
|
||||
END IF;
|
||||
|
||||
-- Loans before transaction: "home loan account" must not match '%account%'.
|
||||
IF v LIKE '%loan%' OR v LIKE '%mortgage%' THEN
|
||||
RETURN 'loan';
|
||||
END IF;
|
||||
|
||||
-- Cards: covers 'Credit Card', 'credit card', 'Business Card', 'Charge Card'
|
||||
IF v LIKE '%card%' THEN
|
||||
RETURN 'credit_card';
|
||||
END IF;
|
||||
|
||||
IF v LIKE '%saving%' OR v LIKE '%term deposit%' THEN
|
||||
RETURN 'savings';
|
||||
END IF;
|
||||
|
||||
IF v LIKE '%invest%' OR v LIKE '%share%' OR v LIKE '%broker%' THEN
|
||||
RETURN 'investment';
|
||||
END IF;
|
||||
|
||||
-- Everyday transaction accounts. Bank-specific product names go here; the
|
||||
-- generic keywords catch the rest.
|
||||
IF v LIKE '%access advantage%' -- ANZ
|
||||
OR v LIKE '%complete access%' -- Westpac
|
||||
OR v LIKE '%smart access%' -- CommBank
|
||||
OR v LIKE '%netbank%' -- CommBank
|
||||
OR v LIKE '%classic banking%' -- NAB
|
||||
OR v LIKE '%multi-currency%' -- Wise
|
||||
OR v LIKE '%multi currency%'
|
||||
OR v LIKE '%transaction%'
|
||||
OR v LIKE '%everyday%'
|
||||
OR v LIKE '%cheque%'
|
||||
OR v LIKE '%current account%'
|
||||
OR v LIKE '%debit%'
|
||||
THEN
|
||||
RETURN 'transaction';
|
||||
END IF;
|
||||
|
||||
RETURN 'other';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
-- Normalise on write so the N8N workflow can keep sending raw account_type.
|
||||
CREATE OR REPLACE FUNCTION statements_normalize_type_trigger()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.statement_type := normalize_statement_type(NEW.statement_type);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_statements_normalize_type ON statements;
|
||||
CREATE TRIGGER trg_statements_normalize_type
|
||||
BEFORE INSERT OR UPDATE OF statement_type ON statements
|
||||
FOR EACH ROW EXECUTE FUNCTION statements_normalize_type_trigger();
|
||||
|
||||
-- Backfill existing rows.
|
||||
UPDATE statements
|
||||
SET statement_type = normalize_statement_type(statement_type)
|
||||
WHERE statement_type IS DISTINCT FROM normalize_statement_type(statement_type);
|
||||
|
||||
-- Guard the vocabulary. Safe because the trigger runs first on every write.
|
||||
ALTER TABLE statements DROP CONSTRAINT IF EXISTS statements_statement_type_check;
|
||||
ALTER TABLE statements
|
||||
ADD CONSTRAINT statements_statement_type_check
|
||||
CHECK (statement_type IN ('credit_card', 'transaction', 'savings', 'loan', 'offset', 'investment', 'other'));
|
||||
|
||||
ALTER TABLE statements ALTER COLUMN statement_type SET DEFAULT 'other';
|
||||
@@ -0,0 +1,85 @@
|
||||
-- Loan statement support.
|
||||
--
|
||||
-- Migration 0013 taught the system that a statement can be a loan; this gives
|
||||
-- loans somewhere to put the data that only loans have.
|
||||
--
|
||||
-- The core accounting problem: a loan repayment is not an expense. A $3,000
|
||||
-- mortgage repayment is roughly $1,200 of principal (a balance-sheet move that
|
||||
-- builds equity) and $1,800 of interest (the only part that is genuinely spend).
|
||||
-- Counting the whole repayment as spending overstates expenses badly.
|
||||
--
|
||||
-- Two statement shapes are handled:
|
||||
-- (a) The common Australian case — the loan statement lists repayments and
|
||||
-- "Interest Charged" as separate rows. transaction_type already carries
|
||||
-- this: 'interest' rows count as spend, 'payment' rows do not.
|
||||
-- (b) Some lenders itemise principal and interest on the repayment row itself.
|
||||
-- That's what principal_amount / interest_amount are for: when
|
||||
-- interest_amount is set, analytics count that instead of the full amount.
|
||||
--
|
||||
-- Idempotent: safe to re-run.
|
||||
|
||||
-- Per-transaction principal/interest split (shape (b) above).
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS principal_amount NUMERIC(12,2),
|
||||
ADD COLUMN IF NOT EXISTS interest_amount NUMERIC(12,2);
|
||||
|
||||
COMMENT ON COLUMN transactions.principal_amount IS
|
||||
'Principal portion of a loan repayment, when the statement itemises it. Not spend.';
|
||||
COMMENT ON COLUMN transactions.interest_amount IS
|
||||
'Interest portion of a loan repayment, when the statement itemises it. This is the part that counts as spend.';
|
||||
|
||||
-- Partial index: only loan repayment rows carry a split.
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_interest_amount
|
||||
ON transactions (interest_amount)
|
||||
WHERE interest_amount IS NOT NULL;
|
||||
|
||||
-- Loan-level terms, read off the statement header.
|
||||
ALTER TABLE statements
|
||||
ADD COLUMN IF NOT EXISTS interest_rate NUMERIC(6,3),
|
||||
ADD COLUMN IF NOT EXISTS scheduled_repayment NUMERIC(12,2),
|
||||
ADD COLUMN IF NOT EXISTS repayment_frequency TEXT,
|
||||
ADD COLUMN IF NOT EXISTS redraw_available NUMERIC(12,2),
|
||||
ADD COLUMN IF NOT EXISTS loan_term_months INTEGER;
|
||||
|
||||
COMMENT ON COLUMN statements.interest_rate IS 'Annual interest rate as a percentage, e.g. 6.140';
|
||||
COMMENT ON COLUMN statements.redraw_available IS 'Funds available to redraw (loans) — not the same as available_credit on a card.';
|
||||
|
||||
-- Free text varies by lender ("Monthly", "Fortnightly"); normalise the common
|
||||
-- spellings rather than constraining, so an unexpected value never blocks an import.
|
||||
ALTER TABLE statements DROP CONSTRAINT IF EXISTS statements_repayment_frequency_check;
|
||||
|
||||
CREATE OR REPLACE FUNCTION normalize_repayment_frequency(raw TEXT)
|
||||
RETURNS TEXT AS $$
|
||||
DECLARE
|
||||
v TEXT := lower(trim(coalesce(raw, '')));
|
||||
BEGIN
|
||||
IF v = '' THEN RETURN NULL; END IF;
|
||||
-- Check fortnightly spellings before the bare '%week%' match below.
|
||||
IF v LIKE '%fortnight%' OR v LIKE '%bi-week%' OR v LIKE '%biweek%'
|
||||
OR v LIKE '%2 week%' OR v LIKE '%two week%' OR v LIKE '%14 day%'
|
||||
THEN RETURN 'fortnightly'; END IF;
|
||||
IF v LIKE '%month%' THEN RETURN 'monthly'; END IF;
|
||||
IF v LIKE '%week%' THEN RETURN 'weekly'; END IF;
|
||||
IF v LIKE '%quarter%' THEN RETURN 'quarterly'; END IF;
|
||||
IF v LIKE '%annual%' OR v LIKE '%year%' THEN RETURN 'annually'; END IF;
|
||||
RETURN v;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION statements_normalize_loan_fields_trigger()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.repayment_frequency := normalize_repayment_frequency(NEW.repayment_frequency);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_statements_normalize_loan_fields ON statements;
|
||||
CREATE TRIGGER trg_statements_normalize_loan_fields
|
||||
BEFORE INSERT OR UPDATE OF repayment_frequency ON statements
|
||||
FOR EACH ROW EXECUTE FUNCTION statements_normalize_loan_fields_trigger();
|
||||
|
||||
UPDATE statements
|
||||
SET repayment_frequency = normalize_repayment_frequency(repayment_frequency)
|
||||
WHERE repayment_frequency IS NOT NULL
|
||||
AND repayment_frequency IS DISTINCT FROM normalize_repayment_frequency(repayment_frequency);
|
||||
@@ -0,0 +1,137 @@
|
||||
-- Canonical category vocabulary, enforced at the database.
|
||||
--
|
||||
-- Categories arrive from three places: the Gemini extraction (which writes
|
||||
-- straight to Postgres from N8N, bypassing the app entirely), CSV import, and
|
||||
-- manual edits. Only the DB sits under all three, so that is where the rule has
|
||||
-- to live -- the same reasoning as normalize_statement_type() in 0013.
|
||||
--
|
||||
-- What had leaked in without it:
|
||||
-- payment 19 rows $42,569.42 transaction_type written into the category
|
||||
-- refund 15 rows $8,558.99 ditto
|
||||
-- Shopping 13 rows $2,917.44 title-case duplicates of real categories,
|
||||
-- Dining 10 rows $127.30 which every GROUP BY counted separately
|
||||
-- (NULL) 3 rows $1,135.05
|
||||
|
||||
CREATE OR REPLACE FUNCTION normalize_category(raw TEXT)
|
||||
RETURNS TEXT AS $$
|
||||
DECLARE
|
||||
v TEXT;
|
||||
BEGIN
|
||||
IF raw IS NULL OR btrim(raw) = '' THEN RETURN 'other'; END IF;
|
||||
|
||||
-- Title-case and spaced variants collapse onto the canonical spelling:
|
||||
-- 'Home Goods' -> 'home_goods', 'Shopping' -> 'shopping'.
|
||||
v := replace(replace(lower(btrim(raw)), ' ', '_'), '-', '_');
|
||||
|
||||
IF v IN ('groceries','dining','transport','fuel','shopping','utilities',
|
||||
'entertainment','travel','health','insurance','subscriptions',
|
||||
'cash_advance','government','education','rent','home_goods',
|
||||
'home_maintenance','transfers','income','investment','loan_interest',
|
||||
'personal_care','pets','gifts','charity','fees','other')
|
||||
THEN RETURN v; END IF;
|
||||
|
||||
-- transaction_type leaking into the category field. A payment is money moving
|
||||
-- to a card or account, which is exactly what 'transfers' means here.
|
||||
IF v IN ('payment','payments','transfer') THEN RETURN 'transfers'; END IF;
|
||||
|
||||
-- Bare 'interest' is credit-card interest; loan interest is categorised
|
||||
-- loan_interest by the extraction prompt and matches the list above.
|
||||
IF v IN ('fee','bank_fees','interest') THEN RETURN 'fees'; END IF;
|
||||
|
||||
-- Singular/plural and common synonyms.
|
||||
IF v IN ('grocery','supermarket') THEN RETURN 'groceries'; END IF;
|
||||
IF v IN ('subscription') THEN RETURN 'subscriptions'; END IF;
|
||||
IF v IN ('utility','bills') THEN RETURN 'utilities'; END IF;
|
||||
IF v IN ('gift') THEN RETURN 'gifts'; END IF;
|
||||
IF v IN ('pet') THEN RETURN 'pets'; END IF;
|
||||
IF v IN ('restaurant','restaurants','food','takeaway') THEN RETURN 'dining'; END IF;
|
||||
IF v IN ('petrol','gas','gasoline') THEN RETURN 'fuel'; END IF;
|
||||
IF v IN ('medical','pharmacy') THEN RETURN 'health'; END IF;
|
||||
IF v IN ('donation','donations') THEN RETURN 'charity'; END IF;
|
||||
IF v IN ('salary','wages') THEN RETURN 'income'; END IF;
|
||||
IF v IN ('investments','savings') THEN RETURN 'investment'; END IF;
|
||||
IF v IN ('housing','mortgage') THEN RETURN 'rent'; END IF;
|
||||
|
||||
-- A 'refund' is not a category -- it says nothing about what was bought. The
|
||||
-- backfill below recovers the real category from the merchant where it can;
|
||||
-- anything still unknown lands in 'other' rather than inventing a category.
|
||||
RETURN 'other';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
|
||||
-- Recover categories for rows typed as refunds before collapsing them to
|
||||
-- 'other': use the most common category that merchant has on ordinary spend.
|
||||
UPDATE transactions t
|
||||
SET category = m.mode_category
|
||||
FROM (
|
||||
SELECT merchant_normalized,
|
||||
MODE() WITHIN GROUP (ORDER BY category) AS mode_category
|
||||
FROM transactions
|
||||
WHERE merchant_normalized IS NOT NULL
|
||||
AND category IS NOT NULL
|
||||
AND category NOT IN ('payment','refund','fee','interest')
|
||||
AND transaction_type IN ('debit','fee','interest')
|
||||
GROUP BY merchant_normalized
|
||||
) m
|
||||
WHERE t.merchant_normalized = m.merchant_normalized
|
||||
AND t.category = 'refund'
|
||||
AND m.mode_category IS NOT NULL;
|
||||
|
||||
|
||||
-- Backfill everything else through the function.
|
||||
UPDATE transactions
|
||||
SET category = normalize_category(category)
|
||||
WHERE category IS NULL OR category <> normalize_category(category);
|
||||
|
||||
UPDATE transaction_overrides
|
||||
SET category_override = normalize_category(category_override)
|
||||
WHERE category_override IS NOT NULL
|
||||
AND category_override <> normalize_category(category_override);
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION transactions_normalize_category()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.category := normalize_category(NEW.category);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_transactions_normalize_category ON transactions;
|
||||
CREATE TRIGGER trg_transactions_normalize_category
|
||||
BEFORE INSERT OR UPDATE OF category ON transactions
|
||||
FOR EACH ROW EXECUTE FUNCTION transactions_normalize_category();
|
||||
|
||||
-- Overrides keep NULL meaning "no override"; only a set value is normalised.
|
||||
CREATE OR REPLACE FUNCTION overrides_normalize_category()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.category_override IS NOT NULL THEN
|
||||
NEW.category_override := normalize_category(NEW.category_override);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_overrides_normalize_category ON transaction_overrides;
|
||||
CREATE TRIGGER trg_overrides_normalize_category
|
||||
BEFORE INSERT OR UPDATE OF category_override ON transaction_overrides
|
||||
FOR EACH ROW EXECUTE FUNCTION overrides_normalize_category();
|
||||
|
||||
|
||||
ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_category_check;
|
||||
ALTER TABLE transactions ADD CONSTRAINT transactions_category_check
|
||||
CHECK (category IS NULL OR category IN (
|
||||
'groceries','dining','transport','fuel','shopping','utilities','entertainment',
|
||||
'travel','health','insurance','subscriptions','cash_advance','government',
|
||||
'education','rent','home_goods','home_maintenance','transfers','income',
|
||||
'investment','loan_interest','personal_care','pets','gifts','charity','fees','other'));
|
||||
|
||||
ALTER TABLE transaction_overrides DROP CONSTRAINT IF EXISTS overrides_category_check;
|
||||
ALTER TABLE transaction_overrides ADD CONSTRAINT overrides_category_check
|
||||
CHECK (category_override IS NULL OR category_override IN (
|
||||
'groceries','dining','transport','fuel','shopping','utilities','entertainment',
|
||||
'travel','health','insurance','subscriptions','cash_advance','government',
|
||||
'education','rent','home_goods','home_maintenance','transfers','income',
|
||||
'investment','loan_interest','personal_care','pets','gifts','charity','fees','other'));
|
||||
@@ -0,0 +1,35 @@
|
||||
-- How a transaction was paid for, so cash can be told apart from everything else.
|
||||
--
|
||||
-- The problem this solves: getPendingReconciliations treats every unreconciled
|
||||
-- manual transaction as awaiting a matching statement row. A cash purchase never
|
||||
-- appears on a statement, so it sits in the queue forever and is offered matches
|
||||
-- within 3 days and 1% on amount. Accepting one is silently destructive --
|
||||
-- reconciled manual rows are excluded from every query, so the cash spend
|
||||
-- disappears while the card transaction it matched claims to be that same spend.
|
||||
--
|
||||
-- NULL means unknown, which is treated as reconcilable -- the existing behaviour
|
||||
-- for every row already in the table.
|
||||
--
|
||||
-- Only 'cash' is excluded from reconciliation. A bank transfer DOES appear on a
|
||||
-- statement now that transaction accounts are being imported, so it stays a
|
||||
-- reconciliation candidate.
|
||||
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS payment_method TEXT;
|
||||
|
||||
ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_payment_method_check;
|
||||
ALTER TABLE transactions ADD CONSTRAINT transactions_payment_method_check
|
||||
CHECK (payment_method IS NULL
|
||||
OR payment_method IN ('card', 'cash', 'bank_transfer', 'other'));
|
||||
|
||||
-- Partial index: the reconciliation query filters on this, and cash is expected
|
||||
-- to stay a small minority of rows.
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_payment_method
|
||||
ON transactions (payment_method) WHERE payment_method IS NOT NULL;
|
||||
|
||||
-- Backfill the one row that is unambiguously cash. Statement-linked rows are
|
||||
-- left NULL: they came from a statement, so by definition they are not cash.
|
||||
UPDATE transactions
|
||||
SET payment_method = 'cash'
|
||||
WHERE statement_id IS NULL
|
||||
AND description ILIKE '%cash transaction%';
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Record which rule a run came from.
|
||||
--
|
||||
-- rule_apply_runs stored only counts ("13 matches · 13 transactions"), which is
|
||||
-- not enough to decide whether to revert: you cannot tell a merchant rename from
|
||||
-- a 50/50 split of your entire history. The snapshot column already holds the
|
||||
-- before-state, but nothing said what was applied or why.
|
||||
--
|
||||
-- rule_name is denormalised deliberately. A run must stay readable after the
|
||||
-- rule it came from is edited or deleted -- the history is a record of what
|
||||
-- happened, not a pointer to what the rule says today.
|
||||
|
||||
ALTER TABLE rule_apply_runs
|
||||
ADD COLUMN IF NOT EXISTS rule_id INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS rule_name TEXT,
|
||||
-- 'all' = bulk run over every enabled rule; 'rule' = one rule by conditions;
|
||||
-- 'selection' = one rule against hand-picked transactions (preview → apply).
|
||||
ADD COLUMN IF NOT EXISTS source TEXT;
|
||||
|
||||
ALTER TABLE rule_apply_runs DROP CONSTRAINT IF EXISTS rule_apply_runs_source_check;
|
||||
ALTER TABLE rule_apply_runs ADD CONSTRAINT rule_apply_runs_source_check
|
||||
CHECK (source IS NULL OR source IN ('all', 'rule', 'selection'));
|
||||
|
||||
-- No FK to rules: deleting a rule must not cascade away the audit trail.
|
||||
CREATE INDEX IF NOT EXISTS idx_rule_apply_runs_rule
|
||||
ON rule_apply_runs (rule_id) WHERE rule_id IS NOT NULL;
|
||||
|
||||
-- Existing rows stay NULL. There is no way to recover which rule they ran;
|
||||
-- the UI shows them as "unknown rule" rather than guessing.
|
||||
+137
-9
@@ -7,13 +7,29 @@ datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
model trips {
|
||||
id Int @id @default(autoincrement())
|
||||
owner_id Int
|
||||
name String
|
||||
description String?
|
||||
start_date DateTime? @db.Date
|
||||
end_date DateTime? @db.Date
|
||||
color String @default("#6366f1")
|
||||
archived Boolean @default(false)
|
||||
created_at DateTime @default(now())
|
||||
overrides transaction_overrides[]
|
||||
}
|
||||
|
||||
model transaction_overrides {
|
||||
id Int @id @default(autoincrement())
|
||||
transaction_id Int @unique
|
||||
merchant_normalized String?
|
||||
category_override String?
|
||||
notes String?
|
||||
my_share_percent Decimal? @db.Decimal(5, 2)
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
trip_id Int?
|
||||
trip trips? @relation(fields: [trip_id], references: [id], onDelete: SetNull)
|
||||
}
|
||||
|
||||
model participants {
|
||||
@@ -23,6 +39,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 +67,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
|
||||
@@ -67,15 +98,16 @@ model transaction_tags {
|
||||
}
|
||||
|
||||
model rules {
|
||||
id Int @id @default(autoincrement())
|
||||
owner_id Int
|
||||
name String
|
||||
conditions Json @default("[]")
|
||||
actions Json @default("{}")
|
||||
enabled Boolean @default(true)
|
||||
priority Int @default(0)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
owner_id Int
|
||||
name String
|
||||
conditions Json @default("[]")
|
||||
actions Json @default("{}")
|
||||
enabled Boolean @default(true)
|
||||
manual_only Boolean @default(false)
|
||||
priority Int @default(0)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
}
|
||||
|
||||
model budgets {
|
||||
@@ -89,3 +121,99 @@ model budgets {
|
||||
|
||||
@@unique([owner_id, category, month])
|
||||
}
|
||||
|
||||
model statements {
|
||||
id Int @id @default(autoincrement())
|
||||
filename String
|
||||
bank_name String?
|
||||
card_name String?
|
||||
account_type String?
|
||||
account_number String
|
||||
billing_start_date DateTime? @db.Date
|
||||
billing_end_date DateTime? @db.Date
|
||||
total_amount_due Decimal? @db.Decimal(12, 2)
|
||||
minimum_amount_due Decimal? @db.Decimal(12, 2)
|
||||
payment_due_date DateTime? @db.Date
|
||||
event_created Boolean? @default(false)
|
||||
tier_used String?
|
||||
created_at DateTime? @default(now())
|
||||
statement_type String @default("other")
|
||||
currency String? @default("AUD")
|
||||
opening_balance Decimal? @db.Decimal(12, 2)
|
||||
closing_balance Decimal? @db.Decimal(12, 2)
|
||||
total_credits Decimal? @db.Decimal(12, 2)
|
||||
total_debits Decimal? @db.Decimal(12, 2)
|
||||
interest_charged Decimal? @db.Decimal(12, 2)
|
||||
fees_charged Decimal? @db.Decimal(12, 2)
|
||||
credit_limit Decimal? @db.Decimal(12, 2)
|
||||
available_credit Decimal? @db.Decimal(12, 2)
|
||||
owner_id Int @default(1)
|
||||
account_holder_name String?
|
||||
exchange_rate_to_aud Decimal? @db.Decimal(10, 6)
|
||||
paperless_doc_id Int? @unique
|
||||
interest_rate Decimal? @db.Decimal(6, 3)
|
||||
scheduled_repayment Decimal? @db.Decimal(12, 2)
|
||||
repayment_frequency String?
|
||||
redraw_available Decimal? @db.Decimal(12, 2)
|
||||
loan_term_months Int?
|
||||
transactions transactions[]
|
||||
}
|
||||
|
||||
model transactions {
|
||||
id Int @id @default(autoincrement())
|
||||
statement_id Int?
|
||||
transaction_date DateTime @db.Date
|
||||
description String?
|
||||
amount Decimal @db.Decimal(12, 2)
|
||||
created_at DateTime? @default(now())
|
||||
transaction_type String? @default("debit")
|
||||
merchant_name String?
|
||||
location String?
|
||||
foreign_currency_amount Decimal? @db.Decimal(12, 2)
|
||||
foreign_currency_code String?
|
||||
category String?
|
||||
row_index Int?
|
||||
merchant_normalized String?
|
||||
amount_aud Decimal? @db.Decimal(12, 2)
|
||||
payment_method String? // card | cash | bank_transfer | other; NULL = unknown (migration 0016)
|
||||
owner_id Int?
|
||||
reconciled_with_id Int?
|
||||
principal_amount Decimal? @db.Decimal(12, 2)
|
||||
interest_amount Decimal? @db.Decimal(12, 2)
|
||||
statement statements? @relation(fields: [statement_id], references: [id], onDelete: Cascade)
|
||||
reconciled_with transactions? @relation("reconciled", fields: [reconciled_with_id], references: [id], onDelete: SetNull)
|
||||
reconciled_by transactions[] @relation("reconciled")
|
||||
expense_metadata expense_metadata?
|
||||
}
|
||||
|
||||
model expense_metadata {
|
||||
id Int @id @default(autoincrement())
|
||||
transaction_id Int? @unique
|
||||
source String @default("email")
|
||||
paperless_doc_id Int? @unique
|
||||
source_email_subject String?
|
||||
source_email_from String?
|
||||
payment_method String?
|
||||
payment_method_detail String?
|
||||
order_reference String?
|
||||
line_items Json @default("[]")
|
||||
tax_amount Decimal? @db.Decimal(12, 2)
|
||||
subtotal Decimal? @db.Decimal(12, 2)
|
||||
merchant_normalized String?
|
||||
amount Decimal? @db.Decimal(12, 2)
|
||||
transaction_date DateTime? @db.Date
|
||||
extraction_model String? @default("gemini-2.5-flash")
|
||||
created_at DateTime? @default(now())
|
||||
transaction transactions? @relation(fields: [transaction_id], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model rule_apply_runs {
|
||||
id Int @id @default(autoincrement())
|
||||
owner_id Int
|
||||
applied_at DateTime @default(now())
|
||||
split_from DateTime? @db.Date
|
||||
matched Int @default(0)
|
||||
transactions_affected Int @default(0)
|
||||
reverted_at DateTime?
|
||||
snapshot Json @default("[]")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Personal Finance Tracker — Project Context
|
||||
|
||||
## What Is This?
|
||||
|
||||
A self-hosted personal finance tracker built from scratch. It automatically ingests bank statements, categorises transactions using AI, and provides a web UI for reviewing spending, managing shared expenses, and running analytics.
|
||||
|
||||
---
|
||||
|
||||
## How It Works (High Level)
|
||||
|
||||
### Automatic Statement Ingestion
|
||||
|
||||
Bank statements (PDFs) are uploaded to a document management system (Paperless-NGX). An automation workflow (N8N) polls for new documents every 5 minutes and:
|
||||
|
||||
1. Sends the PDF to Google Gemini (AI) for structured data extraction
|
||||
2. Normalises the extracted data (merchant names, currencies, account numbers)
|
||||
3. Inserts the statement summary and individual transactions into a PostgreSQL database
|
||||
4. Creates a Google Calendar reminder for credit card payment due dates
|
||||
5. For new/unknown bank accounts, requires a human approval before inserting
|
||||
|
||||
---
|
||||
|
||||
## Core Features
|
||||
|
||||
### Transactions View
|
||||
- Full paginated list of all transactions across all bank accounts
|
||||
- Filters: date range, category, bank, tags, transaction type, amount range, split status, free-text search
|
||||
- Sortable columns including transaction date, amount, and import date
|
||||
- Inline editing of category, merchant name, and notes
|
||||
- Tagging system with user-defined coloured labels
|
||||
|
||||
### Statements View
|
||||
- One row per billing period per account
|
||||
- Filters by bank, statement type, owner, year
|
||||
- Click a statement to see only its transactions
|
||||
|
||||
### Analytics / Insights
|
||||
- Monthly spend breakdown by category (stacked bar chart)
|
||||
- Category trend lines over time
|
||||
- Pareto chart (which categories drive the most spend)
|
||||
- Cumulative spend curve
|
||||
- Savings rate over time
|
||||
- Recurring charge detection (subscriptions/recurring merchants)
|
||||
- Fees and interest audit (tracks what's been paid in bank fees and interest charges)
|
||||
- Committed vs discretionary spend split
|
||||
|
||||
### Merchant Profiles
|
||||
- Per-merchant transaction history and net spend
|
||||
- Scatter plot of spend over time
|
||||
- Accounts for refunds/credits
|
||||
|
||||
### Shared Expenses
|
||||
- Split transactions between multiple people (by percentage)
|
||||
- Tracks who owes what with a running balance
|
||||
- Record cash settlements between participants
|
||||
- Tag filter on shared view to track specific projects/events
|
||||
|
||||
### Rules Engine
|
||||
- Create saved rules that auto-apply categories, merchant names, tags, or splits to matching transactions
|
||||
- Conditions: merchant name, description, category, bank, amount, transaction type
|
||||
- Operators: contains, equals, starts with, greater/less than, not equals
|
||||
- Bulk-apply all rules at once with full revert support (snapshot stored before each run)
|
||||
|
||||
### Manual Transactions + Reconciliation
|
||||
- Enter transactions manually (cash, receipts not on a statement)
|
||||
- CSV import for bulk entry
|
||||
- Reconcile manual transactions against statement transactions when the statement arrives — merges tags, splits, and notes onto the statement version
|
||||
|
||||
### Multi-Owner Support
|
||||
- Multiple people's accounts can be tracked in the one app
|
||||
- Each statement/transaction is scoped to an owner
|
||||
- The logged-in user only sees their own data
|
||||
|
||||
---
|
||||
|
||||
## Technology
|
||||
|
||||
- **Frontend**: Next.js (React), TypeScript, Tailwind CSS, Recharts for charts
|
||||
- **Backend**: Next.js API routes with raw PostgreSQL queries (no ORM at query time)
|
||||
- **Database**: PostgreSQL
|
||||
- **AI extraction**: Google Gemini 2.5 Flash (PDF → structured JSON)
|
||||
- **Automation**: N8N workflow orchestrates the ingestion pipeline
|
||||
- **Auth**: Users are authenticated by the reverse proxy before reaching the app
|
||||
- **Hosting**: Self-hosted Docker container on a home server
|
||||
|
||||
---
|
||||
|
||||
## Data Structure (Summary)
|
||||
|
||||
| Concept | Description |
|
||||
|---------|-------------|
|
||||
| **Statement** | One billing period for one bank account. Has summary totals (closing balance, fees, interest, credit limit, etc.) |
|
||||
| **Transaction** | A line item. Has date, amount, merchant, category, transaction type (debit/credit/refund/fee/etc.) |
|
||||
| **Override** | User correction to AI-extracted merchant name or category. Stored separately to preserve the original AI output |
|
||||
| **Split** | A transaction shared with another person — records their percentage share and whether it's been settled |
|
||||
| **Tag** | A free-form label applied to transactions (e.g. "Europe Trip 2026", "Home Reno") |
|
||||
| **Rule** | A saved condition→action pair applied in bulk to auto-categorise/tag/split transactions |
|
||||
| **Participant** | A person (account owner or expense-sharing partner) |
|
||||
| **Split Payment** | A recorded cash settlement between two participants |
|
||||
|
||||
### Category Taxonomy
|
||||
|
||||
Fixed set used by AI and overridable by the user:
|
||||
`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`
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations / Planned Work
|
||||
|
||||
- **Payment provider conflation**: Transactions processed through PayPal, Afterpay, Zip etc. sometimes show the payment provider as the merchant rather than the actual store. Plan is to extract `payment_provider` as a separate field so the real merchant is preserved.
|
||||
- **Budgets**: The database has a budget table but it's not currently surfaced in the UI (the analytics/insights views replaced it for now).
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
# Creates the personal_test database and writes .env.test
|
||||
# Run once before integration tests: npm run test:setup
|
||||
|
||||
set -e
|
||||
|
||||
PG_CONTAINER=postgres-personal
|
||||
PG_USER=personal
|
||||
PG_PASS=personalpassword123
|
||||
TEST_DB=personal_test
|
||||
|
||||
# Discover the container's bridge IP (accessible from the host on Linux)
|
||||
PG_HOST=$(docker inspect "$PG_CONTAINER" \
|
||||
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 2>/dev/null | head -1)
|
||||
|
||||
if [ -z "$PG_HOST" ]; then
|
||||
echo "ERROR: Could not find container '$PG_CONTAINER'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Postgres container at $PG_HOST:5432"
|
||||
|
||||
# Create test database (ignore error if it already exists)
|
||||
docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres \
|
||||
-c "CREATE DATABASE $TEST_DB;" 2>/dev/null || true
|
||||
|
||||
# Wipe and rebuild schema from production (schema only, no data)
|
||||
echo "Copying schema from $PG_USER to $TEST_DB..."
|
||||
docker exec "$PG_CONTAINER" pg_dump -U "$PG_USER" --schema-only "$PG_USER" \
|
||||
| docker exec -i "$PG_CONTAINER" psql -U "$PG_USER" -d "$TEST_DB" -q
|
||||
|
||||
echo "Schema ready."
|
||||
|
||||
# Write .env.test
|
||||
cat > "$(dirname "$0")/../.env.test" << EOF
|
||||
DATABASE_URL=postgresql://$PG_USER:$PG_PASS@$PG_HOST:5432/$TEST_DB
|
||||
EOF
|
||||
|
||||
echo ".env.test written — integration tests can now run with: npm run test:integration"
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Pool } from "pg";
|
||||
import { vi } from "vitest";
|
||||
|
||||
export function createPool() {
|
||||
return new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
}
|
||||
|
||||
// Replace the app's Prisma-based queryRaw with a direct pg call so that
|
||||
// tests don't depend on Prisma's singleton picking up the right DATABASE_URL.
|
||||
// Must be called BEFORE dynamically importing any module that uses @/lib/db.
|
||||
// Uses vi.doMock (not vi.mock) so it is NOT hoisted and CAN close over `p`.
|
||||
export function mockDbWithPool(p: Pool) {
|
||||
vi.resetModules(); // clear module cache so fresh imports pick up the mock
|
||||
vi.doMock("@/lib/db", () => ({
|
||||
queryRaw: async (sql: string, params: unknown[] = []) => {
|
||||
const result = await p.query(sql, params);
|
||||
return result.rows;
|
||||
},
|
||||
prisma: p,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Wipe all data tables and restart sequences between tests. */
|
||||
export async function resetDB(pool: Pool) {
|
||||
await pool.query(`
|
||||
TRUNCATE
|
||||
split_payments,
|
||||
transaction_splits,
|
||||
transaction_tags,
|
||||
transaction_overrides,
|
||||
rule_apply_runs,
|
||||
rules,
|
||||
budgets,
|
||||
account_owner_mappings,
|
||||
transactions,
|
||||
statements,
|
||||
tags,
|
||||
participants
|
||||
RESTART IDENTITY CASCADE
|
||||
`);
|
||||
}
|
||||
|
||||
/** Seed two participants and return their IDs. */
|
||||
export async function seedParticipants(pool: Pool, names: [string, string] = ["Alice", "Bob"]) {
|
||||
const r1 = await pool.query(
|
||||
`INSERT INTO participants (name) VALUES ($1) RETURNING id`,
|
||||
[names[0]]
|
||||
);
|
||||
const r2 = await pool.query(
|
||||
`INSERT INTO participants (name) VALUES ($1) RETURNING id`,
|
||||
[names[1]]
|
||||
);
|
||||
return { ownerId: r1.rows[0].id as number, otherId: r2.rows[0].id as number };
|
||||
}
|
||||
|
||||
/** Insert a manual transaction (no statement) and return its id. */
|
||||
export async function insertTransaction(
|
||||
pool: Pool,
|
||||
ownerId: number,
|
||||
overrides: {
|
||||
description?: string;
|
||||
amount?: number;
|
||||
category?: string;
|
||||
transaction_type?: string;
|
||||
transaction_date?: string;
|
||||
merchant_normalized?: string;
|
||||
} = {}
|
||||
): Promise<number> {
|
||||
const r = await pool.query(
|
||||
`INSERT INTO transactions
|
||||
(owner_id, statement_id, transaction_date, description, amount, transaction_type, category, row_index)
|
||||
VALUES ($1, NULL, $2, $3, $4, $5, $6, 0) RETURNING id`,
|
||||
[
|
||||
ownerId,
|
||||
overrides.transaction_date ?? "2024-06-15",
|
||||
overrides.description ?? "Test transaction",
|
||||
overrides.amount ?? 100,
|
||||
overrides.transaction_type ?? "debit",
|
||||
overrides.category ?? "other",
|
||||
]
|
||||
);
|
||||
return r.rows[0].id as number;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||
import { createPool, resetDB } from "./helpers";
|
||||
|
||||
const pool = createPool();
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
// This tests the name-substitution logic applied in /api/participants GET.
|
||||
// The rule: the participant matching the current user's ID gets name "Me";
|
||||
// everyone else keeps their real name.
|
||||
function substituteMe(
|
||||
participants: { id: number; name: string }[],
|
||||
currentUserId: number
|
||||
) {
|
||||
return participants.map((p) =>
|
||||
p.id === currentUserId ? { ...p, name: "Me" } : p
|
||||
);
|
||||
}
|
||||
|
||||
describe("participant Me substitution", () => {
|
||||
it("replaces the current user's name with Me", async () => {
|
||||
const r = await pool.query(
|
||||
`INSERT INTO participants (name) VALUES ('Siddharth') RETURNING id`
|
||||
);
|
||||
const userId = r.rows[0].id;
|
||||
const participants = [
|
||||
{ id: userId, name: "Siddharth" },
|
||||
{ id: userId + 1, name: "Sonu" },
|
||||
];
|
||||
|
||||
const result = substituteMe(participants, userId);
|
||||
expect(result.find((p) => p.id === userId)?.name).toBe("Me");
|
||||
expect(result.find((p) => p.id === userId + 1)?.name).toBe("Sonu");
|
||||
});
|
||||
|
||||
it("leaves all names unchanged when currentUserId does not match", () => {
|
||||
const participants = [
|
||||
{ id: 1, name: "Siddharth" },
|
||||
{ id: 2, name: "Sonu" },
|
||||
];
|
||||
const result = substituteMe(participants, 999);
|
||||
expect(result).toEqual(participants);
|
||||
});
|
||||
|
||||
it("Sonu sees Me for herself and Siddharth for the primary user", async () => {
|
||||
const r1 = await pool.query(
|
||||
`INSERT INTO participants (name) VALUES ('Siddharth') RETURNING id`
|
||||
);
|
||||
const r2 = await pool.query(
|
||||
`INSERT INTO participants (name, email) VALUES ('Sonu', 'sonu@example.com') RETURNING id`
|
||||
);
|
||||
const siddharthId = r1.rows[0].id;
|
||||
const sonuId = r2.rows[0].id;
|
||||
|
||||
const rawParticipants = [
|
||||
{ id: siddharthId, name: "Siddharth" },
|
||||
{ id: sonuId, name: "Sonu" },
|
||||
];
|
||||
|
||||
// Siddharth's view
|
||||
const siddharthView = substituteMe(rawParticipants, siddharthId);
|
||||
expect(siddharthView.find((p) => p.id === siddharthId)?.name).toBe("Me");
|
||||
expect(siddharthView.find((p) => p.id === sonuId)?.name).toBe("Sonu");
|
||||
|
||||
// Sonu's view
|
||||
const sonuView = substituteMe(rawParticipants, sonuId);
|
||||
expect(sonuView.find((p) => p.id === siddharthId)?.name).toBe("Siddharth");
|
||||
expect(sonuView.find((p) => p.id === sonuId)?.name).toBe("Me");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
|
||||
import { createPool, mockDbWithPool, resetDB, seedParticipants, insertTransaction } from "./helpers";
|
||||
|
||||
// Create a pool and mock @/lib/db BEFORE any dynamic imports that use it.
|
||||
// vi.doMock is NOT hoisted so it can close over the pool instance.
|
||||
const pool = createPool();
|
||||
mockDbWithPool(pool);
|
||||
|
||||
// Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances
|
||||
// use the test pool rather than Prisma's singleton.
|
||||
const { getTransactions, getParticipantBalances } = await import("@/lib/queries");
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── getTransactions ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("getTransactions — owner scoping", () => {
|
||||
it("returns only the owner's transactions", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "Alice groceries" });
|
||||
await insertTransaction(pool, otherId, { description: "Bob petrol" });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].description).toBe("Alice groceries");
|
||||
});
|
||||
|
||||
it("includes transactions where owner is a split participant", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, otherId, { description: "Shared dinner" });
|
||||
|
||||
// Add Alice as a split participant
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[txId, ownerId]
|
||||
);
|
||||
|
||||
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||
expect(data.some((t) => t.description === "Shared dinner")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns correct total count", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "tx1" });
|
||||
await insertTransaction(pool, ownerId, { description: "tx2" });
|
||||
await insertTransaction(pool, ownerId, { description: "tx3" });
|
||||
|
||||
const { total } = await getTransactions(ownerId, { limit: 2, offset: 0 });
|
||||
expect(total).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — date filters", () => {
|
||||
it("filters by from date", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "old tx", transaction_date: "2024-01-10" });
|
||||
await insertTransaction(pool, ownerId, { description: "new tx", transaction_date: "2024-03-01" });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { from: "2024-02-01", limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].description).toBe("new tx");
|
||||
});
|
||||
|
||||
it("filters by to date", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "old tx", transaction_date: "2024-01-10" });
|
||||
await insertTransaction(pool, ownerId, { description: "new tx", transaction_date: "2024-03-01" });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { to: "2024-01-31", limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].description).toBe("old tx");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — category filter", () => {
|
||||
it("filters by category", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "Grocery run", category: "groceries" });
|
||||
await insertTransaction(pool, ownerId, { description: "Dinner out", category: "dining" });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { categories: ["groceries"], limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].description).toBe("Grocery run");
|
||||
});
|
||||
|
||||
it("category override takes precedence over raw category", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId, { category: "dining" });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_overrides (transaction_id, category_override) VALUES ($1, 'groceries')`,
|
||||
[txId]
|
||||
);
|
||||
|
||||
const { data: dining } = await getTransactions(ownerId, { categories: ["dining"], limit: 50, offset: 0 });
|
||||
const { data: groceries } = await getTransactions(ownerId, { categories: ["groceries"], limit: 50, offset: 0 });
|
||||
expect(dining).toHaveLength(0); // override hides original
|
||||
expect(groceries).toHaveLength(1); // override exposes new category
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — search filter", () => {
|
||||
it("searches description case-insensitively", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "COLES WYNDHAM" });
|
||||
await insertTransaction(pool, ownerId, { description: "ALDI POINT COOK" });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { search: "coles", limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].description).toBe("COLES WYNDHAM");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — amount filters", () => {
|
||||
it("filters by amount_min", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { amount: 20 });
|
||||
await insertTransaction(pool, ownerId, { amount: 200 });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { amount_min: 100, limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(Number(data[0].amount)).toBe(200);
|
||||
});
|
||||
|
||||
it("filters by amount_max", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { amount: 20 });
|
||||
await insertTransaction(pool, ownerId, { amount: 200 });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { amount_max: 50, limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(Number(data[0].amount)).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — pagination", () => {
|
||||
it("respects limit and offset", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await insertTransaction(pool, ownerId, { description: `tx-${i}`, transaction_date: `2024-0${i + 1}-01` });
|
||||
}
|
||||
|
||||
const page1 = await getTransactions(ownerId, { limit: 2, offset: 0 });
|
||||
const page2 = await getTransactions(ownerId, { limit: 2, offset: 2 });
|
||||
expect(page1.data).toHaveLength(2);
|
||||
expect(page2.data).toHaveLength(2);
|
||||
expect(page1.data[0].description).not.toBe(page2.data[0].description);
|
||||
expect(page1.total).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — splits and tags attached", () => {
|
||||
it("attaches empty arrays when no splits or tags", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId);
|
||||
|
||||
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||
expect(data[0].splits).toEqual([]);
|
||||
expect(data[0].tags).toEqual([]);
|
||||
});
|
||||
|
||||
it("attaches split participants", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool, ["Alice", "Bob"]);
|
||||
const txId = await insertTransaction(pool, ownerId);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
|
||||
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||
expect(data[0].splits).toHaveLength(1);
|
||||
expect(data[0].splits[0].name).toBe("Bob");
|
||||
expect(Number(data[0].splits[0].share_percent)).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getParticipantBalances ────────────────────────────────────────────────────
|
||||
|
||||
describe("getParticipantBalances", () => {
|
||||
it("shows zero balance when no splits", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
void otherId;
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
expect(balances.every((b) => Number(b.total_owed) === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("calculates positive balance when participant owes owner", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
// Alice pays $100, Bob owes 50%
|
||||
const txId = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bobBalance = balances.find((b) => b.id === otherId);
|
||||
expect(bobBalance).toBeDefined();
|
||||
expect(Number(bobBalance!.total_owed)).toBeCloseTo(50);
|
||||
});
|
||||
|
||||
it("reduces balance after recording a payment", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
// Bob pays Alice $30
|
||||
await pool.query(
|
||||
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date)
|
||||
VALUES ($1, $2, 30, '2024-06-20')`,
|
||||
[otherId, ownerId]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bobBalance = balances.find((b) => b.id === otherId);
|
||||
expect(Number(bobBalance!.total_owed)).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
it("shows negative balance when owner owes participant", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
// Bob pays $100 for a shared expense, Alice owes 50%
|
||||
const txId = await insertTransaction(pool, otherId, { amount: 100 });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[txId, ownerId]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bobBalance = balances.find((b) => b.id === otherId);
|
||||
expect(Number(bobBalance!.total_owed)).toBeCloseTo(-50);
|
||||
});
|
||||
|
||||
it("unsettled_count reflects open splits", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const tx1 = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
const tx2 = await insertTransaction(pool, ownerId, { amount: 80 });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50), ($3, $4, 50)`,
|
||||
[tx1, otherId, tx2, otherId]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bobBalance = balances.find((b) => b.id === otherId);
|
||||
expect(bobBalance!.unsettled_count).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
|
||||
describe("formatCategory", () => {
|
||||
it("capitalises single word", () => {
|
||||
expect(formatCategory("groceries")).toBe("Groceries");
|
||||
});
|
||||
it("capitalises and spaces underscore-separated words", () => {
|
||||
expect(formatCategory("home_goods")).toBe("Home Goods");
|
||||
});
|
||||
it("handles three-word categories", () => {
|
||||
expect(formatCategory("home_maintenance")).toBe("Home Maintenance");
|
||||
});
|
||||
it("handles cash_advance", () => {
|
||||
expect(formatCategory("cash_advance")).toBe("Cash Advance");
|
||||
});
|
||||
it("handles personal_care", () => {
|
||||
expect(formatCategory("personal_care")).toBe("Personal Care");
|
||||
});
|
||||
it("handles single-word categories without underscores", () => {
|
||||
expect(formatCategory("travel")).toBe("Travel");
|
||||
expect(formatCategory("fees")).toBe("Fees");
|
||||
expect(formatCategory("other")).toBe("Other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CATEGORIES", () => {
|
||||
it("contains expected core categories", () => {
|
||||
const cats = CATEGORIES as readonly string[];
|
||||
expect(cats).toContain("groceries");
|
||||
expect(cats).toContain("dining");
|
||||
expect(cats).toContain("transport");
|
||||
expect(cats).toContain("health");
|
||||
expect(cats).toContain("other");
|
||||
});
|
||||
it("has no duplicates", () => {
|
||||
const cats = CATEGORIES as readonly string[];
|
||||
expect(new Set(cats).size).toBe(cats.length);
|
||||
});
|
||||
it("all entries are lowercase with only letters and underscores", () => {
|
||||
for (const cat of CATEGORIES) {
|
||||
expect(cat).toMatch(/^[a-z_]+$/);
|
||||
}
|
||||
});
|
||||
it("formatCategory produces unique display names", () => {
|
||||
const formatted = CATEGORIES.map(formatCategory);
|
||||
expect(new Set(formatted).size).toBe(formatted.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { evaluateCondition, type Condition, type TxFields } from "@/lib/rules";
|
||||
|
||||
function tx(overrides: Partial<TxFields> = {}): TxFields {
|
||||
return {
|
||||
effective_category: "groceries",
|
||||
effective_merchant: "Coles",
|
||||
description: "COLES WYNDHAM VALE",
|
||||
bank_name: "ANZ",
|
||||
amount: 42.5,
|
||||
transaction_type: "debit",
|
||||
tags: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function cond(field: Condition["field"], operator: Condition["operator"], value: string): Condition {
|
||||
return { field, operator, value };
|
||||
}
|
||||
|
||||
// ── String fields ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("merchant_normalized", () => {
|
||||
it("contains — matches substring", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "contains", "coles"), tx())).toBe(true);
|
||||
});
|
||||
it("contains — case-insensitive", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "contains", "COLES"), tx())).toBe(true);
|
||||
});
|
||||
it("contains — no match", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "contains", "woolworths"), tx())).toBe(false);
|
||||
});
|
||||
it("equals — exact match (case-insensitive)", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "equals", "coles"), tx())).toBe(true);
|
||||
});
|
||||
it("equals — no match", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "equals", "cole"), tx())).toBe(false);
|
||||
});
|
||||
it("starts_with — matches prefix", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "starts_with", "col"), tx())).toBe(true);
|
||||
});
|
||||
it("starts_with — no match", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "starts_with", "oles"), tx())).toBe(false);
|
||||
});
|
||||
it("not_equals — different value", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "not_equals", "woolworths"), tx())).toBe(true);
|
||||
});
|
||||
it("not_equals — same value", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "not_equals", "coles"), tx())).toBe(false);
|
||||
});
|
||||
it("empty merchant falls back to empty string", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "contains", "coles"), tx({ effective_merchant: "" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("description", () => {
|
||||
it("contains — matches", () => {
|
||||
expect(evaluateCondition(cond("description", "contains", "wyndham"), tx())).toBe(true);
|
||||
});
|
||||
it("equals — exact (case-insensitive)", () => {
|
||||
expect(evaluateCondition(cond("description", "equals", "coles wyndham vale"), tx())).toBe(true);
|
||||
});
|
||||
it("starts_with", () => {
|
||||
expect(evaluateCondition(cond("description", "starts_with", "coles"), tx())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("category", () => {
|
||||
it("equals category", () => {
|
||||
expect(evaluateCondition(cond("category", "equals", "groceries"), tx())).toBe(true);
|
||||
});
|
||||
it("not_equals different category", () => {
|
||||
expect(evaluateCondition(cond("category", "not_equals", "dining"), tx())).toBe(true);
|
||||
});
|
||||
it("contains partial", () => {
|
||||
expect(evaluateCondition(cond("category", "contains", "grocer"), tx())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bank_name", () => {
|
||||
it("equals bank", () => {
|
||||
expect(evaluateCondition(cond("bank_name", "equals", "anz"), tx())).toBe(true);
|
||||
});
|
||||
it("not_equals different bank", () => {
|
||||
expect(evaluateCondition(cond("bank_name", "not_equals", "nab"), tx())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transaction_type", () => {
|
||||
it("equals debit", () => {
|
||||
expect(evaluateCondition(cond("transaction_type", "equals", "debit"), tx())).toBe(true);
|
||||
});
|
||||
it("not_equals credit", () => {
|
||||
expect(evaluateCondition(cond("transaction_type", "not_equals", "credit"), tx())).toBe(true);
|
||||
});
|
||||
it("equals credit — no match on debit tx", () => {
|
||||
expect(evaluateCondition(cond("transaction_type", "equals", "credit"), tx())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Amount field ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("amount", () => {
|
||||
it("equals exact amount", () => {
|
||||
expect(evaluateCondition(cond("amount", "equals", "42.5"), tx())).toBe(true);
|
||||
});
|
||||
it("equals wrong amount", () => {
|
||||
expect(evaluateCondition(cond("amount", "equals", "42"), tx())).toBe(false);
|
||||
});
|
||||
it("not_equals different amount", () => {
|
||||
expect(evaluateCondition(cond("amount", "not_equals", "100"), tx())).toBe(true);
|
||||
});
|
||||
it("gt — amount is greater", () => {
|
||||
expect(evaluateCondition(cond("amount", "gt", "40"), tx())).toBe(true);
|
||||
});
|
||||
it("gt — amount is equal (not strictly greater)", () => {
|
||||
expect(evaluateCondition(cond("amount", "gt", "42.5"), tx())).toBe(false);
|
||||
});
|
||||
it("gt — amount is less", () => {
|
||||
expect(evaluateCondition(cond("amount", "gt", "50"), tx())).toBe(false);
|
||||
});
|
||||
it("lt — amount is less", () => {
|
||||
expect(evaluateCondition(cond("amount", "lt", "50"), tx())).toBe(true);
|
||||
});
|
||||
it("lt — amount is equal (not strictly less)", () => {
|
||||
expect(evaluateCondition(cond("amount", "lt", "42.5"), tx())).toBe(false);
|
||||
});
|
||||
it("lt — amount is greater", () => {
|
||||
expect(evaluateCondition(cond("amount", "lt", "40"), tx())).toBe(false);
|
||||
});
|
||||
it("unsupported operator (contains) returns false", () => {
|
||||
expect(evaluateCondition(cond("amount", "contains", "42"), tx())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tag field ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("tag", () => {
|
||||
it("equals — tag present", () => {
|
||||
expect(evaluateCondition(cond("tag", "equals", "5"), tx({ tags: [{ id: 5 }] }))).toBe(true);
|
||||
});
|
||||
it("equals — tag absent", () => {
|
||||
expect(evaluateCondition(cond("tag", "equals", "5"), tx({ tags: [] }))).toBe(false);
|
||||
});
|
||||
it("equals — different tag", () => {
|
||||
expect(evaluateCondition(cond("tag", "equals", "5"), tx({ tags: [{ id: 7 }] }))).toBe(false);
|
||||
});
|
||||
it("not_equals — tag absent", () => {
|
||||
expect(evaluateCondition(cond("tag", "not_equals", "5"), tx({ tags: [] }))).toBe(true);
|
||||
});
|
||||
it("not_equals — tag present", () => {
|
||||
expect(evaluateCondition(cond("tag", "not_equals", "5"), tx({ tags: [{ id: 5 }] }))).toBe(false);
|
||||
});
|
||||
it("matches one of multiple tags", () => {
|
||||
expect(evaluateCondition(cond("tag", "equals", "3"), tx({ tags: [{ id: 1 }, { id: 3 }] }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Unknown field ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("unknown field", () => {
|
||||
it("returns false for unrecognised field", () => {
|
||||
// @ts-expect-error intentional invalid field for regression guard
|
||||
expect(evaluateCondition({ field: "nonexistent", operator: "equals", value: "x" }, tx())).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { OWNER_SCOPE, STATEMENTS_JOIN, mySplitOf } from "@/lib/analytics-sql";
|
||||
|
||||
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,
|
||||
${mySplitOf(`COALESCE(t.amount_aud, t.amount)`)}::numeric(12,2) AS my_amount,
|
||||
COALESCE(s.bank_name, 'Manual') AS 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
|
||||
${STATEMENTS_JOIN}
|
||||
WHERE ${OWNER_SCOPE} = $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,56 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { OWNER_SCOPE, STATEMENTS_JOIN, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
|
||||
|
||||
const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`);
|
||||
|
||||
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 -${MY_AMOUNT}
|
||||
ELSE ${MY_AMOUNT}
|
||||
END::numeric(10,2) as my_amount,
|
||||
t.transaction_type,
|
||||
${EFFECTIVE_CATEGORY} as category,
|
||||
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||
t.statement_id
|
||||
FROM transactions t
|
||||
${STATEMENTS_JOIN}
|
||||
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 ${OWNER_SCOPE} = $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 });
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
|
||||
|
||||
// Split-adjusted amount helper (positive for spend, negative for refunds)
|
||||
const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`);
|
||||
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 ${EFFECTIVE_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
|
||||
${STATEMENTS_JOIN}
|
||||
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 ${OWNER_SCOPE} = $1
|
||||
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
|
||||
AND t.transaction_date >= $2
|
||||
AND ${EXCLUDE_NON_SPEND}
|
||||
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
|
||||
${STATEMENTS_JOIN}
|
||||
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 ${OWNER_SCOPE} = $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 ${EXCLUDE_NON_SPEND}
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import {
|
||||
OWNER_SCOPE,
|
||||
STATEMENTS_JOIN,
|
||||
EFFECTIVE_CATEGORY,
|
||||
EXCLUDE_NON_SPEND,
|
||||
NET_SPEND_ROWS,
|
||||
SPEND_SIGNED,
|
||||
mySplitOf,
|
||||
} from "@/lib/analytics-sql";
|
||||
|
||||
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,
|
||||
${EFFECTIVE_CATEGORY} as category,
|
||||
SUM(${mySplitOf(SPEND_SIGNED)})::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
|
||||
${STATEMENTS_JOIN}
|
||||
WHERE ${OWNER_SCOPE} = $1
|
||||
AND ${NET_SPEND_ROWS}
|
||||
AND ${EXCLUDE_NON_SPEND}
|
||||
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
|
||||
${STATEMENTS_JOIN}
|
||||
WHERE ${OWNER_SCOPE} = $1
|
||||
AND t.transaction_type IN ('credit', 'payment')
|
||||
AND ${EFFECTIVE_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
|
||||
${STATEMENTS_JOIN}
|
||||
WHERE ${OWNER_SCOPE} = $1
|
||||
AND ${EFFECTIVE_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,132 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
|
||||
|
||||
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,
|
||||
${EFFECTIVE_CATEGORY} AS category,
|
||||
t.transaction_date,
|
||||
${mySplitOf(`COALESCE(t.amount_aud, t.amount)`)} 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
|
||||
${STATEMENTS_JOIN}
|
||||
WHERE ${OWNER_SCOPE} = $1
|
||||
AND t.transaction_type IN ('debit', 'fee')
|
||||
AND ${EXCLUDE_NON_SPEND}
|
||||
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)::int 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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { ensureTag, batchInsertCSVTransactions } from "@/lib/queries";
|
||||
|
||||
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 {
|
||||
bank_name: string;
|
||||
transactions: {
|
||||
date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
transaction_type: string;
|
||||
merchant_name?: string;
|
||||
foreign_currency_amount?: number;
|
||||
foreign_currency_code?: string;
|
||||
category?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
if (!Array.isArray(body.transactions) || body.transactions.length === 0) {
|
||||
return NextResponse.json({ error: "No transactions provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
const tagId = await ensureTag("csv-import", "#8b5cf6");
|
||||
const inserted = await batchInsertCSVTransactions(user.id, body.transactions, tagId);
|
||||
|
||||
return NextResponse.json({ inserted }, { status: 201 });
|
||||
}
|
||||
@@ -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,13 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getMerchantSuggestions, getBankNames } 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: 403 });
|
||||
const search = req.nextUrl.searchParams.get("search");
|
||||
const type = req.nextUrl.searchParams.get("type");
|
||||
|
||||
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,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
|
||||
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 user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
const { id } = await params;
|
||||
|
||||
const rows = await queryRaw<BalanceRow>(
|
||||
`SELECT ts.participant_id, p.name,
|
||||
SUM(COALESCE(t.amount_aud, 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);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
const participants = await prisma.participants.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
if (user) {
|
||||
return NextResponse.json(
|
||||
participants.map((p) => (p.id === user.id ? { ...p, name: "Me" } : p))
|
||||
);
|
||||
}
|
||||
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,11 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { getPendingReconciliations } from "@/lib/queries";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const data = await getPendingReconciliations(user.id);
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { getTransactions } from "@/lib/queries";
|
||||
import { evaluateCondition, type Condition, type Actions } from "@/lib/rules";
|
||||
|
||||
/**
|
||||
* Dry run: which transactions would this rule touch, and what would change?
|
||||
*
|
||||
* Nothing is written. This exists so a rule can be inspected before it is
|
||||
* applied — the apply path (POST /api/transactions/bulk with action
|
||||
* "apply_rule") takes an explicit list of ids, so the flow is preview, pick,
|
||||
* then apply.
|
||||
*
|
||||
* Matches are split into two groups because they need different attention:
|
||||
* `changes` are rows the rule would actually alter, `noops` already have the
|
||||
* value the rule would set. A merchant-normalisation rule matching 400 rows
|
||||
* where 380 are already correct is 20 changes and 380 rows of noise.
|
||||
*/
|
||||
|
||||
interface Change {
|
||||
field: "category" | "merchant" | "tags" | "split";
|
||||
from: string | null;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
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 rules = await queryRaw<{
|
||||
id: number; name: string; conditions: unknown; actions: unknown;
|
||||
}>(
|
||||
`SELECT id, name, conditions, actions FROM rules WHERE id = $1 AND owner_id = $2`,
|
||||
[Number(id), user.id]
|
||||
);
|
||||
if (!rules.length) return NextResponse.json({ error: "Rule not found" }, { status: 404 });
|
||||
|
||||
const rule = rules[0];
|
||||
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;
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const statementId = searchParams.get("statement_id");
|
||||
const limit = Math.min(2000, Math.max(1, Number(searchParams.get("limit") || "500")));
|
||||
|
||||
const { data: transactions } = await getTransactions(user.id, {
|
||||
limit: 100000,
|
||||
offset: 0,
|
||||
...(statementId ? { statement_id: statementId } : {}),
|
||||
});
|
||||
|
||||
// A rule with no conditions matches every transaction. That is how the apply
|
||||
// endpoint behaves too, so the preview must show it rather than hide it —
|
||||
// seeing "matches 3,721" is the point.
|
||||
const matchAll = conditions.length === 0;
|
||||
|
||||
const tagIds = new Set(actions.add_tag_ids ?? []);
|
||||
const splitTarget = actions.apply_split ?? [];
|
||||
|
||||
const changes: unknown[] = [];
|
||||
let noops = 0;
|
||||
|
||||
for (const tx of transactions) {
|
||||
if (!matchAll && !conditions.every((c) => evaluateCondition(c, tx))) continue;
|
||||
|
||||
const diff: Change[] = [];
|
||||
|
||||
if (actions.set_category && tx.effective_category !== actions.set_category) {
|
||||
diff.push({ field: "category", from: tx.effective_category ?? null, to: actions.set_category });
|
||||
}
|
||||
if (actions.set_merchant && tx.effective_merchant !== actions.set_merchant) {
|
||||
diff.push({ field: "merchant", from: tx.effective_merchant ?? null, to: actions.set_merchant });
|
||||
}
|
||||
if (tagIds.size) {
|
||||
const have = new Set((tx.tags ?? []).map((t) => t.id));
|
||||
const missing = [...tagIds].filter((t) => !have.has(t));
|
||||
if (missing.length) {
|
||||
diff.push({ field: "tags", from: null, to: missing.join(",") });
|
||||
}
|
||||
}
|
||||
if (splitTarget.length) {
|
||||
// Compare the participant/share set the rule would write against what is
|
||||
// already there; a re-run that changes nothing should not look like work.
|
||||
const have = new Map((tx.splits ?? []).map((s) => [s.participant_id, Number(s.share_percent)]));
|
||||
const differs = splitTarget.length !== have.size
|
||||
|| splitTarget.some((s) => have.get(s.participant_id) !== Number(s.share_percent));
|
||||
if (differs) {
|
||||
diff.push({
|
||||
field: "split",
|
||||
from: [...have.entries()].map(([p, s]) => `${p}:${s}%`).join(" ") || null,
|
||||
to: splitTarget.map((s) => `${s.participant_id}:${s.share_percent}%`).join(" "),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (diff.length === 0) { noops++; continue; }
|
||||
|
||||
changes.push({
|
||||
id: tx.id,
|
||||
transaction_date: tx.transaction_date,
|
||||
description: tx.description,
|
||||
amount: tx.amount,
|
||||
amount_aud: tx.amount_aud,
|
||||
currency: tx.currency,
|
||||
bank_name: tx.bank_name,
|
||||
effective_merchant: tx.effective_merchant,
|
||||
effective_category: tx.effective_category,
|
||||
changes: diff,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
rule: { id: rule.id, name: rule.name, matches_everything: matchAll },
|
||||
total_matched: changes.length + noops,
|
||||
would_change: changes.length,
|
||||
already_correct: noops,
|
||||
transactions: changes.slice(0, limit),
|
||||
truncated: changes.length > limit,
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
|
||||
...(body.conditions !== undefined && { conditions: body.conditions }),
|
||||
...(body.actions !== undefined && { actions: body.actions }),
|
||||
...(body.enabled !== undefined && { enabled: body.enabled }),
|
||||
...(body.manual_only !== undefined && { manual_only: body.manual_only }),
|
||||
...(body.priority !== undefined && { priority: body.priority }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -2,114 +2,106 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { getTransactions } from "@/lib/queries";
|
||||
import { evaluateCondition, type Condition, type Actions } from "@/lib/rules";
|
||||
import { applyRuleActions, captureSnapshot } from "@/lib/rule-actions";
|
||||
|
||||
interface Condition {
|
||||
field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount";
|
||||
operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals";
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Actions {
|
||||
set_category?: string;
|
||||
add_tag_ids?: number[];
|
||||
set_merchant?: string;
|
||||
}
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
interface TxFields {
|
||||
effective_category: string;
|
||||
effective_merchant: string;
|
||||
description: string;
|
||||
bank_name: string;
|
||||
amount: number;
|
||||
}
|
||||
const runs = await queryRaw<{
|
||||
id: number;
|
||||
applied_at: string;
|
||||
split_from: string | null;
|
||||
matched: number;
|
||||
transactions_affected: number;
|
||||
reverted_at: string | null;
|
||||
rule_id: number | null;
|
||||
rule_name: string | null;
|
||||
source: string | null;
|
||||
}>(
|
||||
`SELECT id, applied_at, split_from, matched, transactions_affected, reverted_at,
|
||||
rule_id, rule_name, source
|
||||
FROM rule_apply_runs WHERE owner_id = $1 ORDER BY applied_at DESC LIMIT 20`,
|
||||
[user.id]
|
||||
);
|
||||
|
||||
function evaluateCondition(cond: Condition, tx: TxFields): boolean {
|
||||
if (cond.field === "amount") {
|
||||
const numVal = Number(tx.amount);
|
||||
const numCond = Number(cond.value);
|
||||
switch (cond.operator) {
|
||||
case "equals": return numVal === numCond;
|
||||
case "not_equals": return numVal !== numCond;
|
||||
case "gt": return numVal > numCond;
|
||||
case "lt": return numVal < numCond;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
let fieldVal: string;
|
||||
switch (cond.field) {
|
||||
case "merchant_normalized": fieldVal = tx.effective_merchant || ""; break;
|
||||
case "description": fieldVal = tx.description || ""; break;
|
||||
case "category": fieldVal = tx.effective_category || ""; break;
|
||||
case "bank_name": fieldVal = tx.bank_name || ""; break;
|
||||
default: return false;
|
||||
}
|
||||
|
||||
const strVal = fieldVal.toLowerCase();
|
||||
const strCond = cond.value.toLowerCase();
|
||||
switch (cond.operator) {
|
||||
case "contains": return strVal.includes(strCond);
|
||||
case "equals": return strVal === strCond;
|
||||
case "starts_with": return strVal.startsWith(strCond);
|
||||
case "not_equals": return strVal !== strCond;
|
||||
default: return false;
|
||||
}
|
||||
return NextResponse.json(runs);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const rules = await queryRaw<{ id: number; conditions: unknown; actions: unknown }>(
|
||||
`SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND enabled = true ORDER BY priority DESC`,
|
||||
[user.id]
|
||||
const body = await req.json().catch(() => ({})) as { splitFrom?: string | null; ruleId?: number | null };
|
||||
const splitFrom = body.splitFrom || null;
|
||||
const ruleId = body.ruleId || null;
|
||||
|
||||
// Manual-only rules ("quick actions") never take part in a condition-matched
|
||||
// run — their conditions are typically empty, so they would match every
|
||||
// transaction. They are fired from the transactions page against a selection.
|
||||
const rules = await queryRaw<{ id: number; name: string; conditions: unknown; actions: unknown }>(
|
||||
ruleId
|
||||
? `SELECT id, name, conditions, actions FROM rules
|
||||
WHERE owner_id = $1 AND id = $2 AND manual_only = false`
|
||||
: `SELECT id, name, conditions, actions FROM rules
|
||||
WHERE owner_id = $1 AND enabled = true AND manual_only = false
|
||||
ORDER BY priority DESC`,
|
||||
ruleId ? [user.id, ruleId] : [user.id]
|
||||
);
|
||||
|
||||
if (!rules.length) return NextResponse.json({ matched: 0, transactions_affected: 0 });
|
||||
|
||||
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 = await captureSnapshot(Array.from(matchedIds));
|
||||
|
||||
// --- 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++;
|
||||
affectedIds.add(tx.id);
|
||||
|
||||
if (actions.set_category || actions.set_merchant) {
|
||||
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 = COALESCE($2, transaction_overrides.category_override),
|
||||
merchant_normalized = COALESCE($3, transaction_overrides.merchant_normalized),
|
||||
updated_at = NOW()`,
|
||||
[tx.id, actions.set_category || null, actions.set_merchant || null]
|
||||
);
|
||||
}
|
||||
|
||||
if (actions.add_tag_ids?.length) {
|
||||
for (const tagId of actions.add_tag_ids) {
|
||||
await queryRaw(
|
||||
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[tx.id, tagId]
|
||||
);
|
||||
}
|
||||
}
|
||||
// splitFrom holds the split back to transactions on/after that date;
|
||||
// category/merchant/tags still apply to everything matched.
|
||||
await applyRuleActions(tx.id, actions, {
|
||||
skipSplit: !!splitFrom && tx.transaction_date < splitFrom,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
rule_id, rule_name, source)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
|
||||
[user.id, splitFrom, matched, affectedIds.size, JSON.stringify(snapshot),
|
||||
ruleId, ruleId ? (rules[0]?.name ?? null) : null, ruleId ? "rule" : "all"]
|
||||
);
|
||||
|
||||
return NextResponse.json({ id: run[0].id, matched, transactions_affected: affectedIds.size });
|
||||
}
|
||||
|
||||
@@ -12,10 +12,11 @@ export async function GET(req: NextRequest) {
|
||||
conditions: unknown;
|
||||
actions: unknown;
|
||||
enabled: boolean;
|
||||
manual_only: boolean;
|
||||
priority: number;
|
||||
created_at: string;
|
||||
}>(
|
||||
`SELECT id, name, conditions, actions, enabled, priority, created_at
|
||||
`SELECT id, name, conditions, actions, enabled, manual_only, priority, created_at
|
||||
FROM rules WHERE owner_id = $1 ORDER BY priority DESC, id ASC`,
|
||||
[user.id]
|
||||
);
|
||||
@@ -26,7 +27,7 @@ export async function POST(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const { name, conditions, actions, enabled = true, priority = 0 } = await req.json();
|
||||
const { name, conditions, actions, enabled = true, manual_only = false, priority = 0 } = await req.json();
|
||||
if (!name) return NextResponse.json({ error: "name required" }, { status: 400 });
|
||||
|
||||
const rule = await prisma.rules.create({
|
||||
@@ -36,6 +37,7 @@ export async function POST(req: NextRequest) {
|
||||
conditions: conditions ?? [],
|
||||
actions: actions ?? {},
|
||||
enabled,
|
||||
manual_only,
|
||||
priority,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* What a rule run actually did — the before-state from its snapshot set against
|
||||
* the values now.
|
||||
*
|
||||
* The run list only had counts, which is not enough to decide whether to revert:
|
||||
* "13 matches · 13 transactions" reads the same whether it renamed a merchant or
|
||||
* split your history with someone. Reverting is destructive, so the detail has
|
||||
* to be visible before the button is pressed.
|
||||
*/
|
||||
|
||||
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 GET(
|
||||
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 runs = await queryRaw<{
|
||||
id: number; applied_at: string; reverted_at: string | null;
|
||||
split_from: string | null; matched: number; transactions_affected: number;
|
||||
rule_id: number | null; rule_name: string | null; source: string | null;
|
||||
snapshot: unknown;
|
||||
}>(
|
||||
`SELECT id, applied_at, reverted_at, split_from, matched, transactions_affected,
|
||||
rule_id, rule_name, source, snapshot
|
||||
FROM rule_apply_runs WHERE id = $1 AND owner_id = $2`,
|
||||
[Number(id), user.id]
|
||||
);
|
||||
if (!runs.length) return NextResponse.json({ error: "Run not found" }, { status: 404 });
|
||||
|
||||
const run = runs[0];
|
||||
const snapshot = (typeof run.snapshot === "string"
|
||||
? JSON.parse(run.snapshot) : run.snapshot) as SnapshotEntry[];
|
||||
const byId = new Map(snapshot.map((s) => [s.transaction_id, s]));
|
||||
const ids = snapshot.map((s) => s.transaction_id);
|
||||
|
||||
if (ids.length === 0) {
|
||||
return NextResponse.json({ run: { ...run, snapshot: undefined }, transactions: [] });
|
||||
}
|
||||
|
||||
const current = await queryRaw<{
|
||||
id: number; transaction_date: string; description: string;
|
||||
amount: number; amount_aud: number | null; bank_name: string;
|
||||
category: string | null; category_override: string | null;
|
||||
merchant_normalized: string | null; merchant_override: string | null;
|
||||
merchant_name: string | null;
|
||||
tag_ids: number[]; splits: { participant_id: number; share_percent: number }[];
|
||||
}>(
|
||||
`SELECT t.id, t.transaction_date::text, t.description, t.amount, t.amount_aud,
|
||||
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||
t.category, o.category_override,
|
||||
t.merchant_normalized, o.merchant_normalized as merchant_override, t.merchant_name,
|
||||
COALESCE((SELECT json_agg(tt.tag_id) FROM transaction_tags tt WHERE tt.transaction_id = t.id), '[]'::json) as tag_ids,
|
||||
COALESCE((SELECT json_agg(json_build_object('participant_id', ts.participant_id, 'share_percent', ts.share_percent))
|
||||
FROM transaction_splits ts WHERE ts.transaction_id = t.id), '[]'::json) as splits
|
||||
FROM transactions t
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
WHERE t.id = ANY($1::int[])
|
||||
ORDER BY t.transaction_date DESC`,
|
||||
[ids]
|
||||
);
|
||||
|
||||
const parse = <T,>(v: unknown, fallback: T): T =>
|
||||
(typeof v === "string" ? JSON.parse(v) : v) ?? fallback;
|
||||
|
||||
const transactions = current.map((t) => {
|
||||
const prev = byId.get(t.id);
|
||||
const nowTags: number[] = parse(t.tag_ids, []);
|
||||
const nowSplits = parse<{ participant_id: number; share_percent: number }[]>(t.splits, []);
|
||||
const prevTags = prev?.prev_tag_ids ?? [];
|
||||
const prevSplits = prev?.prev_splits ?? [];
|
||||
|
||||
const nowCategory = t.category_override ?? t.category;
|
||||
const prevCategory = prev?.prev_category_override ?? t.category;
|
||||
const nowMerchant = t.merchant_override ?? t.merchant_normalized ?? t.merchant_name;
|
||||
const prevMerchant = prev?.prev_merchant_normalized ?? t.merchant_normalized ?? t.merchant_name;
|
||||
|
||||
const changes: { field: string; from: string | null; to: string | null }[] = [];
|
||||
if (nowCategory !== prevCategory) {
|
||||
changes.push({ field: "category", from: prevCategory, to: nowCategory });
|
||||
}
|
||||
if (nowMerchant !== prevMerchant) {
|
||||
changes.push({ field: "merchant", from: prevMerchant, to: nowMerchant });
|
||||
}
|
||||
const addedTags = nowTags.filter((x) => !prevTags.includes(x));
|
||||
if (addedTags.length) {
|
||||
changes.push({ field: "tags", from: null, to: addedTags.join(",") });
|
||||
}
|
||||
const fmtSplits = (arr: { participant_id: number; share_percent: number }[]) =>
|
||||
arr.length ? arr.map((s) => `${s.participant_id}:${Number(s.share_percent)}%`).sort().join(" ") : null;
|
||||
if (fmtSplits(nowSplits) !== fmtSplits(prevSplits)) {
|
||||
changes.push({ field: "split", from: fmtSplits(prevSplits), to: fmtSplits(nowSplits) });
|
||||
}
|
||||
|
||||
return {
|
||||
id: t.id,
|
||||
transaction_date: t.transaction_date,
|
||||
description: t.description,
|
||||
amount: t.amount,
|
||||
amount_aud: t.amount_aud,
|
||||
bank_name: t.bank_name,
|
||||
merchant: nowMerchant,
|
||||
changes,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
run: {
|
||||
id: run.id, applied_at: run.applied_at, reverted_at: run.reverted_at,
|
||||
split_from: run.split_from, matched: run.matched,
|
||||
transactions_affected: run.transactions_affected,
|
||||
rule_id: run.rule_id, rule_name: run.rule_name, source: run.source,
|
||||
},
|
||||
// A reverted run still lists its transactions, but they will show no changes
|
||||
// because the values are back where they started.
|
||||
transactions,
|
||||
still_changed: transactions.filter((t) => t.changes.length > 0).length,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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 rawIds = tagParam ? tagParam.split(",").filter(Boolean) : [];
|
||||
const noTags = rawIds.includes("untagged");
|
||||
const tagIds = rawIds.filter((id) => id !== "untagged").map(Number).filter((n) => !isNaN(n));
|
||||
const participantParam = req.nextUrl.searchParams.get("participant_id");
|
||||
const participantId = participantParam ? Number(participantParam) : undefined;
|
||||
const transactions = await getSharedTransactions(user.id, tagIds.length ? tagIds : undefined, noTags, participantId);
|
||||
return NextResponse.json(transactions);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getStatementById } from "@/lib/queries";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
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 stmt = await getStatementById(Number(id));
|
||||
if (!stmt) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (!stmt || stmt.owner_id !== user.id) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(stmt);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { createTrip, assignTransactionsToTrip, getTagTransactionIds } from "@/lib/queries";
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const { id } = await params;
|
||||
const tagId = Number(id);
|
||||
const { start_date, end_date } = await req.json().catch(() => ({}));
|
||||
|
||||
// Fetch the tag
|
||||
const tags = await queryRaw<{ id: number; name: string; color: string }>(
|
||||
`SELECT id, name, color FROM tags WHERE id = $1`,
|
||||
[tagId]
|
||||
);
|
||||
if (!tags[0]) return NextResponse.json({ error: "Tag not found" }, { status: 404 });
|
||||
const tag = tags[0];
|
||||
|
||||
// Create trip from tag metadata
|
||||
const trip = await createTrip(user.id, {
|
||||
name: tag.name,
|
||||
color: tag.color,
|
||||
start_date: start_date ?? null,
|
||||
end_date: end_date ?? null,
|
||||
});
|
||||
|
||||
// Assign all transactions with this tag to the new trip
|
||||
const transactionIds = await getTagTransactionIds(tagId);
|
||||
if (transactionIds.length > 0) {
|
||||
await assignTransactionsToTrip(trip.id, transactionIds);
|
||||
}
|
||||
|
||||
return NextResponse.json({ trip, assigned: transactionIds.length }, { status: 201 });
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
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;
|
||||
await queryRaw(`DELETE FROM tags WHERE id = $1`, [Number(id)]);
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getTags } from "@/lib/queries";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
const tags = await getTags();
|
||||
return NextResponse.json(tags);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
const { name, color } = await req.json();
|
||||
if (!name?.trim()) {
|
||||
return NextResponse.json({ error: "name required" }, { status: 400 });
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getTransactionById } from "@/lib/queries";
|
||||
import { getTransactionById, canAccessTransactions } from "@/lib/queries";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
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,
|
||||
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;
|
||||
if (!(await canAccessTransactions(user.id, [Number(id)]))) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
const txn = await getTransactionById(Number(id));
|
||||
if (!txn) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
return NextResponse.json(txn);
|
||||
@@ -16,20 +25,91 @@ export async function PATCH(
|
||||
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 transactionId = Number(id);
|
||||
if (!(await canAccessTransactions(user.id, [transactionId]))) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
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, trip_id, payment_method } = body as {
|
||||
category?: string;
|
||||
merchant_normalized?: string;
|
||||
notes?: string;
|
||||
transaction_type?: string;
|
||||
payment_method?: string | null;
|
||||
my_share_percent?: number | null;
|
||||
description?: string;
|
||||
amount?: number;
|
||||
transaction_date?: string;
|
||||
trip_id?: number | null;
|
||||
};
|
||||
|
||||
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]
|
||||
);
|
||||
}
|
||||
|
||||
// payment_method is a property of the transaction itself, not a user override
|
||||
// of extracted data, so it lives on the transactions table.
|
||||
if (payment_method !== undefined) {
|
||||
const VALID_METHODS = ["card", "cash", "bank_transfer", "other"];
|
||||
if (payment_method !== null && !VALID_METHODS.includes(payment_method)) {
|
||||
return NextResponse.json({ error: "Invalid payment_method" }, { status: 400 });
|
||||
}
|
||||
await queryRaw(
|
||||
`UPDATE transactions SET payment_method = $1 WHERE id = $2`,
|
||||
[payment_method, transactionId]
|
||||
);
|
||||
}
|
||||
|
||||
// category/merchant/notes/my_share_percent/trip_id go through the overrides table
|
||||
const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined || my_share_percent !== undefined || trip_id !== 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;
|
||||
if (trip_id !== undefined) data.trip_id = trip_id;
|
||||
|
||||
const override = await prisma.transaction_overrides.upsert({
|
||||
where: { transaction_id: transactionId },
|
||||
@@ -39,6 +119,8 @@ 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,
|
||||
trip_id: trip_id ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { canAccessTransactions } from "@/lib/queries";
|
||||
|
||||
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 user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
const { id } = await params;
|
||||
if (!(await canAccessTransactions(user.id, [Number(id)]))) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
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 user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
const { id } = await params;
|
||||
const transactionId = Number(id);
|
||||
if (!(await canAccessTransactions(user.id, [transactionId]))) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { canAccessTransactions } from "@/lib/queries";
|
||||
|
||||
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;
|
||||
if (!(await canAccessTransactions(user.id, [Number(id)]))) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
const { tag_id } = await req.json();
|
||||
if (!tag_id) return NextResponse.json({ error: "tag_id required" }, { status: 400 });
|
||||
await queryRaw(
|
||||
@@ -13,7 +20,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
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;
|
||||
if (!(await canAccessTransactions(user.id, [Number(id)]))) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
const { tag_id } = await req.json();
|
||||
if (!tag_id) return NextResponse.json({ error: "tag_id required" }, { status: 400 });
|
||||
await queryRaw(
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma, queryRaw } from "@/lib/db";
|
||||
import { assignTransactionsToTrip, canAccessTransactions } from "@/lib/queries";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { applyRuleActions, captureSnapshot } from "@/lib/rule-actions";
|
||||
import type { Actions } from "@/lib/rules";
|
||||
|
||||
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();
|
||||
const { action, ids, category, merchant_normalized, splits, tag_id } = body as {
|
||||
action: string;
|
||||
@@ -16,6 +22,10 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "ids required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!(await canAccessTransactions(user.id, ids.map(Number)))) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (action === "categorize" && category) {
|
||||
const ops = ids.map((id) =>
|
||||
prisma.transaction_overrides.upsert({
|
||||
@@ -77,5 +87,44 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ updated: ids.length });
|
||||
}
|
||||
|
||||
// Quick action: fire a saved rule's actions at the current selection. The
|
||||
// selection replaces the rule's conditions, which are not evaluated at all.
|
||||
// Recorded as a rule_apply_run so it can be reverted like any other run.
|
||||
if (action === "apply_rule") {
|
||||
const { rule_id } = body as { rule_id?: number };
|
||||
if (!rule_id) return NextResponse.json({ error: "rule_id required" }, { status: 400 });
|
||||
|
||||
const rules = await queryRaw<{ id: number; name: string; actions: unknown }>(
|
||||
`SELECT id, name, actions FROM rules WHERE id = $1 AND owner_id = $2`,
|
||||
[Number(rule_id), user.id]
|
||||
);
|
||||
if (!rules.length) return NextResponse.json({ error: "Rule not found" }, { status: 404 });
|
||||
|
||||
const actions = (typeof rules[0].actions === "string"
|
||||
? JSON.parse(rules[0].actions)
|
||||
: rules[0].actions) as Actions;
|
||||
|
||||
const snapshot = await captureSnapshot(ids.map(Number));
|
||||
for (const id of ids) {
|
||||
await applyRuleActions(Number(id), actions);
|
||||
}
|
||||
|
||||
const run = await queryRaw<{ id: number }>(
|
||||
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot,
|
||||
rule_id, rule_name, source)
|
||||
VALUES ($1, NULL, $2, $3, $4, $5, $6, 'selection') RETURNING id`,
|
||||
[user.id, ids.length, ids.length, JSON.stringify(snapshot),
|
||||
rules[0].id, rules[0].name]
|
||||
);
|
||||
|
||||
return NextResponse.json({ updated: ids.length, run_id: run[0].id, rule: rules[0].name });
|
||||
}
|
||||
|
||||
if (action === "assign_trip") {
|
||||
const { trip_id } = body as { ids: number[]; trip_id: number | null };
|
||||
await assignTransactionsToTrip(trip_id, ids);
|
||||
return NextResponse.json({ updated: ids.length });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma, queryRaw } from "@/lib/db";
|
||||
|
||||
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 {
|
||||
matches: { manual_id: number; statement_tx_id: number }[];
|
||||
};
|
||||
|
||||
if (!Array.isArray(body.matches) || body.matches.length === 0) {
|
||||
return NextResponse.json({ error: "No matches provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify all manual_ids belong to this user
|
||||
const manualIds = body.matches.map((m) => m.manual_id);
|
||||
const owned = await queryRaw<{ id: number }>(
|
||||
`SELECT id FROM transactions WHERE id = ANY($1::int[]) AND statement_id IS NULL AND owner_id = $2`,
|
||||
[manualIds, user.id]
|
||||
);
|
||||
if (owned.length !== manualIds.length) {
|
||||
return NextResponse.json({ error: "One or more transactions not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
let reconciled = 0;
|
||||
|
||||
for (const { manual_id, statement_tx_id } of body.matches) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Copy overrides: manual → statement tx
|
||||
const override = await tx.transaction_overrides.findUnique({
|
||||
where: { transaction_id: manual_id },
|
||||
});
|
||||
if (override) {
|
||||
await tx.transaction_overrides.upsert({
|
||||
where: { transaction_id: statement_tx_id },
|
||||
update: {
|
||||
category_override: override.category_override,
|
||||
merchant_normalized: override.merchant_normalized,
|
||||
notes: override.notes,
|
||||
my_share_percent: override.my_share_percent,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
create: {
|
||||
transaction_id: statement_tx_id,
|
||||
category_override: override.category_override,
|
||||
merchant_normalized: override.merchant_normalized,
|
||||
notes: override.notes,
|
||||
my_share_percent: override.my_share_percent,
|
||||
},
|
||||
});
|
||||
await tx.transaction_overrides.deleteMany({ where: { transaction_id: manual_id } });
|
||||
}
|
||||
|
||||
// Move tags: manual → statement tx
|
||||
const tags = await tx.transaction_tags.findMany({ where: { transaction_id: manual_id } });
|
||||
if (tags.length) {
|
||||
await tx.transaction_tags.createMany({
|
||||
data: tags.map((t) => ({ transaction_id: statement_tx_id, tag_id: t.tag_id })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
await tx.transaction_tags.deleteMany({ where: { transaction_id: manual_id } });
|
||||
}
|
||||
|
||||
// Move splits: manual → statement tx
|
||||
const splits = await tx.transaction_splits.findMany({ where: { transaction_id: manual_id } });
|
||||
if (splits.length) {
|
||||
await tx.transaction_splits.createMany({
|
||||
data: splits.map((s) => ({
|
||||
transaction_id: statement_tx_id,
|
||||
participant_id: s.participant_id,
|
||||
share_percent: s.share_percent,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
await tx.transaction_splits.deleteMany({ where: { transaction_id: manual_id } });
|
||||
}
|
||||
|
||||
// Mark manual tx as reconciled (link to statement tx)
|
||||
await tx.$executeRawUnsafe(
|
||||
`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`,
|
||||
statement_tx_id,
|
||||
manual_id
|
||||
);
|
||||
});
|
||||
reconciled++;
|
||||
}
|
||||
|
||||
return NextResponse.json({ reconciled });
|
||||
}
|
||||
@@ -1,25 +1,85 @@
|
||||
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,
|
||||
has_split: sp.get("has_split") || undefined,
|
||||
trip_id: sp.get("trip_id") || 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;
|
||||
payment_method?: 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, payment_method, row_index)
|
||||
VALUES (NULL, $1, $2, $3, $4, $5, $6, $7, $8, (
|
||||
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,
|
||||
body.payment_method || 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 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { getTripAnalytics } from "@/lib/queries";
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
const { id } = await params;
|
||||
try {
|
||||
const analytics = await getTripAnalytics(Number(id), user.id);
|
||||
return NextResponse.json(analytics);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { getTripById, updateTrip, deleteTrip } from "@/lib/queries";
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
const { id } = await params;
|
||||
const trip = await getTripById(Number(id), user.id);
|
||||
if (!trip) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
return NextResponse.json(trip);
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const trip = await updateTrip(Number(id), user.id, body);
|
||||
if (!trip) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
return NextResponse.json(trip);
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
const { id } = await params;
|
||||
await deleteTrip(Number(id), user.id);
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { assignTransactionsToTrip } from "@/lib/queries";
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
const { id } = await params;
|
||||
const { transactionIds } = await req.json() as { transactionIds: number[] };
|
||||
if (!Array.isArray(transactionIds) || !transactionIds.length) {
|
||||
return NextResponse.json({ error: "transactionIds must be a non-empty array" }, { status: 400 });
|
||||
}
|
||||
await assignTransactionsToTrip(Number(id), transactionIds);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { getTrips, createTrip } from "@/lib/queries";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
const trips = await getTrips(user.id);
|
||||
return NextResponse.json(trips);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
const body = await req.json();
|
||||
const { name, description, start_date, end_date, color } = body;
|
||||
if (!name?.trim()) return NextResponse.json({ error: "name is required" }, { status: 400 });
|
||||
const trip = await createTrip(user.id, { name: name.trim(), description, start_date, end_date, color });
|
||||
return NextResponse.json(trip, { status: 201 });
|
||||
}
|
||||
+668
-4
@@ -1,8 +1,672 @@
|
||||
export default function BudgetPage() {
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, Fragment, useMemo } from "react";
|
||||
import {
|
||||
ComposedChart,
|
||||
LineChart,
|
||||
AreaChart,
|
||||
Area,
|
||||
Bar,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
||||
import { formatCategory, CATEGORIES } from "@/lib/categories";
|
||||
import { CATEGORY_COLORS, CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
|
||||
|
||||
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 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 `$${Math.round(n).toLocaleString()}`; }
|
||||
function fmtExact(n: number): string { return `$${n.toFixed(2)}`; }
|
||||
function fmtSigned(n: number): string { return `${n >= 0 ? "+" : "−"}$${Math.abs(n) >= 100 ? Math.round(Math.abs(n)).toLocaleString() : Math.abs(n).toFixed(0)}`; }
|
||||
|
||||
// ─── Tooltips ────────────────────────────────────────────────────────────────
|
||||
|
||||
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>
|
||||
<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">
|
||||
<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 className="font-mono">{fmtExact(d.spent)}</span></div>
|
||||
<div className="flex justify-between gap-4"><span className="text-zinc-400">Share</span><span className="font-mono">{d.pct}%</span></div>
|
||||
<div className="flex justify-between gap-4"><span className="text-zinc-400">Cumulative</span><span className="font-mono">{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 font-mono tabular-nums ml-auto pl-2">{fmtExact(p.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Month spine ─────────────────────────────────────────────────────────────
|
||||
// Twelve clickable columns, one per month, scaled to that month's spend.
|
||||
// Doubles as period navigation and year-at-a-glance context.
|
||||
|
||||
function MonthSpine({
|
||||
months,
|
||||
totals,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
months: string[]; // ascending
|
||||
totals: Record<string, { spent: number }>;
|
||||
selected: string;
|
||||
onSelect: (m: string) => void;
|
||||
}) {
|
||||
const max = Math.max(...months.map((m) => totals[m]?.spent || 0), 1);
|
||||
return (
|
||||
<div className="flex items-end gap-1 sm:gap-1.5">
|
||||
{months.map((m) => {
|
||||
const spent = totals[m]?.spent || 0;
|
||||
const h = Math.max(4, Math.round((spent / max) * 56));
|
||||
const active = m === selected;
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => onSelect(m)}
|
||||
title={`${formatMonth(m)} — ${fmt(spent)}`}
|
||||
className="group flex-1 flex flex-col items-center gap-1.5 min-w-0"
|
||||
>
|
||||
<span
|
||||
style={{ height: h }}
|
||||
className={`w-full max-w-9 rounded-t-sm transition-colors ${
|
||||
active ? "bg-indigo-400" : "bg-zinc-700 group-hover:bg-zinc-600"
|
||||
}`}
|
||||
/>
|
||||
<span className={`text-[10px] leading-none uppercase tracking-wide ${active ? "text-indigo-300" : "text-zinc-500 group-hover:text-zinc-400"}`}>
|
||||
{formatShortMonth(m)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CategoryPanel (drill-down) ──────────────────────────────────────────────
|
||||
|
||||
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 font-mono 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 font-mono 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(12);
|
||||
|
||||
const months = useMemo(() => analytics ? [...analytics.months].reverse() : [], [analytics]);
|
||||
|
||||
// If the initial month has no data yet (e.g. the 1st of the month), land on
|
||||
// the most recent month that does.
|
||||
useEffect(() => {
|
||||
if (months.length && !months.includes(selectedMonth)) {
|
||||
setSelectedMonth(months[months.length - 1]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [months]);
|
||||
|
||||
// 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 });
|
||||
|
||||
// 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]);
|
||||
|
||||
// Small multiples — top 8 categories by 12-month total
|
||||
const sparkData = useMemo(() => {
|
||||
if (!analytics) return [];
|
||||
return analytics.rows
|
||||
.map((r) => ({
|
||||
category: r.category,
|
||||
total: months.reduce((s, m) => s + (r.spent[m] || 0), 0),
|
||||
thisMonth: r.spent[selectedMonth] || 0,
|
||||
delta: (r.spent[selectedMonth] || 0) - (r.spent[prevMonth(selectedMonth)] || 0),
|
||||
series: months.map((m) => ({ month: m, v: r.spent[m] || 0 })),
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 8);
|
||||
}, [analytics, months, selectedMonth]);
|
||||
|
||||
// Top movers vs previous month
|
||||
const movers = useMemo(() => {
|
||||
if (!analytics) return [];
|
||||
const pm = prevMonth(selectedMonth);
|
||||
if (!months.includes(pm)) return [];
|
||||
return analytics.rows
|
||||
.map((r) => ({
|
||||
category: r.category,
|
||||
delta: (r.spent[selectedMonth] || 0) - (r.spent[pm] || 0),
|
||||
now: r.spent[selectedMonth] || 0,
|
||||
before: r.spent[pm] || 0,
|
||||
}))
|
||||
.filter((r) => Math.abs(r.delta) >= 1)
|
||||
.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
|
||||
.slice(0, 6);
|
||||
}, [analytics, months, selectedMonth]);
|
||||
|
||||
// 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;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 max-w-5xl">
|
||||
<h2 className="text-2xl font-display">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 hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0);
|
||||
const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) > 0);
|
||||
|
||||
// Hero delta vs the average of the other months that have data
|
||||
const otherMonths = months.filter((m) => m !== selectedMonth && (analytics.totals[m]?.spent || 0) > 0);
|
||||
const avgSpend = otherMonths.length
|
||||
? otherMonths.reduce((s, m) => s + (analytics.totals[m]?.spent || 0), 0) / otherMonths.length
|
||||
: 0;
|
||||
const avgDeltaPct = avgSpend > 0 ? Math.round(((totals.spent - avgSpend) / avgSpend) * 100) : 0;
|
||||
const heroSentence =
|
||||
avgSpend === 0 ? "" :
|
||||
Math.abs(avgDeltaPct) <= 3 ? `in line with your ${otherMonths.length}-month average` :
|
||||
`${Math.abs(avgDeltaPct)}% ${avgDeltaPct > 0 ? "above" : "below"} your ${otherMonths.length}-month average of ${fmt(avgSpend)}`;
|
||||
|
||||
const pareto80idx = paretoData.findIndex((r) => r.cumulative >= 80);
|
||||
const tableMonths = analytics.months.slice(0, 6); // newest-first, last 6
|
||||
const maxMoverDelta = Math.max(...movers.map((m) => Math.abs(m.delta)), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl">
|
||||
|
||||
{/* ── Hero + month spine ── */}
|
||||
<div className="border-b border-zinc-800 pb-5">
|
||||
<p className="text-[11px] uppercase tracking-[0.18em] text-zinc-500 mb-2">Ledger · {formatMonth(selectedMonth)}</p>
|
||||
<div className="flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
|
||||
<div>
|
||||
<p className="font-display text-5xl text-zinc-50 leading-none">
|
||||
{fmt(totals.spent)}
|
||||
</p>
|
||||
{heroSentence && (
|
||||
<p className="text-sm text-zinc-400 mt-2">
|
||||
<span className={avgDeltaPct > 3 ? "text-indigo-300" : avgDeltaPct < -3 ? "text-emerald-400" : "text-zinc-400"}>
|
||||
{heroSentence}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full sm:w-auto sm:min-w-80 sm:flex-1 sm:max-w-md">
|
||||
<MonthSpine months={months} totals={analytics.totals} selected={selectedMonth} onSelect={(m) => { setSelectedMonth(m); setExpandedCategory(null); }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Cashflow strip ── */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl grid grid-cols-2 sm:grid-cols-4 divide-x divide-y sm:divide-y-0 divide-zinc-800 overflow-hidden">
|
||||
<div className="px-4 py-3.5">
|
||||
<p className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Income</p>
|
||||
<p className={`text-xl font-mono tabular-nums ${hasIncome ? "text-emerald-400" : "text-zinc-600"}`}>{hasIncome ? fmt(totals.income) : "—"}</p>
|
||||
</div>
|
||||
<div className="px-4 py-3.5">
|
||||
<p className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Expenses</p>
|
||||
<p className="text-xl font-mono tabular-nums text-zinc-100">{fmt(totals.spent)}</p>
|
||||
</div>
|
||||
<div className="px-4 py-3.5">
|
||||
<p className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Invested</p>
|
||||
<p className={`text-xl font-mono tabular-nums ${hasInvestments ? "text-indigo-300" : "text-zinc-600"}`}>{hasInvestments ? fmt(totals.investments) : "—"}</p>
|
||||
</div>
|
||||
<div className="px-4 py-3.5">
|
||||
<p className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Net cash</p>
|
||||
{hasIncome ? (
|
||||
<p className={`text-xl font-mono tabular-nums ${totals.net >= 0 ? "text-emerald-400" : "text-red-400"}`}>{totals.net >= 0 ? "+" : ""}{fmt(totals.net)}</p>
|
||||
) : (
|
||||
<p className="text-xl font-mono tabular-nums text-zinc-600">—</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Top movers vs last month ── */}
|
||||
{movers.length > 0 && (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
|
||||
<h3 className="text-sm font-medium mb-1">What changed</h3>
|
||||
<p className="text-xs text-zinc-500 mb-4">Biggest category moves vs {formatShortMonth(prevMonth(selectedMonth))}</p>
|
||||
<div className="grid sm:grid-cols-2 gap-x-8 gap-y-2.5">
|
||||
{movers.map((m) => (
|
||||
<div key={m.category} className="flex items-center gap-3">
|
||||
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[m.category] || CHART.axis }} />
|
||||
<span className="text-sm text-zinc-300 w-32 truncate shrink-0">{formatCategory(m.category)}</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-zinc-800 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${m.delta > 0 ? "bg-indigo-400" : "bg-emerald-500"}`}
|
||||
style={{ width: `${Math.max(6, Math.round((Math.abs(m.delta) / maxMoverDelta) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs font-mono tabular-nums w-16 text-right ${m.delta > 0 ? "text-indigo-300" : "text-emerald-400"}`}>
|
||||
{fmtSigned(m.delta)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Category small multiples ── */}
|
||||
{sparkData.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">Category trends</h3>
|
||||
<span className="text-xs text-zinc-500">12 months · top {sparkData.length} by total spend</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{sparkData.map((s) => (
|
||||
<div key={s.category} className="bg-zinc-900 border border-zinc-800 rounded-xl px-3.5 pt-3 pb-1.5">
|
||||
<div className="flex items-center justify-between gap-2 mb-0.5">
|
||||
<span className="flex items-center gap-1.5 text-xs text-zinc-400 truncate">
|
||||
<span className="w-1.5 h-1.5 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[s.category] || CHART.axis }} />
|
||||
{formatCategory(s.category)}
|
||||
</span>
|
||||
{Math.abs(s.delta) >= 1 && (
|
||||
<span className={`text-[10px] font-mono tabular-nums shrink-0 ${s.delta > 0 ? "text-indigo-300" : "text-emerald-400"}`}>{fmtSigned(s.delta)}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-mono tabular-nums text-zinc-100 mb-1">{fmt(s.thisMonth)}</p>
|
||||
<div className="h-10 -mx-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={s.series} margin={{ top: 2, right: 0, bottom: 0, left: 0 }}>
|
||||
<Area
|
||||
dataKey="v"
|
||||
stroke={CATEGORY_COLORS[s.category] || CHART.axis}
|
||||
strokeWidth={1.5}
|
||||
fill={CATEGORY_COLORS[s.category] || CHART.axis}
|
||||
fillOpacity={0.12}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Pareto ── */}
|
||||
{paretoData.length > 0 && (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium">Spend concentration</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: CHART.axis, fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={formatCategory}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fill: CHART.axis, fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) => `$${v}`}
|
||||
width={52}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fill: CHART.axis, fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
domain={[0, 100]}
|
||||
width={36}
|
||||
/>
|
||||
<Tooltip content={<ParetoTooltip />} cursor={{ fill: "rgba(232,224,204,0.04)" }} />
|
||||
<ReferenceLine yAxisId="right" y={80} stroke={CHART.faint} strokeDasharray="4 2" label={{ value: "80%", fill: CHART.axis, 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] || CHART.accent) : CHART.dim}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
dataKey="cumulative"
|
||||
stroke={CHART.accentSoft}
|
||||
strokeWidth={2}
|
||||
dot={{ fill: CHART.accentSoft, 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 inline-block" style={{ background: CHART.accentSoft }} />Cumulative %</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm inline-block" style={{ background: CHART.dim }} />Beyond 80%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Cumulative pace ── */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium">Spend pace</h3>
|
||||
<span className="text-xs text-zinc-500">cumulative through the month, vs typical</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<LineChart data={cumulativeData} margin={{ top: 4, right: 8, bottom: 0, left: 8 }}>
|
||||
<XAxis dataKey="day" tick={{ fill: CHART.axis, fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `${v}`} interval={4} />
|
||||
<YAxis tick={{ fill: CHART.axis, fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(1)}k`} width={44} />
|
||||
<Tooltip content={<CumulativeTooltip />} cursor={{ stroke: "rgba(232,224,204,0.08)", strokeWidth: 1 }} />
|
||||
<Line dataKey="typical" stroke={CHART.faint} strokeWidth={1.5} strokeDasharray="4 3" dot={false} name="typical" />
|
||||
<Line dataKey="actual" stroke={CHART.accent} 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 inline-block" style={{ background: CHART.accent }} />This month</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-4 h-0.5 inline-block border-t border-dashed" style={{ borderColor: CHART.faint }} />Typical pace</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Category breakdown table — expandable rows ── */}
|
||||
{categoryRows.length > 0 && (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-zinc-800">
|
||||
<h3 className="text-sm font-medium">Where it went — {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] || CHART.axis }} />
|
||||
{formatCategory(category)}
|
||||
<span className="text-zinc-600 text-xs ml-1">{isExpanded ? "▲" : "▼"}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono tabular-nums">{fmtExact(spent)}</td>
|
||||
<td className="px-4 py-2.5 text-right text-zinc-400 font-mono tabular-nums">{txCount}</td>
|
||||
<td className="px-4 py-2.5 text-right text-zinc-400 font-mono 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 ledger table (heat-tinted) ── */}
|
||||
{tableMonths.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-zinc-400 mb-3">Six-month ledger</h3>
|
||||
<div className="overflow-x-auto rounded-xl border border-zinc-800">
|
||||
<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>
|
||||
{tableMonths.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-300" : "text-zinc-500"}`}
|
||||
onClick={() => setSelectedMonth(m)}
|
||||
>
|
||||
{formatShortMonth(m)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{analytics.rows.map((row) => {
|
||||
const rowMax = Math.max(...tableMonths.map((m) => row.spent[m] || 0), 1);
|
||||
return (
|
||||
<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] || CHART.axis }} />
|
||||
{formatCategory(row.category)}
|
||||
</span>
|
||||
</td>
|
||||
{tableMonths.map((m) => {
|
||||
const spent = row.spent[m];
|
||||
const heat = spent !== undefined ? (spent / rowMax) * 0.28 : 0;
|
||||
return (
|
||||
<td
|
||||
key={m}
|
||||
className={`px-3 py-2 text-right font-mono tabular-nums ${spent === undefined ? "text-zinc-700" : "text-zinc-300"}`}
|
||||
style={heat > 0.02 ? { background: `rgba(188, 111, 48, ${heat.toFixed(3)})` } : undefined}
|
||||
>
|
||||
{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>
|
||||
{tableMonths.map((m) => {
|
||||
const inc = analytics.income[m];
|
||||
return (
|
||||
<td key={m} className="px-3 py-2 text-right font-mono tabular-nums text-emerald-500">
|
||||
{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-400">Invested</td>
|
||||
{tableMonths.map((m) => {
|
||||
const inv = analytics.investments[m];
|
||||
return (
|
||||
<td key={m} className="px-3 py-2 text-right font-mono tabular-nums text-indigo-300">
|
||||
{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>
|
||||
{tableMonths.map((m) => {
|
||||
const t = analytics.totals[m];
|
||||
return (
|
||||
<td key={m} className={`px-3 py-2 text-right font-mono tabular-nums ${m === selectedMonth ? "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>
|
||||
{tableMonths.map((m) => {
|
||||
const t = analytics.totals[m];
|
||||
const net = t?.net || 0;
|
||||
return (
|
||||
<td key={m} className={`px-3 py-2 text-right font-mono tabular-nums ${net >= 0 ? "text-emerald-400" : "text-red-400"}`}>
|
||||
{net >= 0 ? "+" : ""}{fmt(net)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+49
-1
@@ -1,6 +1,54 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme inline {
|
||||
/*
|
||||
* Ink & copper ledger theme.
|
||||
*
|
||||
* The whole app is written against Tailwind's zinc (neutrals) and indigo
|
||||
* (accent) scales, so the retheme happens here: zinc is remapped to warm
|
||||
* ink/paper tones and indigo to copper. Pages therefore inherit the palette
|
||||
* without per-page edits; chart hexes live in src/lib/category-colors.ts.
|
||||
*/
|
||||
@theme {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-display: var(--font-fraunces), Georgia, serif;
|
||||
|
||||
/* Ink / paper neutrals (replaces zinc) */
|
||||
--color-zinc-950: #0f0d0a;
|
||||
--color-zinc-900: #171410;
|
||||
--color-zinc-800: #242019;
|
||||
--color-zinc-700: #332d23;
|
||||
--color-zinc-600: #4d4536;
|
||||
--color-zinc-500: #6e644f;
|
||||
--color-zinc-400: #94896f;
|
||||
--color-zinc-300: #b3a88e;
|
||||
--color-zinc-200: #d1c7af;
|
||||
--color-zinc-100: #e8e0cc;
|
||||
--color-zinc-50: #f3edde;
|
||||
|
||||
/* Copper accent (replaces indigo) */
|
||||
--color-indigo-950: #2a1708;
|
||||
--color-indigo-900: #3e2410;
|
||||
--color-indigo-800: #5c3517;
|
||||
--color-indigo-700: #7c4820;
|
||||
--color-indigo-600: #9c5b28;
|
||||
--color-indigo-500: #bc6f30;
|
||||
--color-indigo-400: #d28a47;
|
||||
--color-indigo-300: #e3a968;
|
||||
--color-indigo-200: #efc795;
|
||||
--color-indigo-100: #f7e2c4;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #bc6f30;
|
||||
color: #0f0d0a;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
"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";
|
||||
import { CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
|
||||
|
||||
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 }: { active?: boolean; payload?: { dataKey: string; value: number }[]; label?: string }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const regular = payload.find((p) => p.dataKey === "regular")?.value ?? 0;
|
||||
const occasional = payload.find((p) => p.dataKey === "occasional")?.value ?? 0;
|
||||
return (
|
||||
<div style={TOOLTIP_STYLE} className="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-300">Regular</span><span className="font-mono">{fmt(regular)}</span></div>
|
||||
<div className="flex justify-between gap-4"><span className="text-zinc-400">Occasional</span><span className="font-mono">{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 className="font-mono">{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">Total</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) => {
|
||||
// Share is resolved server-side (see getTransactions) so this
|
||||
// matches the category total being drilled into. The old
|
||||
// `my_share_percent ?? 100` missed transaction_splits rows
|
||||
// entirely and assumed 100% when a transaction was allocated
|
||||
// wholly to someone else.
|
||||
const sharePct = Number(t.my_share_pct ?? 100);
|
||||
const grossAmt = Number(t.amount_aud ?? t.amount);
|
||||
const effectiveAmt = Number(t.my_amount ?? grossAmt);
|
||||
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 text-zinc-500">
|
||||
{fmtExact(grossAmt)}
|
||||
</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">({sharePct}%)</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 as Set<string>).has(r.category));
|
||||
const occasionalRows = categoryData.filter((r) => !(REGULAR_CATEGORIES as Set<string>).has(r.category));
|
||||
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-800 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 as Set<string>).has(row.category)) 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">
|
||||
<p className="text-[11px] uppercase tracking-[0.18em] text-zinc-500 mb-1">Ledger · patterns</p>
|
||||
<h2 className="text-2xl font-display mb-6">Insights</h2>
|
||||
|
||||
{/* ── 1. Regular vs Occasional ── */}
|
||||
<Section title="Regular vs occasional spend">
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl grid grid-cols-3 divide-x divide-zinc-800 overflow-hidden mb-4">
|
||||
<div className="px-4 py-3.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">This month · regular</div>
|
||||
<div className="text-xl font-mono tabular-nums text-indigo-300">{fmt(latestRegular)}</div>
|
||||
</div>
|
||||
<div className="px-4 py-3.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">12-month average</div>
|
||||
<div className="text-xl font-mono tabular-nums text-zinc-200">{fmt(avgRegular)}</div>
|
||||
</div>
|
||||
<div className="px-4 py-3.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Trend · first 3 vs last 3</div>
|
||||
<div className={`text-xl font-mono tabular-nums ${regularTrend.dir === "up" ? "text-red-400" : regularTrend.dir === "down" ? "text-emerald-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-xl p-4">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<ComposedChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<XAxis dataKey="month" tick={{ fill: CHART.axis, fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: CHART.axis, fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} width={44} />
|
||||
<Tooltip content={<RegularTooltip />} cursor={{ fill: "rgba(232,224,204,0.04)" }} />
|
||||
<Bar dataKey="regular" stackId="a" fill={CHART.accent} name="Regular" radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="occasional" stackId="a" fill={CHART.dim} name="Occasional" radius={[3, 3, 0, 0]} />
|
||||
<Line type="monotone" dataKey="regular" stroke={CHART.accentSoft} 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 inline-block" style={{ background: CHART.accent }} />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 inline-block" style={{ background: CHART.dim }} />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-800 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-800 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-800 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>
|
||||
);
|
||||
}
|
||||
+9
-3
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Geist, Geist_Mono, Fraunces } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { Sidebar } from "@/components/sidebar";
|
||||
@@ -14,6 +14,12 @@ const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const fraunces = Fraunces({
|
||||
variable: "--font-fraunces",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Finance",
|
||||
description: "Personal Finance Dashboard",
|
||||
@@ -27,12 +33,12 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-zinc-950 text-zinc-100`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${fraunces.variable} antialiased bg-zinc-950 text-zinc-100`}
|
||||
>
|
||||
<Providers>
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 p-6 overflow-auto">{children}</main>
|
||||
<main className="flex-1 pt-[calc(3.5rem+1rem)] px-3 pb-3 md:p-6 md:pt-6 overflow-auto">{children}</main>
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
|
||||
@@ -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] || "#bc6f30";
|
||||
|
||||
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="#242019" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: "#94896f", fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: "#94896f", fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) => `$${Math.round(v)}`}
|
||||
width={45}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ background: "#171410", border: "1px solid #332d23", borderRadius: "8px" }}
|
||||
labelStyle={{ color: "#b3a88e" }}
|
||||
/>
|
||||
<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-2xl font-display text-zinc-50">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="#242019" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="debit_count"
|
||||
name="Transactions"
|
||||
type="number"
|
||||
tick={{ fill: "#94896f", fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
label={{ value: "Transaction Count", position: "insideBottom", offset: -10, fill: "#6e644f", fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="net_spend"
|
||||
name="Net Spend"
|
||||
type="number"
|
||||
tick={{ fill: "#94896f", 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: "#6e644f", fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip content={<ScatterTooltip />} cursor={{ strokeDasharray: "3 3", stroke: "#6e644f" }} />
|
||||
<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] || "#bc6f30"}
|
||||
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] || "#bc6f30";
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { usePendingReconciliations, useReconcile } from "@/lib/hooks";
|
||||
import type { ManualTxWithMatches, PotentialMatch } from "@/lib/queries";
|
||||
|
||||
function formatDate(d: string | Date) {
|
||||
return new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
function formatAmt(amount: number, type: string) {
|
||||
const f = new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD" }).format(amount);
|
||||
return ["debit", "fee", "interest"].includes(type) ? f : `+${f}`;
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
// Selections: manual_id → statement_tx_id or null (skip)
|
||||
type Selections = Record<number, number | null>;
|
||||
|
||||
export default function ReconcilePage() {
|
||||
const { data: pending = [], isLoading, refetch } = usePendingReconciliations();
|
||||
const reconcile = useReconcile();
|
||||
|
||||
const [selections, setSelections] = useState<Selections>({});
|
||||
const [error, setError] = useState("");
|
||||
const [done, setDone] = useState<{ reconciled: number } | null>(null);
|
||||
|
||||
const withMatches = pending.filter((tx) => tx.matches.length > 0);
|
||||
const noMatches = pending.filter((tx) => tx.matches.length === 0);
|
||||
|
||||
// Statement tx IDs already chosen in another row this session
|
||||
const usedStatementIds = new Set(Object.values(selections).filter((v): v is number => v !== null));
|
||||
|
||||
function selectMatch(manualId: number, matchId: number) {
|
||||
setSelections((prev) => {
|
||||
// If this matchId was previously selected for a different manual tx, clear that
|
||||
const updated: Selections = { ...prev };
|
||||
for (const [k, v] of Object.entries(updated)) {
|
||||
if (v === matchId && Number(k) !== manualId) delete updated[Number(k)];
|
||||
}
|
||||
updated[manualId] = matchId;
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
function skipManual(manualId: number) {
|
||||
setSelections((prev) => ({ ...prev, [manualId]: null }));
|
||||
}
|
||||
|
||||
const confirmedMatches = Object.entries(selections)
|
||||
.filter(([, v]) => v !== null)
|
||||
.map(([k, v]) => ({ manual_id: Number(k), statement_tx_id: v as number }));
|
||||
|
||||
async function handleApply() {
|
||||
if (!confirmedMatches.length) return;
|
||||
setError("");
|
||||
try {
|
||||
const result = await reconcile.mutateAsync(confirmedMatches);
|
||||
setDone(result);
|
||||
setSelections({});
|
||||
refetch();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Reconcile failed");
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-6 text-zinc-500 text-sm">Loading...</div>;
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="p-6 max-w-lg">
|
||||
<div className="bg-emerald-900/20 border border-emerald-700/50 rounded-xl p-6 text-center space-y-2">
|
||||
<p className="text-emerald-400 font-semibold text-lg">✓ {done.reconciled} transaction{done.reconciled !== 1 ? "s" : ""} reconciled</p>
|
||||
<p className="text-zinc-400 text-sm">Overrides, tags, and splits copied to statement transactions.</p>
|
||||
<button onClick={() => setDone(null)} className="mt-3 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||
Continue reconciling
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pending.length === 0) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-2xl font-display mb-2">Reconcile</h2>
|
||||
<p className="text-zinc-500 text-sm">No unreconciled manual transactions. Import a CSV to get started.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-display">Reconcile</h2>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">
|
||||
{pending.length} manual transaction{pending.length !== 1 ? "s" : ""} ·{" "}
|
||||
{withMatches.length} with potential matches
|
||||
</p>
|
||||
</div>
|
||||
{confirmedMatches.length > 0 && (
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={reconcile.isPending}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{reconcile.isPending ? "Reconciling..." : `Apply ${confirmedMatches.length} match${confirmedMatches.length !== 1 ? "es" : ""}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
|
||||
{/* Transactions with matches */}
|
||||
{withMatches.map((tx) => (
|
||||
<ReconcileRow
|
||||
key={tx.id}
|
||||
tx={tx}
|
||||
selection={selections[tx.id]}
|
||||
usedStatementIds={usedStatementIds}
|
||||
onSelect={(matchId) => selectMatch(tx.id, matchId)}
|
||||
onSkip={() => skipManual(tx.id)}
|
||||
onClear={() => setSelections((prev) => { const n = { ...prev }; delete n[tx.id]; return n; })}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Transactions with no matches */}
|
||||
{noMatches.length > 0 && (
|
||||
<div className="border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 bg-zinc-900/60 border-b border-zinc-800">
|
||||
<p className="text-sm font-medium text-zinc-400">No statement matches found ({noMatches.length})</p>
|
||||
<p className="text-xs text-zinc-600 mt-0.5">These may not have hit a statement yet, or the statement hasn't been imported.</p>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800">
|
||||
{noMatches.map((tx) => (
|
||||
<div key={tx.id} className="px-4 py-3 flex items-center gap-3">
|
||||
<span className="text-zinc-500 text-xs w-20 flex-shrink-0">{formatDate(tx.transaction_date)}</span>
|
||||
<span className="text-zinc-300 text-sm flex-1 truncate">{tx.effective_merchant || tx.description}</span>
|
||||
<span className={`text-sm font-mono ${["debit","fee","interest"].includes(tx.transaction_type) ? "text-red-400" : "text-green-400"}`}>
|
||||
{formatAmt(tx.amount, tx.transaction_type)}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{(tx.tags as { id: number; name: string; color: string }[]).map((tag) => (
|
||||
<span key={tag.id} className="w-2 h-2 rounded-full" style={{ backgroundColor: tag.color }} title={tag.name} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReconcileRow({
|
||||
tx, selection, usedStatementIds, onSelect, onSkip, onClear,
|
||||
}: {
|
||||
tx: ManualTxWithMatches;
|
||||
selection: number | null | undefined;
|
||||
usedStatementIds: Set<number>;
|
||||
onSelect: (matchId: number) => void;
|
||||
onSkip: () => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const isSkipped = selection === null;
|
||||
const selectedMatchId = typeof selection === "number" ? selection : null;
|
||||
const tags = tx.tags as { id: number; name: string; color: string }[];
|
||||
|
||||
return (
|
||||
<div className={`border rounded-xl overflow-hidden transition-colors ${
|
||||
selectedMatchId ? "border-emerald-700/60" : isSkipped ? "border-zinc-700/40 opacity-60" : "border-zinc-700"
|
||||
}`}>
|
||||
{/* Manual tx header */}
|
||||
<div className="px-4 py-3 bg-zinc-900/60 border-b border-zinc-800 flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-zinc-500 text-xs flex-shrink-0">{formatDate(tx.transaction_date)}</span>
|
||||
<span className="text-zinc-200 text-sm truncate">{tx.effective_merchant || tx.description}</span>
|
||||
{tx.effective_merchant && (
|
||||
<span className="text-zinc-600 text-xs truncate hidden sm:inline">{tx.description}</span>
|
||||
)}
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag.id} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs"
|
||||
style={{ backgroundColor: tag.color + "33", color: tag.color }}>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-sm font-mono flex-shrink-0 ${["debit","fee","interest"].includes(tx.transaction_type) ? "text-red-400" : "text-green-400"}`}>
|
||||
{formatAmt(tx.amount, tx.transaction_type)}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ${TYPE_COLORS[tx.transaction_type] || "bg-zinc-800 text-zinc-400"}`}>
|
||||
{tx.transaction_type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Matches */}
|
||||
<div className="divide-y divide-zinc-800/50">
|
||||
{tx.matches.map((match) => {
|
||||
const isSelected = selectedMatchId === match.id;
|
||||
const isUsedElsewhere = !isSelected && usedStatementIds.has(match.id);
|
||||
return (
|
||||
<MatchRow
|
||||
key={match.id}
|
||||
match={match}
|
||||
isSelected={isSelected}
|
||||
isDisabled={isUsedElsewhere}
|
||||
onSelect={() => onSelect(match.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Skip / status row */}
|
||||
<div className="px-4 py-2.5 flex items-center justify-between bg-zinc-950/30">
|
||||
{selectedMatchId ? (
|
||||
<span className="text-emerald-400 text-xs font-medium">✓ Match selected</span>
|
||||
) : isSkipped ? (
|
||||
<span className="text-zinc-500 text-xs">Skipped — will stay as manual transaction</span>
|
||||
) : (
|
||||
<span className="text-zinc-600 text-xs">Select a match above, or skip</span>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{(selectedMatchId || isSkipped) && (
|
||||
<button onClick={onClear} className="text-xs text-zinc-500 hover:text-zinc-300 px-2 py-1 rounded hover:bg-zinc-800">
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
{!isSkipped && (
|
||||
<button onClick={onSkip} className="text-xs text-zinc-500 hover:text-zinc-300 px-2 py-1 rounded hover:bg-zinc-800">
|
||||
Skip (no match)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchRow({
|
||||
match, isSelected, isDisabled, onSelect,
|
||||
}: {
|
||||
match: PotentialMatch;
|
||||
isSelected: boolean;
|
||||
isDisabled: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
disabled={isDisabled}
|
||||
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors ${
|
||||
isSelected
|
||||
? "bg-emerald-900/20 hover:bg-emerald-900/30"
|
||||
: isDisabled
|
||||
? "opacity-30 cursor-not-allowed"
|
||||
: "hover:bg-zinc-800/50"
|
||||
}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center ${
|
||||
isSelected ? "border-emerald-500 bg-emerald-500" : "border-zinc-600"
|
||||
}`}>
|
||||
{isSelected && <div className="w-2 h-2 rounded-full bg-white" />}
|
||||
</div>
|
||||
<span className="text-zinc-500 text-xs w-20 flex-shrink-0">{formatDate(match.transaction_date)}</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="text-zinc-300 text-sm truncate block">
|
||||
{match.effective_merchant || match.description}
|
||||
</span>
|
||||
{match.effective_merchant && (
|
||||
<span className="text-zinc-600 text-xs truncate block">{match.description}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-zinc-500 text-xs flex-shrink-0">{match.bank_name}</span>
|
||||
<span className={`text-sm font-mono flex-shrink-0 ${["debit","fee","interest"].includes(match.transaction_type) ? "text-red-400" : "text-green-400"}`}>
|
||||
{formatAmt(match.amount, match.transaction_type)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+310
-38
@@ -1,8 +1,10 @@
|
||||
"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";
|
||||
import { RulePreviewModal } from "@/components/rule-preview-modal";
|
||||
import { RuleRunDetail } from "@/components/rule-run-detail";
|
||||
|
||||
const FIELDS = [
|
||||
{ value: "merchant_normalized", label: "Merchant" },
|
||||
@@ -10,6 +12,8 @@ const FIELDS = [
|
||||
{ value: "category", label: "Category" },
|
||||
{ value: "bank_name", label: "Bank" },
|
||||
{ value: "amount", label: "Amount" },
|
||||
{ value: "transaction_type", label: "Transaction Type" },
|
||||
{ value: "tag", label: "Tag" },
|
||||
] as const;
|
||||
|
||||
const TEXT_OPS = [
|
||||
@@ -24,18 +28,28 @@ 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 {
|
||||
function humanCondition(c: Condition, tagNames?: Map<number, string>): string {
|
||||
const fieldLabel = FIELDS.find((f) => f.value === c.field)?.label || c.field;
|
||||
const ops = [...TEXT_OPS, ...AMOUNT_OPS];
|
||||
if (c.field === "tag") {
|
||||
const tagName = tagNames?.get(Number(c.value)) || `tag#${c.value}`;
|
||||
return `Tag ${c.operator === "not_equals" ? "is not" : "is"} "${tagName}"`;
|
||||
}
|
||||
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,25 +57,66 @@ 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-09");
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null);
|
||||
const [preview, setPreview] = useState<{ id: number; name: string } | null>(null);
|
||||
const [expandedRun, setExpandedRun] = useState<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);
|
||||
const [manualOnly, setManualOnly] = useState(false);
|
||||
|
||||
function openNewForm() {
|
||||
setEditingId(null);
|
||||
setName("");
|
||||
setConditions([]);
|
||||
setActions(EMPTY_ACTIONS);
|
||||
setPriority(0);
|
||||
setManualOnly(false);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
function openEditForm(rule: { id: number; name: string; conditions: Condition[]; actions: Actions; priority: number; manual_only?: boolean }) {
|
||||
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);
|
||||
setManualOnly(!!rule.manual_only);
|
||||
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,35 +130,63 @@ export default function RulesPage() {
|
||||
setConditions(conditions.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
await createRule.mutateAsync({ name, conditions, actions, enabled: true, priority });
|
||||
setName("");
|
||||
setConditions([]);
|
||||
setActions({});
|
||||
setPriority(0);
|
||||
setShowForm(false);
|
||||
function addSplitEntry() {
|
||||
if (!participants.length) return;
|
||||
const existing = actions.apply_split || [];
|
||||
setActions({ ...actions, apply_split: [...existing, { participant_id: participants[0].id, share_percent: 0 }] });
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
const result = await applyRules.mutateAsync();
|
||||
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();
|
||||
const payload = { name, conditions, actions, enabled: true, manual_only: manualOnly, priority };
|
||||
if (editingId !== null) {
|
||||
await updateRule.mutateAsync({ id: editingId, ...payload });
|
||||
} else {
|
||||
await createRule.mutateAsync(payload);
|
||||
}
|
||||
closeForm();
|
||||
}
|
||||
|
||||
async function handleApply(ruleId?: number) {
|
||||
const result = await applyRules.mutateAsync({ splitFrom: applyFrom || undefined, ruleId });
|
||||
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">
|
||||
<h2 className="text-2xl font-display">Rules</h2>
|
||||
<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}
|
||||
onClick={() => handleApply()}
|
||||
disabled={applyRules.isPending}
|
||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{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 +206,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 +228,22 @@ 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 isTag = cond.field === "tag";
|
||||
const ops = isAmount ? AMOUNT_OPS : (isEnum || isTag) ? 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 if (newField === "tag") { patch.operator = "equals"; patch.value = tags[0] ? String(tags[0].id) : ""; }
|
||||
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 +263,35 @@ 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"
|
||||
/>
|
||||
{isTag ? (
|
||||
<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"
|
||||
>
|
||||
{tags.map((t) => (
|
||||
<option key={t.id} value={String(t.id)}>{t.name}</option>
|
||||
))}
|
||||
{tags.length === 0 && <option value="">No tags</option>}
|
||||
</select>
|
||||
) : 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 +363,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>
|
||||
@@ -262,17 +423,85 @@ export default function RulesPage() {
|
||||
className="w-24 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-400 cursor-pointer select-none pb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={manualOnly}
|
||||
onChange={(e) => setManualOnly(e.target.checked)}
|
||||
className="accent-amber-500"
|
||||
/>
|
||||
<span>
|
||||
Quick action only
|
||||
<span className="block text-xs text-zinc-600">
|
||||
Never runs automatically — appears as a button on selected transactions
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<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={`rounded-lg border text-sm overflow-hidden ${run.reverted_at ? "bg-zinc-900/40 border-zinc-800 opacity-60" : "bg-zinc-900 border-zinc-700"}`}>
|
||||
<div className="flex items-center justify-between px-4 py-2.5">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onClick={() => setExpandedRun(expandedRun === run.id ? null : run.id)}
|
||||
className="text-zinc-500 hover:text-zinc-200 w-4 text-left"
|
||||
title="Show what this run changed"
|
||||
>
|
||||
{expandedRun === run.id ? "▾" : "▸"}
|
||||
</button>
|
||||
<span className="text-zinc-300">{new Date(run.applied_at).toLocaleString()}</span>
|
||||
{/* Which rule ran matters most: a merchant rename and a
|
||||
50/50 split of everything look identical as counts. */}
|
||||
<span className="text-zinc-200">
|
||||
{run.rule_name ?? (run.source === "all" ? "All rules" : "Unknown rule")}
|
||||
</span>
|
||||
{run.source === "selection" && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-indigo-900/50 text-indigo-300">
|
||||
selection
|
||||
</span>
|
||||
)}
|
||||
<span className="text-zinc-500">{run.transactions_affected} transactions</span>
|
||||
{run.split_from && (
|
||||
<span className="text-zinc-600 text-xs">splits from {String(run.split_from).slice(0, 10)}</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>
|
||||
{expandedRun === run.id && <RuleRunDetail runId={run.id} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-zinc-500 text-sm">Loading rules...</p>
|
||||
) : rules.length === 0 ? (
|
||||
@@ -289,14 +518,43 @@ export default function RulesPage() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="font-medium text-sm">{rule.name}</span>
|
||||
{rule.manual_only && (
|
||||
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-900/40 text-amber-300 border border-amber-800/60">
|
||||
Quick action
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-zinc-500">priority: {rule.priority}</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400">
|
||||
{conds.length > 0 ? conds.map(humanCondition).join(" AND ") : "(matches all)"}
|
||||
{rule.manual_only
|
||||
? "(fired by hand on selected transactions)"
|
||||
: conds.length > 0
|
||||
? conds.map((c) => humanCondition(c, tagNames)).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">
|
||||
{/* Preview is safe for every rule, including quick actions:
|
||||
it writes nothing, and the apply step takes the ids you
|
||||
tick rather than re-running the conditions. */}
|
||||
<button
|
||||
onClick={() => setPreview({ id: rule.id, name: rule.name })}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
{/* No blind Apply for quick actions: their conditions are
|
||||
empty, so a bulk apply would hit every transaction. */}
|
||||
{!rule.manual_only && (
|
||||
<button
|
||||
onClick={() => handleApply(rule.id)}
|
||||
disabled={applyRules.isPending}
|
||||
className="text-xs text-emerald-400 hover:text-emerald-300 disabled:opacity-50"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => updateRule.mutate({ id: rule.id, enabled: !rule.enabled })}
|
||||
className={`relative inline-flex h-5 w-9 rounded-full transition-colors ${
|
||||
@@ -309,6 +567,12 @@ export default function RulesPage() {
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditForm({ id: rule.id, name: rule.name, conditions: conds as Condition[], actions: acts, priority: rule.priority, manual_only: rule.manual_only })}
|
||||
className="text-zinc-400 hover:text-white text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm("Delete this rule?")) deleteRule.mutate(rule.id);
|
||||
@@ -324,6 +588,14 @@ export default function RulesPage() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<RulePreviewModal
|
||||
ruleId={preview.id}
|
||||
ruleName={preview.name}
|
||||
onClose={() => setPreview(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+485
-4
@@ -1,8 +1,489 @@
|
||||
export default function SharedPage() {
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
useSharedTransactions,
|
||||
useParticipantBalances,
|
||||
useParticipants,
|
||||
useCreateParticipant,
|
||||
useRecordPayment,
|
||||
usePaymentHistory,
|
||||
useDeletePayment,
|
||||
useCurrentUser,
|
||||
useTags,
|
||||
type SplitPayment,
|
||||
} from "@/lib/hooks";
|
||||
import type { SharedTransactionRow } from "@/lib/queries";
|
||||
import { EditTransactionModal } from "@/components/edit-transaction-modal";
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
let next: string[];
|
||||
if (value.includes(id)) {
|
||||
next = value.filter((x) => x !== id);
|
||||
} else if (id === "untagged") {
|
||||
next = ["untagged"];
|
||||
} else {
|
||||
next = [...value.filter((x) => x !== "untagged"), id];
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const label = value.length === 0 ? "All Tags"
|
||||
: value.includes("untagged") ? "No 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">
|
||||
<label className="flex items-center gap-2 px-3 py-1.5 hover:bg-zinc-800 cursor-pointer text-sm border-b border-zinc-800">
|
||||
<input type="checkbox" checked={value.includes("untagged")} onChange={() => toggle("untagged")}
|
||||
className="accent-indigo-500 flex-shrink-0" />
|
||||
<span className="text-zinc-400 italic">No tags</span>
|
||||
</label>
|
||||
{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 ─────────────────────────────────────────────────────────────────
|
||||
type SortCol = "transaction_date" | "created_at" | "amount";
|
||||
|
||||
export default function SharedPage() {
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
const [participantId, setParticipantId] = useState<number | undefined>(undefined);
|
||||
const [sortCol, setSortCol] = useState<SortCol>("transaction_date");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
const realTagIds = tagIds.filter((id) => id !== "untagged");
|
||||
const { data: participants = [] } = useParticipants();
|
||||
const { data: rawTransactions = [], isLoading: txLoading } = useSharedTransactions(tagIds, participantId);
|
||||
|
||||
const transactions = [...rawTransactions].sort((a, b) => {
|
||||
const av = sortCol === "amount" ? Number(a.amount) : new Date(a[sortCol]).getTime();
|
||||
const bv = sortCol === "amount" ? Number(b.amount) : new Date(b[sortCol]).getTime();
|
||||
return sortDir === "desc" ? bv - av : av - bv;
|
||||
});
|
||||
|
||||
function toggleSort(col: SortCol) {
|
||||
if (sortCol === col) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
|
||||
else { setSortCol(col); setSortDir("desc"); }
|
||||
}
|
||||
|
||||
function SortIcon({ col }: { col: SortCol }) {
|
||||
if (sortCol !== col) return <span className="text-zinc-600 ml-0.5">↕</span>;
|
||||
return <span className="ml-0.5">{sortDir === "desc" ? "↓" : "↑"}</span>;
|
||||
}
|
||||
const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds);
|
||||
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);
|
||||
const [editModal, setEditModal] = useState<SharedTransactionRow | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-2xl font-display">Shared Expenses</h2>
|
||||
<div className="flex items-center gap-2 ml-auto flex-wrap">
|
||||
<select
|
||||
value={participantId ?? ""}
|
||||
onChange={(e) => setParticipantId(e.target.value ? Number(e.target.value) : undefined)}
|
||||
className={`border rounded px-3 py-1.5 text-sm bg-zinc-900 ${participantId ? "border-indigo-500 text-white" : "border-zinc-700 text-zinc-400"}`}
|
||||
>
|
||||
<option value="">All People</option>
|
||||
{participants.map((p: { id: number; name: string }) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<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 */}
|
||||
{realTagIds.length === 0 && tagIds.includes("untagged") ? null : realTagIds.length > 0 && (
|
||||
<p className="text-xs text-zinc-500 mb-2">Showing split totals for selected tag — payments excluded (payments settle overall debt, not per-tag)</p>
|
||||
)}
|
||||
<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-x-auto">
|
||||
<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 min-w-[520px]">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th
|
||||
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
|
||||
onClick={() => toggleSort("transaction_date")}
|
||||
>
|
||||
Date <SortIcon col="transaction_date" />
|
||||
</th>
|
||||
<th
|
||||
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
|
||||
onClick={() => toggleSort("created_at")}
|
||||
>
|
||||
Imported <SortIcon col="created_at" />
|
||||
</th>
|
||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">Description</th>
|
||||
<th
|
||||
className="text-right px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white"
|
||||
onClick={() => toggleSort("amount")}
|
||||
>
|
||||
Amount <SortIcon col="amount" />
|
||||
</th>
|
||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Splits</th>
|
||||
<th className="px-4 py-2"></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 text-zinc-500 text-xs whitespace-nowrap">{formatDate(tx.created_at)}</td>
|
||||
<td className="px-4 py-3 max-w-xs sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">
|
||||
<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.participant_id === me?.id ? "Me" : s.name} {s.share_percent}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => setEditModal(tx)}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editModal && (
|
||||
<EditTransactionModal
|
||||
transaction={editModal}
|
||||
onClose={() => setEditModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+244
-48
@@ -1,10 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useStatements } from "@/lib/hooks";
|
||||
import { useStatements, useParticipants, useUpdateStatement } from "@/lib/hooks";
|
||||
import {
|
||||
STATEMENT_TYPES,
|
||||
STATEMENT_TYPE_LABELS,
|
||||
asStatementType,
|
||||
isLiability,
|
||||
} from "@/lib/statement-types";
|
||||
|
||||
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 +19,253 @@ 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" | (typeof STATEMENT_TYPES)[number]>("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) => {
|
||||
if (bankFilter && s.bank_name !== bankFilter) return false;
|
||||
if (typeFilter !== "all" && asStatementType(s.statement_type) !== typeFilter) 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-2xl font-display">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>
|
||||
{STATEMENT_TYPES.map((t) => (
|
||||
<option key={t} value={t}>{STATEMENT_TYPE_LABELS[t]}</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-x-auto">
|
||||
<table className="w-full text-sm min-w-[800px]">
|
||||
<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 sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">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 hidden sm:table-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((s, idx) => {
|
||||
const stmtType = asStatementType(s.statement_type);
|
||||
const owed = isLiability(s.statement_type);
|
||||
// Cards headline the amount due; everything else (including loans,
|
||||
// where the balance is what's still owed) headlines the balance.
|
||||
const displayAmount =
|
||||
stmtType === "credit_card" ? s.total_amount_due : s.closing_balance;
|
||||
const amount = Number(displayAmount);
|
||||
const amountColor = owed
|
||||
? "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 sticky left-0 z-10 bg-zinc-950 border-r border-zinc-800/80">
|
||||
<div className="font-medium truncate max-w-[160px]" title={s.bank_name}>
|
||||
{s.bank_name}
|
||||
</div>
|
||||
{s.card_name && (
|
||||
<div className="text-xs text-zinc-500 truncate max-w-[160px]">{s.card_name}</div>
|
||||
)}
|
||||
{stmtType === "loan" && (s.interest_rate || s.scheduled_repayment) && (
|
||||
<div className="text-xs text-zinc-500 truncate max-w-[160px]">
|
||||
{[
|
||||
s.interest_rate ? `${Number(s.interest_rate).toFixed(2)}% p.a.` : null,
|
||||
s.scheduled_repayment
|
||||
? `${formatAmount(s.scheduled_repayment)}${
|
||||
s.repayment_frequency ? ` ${s.repayment_frequency}` : ""
|
||||
}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
href={`/transactions?statement_id=${s.id}`}
|
||||
className="sm:hidden text-xs text-indigo-400 hover:text-indigo-300 mt-1 inline-block"
|
||||
>
|
||||
View →
|
||||
</Link>
|
||||
</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">
|
||||
{formatDate(s.payment_due_date ?? 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}
|
||||
{/* Balance assertion: flags statements whose transactions
|
||||
don't add up to the closing balance. */}
|
||||
{s.balance_diff !== null && s.balance_diff !== undefined &&
|
||||
Math.abs(Number(s.balance_diff)) >= 0.02 && (
|
||||
<div
|
||||
className="text-[10px] text-amber-400 mt-0.5 whitespace-nowrap"
|
||||
title={`Transactions don't reconcile: expected closing ${formatAmount(
|
||||
s.expected_closing
|
||||
)}, statement says ${formatAmount(s.closing_balance)}`}
|
||||
>
|
||||
⚠ {formatAmount(Math.abs(Number(s.balance_diff)))} off
|
||||
</div>
|
||||
)}
|
||||
</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 hidden sm:table-cell">
|
||||
<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>
|
||||
|
||||
+140
-22
@@ -1,21 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTags, useCreateTag, useDeleteTag } from "@/lib/hooks";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const PRESET_COLORS = [
|
||||
"#6366f1", // indigo
|
||||
"#8b5cf6", // violet
|
||||
"#ec4899", // pink
|
||||
"#ef4444", // red
|
||||
"#f97316", // orange
|
||||
"#eab308", // yellow
|
||||
"#22c55e", // green
|
||||
"#14b8a6", // teal
|
||||
"#3b82f6", // blue
|
||||
"#6b7280", // gray
|
||||
"#6366f1", "#8b5cf6", "#ec4899", "#ef4444", "#f97316",
|
||||
"#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#6b7280",
|
||||
];
|
||||
|
||||
function ConvertModal({
|
||||
tag,
|
||||
onClose,
|
||||
}: {
|
||||
tag: { id: number; name: string; color: string; transaction_count: number };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [deleteTag, setDeleteTag] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
|
||||
async function handleConvert() {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`/api/tags/${tag.id}/convert-to-trip`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ start_date: startDate || null, end_date: endDate || null }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || "Failed");
|
||||
const { trip } = await res.json();
|
||||
|
||||
if (deleteTag) {
|
||||
await fetch(`/api/tags/${tag.id}`, { method: "DELETE" });
|
||||
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||
}
|
||||
|
||||
qc.invalidateQueries({ queryKey: ["trips"] });
|
||||
router.push(`/trips/${trip.id}`);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to convert");
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
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-sm mx-4 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-6 pt-5 pb-4 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: tag.color }} />
|
||||
<h3 className="font-semibold text-sm text-zinc-300">Convert "{tag.name}" to Trip</h3>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 mt-1">
|
||||
Creates a trip with the same name and color, and assigns all {tag.transaction_count} tagged transaction{tag.transaction_count !== 1 ? "s" : ""} to it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-600">Dates are optional — you can set them later from the trip page.</p>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteTag}
|
||||
onChange={(e) => setDeleteTag(e.target.checked)}
|
||||
className="accent-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-zinc-400">Delete this tag after converting</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-zinc-800 flex gap-2 items-center">
|
||||
{error && <p className="text-red-400 text-xs mr-auto">{error}</p>}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<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
|
||||
onClick={handleConvert}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{saving ? "Converting…" : "Convert to Trip"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TagsPage() {
|
||||
const { data: tags, isLoading } = useTags();
|
||||
const createTag = useCreateTag();
|
||||
@@ -24,6 +131,7 @@ export default function TagsPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState(PRESET_COLORS[0]);
|
||||
const [error, setError] = useState("");
|
||||
const [convertTag, setConvertTag] = useState<{ id: number; name: string; color: string; transaction_count: number } | null>(null);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) return;
|
||||
@@ -38,7 +146,7 @@ export default function TagsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Tags</h2>
|
||||
<h2 className="text-2xl font-display mb-4">Tags</h2>
|
||||
|
||||
{/* Create form */}
|
||||
<div className="mb-6 p-4 bg-zinc-900/50 border border-zinc-800 rounded-lg">
|
||||
@@ -83,28 +191,38 @@ export default function TagsPage() {
|
||||
{tags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center justify-between px-4 py-2.5 bg-zinc-900/50 border border-zinc-800 rounded-lg"
|
||||
className="flex items-center justify-between px-4 py-2.5 bg-zinc-900/50 border border-zinc-800 rounded-lg group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: tag.color }} />
|
||||
<span className="text-sm font-medium">{tag.name}</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{tag.transaction_count} transaction{tag.transaction_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteTag.mutate(tag.id)}
|
||||
className="text-xs text-zinc-600 hover:text-red-400 transition-colors px-2 py-0.5 rounded hover:bg-zinc-800"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => setConvertTag(tag)}
|
||||
className="text-xs text-zinc-400 hover:text-indigo-400 transition-colors px-2 py-1 rounded hover:bg-zinc-800"
|
||||
title="Convert to Trip"
|
||||
>
|
||||
→ Trip
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteTag.mutate(tag.id)}
|
||||
className="text-xs text-zinc-600 hover:text-red-400 transition-colors px-2 py-1 rounded hover:bg-zinc-800"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{convertTag && (
|
||||
<ConvertModal tag={convertTag} onClose={() => setConvertTag(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+763
-67
@@ -1,10 +1,16 @@
|
||||
"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, useTrips, useAssignTransactionsToTrip, useRules } 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 { CsvImportModal } from "@/components/csv-import-modal";
|
||||
import type { TransactionRow } from "@/lib/queries";
|
||||
import type { RuleRow } from "@/lib/hooks";
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString("en-AU", {
|
||||
@@ -14,30 +20,160 @@ function formatDate(d: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function formatAmount(amount: number, type: string) {
|
||||
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
|
||||
|
||||
// `amount` is in the statement's native currency; pass the currency to label it
|
||||
// correctly. Callers showing a headline figure should pass amount_aud, which is
|
||||
// what every analytics query totals.
|
||||
function formatAmount(amount: number, type: string, currency = "AUD") {
|
||||
const formatted = new Intl.NumberFormat("en-AU", {
|
||||
style: "currency",
|
||||
currency: "AUD",
|
||||
currency,
|
||||
}).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 }));
|
||||
|
||||
/** Tooltip text for a quick-action button: what the rule will actually do. */
|
||||
function describeActions(
|
||||
actions: RuleRow["actions"],
|
||||
tags: { id: number; name: string }[] = [],
|
||||
participants: { id: number; name: string }[] = []
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (actions.set_category) parts.push(`category → ${formatCategory(actions.set_category)}`);
|
||||
if (actions.set_merchant) parts.push(`merchant → ${actions.set_merchant}`);
|
||||
if (actions.add_tag_ids?.length) {
|
||||
const names = actions.add_tag_ids.map((id) => tags.find((t) => t.id === id)?.name ?? `tag#${id}`);
|
||||
parts.push(`tag: ${names.join(", ")}`);
|
||||
}
|
||||
if (actions.apply_split?.length) {
|
||||
const shares = actions.apply_split.map(
|
||||
(s) => `${participants.find((p) => p.id === s.participant_id)?.name ?? `#${s.participant_id}`} ${s.share_percent}%`
|
||||
);
|
||||
parts.push(`split: ${shares.join(" / ")}`);
|
||||
}
|
||||
return parts.join(" · ") || "no actions";
|
||||
}
|
||||
|
||||
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,29 +233,331 @@ 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.id !== me?.id);
|
||||
|
||||
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,
|
||||
has_split: "" as string,
|
||||
trip_id: "" as string,
|
||||
});
|
||||
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 [bulkTripId, setBulkTripId] = useState("");
|
||||
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 [showImportModal, setShowImportModal] = useState(false);
|
||||
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: me } = useCurrentUser();
|
||||
const { data: statementInfo } = useStatement(parseInt(filters.statement_id) || 0);
|
||||
const updateTxn = useUpdateTransaction();
|
||||
const bulkAction = useBulkAction();
|
||||
const { data: trips = [] } = useTrips();
|
||||
const assignToTrip = useAssignTransactionsToTrip();
|
||||
const { data: allRules = [] } = useRules();
|
||||
const { data: participants = [] } = useParticipants();
|
||||
const quickActions = allRules.filter((r) => r.manual_only);
|
||||
const [quickResult, setQuickResult] = useState<string | null>(null);
|
||||
|
||||
const toggleSelect = useCallback((id: number) => {
|
||||
setSelected((prev) => {
|
||||
@@ -159,17 +597,78 @@ 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-2xl font-display">Transactions</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowImportModal(true)}
|
||||
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Import CSV
|
||||
</button>
|
||||
<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>
|
||||
</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-full 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,34 +681,55 @@ 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"
|
||||
/>
|
||||
|
||||
{/* 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={[{ value: "untagged", label: "No tags" }, ...(tags ?? []).map((t) => ({ value: String(t.id), label: t.name }))]}
|
||||
value={filters.tag_ids}
|
||||
onChange={(v) => {
|
||||
let next = v;
|
||||
if (v.includes("untagged") && !filters.tag_ids.includes("untagged")) next = ["untagged"];
|
||||
else if (filters.tag_ids.includes("untagged") && v.length > 1) next = v.filter((x) => x !== "untagged");
|
||||
setFilters((f) => ({ ...f, tag_ids: next, 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"
|
||||
/>
|
||||
<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"
|
||||
value={filters.has_split}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, has_split: e.target.value, offset: 0 }))}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm text-zinc-300"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>{formatCategory(c)}</option>
|
||||
))}
|
||||
<option value="">All Splits</option>
|
||||
<option value="yes">Split only</option>
|
||||
<option value="no">Unsplit only</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"
|
||||
value={filters.trip_id}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, trip_id: e.target.value, offset: 0 }))}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm text-zinc-300"
|
||||
>
|
||||
<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>
|
||||
<option value="">All Trips</option>
|
||||
<option value="unassigned">No Trip</option>
|
||||
{trips.map((t) => (
|
||||
<option key={t.id} value={String(t.id)}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -273,6 +793,56 @@ export default function TransactionsPage() {
|
||||
>
|
||||
Tag
|
||||
</button>
|
||||
<select
|
||||
value={bulkTripId}
|
||||
onChange={(e) => setBulkTripId(e.target.value)}
|
||||
className="bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="">Assign trip…</option>
|
||||
<option value="remove">Remove from trip</option>
|
||||
{trips.filter((t) => !t.archived).map((t) => (
|
||||
<option key={t.id} value={String(t.id)}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
disabled={!bulkTripId || assignToTrip.isPending}
|
||||
onClick={() => {
|
||||
const tripId = bulkTripId === "remove" ? null : Number(bulkTripId);
|
||||
assignToTrip.mutate(
|
||||
{ tripId, transactionIds: Array.from(selected) },
|
||||
{ onSuccess: () => { setSelected(new Set()); setBulkTripId(""); } }
|
||||
);
|
||||
}}
|
||||
className="px-3 py-1 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-50 rounded text-sm"
|
||||
>
|
||||
{bulkTripId === "remove" ? "Remove" : "Assign"}
|
||||
</button>
|
||||
{quickActions.length > 0 && (
|
||||
<div className="flex items-center gap-2 pl-3 ml-1 border-l border-zinc-700">
|
||||
{quickActions.map((rule) => (
|
||||
<button
|
||||
key={rule.id}
|
||||
disabled={bulkAction.isPending}
|
||||
title={describeActions(rule.actions, tags, participants)}
|
||||
onClick={() => {
|
||||
const count = selected.size;
|
||||
bulkAction.mutate(
|
||||
{ action: "apply_rule", ids: Array.from(selected), rule_id: rule.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSelected(new Set());
|
||||
setQuickResult(`${rule.name} applied to ${count} transaction${count !== 1 ? "s" : ""}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
className="px-3 py-1 bg-amber-700/80 hover:bg-amber-600 disabled:opacity-50 rounded text-sm"
|
||||
>
|
||||
{rule.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSelected(new Set())}
|
||||
className="px-3 py-1 text-zinc-400 hover:text-white text-sm"
|
||||
@@ -282,12 +852,24 @@ export default function TransactionsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{quickResult && (
|
||||
<div className="flex items-center gap-3 mb-3 px-3 py-2 bg-emerald-900/30 border border-emerald-700 rounded text-sm text-emerald-200">
|
||||
<span>{quickResult}</span>
|
||||
<a href="/rules" className="text-emerald-400 hover:text-emerald-300 underline">
|
||||
undo from Rules → Apply History
|
||||
</a>
|
||||
<button onClick={() => setQuickResult(null)} className="ml-auto text-zinc-400 hover:text-white">
|
||||
dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto border border-zinc-800 rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full text-sm min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900/50">
|
||||
<th className="p-2 w-8">
|
||||
<th className="p-2 w-8 sticky left-0 z-10 bg-zinc-900">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data?.data.length ? selected.size === data.data.length : false}
|
||||
@@ -296,11 +878,17 @@ export default function TransactionsPage() {
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
className="p-2 text-left cursor-pointer hover:text-white"
|
||||
className="p-2 text-left cursor-pointer hover:text-white sticky left-8 z-10 bg-zinc-900 border-r border-zinc-800/80 whitespace-nowrap"
|
||||
onClick={() => toggleSort("transaction_date")}
|
||||
>
|
||||
Date {filters.sort_by === "transaction_date" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")}
|
||||
</th>
|
||||
<th
|
||||
className="p-2 text-left text-zinc-500 cursor-pointer hover:text-white whitespace-nowrap"
|
||||
onClick={() => toggleSort("created_at")}
|
||||
>
|
||||
Imported {filters.sort_by === "created_at" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")}
|
||||
</th>
|
||||
<th className="p-2 text-left">Description</th>
|
||||
<th className="p-2 text-left">Merchant</th>
|
||||
<th
|
||||
@@ -318,9 +906,9 @@ export default function TransactionsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr><td colSpan={10} className="p-8 text-center text-zinc-500">Loading...</td></tr>
|
||||
<tr><td colSpan={11} className="p-8 text-center text-zinc-500">Loading...</td></tr>
|
||||
) : !data?.data.length ? (
|
||||
<tr><td colSpan={10} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
|
||||
<tr><td colSpan={11} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
|
||||
) : (
|
||||
data.data.map((t) => (
|
||||
<tr
|
||||
@@ -329,7 +917,7 @@ export default function TransactionsPage() {
|
||||
selected.has(t.id) ? "bg-zinc-800/40" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="p-2">
|
||||
<td className={`p-2 sticky left-0 z-10 ${selected.has(t.id) ? "bg-zinc-800" : "bg-zinc-950"}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(t.id)}
|
||||
@@ -337,13 +925,22 @@ export default function TransactionsPage() {
|
||||
className="accent-blue-600"
|
||||
/>
|
||||
</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 whitespace-nowrap sticky left-8 z-10 border-r border-zinc-800/80 ${selected.has(t.id) ? "bg-zinc-800" : "bg-zinc-950"}`}>{formatDate(t.transaction_date)}</td>
|
||||
<td className="p-2 whitespace-nowrap text-zinc-500 text-xs">{formatDate(t.created_at)}</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 +948,29 @@ 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)}
|
||||
{formatAmount(t.amount_aud ?? t.amount, t.transaction_type)}
|
||||
{t.currency && t.currency !== "AUD" && (
|
||||
<div className="text-[10px] text-zinc-500 mt-0.5">
|
||||
{formatAmount(t.amount, t.transaction_type, t.currency)}
|
||||
</div>
|
||||
)}
|
||||
</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"><TypeBadge type={t.transaction_type} /></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 +994,65 @@ 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.participant_id !== me?.id).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_aud ?? 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.participant_id !== me?.id)
|
||||
? "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,
|
||||
// Duplicates become manual (AUD) transactions, so seed
|
||||
// them with the converted figure, not the native one.
|
||||
amount: t.amount_aud ?? 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 +1069,44 @@ 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()); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showImportModal && <CsvImportModal onClose={() => setShowImportModal(false)} />}
|
||||
|
||||
{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">
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"use client";
|
||||
|
||||
import { use, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { useTripAnalytics, useTrip, useTransactions } from "@/lib/hooks";
|
||||
import { CreateTripModal } from "@/components/create-trip-modal";
|
||||
import { formatCategory } from "@/lib/categories";
|
||||
import { CATEGORY_COLORS, TOOLTIP_STYLE } from "@/lib/category-colors";
|
||||
|
||||
function fmtDate(d: string | null) {
|
||||
if (!d) return null;
|
||||
return new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ backgroundColor: color }} />
|
||||
<p className="text-xs text-zinc-500 mb-1">{label}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums">{value}</p>
|
||||
{sub && <p className="text-xs text-zinc-600 mt-1 truncate">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DailyTooltip({ active, payload, label }: { active?: boolean; payload?: { value: number }[]; label?: string }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs">
|
||||
<p className="text-zinc-400 mb-1">
|
||||
{label ? new Date(label).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" }) : ""}
|
||||
</p>
|
||||
<p className="text-zinc-100 font-medium">${Number(payload[0].value).toFixed(2)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryTooltip({ active, payload }: { active?: boolean; payload?: { payload: { category: string }; value: 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">{formatCategory(payload[0].payload.category)}</p>
|
||||
<p className="text-zinc-100 font-medium">${Number(payload[0].value).toFixed(2)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TripDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const tripId = Number(id);
|
||||
|
||||
const { data: analytics, isLoading } = useTripAnalytics(tripId);
|
||||
const { data: trip } = useTrip(tripId);
|
||||
const [tab, setTab] = useState<"overview" | "transactions">("overview");
|
||||
const [editModal, setEditModal] = useState(false);
|
||||
|
||||
const { data: txData } = useTransactions({ trip_id: id, limit: 500 });
|
||||
|
||||
if (isLoading || !analytics) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-32 bg-zinc-900 rounded-2xl animate-pulse" />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => <div key={i} className="h-24 bg-zinc-900 rounded-xl animate-pulse" />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { total_spend, transaction_count, num_days, daily_average, category_breakdown, daily_spend, top_merchants, tag_breakdown, participant_splits } = analytics;
|
||||
const t = analytics.trip;
|
||||
const maxMerchant = top_merchants[0]?.amount ?? 1;
|
||||
|
||||
const dateRange = t.start_date && t.end_date
|
||||
? `${fmtDate(t.start_date)} – ${fmtDate(t.end_date)}`
|
||||
: t.start_date
|
||||
? `From ${fmtDate(t.start_date)}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Hero */}
|
||||
<div
|
||||
className="relative rounded-2xl overflow-hidden p-6"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${t.color}28 0%, #18181b 60%)`,
|
||||
borderLeft: `3px solid ${t.color}`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Link href="/trips" className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors">
|
||||
← Trips
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">{t.name}</h1>
|
||||
{dateRange && <p className="text-sm text-zinc-400 mt-1">{dateRange}</p>}
|
||||
{t.description && <p className="text-sm text-zinc-500 mt-1">{t.description}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditModal(true)}
|
||||
className="px-3 py-1.5 bg-zinc-800/80 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm transition-colors flex-shrink-0"
|
||||
>
|
||||
Edit Trip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<StatCard label="Total Spend" value={`$${Number(total_spend).toFixed(2)}`} sub="all transactions" color={t.color} />
|
||||
<StatCard label="Transactions" value={String(transaction_count)} sub="total" color={t.color} />
|
||||
<StatCard label="Daily Average" value={`$${Number(daily_average).toFixed(2)}`} sub="per day" color={t.color} />
|
||||
<StatCard label="Days" value={String(num_days)} sub={dateRange ?? "date range"} color={t.color} />
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-0 border-b border-zinc-800">
|
||||
{(["overview", "transactions"] as const).map((tabName) => (
|
||||
<button
|
||||
key={tabName}
|
||||
onClick={() => setTab(tabName)}
|
||||
className={`px-5 py-2.5 text-sm capitalize transition-colors border-b-2 -mb-px ${
|
||||
tab === tabName
|
||||
? "border-current text-white font-medium"
|
||||
: "border-transparent text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
style={tab === tabName ? { borderColor: t.color } : {}}
|
||||
>
|
||||
{tabName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "overview" && (
|
||||
<div className="space-y-5">
|
||||
{/* Daily spend */}
|
||||
{daily_spend.length > 0 && (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
|
||||
<h3 className="text-sm font-medium mb-4">Daily Spend</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={daily_spend} margin={{ top: 4, right: 8, bottom: 0, left: 8 }}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) => new Date(v).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) => `$${v}`}
|
||||
width={52}
|
||||
/>
|
||||
<Tooltip content={<DailyTooltip />} cursor={{ fill: "#27272a" }} />
|
||||
<Bar dataKey="amount" fill={t.color} radius={[3, 3, 0, 0]} maxBarSize={40} opacity={0.85} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
{/* Category breakdown */}
|
||||
{category_breakdown.length > 0 && (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
|
||||
<h3 className="text-sm font-medium mb-4">By Category</h3>
|
||||
<ResponsiveContainer width="100%" height={Math.max(120, category_breakdown.length * 32)}>
|
||||
<BarChart
|
||||
data={category_breakdown}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 60, bottom: 0, left: 100 }}
|
||||
>
|
||||
<XAxis type="number" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${v}`} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="category"
|
||||
tick={{ fill: "#a1a1aa", fontSize: 12 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={formatCategory}
|
||||
width={98}
|
||||
/>
|
||||
<Tooltip content={<CategoryTooltip />} cursor={{ fill: "#27272a" }} />
|
||||
<Bar dataKey="amount" radius={[0, 3, 3, 0]} maxBarSize={22}>
|
||||
{category_breakdown.map((entry) => (
|
||||
<Cell key={entry.category} fill={CATEGORY_COLORS[entry.category] || "#6366f1"} opacity={0.85} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top merchants */}
|
||||
{top_merchants.length > 0 && (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
|
||||
<h3 className="text-sm font-medium mb-4">Top Merchants</h3>
|
||||
<div className="space-y-3">
|
||||
{top_merchants.map((m, i) => (
|
||||
<div key={m.merchant} className="flex items-center gap-3">
|
||||
<span className="text-xs text-zinc-600 w-4 tabular-nums text-right">{i + 1}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm truncate">{m.merchant || "Unknown"}</span>
|
||||
<span className="text-sm font-mono tabular-nums ml-2 flex-shrink-0">${Number(m.amount).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${(m.amount / maxMerchant) * 100}%`,
|
||||
backgroundColor: t.color,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag breakdown */}
|
||||
{tag_breakdown.length > 0 && (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
|
||||
<h3 className="text-sm font-medium mb-3">By Tag</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tag_breakdown.map((tag) => (
|
||||
<div
|
||||
key={tag.tag_id}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-zinc-800 bg-zinc-800/50"
|
||||
>
|
||||
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: tag.color }} />
|
||||
<span className="text-sm font-medium">{tag.name}</span>
|
||||
<span className="text-xs text-zinc-500">{tag.count} txns</span>
|
||||
<span className="text-sm font-mono tabular-nums text-zinc-300">${Number(tag.amount).toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participant splits */}
|
||||
{participant_splits.length > 0 && (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Participant Splits</h3>
|
||||
<Link href="/shared" className="text-xs text-zinc-500 hover:text-zinc-300">
|
||||
View in Shared →
|
||||
</Link>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
{["Person", "Share of this trip"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" ? "text-left" : "text-right"}`}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{participant_splits.map((p) => (
|
||||
<tr key={p.participant_id} className="border-b border-zinc-800/50 last:border-0">
|
||||
<td className="px-5 py-3 font-medium">{p.name}</td>
|
||||
<td className="px-5 py-3 text-right tabular-nums font-mono">${Number(p.owed).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Settled/unsettled was reported from transaction_splits.settled,
|
||||
which nothing sets — so every trip showed 100% unsettled forever,
|
||||
including ones already paid in full. Settlement is tracked across
|
||||
the whole relationship, not per trip: payments carry no trip
|
||||
attribution, so a per-trip figure cannot be computed. */}
|
||||
<p className="px-5 py-2.5 text-xs text-zinc-500 border-t border-zinc-800">
|
||||
Settlement is tracked across all shared expenses, not per trip —
|
||||
see <Link href="/shared" className="text-zinc-400 hover:text-zinc-200 underline">Shared</Link> for
|
||||
what is actually owed.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{category_breakdown.length === 0 && daily_spend.length === 0 && (
|
||||
<div className="text-center py-12 text-zinc-600">
|
||||
<p className="text-sm">No transactions assigned to this trip yet.</p>
|
||||
<Link href="/transactions" className="text-indigo-400 hover:text-indigo-300 text-sm mt-1 inline-block">
|
||||
Go to Transactions to assign some →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "transactions" && (
|
||||
<div>
|
||||
{!txData?.data.length ? (
|
||||
<div className="text-center py-12 text-zinc-600">
|
||||
<p className="text-sm">No transactions assigned to this trip yet.</p>
|
||||
<Link href="/transactions" className="text-indigo-400 hover:text-indigo-300 text-sm mt-1 inline-block">
|
||||
Go to Transactions to assign some →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900/60">
|
||||
{["Date", "Description", "Merchant", "Category", "Amount"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className={`px-4 py-2.5 text-xs text-zinc-500 font-medium ${h === "Amount" ? "text-right" : "text-left"}`}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{txData.data.map((tx) => (
|
||||
<tr key={tx.id} className="border-b border-zinc-800/40 last:border-0 hover:bg-zinc-900/40 transition-colors">
|
||||
<td className="px-4 py-2.5 text-xs text-zinc-400 whitespace-nowrap">
|
||||
{new Date(tx.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 max-w-xs truncate text-zinc-300">{tx.description}</td>
|
||||
<td className="px-4 py-2.5 text-zinc-400 truncate">{tx.effective_merchant || "—"}</td>
|
||||
<td className="px-4 py-2.5 text-zinc-500 text-xs">{formatCategory(tx.effective_category)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums font-mono text-red-400">
|
||||
${Number(tx.amount).toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editModal && trip && (
|
||||
<CreateTripModal trip={trip} onClose={() => setEditModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTrips, useDeleteTrip } from "@/lib/hooks";
|
||||
import type { TripRow } from "@/lib/hooks";
|
||||
import { CreateTripModal } from "@/components/create-trip-modal";
|
||||
|
||||
function fmtDate(d: string | null) {
|
||||
if (!d) return null;
|
||||
return new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
function TripCard({ trip, onEdit, onDelete }: { trip: TripRow; onEdit: () => void; onDelete: () => void }) {
|
||||
const dateRange = trip.start_date && trip.end_date
|
||||
? `${fmtDate(trip.start_date)} – ${fmtDate(trip.end_date)}`
|
||||
: trip.start_date
|
||||
? `From ${fmtDate(trip.start_date)}`
|
||||
: "No dates set";
|
||||
|
||||
return (
|
||||
<div className="relative bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden hover:border-zinc-600 transition-colors group">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 rounded-l-xl" style={{ backgroundColor: trip.color }} />
|
||||
<Link href={`/trips/${trip.id}`} className="block p-5 pl-6">
|
||||
<p className="font-semibold text-base truncate">{trip.name}</p>
|
||||
{trip.description && (
|
||||
<p className="text-xs text-zinc-500 mt-0.5 truncate">{trip.description}</p>
|
||||
)}
|
||||
<p className="text-xs text-zinc-600 mt-1">{dateRange}</p>
|
||||
<div className="mt-4 flex items-baseline gap-3">
|
||||
<span className="text-2xl font-semibold tabular-nums">${Number(trip.total_spend).toFixed(2)}</span>
|
||||
<span className="text-xs text-zinc-500">{trip.transaction_count} transactions</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="px-5 pb-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="text-xs text-zinc-400 hover:text-white px-2 py-1 rounded hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="text-xs text-red-500 hover:text-red-400 px-2 py-1 rounded hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TripsPage() {
|
||||
const { data: trips = [], isLoading } = useTrips();
|
||||
const deleteTrip = useDeleteTrip();
|
||||
const [modal, setModal] = useState<{ trip?: TripRow } | null>(null);
|
||||
|
||||
const active = trips.filter((t) => !t.archived);
|
||||
const archived = trips.filter((t) => t.archived);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-display">Trips</h2>
|
||||
<p className="text-sm text-zinc-500 mt-0.5">Group and analyse expenses by trip</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setModal({})}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
+ New Trip
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 animate-pulse">
|
||||
<div className="h-4 bg-zinc-800 rounded w-2/3 mb-3" />
|
||||
<div className="h-3 bg-zinc-800 rounded w-1/3 mb-4" />
|
||||
<div className="h-7 bg-zinc-800 rounded w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : active.length === 0 ? (
|
||||
<div className="text-center py-20 text-zinc-600">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<p className="text-lg font-medium mb-1">No trips yet</p>
|
||||
<p className="text-sm">Create a trip to group and analyse expenses — holidays, events, work travel.</p>
|
||||
<button
|
||||
onClick={() => setModal({})}
|
||||
className="mt-4 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
Create your first trip
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{active.map((trip) => (
|
||||
<TripCard
|
||||
key={trip.id}
|
||||
trip={trip}
|
||||
onEdit={() => setModal({ trip })}
|
||||
onDelete={() => {
|
||||
if (confirm(`Delete "${trip.name}"? This will unlink all transactions from this trip.`)) {
|
||||
deleteTrip.mutate(trip.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{archived.length > 0 && (
|
||||
<details className="group/archived">
|
||||
<summary className="text-sm text-zinc-500 cursor-pointer hover:text-zinc-300 list-none flex items-center gap-2 select-none">
|
||||
<svg className="w-3 h-3 transition-transform group-open/archived:rotate-90" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M7.293 4.707a1 1 0 011.414 0L14 10l-5.293 5.293a1 1 0 01-1.414-1.414L11.586 10 6.586 5a1 1 0 010-1.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Archived ({archived.length})
|
||||
</summary>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-3">
|
||||
{archived.map((trip) => (
|
||||
<div key={trip.id} className="relative bg-zinc-900/50 border border-zinc-800 rounded-xl overflow-hidden opacity-60 hover:opacity-100 transition-opacity group">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 rounded-l-xl" style={{ backgroundColor: trip.color }} />
|
||||
<Link href={`/trips/${trip.id}`} className="block p-5 pl-6">
|
||||
<p className="font-medium truncate">{trip.name}</p>
|
||||
<p className="text-xs text-zinc-600 mt-1">${Number(trip.total_spend).toFixed(2)} · {trip.transaction_count} transactions</p>
|
||||
</Link>
|
||||
<div className="px-5 pb-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => setModal({ trip })}
|
||||
className="text-xs text-zinc-400 hover:text-white px-2 py-1 rounded hover:bg-zinc-800"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{modal !== null && (
|
||||
<CreateTripModal trip={modal.trip} onClose={() => setModal(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCreateTransaction, useParticipants, useTags } 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 { data: allTags = [] } = useTags();
|
||||
|
||||
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 ?? "");
|
||||
// Cash is excluded from reconciliation — it will never appear on a statement.
|
||||
const [paymentMethod, setPaymentMethod] = useState("");
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
|
||||
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));
|
||||
}
|
||||
|
||||
function toggleTag(id: number) {
|
||||
setSelectedTagIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const result = await createTransaction.mutateAsync({
|
||||
date,
|
||||
description,
|
||||
amount: parseFloat(amount),
|
||||
transaction_type: type,
|
||||
merchant_normalized: merchant || undefined,
|
||||
category: category || undefined,
|
||||
payment_method: paymentMethod || undefined,
|
||||
splits: splits.length ? splits : undefined,
|
||||
});
|
||||
if (selectedTagIds.length && result?.id) {
|
||||
await Promise.all(
|
||||
selectedTagIds.map((tagId) =>
|
||||
fetch(`/api/transactions/${result.id}/tags`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tag_id: tagId }),
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
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">Paid by</label>
|
||||
<select
|
||||
value={paymentMethod}
|
||||
onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">— unknown —</option>
|
||||
<option value="card">Card</option>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="bank_transfer">Bank transfer</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
{paymentMethod === "cash" && (
|
||||
<p className="text-[11px] text-zinc-500 mt-1">
|
||||
Cash won't be offered for reconciliation — it never appears on a statement.
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Tags */}
|
||||
{allTags.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Tags (optional)</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{allTags.map((tag) => {
|
||||
const selected = selectedTagIds.includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs border transition-colors ${
|
||||
selected
|
||||
? "border-transparent text-white"
|
||||
: "border-zinc-600 text-zinc-400 hover:border-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
style={selected ? { backgroundColor: tag.color + "cc", borderColor: tag.color } : {}}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCreateTrip, useUpdateTrip } from "@/lib/hooks";
|
||||
import type { TripRow } from "@/lib/hooks";
|
||||
|
||||
export function CreateTripModal({
|
||||
trip,
|
||||
onClose,
|
||||
}: {
|
||||
trip?: TripRow;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const isEdit = !!trip;
|
||||
const [name, setName] = useState(trip?.name ?? "");
|
||||
const [description, setDescription] = useState(trip?.description ?? "");
|
||||
const [startDate, setStartDate] = useState(trip?.start_date?.slice(0, 10) ?? "");
|
||||
const [endDate, setEndDate] = useState(trip?.end_date?.slice(0, 10) ?? "");
|
||||
const [color, setColor] = useState(trip?.color ?? "#6366f1");
|
||||
const [archived, setArchived] = useState(trip?.archived ?? false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const createTrip = useCreateTrip();
|
||||
const updateTrip = useUpdateTrip();
|
||||
const isPending = createTrip.isPending || updateTrip.isPending;
|
||||
|
||||
async function handleSave() {
|
||||
setError("");
|
||||
if (!name.trim()) { setError("Name is required"); return; }
|
||||
try {
|
||||
if (isEdit) {
|
||||
await updateTrip.mutateAsync({
|
||||
id: trip!.id,
|
||||
name: name.trim(),
|
||||
description: description || null,
|
||||
start_date: startDate || null,
|
||||
end_date: endDate || null,
|
||||
color,
|
||||
archived,
|
||||
});
|
||||
} else {
|
||||
await createTrip.mutateAsync({
|
||||
name: name.trim(),
|
||||
description: description || null,
|
||||
start_date: startDate || null,
|
||||
end_date: endDate || null,
|
||||
color,
|
||||
archived,
|
||||
});
|
||||
}
|
||||
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-md mx-4 shadow-2xl flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-6 pt-5 pb-4 border-b border-zinc-800">
|
||||
<h3 className="font-semibold text-sm text-zinc-300">{isEdit ? "Edit Trip" : "New Trip"}</h3>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Name *</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Europe 2026"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Optional notes about this trip"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm resize-none focus:outline-none focus:border-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="h-9 w-16 rounded border border-zinc-700 bg-zinc-800 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
{isEdit && (
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-400 mt-4 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={archived}
|
||||
onChange={(e) => setArchived(e.target.checked)}
|
||||
className="accent-indigo-500"
|
||||
/>
|
||||
Archived
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-zinc-800 flex gap-2 items-center">
|
||||
{error && <p className="text-red-400 text-xs mr-auto">{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={isPending}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{isPending ? "Saving…" : isEdit ? "Save Changes" : "Create Trip"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
import { useImportCSV } from "@/lib/hooks";
|
||||
import {
|
||||
parseCSVRows, detectHasHeaders, getColumnLabels, getDataRows, applyMapping,
|
||||
saveBankPreset, loadBankPresets,
|
||||
type DateFormat, type ColumnMapping, type ParsedTransaction, type BankPreset,
|
||||
} from "@/lib/csv-parser";
|
||||
|
||||
const DATE_FORMATS: DateFormat[] = ["DD/MM/YYYY", "YYYY-MM-DD", "MM/DD/YYYY", "M/D/YYYY"];
|
||||
const TX_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||
|
||||
type Step = "upload" | "map" | "review" | "done";
|
||||
|
||||
function ColSelect({
|
||||
label, value, onChange, options, required,
|
||||
}: {
|
||||
label: string; value: string; onChange: (v: string) => void;
|
||||
options: string[]; required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">{label}</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
{!required && <option value="">— none —</option>}
|
||||
{options.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CsvImportModal({ onClose }: { onClose: () => void }) {
|
||||
const importCSV = useImportCSV();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [step, setStep] = useState<Step>("upload");
|
||||
const [rawRows, setRawRows] = useState<string[][]>([]);
|
||||
const [hasHeaders, setHasHeaders] = useState(false);
|
||||
const [columnLabels, setColumnLabels] = useState<string[]>([]);
|
||||
const [dataRows, setDataRows] = useState<string[][]>([]);
|
||||
const [bankName, setBankName] = useState("");
|
||||
const [dateFormat, setDateFormat] = useState<DateFormat>("DD/MM/YYYY");
|
||||
const [mapping, setMapping] = useState<ColumnMapping>({
|
||||
dateCol: "", descriptionCol: "", amountMode: "single", amountCol: "",
|
||||
});
|
||||
const [savePreset, setSavePreset] = useState(false);
|
||||
const [editedRows, setEditedRows] = useState<ParsedTransaction[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [presets, setPresets] = useState<BankPreset[]>([]);
|
||||
const [insertedCount, setInsertedCount] = useState(0);
|
||||
|
||||
useEffect(() => { setPresets(loadBankPresets()); }, []);
|
||||
|
||||
function handleFile(file: File) {
|
||||
setError("");
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
const rows = parseCSVRows(text);
|
||||
if (rows.length === 0) { setError("No data found in file"); return; }
|
||||
setRawRows(rows);
|
||||
const headers = detectHasHeaders(rows, dateFormat);
|
||||
setHasHeaders(headers);
|
||||
const labels = getColumnLabels(rows, headers);
|
||||
setColumnLabels(labels);
|
||||
setDataRows(getDataRows(rows, headers));
|
||||
// auto-set first columns as defaults
|
||||
setMapping((m) => ({
|
||||
...m,
|
||||
dateCol: labels[0] ?? "",
|
||||
descriptionCol: labels[1] ?? "",
|
||||
amountCol: labels[2] ?? "",
|
||||
}));
|
||||
setStep("map");
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function applyPreset(preset: BankPreset) {
|
||||
setBankName(preset.bankName);
|
||||
setDateFormat(preset.dateFormat);
|
||||
setMapping(preset.mapping);
|
||||
}
|
||||
|
||||
function refreshColumns() {
|
||||
if (!rawRows.length) return;
|
||||
const headers = detectHasHeaders(rawRows, dateFormat);
|
||||
setHasHeaders(headers);
|
||||
const labels = getColumnLabels(rawRows, headers);
|
||||
setColumnLabels(labels);
|
||||
setDataRows(getDataRows(rawRows, headers));
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
setError("");
|
||||
if (!bankName.trim()) { setError("Bank name is required"); return; }
|
||||
if (!mapping.dateCol) { setError("Date column is required"); return; }
|
||||
if (!mapping.descriptionCol) { setError("Description column is required"); return; }
|
||||
if (mapping.amountMode === "single" && !mapping.amountCol) { setError("Amount column is required"); return; }
|
||||
if (mapping.amountMode === "debit_credit" && !mapping.debitCol && !mapping.creditCol) {
|
||||
setError("At least one of debit/credit columns is required"); return;
|
||||
}
|
||||
const parsed = applyMapping(dataRows, columnLabels, mapping, dateFormat);
|
||||
if (parsed.length === 0) { setError("No valid transactions could be parsed — check your column mapping and date format"); return; }
|
||||
if (savePreset) {
|
||||
saveBankPreset({ bankName: bankName.trim(), mapping, dateFormat });
|
||||
}
|
||||
setEditedRows(parsed);
|
||||
setStep("review");
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
setError("");
|
||||
const valid = editedRows.filter((r) => r.date && r.amount > 0 && r.description);
|
||||
if (!valid.length) { setError("No valid rows to import"); return; }
|
||||
try {
|
||||
const result = await importCSV.mutateAsync({ bank_name: bankName, transactions: valid });
|
||||
setInsertedCount(result.inserted);
|
||||
setStep("done");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Import failed");
|
||||
}
|
||||
}
|
||||
|
||||
function updateRow(i: number, patch: Partial<ParsedTransaction>) {
|
||||
setEditedRows((rows) => rows.map((r, idx) => idx === i ? { ...r, ...patch } : r));
|
||||
}
|
||||
|
||||
function deleteRow(i: number) {
|
||||
setEditedRows((rows) => rows.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
const modalWidth = step === "review" ? "max-w-5xl" : step === "map" ? "max-w-xl" : "max-w-md";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||
<div
|
||||
className={`bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-full ${modalWidth} flex flex-col max-h-[90vh]`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 flex-shrink-0">
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm text-zinc-200">Import CSV</h3>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{(["upload", "map", "review", "done"] as Step[]).map((s, i) => (
|
||||
<span
|
||||
key={s}
|
||||
className={`text-xs ${step === s ? "text-indigo-400 font-medium" : "text-zinc-600"}`}
|
||||
>
|
||||
{i + 1}. {s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300 text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1 px-6 py-5">
|
||||
|
||||
{/* Step 1: Upload */}
|
||||
{step === "upload" && (
|
||||
<div className="space-y-4">
|
||||
{presets.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Load saved preset</label>
|
||||
<select
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
defaultValue=""
|
||||
onChange={(e) => {
|
||||
const p = presets.find((x) => x.bankName === e.target.value);
|
||||
if (p) applyPreset(p);
|
||||
}}
|
||||
>
|
||||
<option value="">— select preset —</option>
|
||||
{presets.map((p) => <option key={p.bankName} value={p.bankName}>{p.bankName}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="w-full border-2 border-dashed border-zinc-700 hover:border-indigo-500 rounded-xl py-12 text-center transition-colors"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files?.[0]; if (f) handleFile(f); }}
|
||||
>
|
||||
<p className="text-zinc-400 text-sm">Drop a CSV file here, or click to browse</p>
|
||||
<p className="text-zinc-600 text-xs mt-1">Westpac, ANZ, CBA, NAB and others</p>
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Map */}
|
||||
{step === "map" && (
|
||||
<div className="space-y-4">
|
||||
{/* Raw preview */}
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-2">First 3 rows from file:</p>
|
||||
<div className="overflow-x-auto rounded border border-zinc-800">
|
||||
<table className="text-xs text-zinc-400 w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
{columnLabels.map((h) => (
|
||||
<th key={h} className="px-2 py-1.5 text-left font-medium text-zinc-300 whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dataRows.slice(0, 3).map((row, i) => (
|
||||
<tr key={i} className="border-b border-zinc-800/50">
|
||||
{columnLabels.map((_, ci) => (
|
||||
<td key={ci} className="px-2 py-1 truncate max-w-[160px]">{row[ci] ?? ""}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Bank Name</label>
|
||||
<input
|
||||
value={bankName}
|
||||
onChange={(e) => setBankName(e.target.value)}
|
||||
placeholder="e.g. Westpac"
|
||||
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">Date Format</label>
|
||||
<select
|
||||
value={dateFormat}
|
||||
onChange={(e) => { setDateFormat(e.target.value as DateFormat); refreshColumns(); }}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
{DATE_FORMATS.map((f) => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ColSelect label="Date Column *" value={mapping.dateCol} onChange={(v) => setMapping((m) => ({ ...m, dateCol: v }))} options={columnLabels} required />
|
||||
<ColSelect label="Description Column *" value={mapping.descriptionCol} onChange={(v) => setMapping((m) => ({ ...m, descriptionCol: v }))} options={columnLabels} required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-2">Amount</label>
|
||||
<div className="flex gap-4 mb-2">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="radio" name="amtmode" value="single" checked={mapping.amountMode === "single"} onChange={() => setMapping((m) => ({ ...m, amountMode: "single" }))} className="accent-indigo-500" />
|
||||
Single signed column
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="radio" name="amtmode" value="debit_credit" checked={mapping.amountMode === "debit_credit"} onChange={() => setMapping((m) => ({ ...m, amountMode: "debit_credit" }))} className="accent-indigo-500" />
|
||||
Separate debit / credit columns
|
||||
</label>
|
||||
</div>
|
||||
{mapping.amountMode === "single" ? (
|
||||
<ColSelect label="Amount Column *" value={mapping.amountCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, amountCol: v }))} options={columnLabels} required />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ColSelect label="Debit Column" value={mapping.debitCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, debitCol: v }))} options={columnLabels} />
|
||||
<ColSelect label="Credit Column" value={mapping.creditCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, creditCol: v }))} options={columnLabels} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ColSelect label="Merchant Column (optional)" value={mapping.merchantCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, merchantCol: v || undefined }))} options={columnLabels} />
|
||||
<ColSelect label="Category Column (optional)" value={mapping.categoryCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, categoryCol: v || undefined }))} options={columnLabels} />
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer text-zinc-400">
|
||||
<input type="checkbox" checked={savePreset} onChange={(e) => setSavePreset(e.target.checked)} className="accent-indigo-500" />
|
||||
Save as preset for {bankName || "this bank"}
|
||||
</label>
|
||||
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Review */}
|
||||
{step === "review" && (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-3">
|
||||
{editedRows.length} transactions parsed. Edit or remove rows before importing.
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded border border-zinc-800">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="border-b border-zinc-800">
|
||||
<tr>
|
||||
{["Date", "Description", "Amount", "Type", "Merchant", "Category", ""].map((h) => (
|
||||
<th key={h} className="px-2 py-2 text-left text-zinc-400 font-medium whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{editedRows.map((row, i) => (
|
||||
<tr key={i} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||||
<td className="px-2 py-1">
|
||||
<input type="date" value={row.date} onChange={(e) => updateRow(i, { date: e.target.value })}
|
||||
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-28 focus:outline-none focus:border-indigo-500" />
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<input value={row.description} onChange={(e) => updateRow(i, { description: e.target.value })}
|
||||
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-48 focus:outline-none focus:border-indigo-500" />
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<input type="number" step="0.01" value={row.amount} onChange={(e) => updateRow(i, { amount: parseFloat(e.target.value) || 0 })}
|
||||
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-20 focus:outline-none focus:border-indigo-500" />
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<select value={row.transaction_type} onChange={(e) => updateRow(i, { transaction_type: e.target.value })}
|
||||
className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5 text-xs">
|
||||
{TX_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<input value={row.merchant_name ?? ""} onChange={(e) => updateRow(i, { merchant_name: e.target.value || undefined })}
|
||||
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-28 focus:outline-none focus:border-indigo-500" />
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<select value={row.category ?? ""} onChange={(e) => updateRow(i, { category: e.target.value || undefined })}
|
||||
className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5 text-xs">
|
||||
<option value="">—</option>
|
||||
{CATEGORIES.map((c) => <option key={c} value={c}>{formatCategory(c)}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<button onClick={() => deleteRow(i)} className="text-zinc-600 hover:text-red-400 text-base leading-none">×</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{error && <p className="text-red-400 text-xs mt-2">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Done */}
|
||||
{step === "done" && (
|
||||
<div className="text-center py-6 space-y-3">
|
||||
<div className="text-4xl">✓</div>
|
||||
<p className="text-zinc-200 font-medium">Imported {insertedCount} transactions</p>
|
||||
<p className="text-zinc-500 text-sm">Tagged with <span className="text-indigo-400">csv-import</span></p>
|
||||
<div className="flex gap-2 justify-center pt-2">
|
||||
<a href="/transactions" className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||
View Transactions
|
||||
</a>
|
||||
<a href="/reconcile" className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm">
|
||||
Reconcile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{step !== "done" && (
|
||||
<div className="flex gap-2 px-6 py-4 border-t border-zinc-800 flex-shrink-0">
|
||||
{step === "map" && (
|
||||
<button onClick={() => setStep("upload")} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
{step === "review" && (
|
||||
<button onClick={() => setStep("map")} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button onClick={onClose} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
{step === "map" && (
|
||||
<button onClick={handleNext} className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium">
|
||||
Next →
|
||||
</button>
|
||||
)}
|
||||
{step === "review" && (
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={importCSV.isPending || editedRows.length === 0}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{importCSV.isPending ? "Importing..." : `Import ${editedRows.length} transactions`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useUpdateTransaction,
|
||||
useTags,
|
||||
useAddTransactionTag,
|
||||
useRemoveTransactionTag,
|
||||
useTransactionSplits,
|
||||
useTrips,
|
||||
} 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();
|
||||
const { data: trips = [] } = useTrips();
|
||||
|
||||
// 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));
|
||||
|
||||
const [tripId, setTripId] = useState<number | null>(transaction.trip_id ?? null);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
if (tripId !== (transaction.trip_id ?? null))
|
||||
patch.trip_id = tripId;
|
||||
|
||||
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>
|
||||
|
||||
{/* Trip */}
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Trip</label>
|
||||
<select
|
||||
value={tripId ?? ""}
|
||||
onChange={(e) => setTripId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">— No Trip —</option>
|
||||
{trips.filter((t) => !t.archived).map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRuleMatches, useApplyRuleToSelection, type RuleMatchChange } from "@/lib/hooks";
|
||||
import { formatCategory } from "@/lib/categories";
|
||||
|
||||
/**
|
||||
* Preview what a rule would do, then apply it to a chosen subset.
|
||||
*
|
||||
* The apply path takes explicit transaction ids rather than re-running the
|
||||
* conditions, so what you tick is exactly what changes — a rule whose
|
||||
* conditions are too broad cannot quietly reach further than the preview showed.
|
||||
*/
|
||||
|
||||
function fmt(amount: number, currency = "AUD") {
|
||||
return new Intl.NumberFormat("en-AU", { style: "currency", currency }).format(amount);
|
||||
}
|
||||
|
||||
function describe(c: RuleMatchChange) {
|
||||
if (c.field === "category") return `${formatCategory(c.from)} → ${formatCategory(c.to)}`;
|
||||
if (c.field === "merchant") return `${c.from ?? "—"} → ${c.to}`;
|
||||
if (c.field === "tags") return `+ tags`;
|
||||
return c.from ? `split ${c.from} → ${c.to}` : `split → ${c.to}`;
|
||||
}
|
||||
|
||||
export function RulePreviewModal({
|
||||
ruleId,
|
||||
ruleName,
|
||||
onClose,
|
||||
}: {
|
||||
ruleId: number;
|
||||
ruleName: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { data, isLoading, error } = useRuleMatches(ruleId);
|
||||
const apply = useApplyRuleToSelection();
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
// Default to everything the rule would change — the common case is "yes, all
|
||||
// of these", with deselection as the exception.
|
||||
useEffect(() => {
|
||||
if (data?.transactions) setSelected(new Set(data.transactions.map((t) => t.id)));
|
||||
}, [data]);
|
||||
|
||||
const rows = data?.transactions ?? [];
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
const totalValue = useMemo(
|
||||
() => rows.filter((r) => selected.has(r.id))
|
||||
.reduce((a, r) => a + Number(r.amount_aud ?? r.amount), 0),
|
||||
[rows, selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-lg w-full max-w-4xl max-h-[85vh] flex flex-col">
|
||||
<div className="px-5 py-4 border-b border-zinc-800">
|
||||
<h2 className="text-lg font-medium">Preview: {ruleName}</h2>
|
||||
{data && (
|
||||
<p className="text-xs text-zinc-500 mt-1">
|
||||
{data.total_matched} matched · <span className="text-amber-400">{data.would_change} would change</span>
|
||||
{data.already_correct > 0 && ` · ${data.already_correct} already correct`}
|
||||
</p>
|
||||
)}
|
||||
{data?.rule.matches_everything && (
|
||||
<p className="text-xs text-red-400 mt-1">
|
||||
This rule has no conditions — it matches every transaction.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto px-5 py-3">
|
||||
{isLoading && <p className="text-sm text-zinc-500">Checking…</p>}
|
||||
{error && <p className="text-sm text-red-400">Failed to load matches.</p>}
|
||||
{data && rows.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
Nothing would change{data.already_correct > 0 && ` — all ${data.already_correct} matching transactions already have these values`}.
|
||||
</p>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-zinc-900">
|
||||
<tr className="border-b border-zinc-800 text-zinc-500">
|
||||
<th className="p-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={(e) =>
|
||||
setSelected(e.target.checked ? new Set(rows.map((r) => r.id)) : new Set())
|
||||
}
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left p-2">Date</th>
|
||||
<th className="text-left p-2">Description</th>
|
||||
<th className="text-right p-2">Amount</th>
|
||||
<th className="text-left p-2">Would change</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-b border-zinc-800/40 hover:bg-zinc-800/30">
|
||||
<td className="p-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(r.id)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(selected);
|
||||
if (e.target.checked) next.add(r.id); else next.delete(r.id);
|
||||
setSelected(next);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2 text-zinc-500 whitespace-nowrap">{String(r.transaction_date).slice(0, 10)}</td>
|
||||
<td className="p-2 text-zinc-300 max-w-[280px] truncate" title={r.description}>
|
||||
{r.effective_merchant || r.description}
|
||||
<span className="text-zinc-600 ml-2">{r.bank_name}</span>
|
||||
</td>
|
||||
<td className="p-2 text-right tabular-nums text-zinc-400">
|
||||
{fmt(Number(r.amount_aud ?? r.amount))}
|
||||
</td>
|
||||
<td className="p-2 text-amber-400">
|
||||
{r.changes.map((c, i) => <div key={i}>{describe(c)}</div>)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{data?.truncated && (
|
||||
<p className="text-xs text-zinc-500 mt-2">Showing the first {rows.length} — narrow the rule to see the rest.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-zinc-800 flex items-center justify-between">
|
||||
<span className="text-xs text-zinc-500">
|
||||
{done ?? `${selected.size} selected · ${fmt(totalValue)}`}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onClose} className="px-3 py-1.5 text-sm text-zinc-400 hover:text-white">
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
disabled={selected.size === 0 || apply.isPending}
|
||||
onClick={() =>
|
||||
apply.mutate(
|
||||
{ ruleId, ids: Array.from(selected) },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setDone(`Applied to ${selected.size} transaction${selected.size !== 1 ? "s" : ""}`);
|
||||
setSelected(new Set());
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
className="px-3 py-1.5 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 rounded text-sm"
|
||||
>
|
||||
{apply.isPending ? "Applying…" : `Apply to ${selected.size}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useRuleRunDetail } from "@/lib/hooks";
|
||||
import { formatCategory } from "@/lib/categories";
|
||||
|
||||
/**
|
||||
* The expanded body of an Apply History row: which transactions the run touched
|
||||
* and what it changed on each, so Revert is an informed decision rather than a
|
||||
* guess from a count.
|
||||
*/
|
||||
|
||||
function fmt(n: number) {
|
||||
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD" }).format(n);
|
||||
}
|
||||
|
||||
function describe(c: { field: string; from: string | null; to: string | null }) {
|
||||
if (c.field === "category") return `${formatCategory(c.from)} → ${formatCategory(c.to)}`;
|
||||
if (c.field === "merchant") return `${c.from ?? "—"} → ${c.to ?? "—"}`;
|
||||
if (c.field === "tags") return `+ tag ${c.to}`;
|
||||
return `split ${c.from ?? "none"} → ${c.to ?? "none"}`;
|
||||
}
|
||||
|
||||
export function RuleRunDetail({ runId }: { runId: number }) {
|
||||
const { data, isLoading, error } = useRuleRunDetail(runId);
|
||||
|
||||
if (isLoading) return <p className="px-4 py-3 text-xs text-zinc-500">Loading…</p>;
|
||||
if (error) return <p className="px-4 py-3 text-xs text-red-400">Failed to load detail.</p>;
|
||||
if (!data) return null;
|
||||
|
||||
const { transactions, still_changed, run } = data;
|
||||
|
||||
return (
|
||||
<div className="border-t border-zinc-800 bg-zinc-950/60">
|
||||
<div className="px-4 py-2 text-xs text-zinc-500 flex gap-4 flex-wrap">
|
||||
<span>{transactions.length} transactions</span>
|
||||
{run.reverted_at ? (
|
||||
<span className="text-zinc-500">already reverted — values are back to their originals</span>
|
||||
) : (
|
||||
<span className="text-amber-400">{still_changed} still showing this run's changes</span>
|
||||
)}
|
||||
{still_changed < transactions.length && !run.reverted_at && (
|
||||
<span className="text-zinc-600">
|
||||
{transactions.length - still_changed} since changed by something else — reverting restores the pre-run value
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-72 overflow-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-zinc-950">
|
||||
<tr className="text-zinc-600 border-b border-zinc-800">
|
||||
<th className="text-left px-4 py-1.5">Date</th>
|
||||
<th className="text-left px-3 py-1.5">Transaction</th>
|
||||
<th className="text-right px-3 py-1.5">Amount</th>
|
||||
<th className="text-left px-3 py-1.5">Changed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transactions.map((t) => (
|
||||
<tr key={t.id} className="border-b border-zinc-800/40">
|
||||
<td className="px-4 py-1.5 text-zinc-500 whitespace-nowrap">
|
||||
{String(t.transaction_date).slice(0, 10)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-zinc-300 max-w-[300px] truncate" title={t.description}>
|
||||
{t.merchant || t.description}
|
||||
<span className="text-zinc-600 ml-2">{t.bank_name}</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-zinc-400">
|
||||
{fmt(Number(t.amount_aud ?? t.amount))}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{t.changes.length === 0 ? (
|
||||
<span className="text-zinc-600">no change</span>
|
||||
) : (
|
||||
t.changes.map((c, i) => (
|
||||
<div key={i} className="text-amber-400">{describe(c)}</div>
|
||||
))
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,19 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/transactions", label: "Transactions", icon: "receipt" },
|
||||
{ href: "/statements", label: "Statements", icon: "file-text" },
|
||||
{ href: "/trips", label: "Trips", icon: "map-pin" },
|
||||
{ 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" },
|
||||
{ href: "/reconcile", label: "Reconcile", icon: "git-merge" },
|
||||
];
|
||||
|
||||
const ICONS: Record<string, React.ReactNode> = {
|
||||
@@ -23,6 +28,12 @@ const ICONS: Record<string, React.ReactNode> = {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
"map-pin": (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
users: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
@@ -44,15 +55,53 @@ 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>
|
||||
),
|
||||
"git-merge": (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 3v12M18 9a3 3 0 100-6 3 3 0 000 6zm0 0v12M6 15a3 3 0 100 6 3 3 0 000-6zm0 0c0-4 3-6 6-6h6" />
|
||||
</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() {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<aside className="w-56 bg-zinc-900 border-r border-zinc-800 flex flex-col min-h-screen">
|
||||
<div className="p-4 border-b border-zinc-800">
|
||||
<h1 className="text-lg font-semibold text-white">Finance</h1>
|
||||
// Close drawer on route change
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Prevent body scroll when drawer is open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const navContent = (
|
||||
<>
|
||||
<div className="p-4 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h1 className="text-xl text-zinc-100 font-display tracking-tight">Finance<span className="text-indigo-400">.</span></h1>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="md:hidden p-1 rounded text-zinc-400 hover:text-white hover:bg-zinc-800"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 p-2">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
@@ -61,10 +110,10 @@ export function Sidebar() {
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm mb-0.5 transition-colors ${
|
||||
className={`relative flex items-center gap-3 px-3 py-2 rounded-md text-sm mb-0.5 transition-colors ${
|
||||
active
|
||||
? "bg-zinc-800 text-white"
|
||||
: "text-zinc-400 hover:text-white hover:bg-zinc-800/50"
|
||||
? "bg-zinc-800 text-zinc-50 before:absolute before:left-0 before:top-1.5 before:bottom-1.5 before:w-0.5 before:rounded-full before:bg-indigo-400"
|
||||
: "text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/50"
|
||||
}`}
|
||||
>
|
||||
{ICONS[item.icon]}
|
||||
@@ -73,6 +122,46 @@ export function Sidebar() {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile header bar */}
|
||||
<div className="md:hidden fixed top-0 left-0 right-0 z-40 bg-zinc-900 border-b border-zinc-800 flex items-center px-4 h-14">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="p-1.5 -ml-1.5 rounded text-zinc-400 hover:text-white hover:bg-zinc-800"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="ml-3 text-lg text-zinc-100 font-display tracking-tight">Finance<span className="text-indigo-400">.</span></span>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer overlay */}
|
||||
{open && (
|
||||
<div
|
||||
className="md:hidden fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<aside
|
||||
className={`md:hidden fixed top-0 left-0 z-50 w-64 h-full bg-zinc-900 border-r border-zinc-800 flex flex-col transform transition-transform duration-200 ease-in-out ${
|
||||
open ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
{navContent}
|
||||
</aside>
|
||||
|
||||
{/* Desktop sidebar — unchanged */}
|
||||
<aside className="hidden md:flex w-56 bg-zinc-900 border-r border-zinc-800 flex-col min-h-screen">
|
||||
{navContent}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction, useCreateRule, useCurrentUser } 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: currentUser } = useCurrentUser();
|
||||
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 || !currentUser) return;
|
||||
if (isBulk) {
|
||||
setSplitsState([{ participant_id: currentUser.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 {
|
||||
setSplitsState([{ participant_id: currentUser.id, share_percent: 100 }]);
|
||||
}
|
||||
}, [existingSplits, participants, isBulk, currentUser]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Shared SQL fragments for analytics queries, so spend/income semantics stay
|
||||
// identical across routes.
|
||||
//
|
||||
// Two rules every analytics query must follow:
|
||||
//
|
||||
// 1. Join `statements` with LEFT JOIN and scope on COALESCE(t.owner_id, s.owner_id).
|
||||
// An INNER JOIN silently drops every manual/CSV transaction (statement_id IS
|
||||
// NULL) — which is most of the reconciliation and cash-spend data.
|
||||
// 2. Exclude `transfers` and `investment` from spend. Once bank statements are
|
||||
// imported, a credit-card payment appears twice: once as a debit leaving the
|
||||
// bank account and again as the underlying purchases on the card statement.
|
||||
// Categorising the money movement as `transfers` and excluding it here is what
|
||||
// stops the double count. Investments are a balance-sheet move, not spend.
|
||||
|
||||
/** Owner scoping that works for both statement-linked and manual transactions. */
|
||||
export const OWNER_SCOPE = `COALESCE(t.owner_id, s.owner_id)`;
|
||||
|
||||
/** Join clause to pair with OWNER_SCOPE. */
|
||||
export const STATEMENTS_JOIN = `LEFT JOIN statements s ON s.id = t.statement_id`;
|
||||
|
||||
/** Transaction types that represent money going out. */
|
||||
export const SPEND_TYPES = `('debit', 'fee', 'interest')`;
|
||||
|
||||
/**
|
||||
* Rows that count towards spend.
|
||||
*
|
||||
* The `interest_amount IS NOT NULL` arm is for loan repayments: when a lender
|
||||
* itemises principal and interest on a single repayment row, that row is usually
|
||||
* typed 'payment' (money reducing the loan balance) and would otherwise be
|
||||
* skipped — but its interest portion is real spend.
|
||||
*/
|
||||
export const SPEND_ROWS = `(t.transaction_type IN ('debit', 'fee', 'interest') OR t.interest_amount IS NOT NULL)`;
|
||||
|
||||
/**
|
||||
* The amount of a row that counts as spend, before split adjustment.
|
||||
*
|
||||
* For an itemised loan repayment only the interest portion is an expense; the
|
||||
* principal builds equity and is a balance-sheet move, not spending.
|
||||
*/
|
||||
export const SPEND_BASE = `CASE WHEN t.interest_amount IS NOT NULL THEN t.interest_amount ELSE COALESCE(t.amount_aud, t.amount) END`;
|
||||
|
||||
/** Effective category, honouring overrides. Never NULL. */
|
||||
export const EFFECTIVE_CATEGORY = `COALESCE(o.category_override, t.category, 'other')`;
|
||||
|
||||
/**
|
||||
* My percentage share of a transaction, 0-100.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. An explicit `transaction_splits` row for me.
|
||||
* 2. The `my_share_percent` override.
|
||||
* 3. Whatever is left after everyone else's shares.
|
||||
*
|
||||
* Step 3 is the one that matters. Assuming 100% when no split row exists for me
|
||||
* is wrong whenever a transaction is allocated entirely to someone else — I paid,
|
||||
* they owe all of it, and there is no row for me to find. Those rows would
|
||||
* otherwise land in my spend at full value.
|
||||
*
|
||||
* Requires `transaction_splits ts` joined on `ts.participant_id = <participant>`
|
||||
* and `transaction_overrides o` joined on the transaction.
|
||||
*/
|
||||
export const myShare = (participant = "$1") => `COALESCE(
|
||||
ts.share_percent,
|
||||
o.my_share_percent,
|
||||
100 - COALESCE((
|
||||
SELECT SUM(x.share_percent) FROM transaction_splits x
|
||||
WHERE x.transaction_id = t.id AND x.participant_id <> ${participant}
|
||||
), 0)
|
||||
)`;
|
||||
|
||||
/** `base` scaled to my share. Use for every per-user spend total. */
|
||||
export const mySplitOf = (base: string, participant = "$1") =>
|
||||
`((${base}) * ${myShare(participant)} / 100)`;
|
||||
|
||||
/**
|
||||
* Predicate excluding categories that are not spend.
|
||||
* The COALESCE matters: a bare `category NOT IN (...)` evaluates to NULL for
|
||||
* uncategorised rows, which silently drops them from spend totals.
|
||||
*/
|
||||
export const EXCLUDE_NON_SPEND = `${EFFECTIVE_CATEGORY} NOT IN ('transfers', 'investment', 'income')`;
|
||||
|
||||
/**
|
||||
* Rows that count towards NET spend — outgoings plus the refunds that cancel
|
||||
* them. Pair with SPEND_SIGNED, which carries the direction.
|
||||
*/
|
||||
export const NET_SPEND_ROWS = `(${SPEND_ROWS} OR t.transaction_type IN ('refund', 'credit'))`;
|
||||
|
||||
/**
|
||||
* SPEND_BASE, signed: refunds and credits come back as negatives so they cancel
|
||||
* the original purchase.
|
||||
*
|
||||
* Without this a refund is counted nowhere. It is excluded from spend by type
|
||||
* and only counted as income if categorised 'income', which a refund is not —
|
||||
* so it falls through both and never reduces anything. A $2,888.92 Expedia
|
||||
* purchase refunded in full eight days later still read as $2,888.92 of spend.
|
||||
*/
|
||||
export const SPEND_SIGNED = `CASE
|
||||
WHEN t.transaction_type IN ('refund', 'credit') THEN -(${SPEND_BASE})
|
||||
ELSE (${SPEND_BASE})
|
||||
END`;
|
||||
@@ -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;
|
||||
}
|
||||
+15
-1
@@ -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,18 +20,26 @@ export const CATEGORIES = [
|
||||
"government",
|
||||
"education",
|
||||
"rent",
|
||||
"home_goods",
|
||||
"home_maintenance",
|
||||
"transfers",
|
||||
"income",
|
||||
"investment",
|
||||
"loan_interest",
|
||||
"personal_care",
|
||||
"pets",
|
||||
"gifts",
|
||||
"charity",
|
||||
"fees",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
export type Category = (typeof CATEGORIES)[number];
|
||||
|
||||
export function formatCategory(cat: string): string {
|
||||
// Tolerates null/undefined: an uncategorised transaction must never be able to
|
||||
// crash a page that renders its category.
|
||||
export function formatCategory(cat: string | null | undefined): string {
|
||||
if (!cat) return "Uncategorised";
|
||||
return cat
|
||||
.split("_")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
export 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",
|
||||
loan_interest: "#9f1239",
|
||||
fees: "#f87171",
|
||||
personal_care: "#fb7185",
|
||||
pets: "#86efac",
|
||||
gifts: "#fcd34d",
|
||||
charity: "#a3e635",
|
||||
home_goods: "#67e8f9",
|
||||
home_maintenance: "#c084fc",
|
||||
other: "#71717a",
|
||||
};
|
||||
|
||||
// Ink & copper chart tokens — keep in sync with the @theme scales in globals.css.
|
||||
export const CHART = {
|
||||
accent: "#bc6f30", // copper (indigo-500)
|
||||
accentSoft: "#d28a47", // copper light (indigo-400)
|
||||
positive: "#34d399",
|
||||
negative: "#e35f4b",
|
||||
axis: "#94896f", // muted paper (zinc-400)
|
||||
faint: "#6e644f", // zinc-500
|
||||
grid: "#242019", // zinc-800
|
||||
dim: "#332d23", // zinc-700 — de-emphasised series
|
||||
};
|
||||
|
||||
export const TOOLTIP_STYLE = {
|
||||
background: "#171410",
|
||||
border: "1px solid #332d23",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
export type DateFormat = "DD/MM/YYYY" | "YYYY-MM-DD" | "MM/DD/YYYY" | "M/D/YYYY";
|
||||
|
||||
export interface ColumnMapping {
|
||||
dateCol: string;
|
||||
descriptionCol: string;
|
||||
amountMode: "single" | "debit_credit";
|
||||
amountCol?: string;
|
||||
debitCol?: string;
|
||||
creditCol?: string;
|
||||
merchantCol?: string;
|
||||
categoryCol?: string;
|
||||
}
|
||||
|
||||
export interface BankPreset {
|
||||
bankName: string;
|
||||
mapping: ColumnMapping;
|
||||
dateFormat: DateFormat;
|
||||
}
|
||||
|
||||
export interface ParsedTransaction {
|
||||
date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
transaction_type: string;
|
||||
merchant_name?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export function parseCSVRows(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
const next = text[i + 1];
|
||||
if (inQuotes) {
|
||||
if (c === '"' && next === '"') { field += '"'; i++; }
|
||||
else if (c === '"') { inQuotes = false; }
|
||||
else { field += c; }
|
||||
} else {
|
||||
if (c === '"') { inQuotes = true; }
|
||||
else if (c === ',') { row.push(field.trim()); field = ""; }
|
||||
else if (c === '\r' && next === '\n') {
|
||||
row.push(field.trim());
|
||||
if (row.some((f) => f !== "")) rows.push(row);
|
||||
row = []; field = ""; i++;
|
||||
} else if (c === '\n' || c === '\r') {
|
||||
row.push(field.trim());
|
||||
if (row.some((f) => f !== "")) rows.push(row);
|
||||
row = []; field = "";
|
||||
} else { field += c; }
|
||||
}
|
||||
}
|
||||
if (field || row.length > 0) {
|
||||
row.push(field.trim());
|
||||
if (row.some((f) => f !== "")) rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function parseDate(raw: string, format: DateFormat): string {
|
||||
const s = raw.trim();
|
||||
try {
|
||||
if (format === "DD/MM/YYYY") {
|
||||
const [d, m, y] = s.split("/");
|
||||
if (!d || !m || !y || y.length !== 4) return "";
|
||||
return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
|
||||
}
|
||||
if (format === "YYYY-MM-DD") {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(s) ? s : "";
|
||||
}
|
||||
if (format === "MM/DD/YYYY" || format === "M/D/YYYY") {
|
||||
const [m, d, y] = s.split("/");
|
||||
if (!d || !m || !y || y.length !== 4) return "";
|
||||
return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
|
||||
}
|
||||
} catch { return ""; }
|
||||
return "";
|
||||
}
|
||||
|
||||
export function detectHasHeaders(rows: string[][], dateFormat: DateFormat): boolean {
|
||||
if (rows.length === 0) return false;
|
||||
return parseDate(rows[0][0] ?? "", dateFormat) === "";
|
||||
}
|
||||
|
||||
export function getColumnLabels(rows: string[][], hasHeaders: boolean): string[] {
|
||||
if (hasHeaders && rows.length > 0) {
|
||||
return rows[0].map((h, i) => h || `Column ${i + 1}`);
|
||||
}
|
||||
const maxCols = rows.reduce((m, r) => Math.max(m, r.length), 0);
|
||||
return Array.from({ length: maxCols }, (_, i) => `Column ${i + 1}`);
|
||||
}
|
||||
|
||||
export function getDataRows(rows: string[][], hasHeaders: boolean): string[][] {
|
||||
return hasHeaders ? rows.slice(1) : rows;
|
||||
}
|
||||
|
||||
export function applyMapping(
|
||||
dataRows: string[][],
|
||||
columnLabels: string[],
|
||||
mapping: ColumnMapping,
|
||||
dateFormat: DateFormat
|
||||
): ParsedTransaction[] {
|
||||
const idx = (name: string) => columnLabels.indexOf(name);
|
||||
const dateIdx = idx(mapping.dateCol);
|
||||
const descIdx = idx(mapping.descriptionCol);
|
||||
const merchantIdx = mapping.merchantCol ? idx(mapping.merchantCol) : -1;
|
||||
const categoryIdx = mapping.categoryCol ? idx(mapping.categoryCol) : -1;
|
||||
|
||||
const results: ParsedTransaction[] = [];
|
||||
for (const row of dataRows) {
|
||||
const date = parseDate(row[dateIdx] ?? "", dateFormat);
|
||||
const description = (row[descIdx] ?? "").trim();
|
||||
if (!date || !description) continue;
|
||||
|
||||
let amount = 0;
|
||||
let transaction_type = "debit";
|
||||
|
||||
if (mapping.amountMode === "single" && mapping.amountCol) {
|
||||
const raw = (row[idx(mapping.amountCol)] ?? "").replace(/[^\d.\-+]/g, "");
|
||||
const val = parseFloat(raw);
|
||||
if (isNaN(val) || val === 0) continue;
|
||||
amount = Math.abs(val);
|
||||
transaction_type = val < 0 ? "debit" : "credit";
|
||||
} else if (mapping.amountMode === "debit_credit") {
|
||||
const debitIdx = mapping.debitCol ? idx(mapping.debitCol) : -1;
|
||||
const creditIdx = mapping.creditCol ? idx(mapping.creditCol) : -1;
|
||||
const dVal = parseFloat((row[debitIdx] ?? "").replace(/[^\d.]/g, ""));
|
||||
const cVal = parseFloat((row[creditIdx] ?? "").replace(/[^\d.]/g, ""));
|
||||
if (!isNaN(dVal) && dVal > 0) { amount = dVal; transaction_type = "debit"; }
|
||||
else if (!isNaN(cVal) && cVal > 0) { amount = cVal; transaction_type = "credit"; }
|
||||
else continue;
|
||||
}
|
||||
|
||||
if (amount <= 0) continue;
|
||||
const tx: ParsedTransaction = { date, description, amount, transaction_type };
|
||||
if (merchantIdx >= 0 && row[merchantIdx]) tx.merchant_name = row[merchantIdx].trim();
|
||||
if (categoryIdx >= 0 && row[categoryIdx]) tx.category = row[categoryIdx].trim();
|
||||
results.push(tx);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function saveBankPreset(preset: BankPreset): void {
|
||||
try {
|
||||
const existing = loadBankPresets().filter((p) => p.bankName !== preset.bankName);
|
||||
localStorage.setItem("csv-presets", JSON.stringify([...existing, preset]));
|
||||
} catch { /* localStorage unavailable */ }
|
||||
}
|
||||
|
||||
export function loadBankPresets(): BankPreset[] {
|
||||
try { return JSON.parse(localStorage.getItem("csv-presets") || "[]"); }
|
||||
catch { return []; }
|
||||
}
|
||||
+592
-21
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { TransactionRow, StatementRow, TagRow } from "./queries";
|
||||
import type { TransactionRow, StatementRow, TagRow, TripRow, TripAnalytics } from "./queries";
|
||||
export type { TripRow, TripAnalytics };
|
||||
import type { CurrentUser } from "./auth";
|
||||
|
||||
interface TransactionsResponse {
|
||||
@@ -14,21 +15,31 @@ 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;
|
||||
has_split?: string;
|
||||
trip_id?: string;
|
||||
}
|
||||
|
||||
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 +94,36 @@ 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;
|
||||
payment_method?: 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 +135,13 @@ 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;
|
||||
trip_id?: number | null;
|
||||
payment_method?: string | null;
|
||||
}) => {
|
||||
const res = await fetch(`/api/transactions/${id}`, {
|
||||
method: "PATCH",
|
||||
@@ -105,6 +153,7 @@ export function useUpdateTransaction() {
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["transaction"] });
|
||||
qc.invalidateQueries({ queryKey: ["analytics"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -119,6 +168,7 @@ export function useBulkAction() {
|
||||
merchant_normalized?: string;
|
||||
splits?: { participant_id: number; share_percent: number }[];
|
||||
tag_id?: number;
|
||||
rule_id?: number;
|
||||
}) => {
|
||||
const res = await fetch("/api/transactions/bulk", {
|
||||
method: "POST",
|
||||
@@ -141,6 +191,16 @@ export function useBulkAction() {
|
||||
if (variables.action === "tag" || variables.action === "untag") {
|
||||
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||
}
|
||||
// A quick action can touch category, tags and splits at once, and records
|
||||
// a revertible run — refresh everything it could have changed.
|
||||
if (variables.action === "apply_rule") {
|
||||
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||
qc.invalidateQueries({ queryKey: ["splits"] });
|
||||
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||
qc.invalidateQueries({ queryKey: ["analytics"] });
|
||||
qc.invalidateQueries({ queryKey: ["rule-runs"] });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -155,21 +215,26 @@ 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[], participantId?: number) {
|
||||
return useQuery({
|
||||
queryKey: ["shared-transactions"],
|
||||
queryKey: ["shared-transactions", tagIds, participantId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/shared-transactions");
|
||||
const sp = new URLSearchParams();
|
||||
if (tagIds?.length) sp.set("tag_ids", tagIds.join(","));
|
||||
if (participantId) sp.set("participant_id", String(participantId));
|
||||
const query = sp.toString() ? `?${sp.toString()}` : "";
|
||||
const res = await fetch(`/api/shared-transactions${query}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
@@ -214,11 +279,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 +323,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,8 +470,10 @@ 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;
|
||||
/** Excluded from the apply-all run; fired by hand as a quick action instead. */
|
||||
manual_only?: boolean;
|
||||
priority: number;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -417,17 +530,475 @@ export function useDeleteRule() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useApplyRules() {
|
||||
export interface RuleMatchChange {
|
||||
field: "category" | "merchant" | "tags" | "split";
|
||||
from: string | null;
|
||||
to: string;
|
||||
}
|
||||
export interface RuleMatchRow {
|
||||
id: number;
|
||||
transaction_date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
amount_aud: number | null;
|
||||
currency: string;
|
||||
bank_name: string;
|
||||
effective_merchant: string;
|
||||
effective_category: string;
|
||||
changes: RuleMatchChange[];
|
||||
}
|
||||
export interface RuleMatches {
|
||||
rule: { id: number; name: string; matches_everything: boolean };
|
||||
total_matched: number;
|
||||
would_change: number;
|
||||
already_correct: number;
|
||||
transactions: RuleMatchRow[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** Dry run — what a rule would change. Writes nothing. */
|
||||
export function useRuleMatches(ruleId: number | null) {
|
||||
return useQuery<RuleMatches>({
|
||||
queryKey: ["rule-matches", ruleId],
|
||||
enabled: ruleId != null,
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/rules/${ruleId}/matches`);
|
||||
if (!res.ok) throw new Error("Failed to load rule matches");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply a rule to a hand-picked set of transactions (conditions ignored). */
|
||||
export function useApplyRuleToSelection() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/rules/apply", { method: "POST" });
|
||||
if (!res.ok) throw new Error("Failed to apply rules");
|
||||
return res.json() as Promise<{ matched: number; transactions_affected: number }>;
|
||||
mutationFn: async ({ ruleId, ids }: { ruleId: number; ids: number[] }) => {
|
||||
const res = await fetch("/api/transactions/bulk", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "apply_rule", rule_id: ruleId, ids }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || "Failed to apply rule");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["rule-matches"] });
|
||||
qc.invalidateQueries({ queryKey: ["rules"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useApplyRules() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (args?: { splitFrom?: string; ruleId?: number }) => {
|
||||
const res = await fetch("/api/rules/apply", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ splitFrom: args?.splitFrom || null, ruleId: args?.ruleId || null }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to apply rules");
|
||||
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;
|
||||
// Provenance (migration 0017). NULL on runs recorded before it existed.
|
||||
rule_id: number | null;
|
||||
rule_name: string | null;
|
||||
source: "all" | "rule" | "selection" | null;
|
||||
}
|
||||
|
||||
export interface RuleRunDetail {
|
||||
run: RuleRun & { transactions_affected: number };
|
||||
transactions: {
|
||||
id: number;
|
||||
transaction_date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
amount_aud: number | null;
|
||||
bank_name: string;
|
||||
merchant: string | null;
|
||||
changes: { field: string; from: string | null; to: string | null }[];
|
||||
}[];
|
||||
still_changed: number;
|
||||
}
|
||||
|
||||
/** What a run actually did — loaded on demand when a row is expanded. */
|
||||
export function useRuleRunDetail(runId: number | null) {
|
||||
return useQuery<RuleRunDetail>({
|
||||
queryKey: ["rule-run-detail", runId],
|
||||
enabled: runId != null,
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/rules/runs/${runId}`);
|
||||
if (!res.ok) throw new Error("Failed to load run detail");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// --- CSV Import & Reconcile ---
|
||||
|
||||
export function useImportCSV() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body: {
|
||||
bank_name: string;
|
||||
transactions: {
|
||||
date: string; description: string; amount: number; transaction_type: string;
|
||||
merchant_name?: string; foreign_currency_amount?: number; foreign_currency_code?: string; category?: string;
|
||||
}[];
|
||||
}) => {
|
||||
const res = await fetch("/api/import/csv", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || "Import failed");
|
||||
return res.json() as Promise<{ inserted: number }>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||
qc.invalidateQueries({ queryKey: ["reconcile-pending"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
import type { ManualTxWithMatches } from "./queries";
|
||||
|
||||
export function usePendingReconciliations() {
|
||||
return useQuery<ManualTxWithMatches[]>({
|
||||
queryKey: ["reconcile-pending"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/reconcile/pending");
|
||||
if (!res.ok) throw new Error("Failed to fetch");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReconcile() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (matches: { manual_id: number; statement_tx_id: number }[]) => {
|
||||
const res = await fetch("/api/transactions/reconcile", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ matches }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || "Reconcile failed");
|
||||
return res.json() as Promise<{ reconciled: number }>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["reconcile-pending"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Trips ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useTrips() {
|
||||
return useQuery<TripRow[]>({
|
||||
queryKey: ["trips"],
|
||||
queryFn: async () => (await fetch("/api/trips")).json(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTrip(id: number) {
|
||||
return useQuery<TripRow>({
|
||||
queryKey: ["trip", id],
|
||||
queryFn: async () => (await fetch(`/api/trips/${id}`)).json(),
|
||||
enabled: id > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTripAnalytics(id: number) {
|
||||
return useQuery<TripAnalytics>({
|
||||
queryKey: ["trip-analytics", id],
|
||||
queryFn: async () => (await fetch(`/api/trips/${id}/analytics`)).json(),
|
||||
enabled: id > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTrip() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: Omit<TripRow, "id" | "owner_id" | "created_at" | "total_spend" | "transaction_count">) => {
|
||||
const res = await fetch("/api/trips", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || "Failed");
|
||||
return res.json() as Promise<TripRow>;
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["trips"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTrip() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...data }: Partial<TripRow> & { id: number }) => {
|
||||
const res = await fetch(`/api/trips/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || "Failed");
|
||||
return res.json() as Promise<TripRow>;
|
||||
},
|
||||
onSuccess: (_d, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: ["trips"] });
|
||||
qc.invalidateQueries({ queryKey: ["trip", id] });
|
||||
qc.invalidateQueries({ queryKey: ["trip-analytics", id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTrip() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await fetch(`/api/trips/${id}`, { method: "DELETE" });
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["trips"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssignTransactionsToTrip() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ tripId, transactionIds }: { tripId: number | null; transactionIds: number[] }) => {
|
||||
const res = await fetch("/api/transactions/bulk", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "assign_trip", ids: transactionIds, trip_id: tripId }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to assign trip");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["trips"] });
|
||||
qc.invalidateQueries({ queryKey: ["trip-analytics"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user