Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
31cffbe1bb |
@@ -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 files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.sops
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
creation_rules:
|
||||||
|
- path_regex: \.env$
|
||||||
|
age: age1tw6wsyxgxa465cc0wx32u7xuw5675pyz35cuzey4huz69hc54v4qp0pvgn
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
### 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()`).
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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
|
- **Frontend**: Next.js 16 App Router, TypeScript, Tailwind CSS, Recharts
|
||||||
npm run dev
|
- **Backend**: Next.js API routes, raw PostgreSQL via `pg` + `@prisma/adapter-pg`
|
||||||
# or
|
- **Database**: PostgreSQL (`postgres-personal` container)
|
||||||
yarn dev
|
- **Auth**: `X-Forwarded-User` header (email) set by Traefik forward-auth → mapped to `participants.email`
|
||||||
# or
|
- **Ingestion**: N8N workflow → Gemini 2.5 Flash (PDF parsing) → PostgreSQL
|
||||||
pnpm dev
|
|
||||||
# or
|
---
|
||||||
bun dev
|
|
||||||
|
## 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.
|
| Migration | What it adds |
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|-----------|-------------|
|
||||||
|
| `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,84 @@
|
|||||||
|
"TransferWise ID",Date,"Date Time",Amount,Currency,Description,"Payment Reference","Running Balance","Exchange From","Exchange To","Exchange Rate","Payer Name","Payee Name","Payee Account Number",Merchant,"Card Last Four Digits","Card Holder Full Name",Attachment,Note,"Total fees","Exchange To Amount","Transaction Type","Transaction Details Type"
|
||||||
|
TRANSFER-2268507611,24-07-2026,"24-07-2026 23:59:50.116",-10000.00,USD,"Sent money to Siddharth Bose",,775.89,,,,,"Siddharth Bose",68844145,,,,,,0.00,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-2235277367,07-07-2026,"07-07-2026 18:05:17.109",10782.00,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2026-06 EXP2687","INV 2026-06 EXP2687",10775.89,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
|
||||||
|
FEE-TRANSFER-2235277367,07-07-2026,"07-07-2026 18:05:17.108",-6.11,USD,"Wise Charges for: TRANSFER-2235277367","INV 2026-06 EXP2687",-6.11,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
|
||||||
|
TRANSFER-2216007380,28-06-2026,"28-06-2026 11:37:55.004",-3968.98,USD,"Sent money to Siddharth Bose (fee: 11.63 USD)",,0.00,USD,AUD,1.44949,,"Siddharth Bose","(939200) 519049940",,,,,,11.63,5753.00,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-2216007380,28-06-2026,"28-06-2026 11:37:55.003",-11.63,USD,"Wise Charges for: TRANSFER-2216007380",,3968.98,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-2171403032,04-06-2026,"04-06-2026 05:11:15.833",-6776.49,USD,"Sent money to Siddharth Bose (fee: 19.51 USD)",,3980.61,USD,AUD,1.40233,,"Siddharth Bose","(939200) 519049940",,,,,,19.51,9502.88,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-2171403032,04-06-2026,"04-06-2026 05:11:15.832",-19.51,USD,"Wise Charges for: TRANSFER-2171403032",,10757.10,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-2157538435,28-05-2026,"28-05-2026 17:32:10.213",10782.00,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2026-05 EXP2557","INV 2026-05 EXP2557",10776.61,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
|
||||||
|
FEE-TRANSFER-2157538435,28-05-2026,"28-05-2026 17:32:10.212",-6.11,USD,"Wise Charges for: TRANSFER-2157538435","INV 2026-05 EXP2557",-5.39,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
|
||||||
|
TRANSFER-2143509822,20-05-2026,"20-05-2026 21:01:23.608",-10765.33,USD,"Sent money to Siddharth Bose (fee: 30.67 USD)",,0.72,USD,AUD,1.40351,,"Siddharth Bose","(013040) 408556264",,,,,,30.67,15109.25,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-2143509822,20-05-2026,"20-05-2026 21:01:23.607",-30.67,USD,"Wise Charges for: TRANSFER-2143509822",,10766.05,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-2128716995,12-05-2026,"12-05-2026 17:33:31.247",10802.83,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2026-04 EXP2421","INV 2026-04 EXP2421",10796.72,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
|
||||||
|
FEE-TRANSFER-2128716995,12-05-2026,"12-05-2026 17:33:31.246",-6.11,USD,"Wise Charges for: TRANSFER-2128716995","INV 2026-04 EXP2421",-6.11,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
|
||||||
|
TRANSFER-2114526979,05-05-2026,"05-05-2026 09:34:50.397",-6835.10,USD,"Sent money to Siddharth Bose (fee: 19.67 USD)",,0.00,USD,AUD,1.39509,,"Siddharth Bose","(939200) 519049940",,,,,,19.67,9535.58,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-2114526979,05-05-2026,"05-05-2026 09:34:50.396",-19.67,USD,"Wise Charges for: TRANSFER-2114526979",,6835.10,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-2075646899,14-04-2026,"14-04-2026 06:19:35.500",-4000.00,USD,"Sent money to Siddharth Bose",,6854.77,,,,,"Siddharth Bose",68844145,,,,,,0.00,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-2054903037,02-04-2026,"02-04-2026 17:07:38.780",10790.80,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2026-03 EXP2282","INV 2026-03 EXP2282",10854.77,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
|
||||||
|
FEE-TRANSFER-2054903037,02-04-2026,"02-04-2026 17:07:38.779",-6.11,USD,"Wise Charges for: TRANSFER-2054903037","INV 2026-03 EXP2282",63.97,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
|
||||||
|
TRANSFER-2043244817,27-03-2026,"27-03-2026 17:25:48.750",-9971.56,USD,"Sent money to Siddharth Bose (fee: 28.44 USD)",,70.08,USD,AUD,1.45117,,"Siddharth Bose","(939200) 519049940",,,,,,28.44,14470.43,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-2043244817,27-03-2026,"27-03-2026 17:25:48.749",-28.44,USD,"Wise Charges for: TRANSFER-2043244817",,10041.64,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-2009199370,07-03-2026,"07-03-2026 10:00:47.387",-6000.00,USD,"Sent money to Siddharth Bose",,10070.08,,,,,"Siddharth Bose",68844145,,,,,,0.00,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-2007700796,06-03-2026,"06-03-2026 18:32:12.643",10790.80,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2026-02 EXP2148","INV 2026-02 EXP2148",16070.08,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
|
||||||
|
FEE-TRANSFER-2007700796,06-03-2026,"06-03-2026 18:32:12.642",-6.11,USD,"Wise Charges for: TRANSFER-2007700796","INV 2026-02 EXP2148",5279.28,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
|
||||||
|
TRANSFER-1960000797,07-02-2026,"07-02-2026 00:12:56.532",-25000.00,USD,"Sent money to Siddharth Bose",,5285.39,,,,,"Siddharth Bose",68844145,,,,,,0.00,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-1953505794,03-02-2026,"03-02-2026 18:16:51.173",10790.80,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2026-01 EXP1987","INV 2026-01 EXP1987",30285.39,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
|
||||||
|
FEE-TRANSFER-1953505794,03-02-2026,"03-02-2026 18:16:51.172",-6.11,USD,"Wise Charges for: TRANSFER-1953505794","INV 2026-01 EXP1987",19494.59,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
|
||||||
|
TRANSFER-1950103478,02-02-2026,"02-02-2026 10:12:23.989",-500.00,USD,"Sent money to Siddharth Bose",,19500.70,,,,,"Siddharth Bose",68844145,,,,,,0.00,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-1946979742,31-01-2026,"31-01-2026 00:45:43.330",-2181.00,USD,"Sent money to Siddharth Bose",,20000.70,,,,,"Siddharth Bose",68844145,,,,,,0.00,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-1908177420,07-01-2026,"07-01-2026 18:02:18.983",10790.80,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2025-12 EXP1817","INV 2025-12 EXP1817",22181.70,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
|
||||||
|
FEE-TRANSFER-1908177420,07-01-2026,"07-01-2026 18:02:18.982",-6.11,USD,"Wise Charges for: TRANSFER-1908177420","INV 2025-12 EXP1817",11390.90,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
|
||||||
|
TRANSFER-1847010079,01-12-2025,"01-12-2025 21:17:07.414",10790.69,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2025-11 EXP1683","INV 2025-11 EXP1683",11397.01,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
|
||||||
|
FEE-TRANSFER-1847010079,01-12-2025,"01-12-2025 21:17:07.413",-6.11,USD,"Wise Charges for: TRANSFER-1847010079","INV 2025-11 EXP1683",606.32,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
|
||||||
|
CARD-3157819100,25-11-2025,"25-11-2025 15:16:16.619",-12.65,USD,"Card transaction of 10.99 EUR issued by Mega Limited AUCKLAND (fee: 0.02 USD)",,612.43,USD,EUR,0.86851,,,,"Mega Limited AUCKLAND",4233,"Siddharth Bose",,,0.02,10.99,DEBIT,CARD
|
||||||
|
FEE-CARD-3157819100,25-11-2025,"25-11-2025 15:16:16.618",-0.02,USD,"Wise Charges for: CARD-3157819100",,625.08,,,,,,,"Mega Limited AUCKLAND",4233,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
TRANSFER-1833504017,24-11-2025,"24-11-2025 01:15:43.299",-10973.71,USD,"Sent money to Siddharth Bose (fee: 26.29 USD)",,625.10,USD,AUD,1.54883,,"Siddharth Bose","(939200) 519049940",,,,,,26.29,16996.41,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-1833504017,24-11-2025,"24-11-2025 01:15:43.298",-26.29,USD,"Wise Charges for: TRANSFER-1833504017",,11598.81,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
CARD-3115289050,13-11-2025,"13-11-2025 20:15:06.123",-34.85,USD,"Card transaction of 30.00 EUR issued by Sta Spa-Ag BOLZANO (fee: 0.10 USD)",,11625.10,USD,EUR,0.86092,,,,"Sta Spa-Ag BOLZANO",8531,"Siddharth Bose",,,0.10,30.00,DEBIT,CARD
|
||||||
|
FEE-CARD-3115289050,13-11-2025,"13-11-2025 20:15:06.122",-0.10,USD,"Wise Charges for: CARD-3115289050",,11659.95,,,,,,,"Sta Spa-Ag BOLZANO",8531,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
TRANSFER-1808432866,07-11-2025,"07-11-2025 18:13:22.798",10790.69,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2025-10 EXP1512","INV 2025-10 EXP1512",11660.05,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
|
||||||
|
FEE-TRANSFER-1808432866,07-11-2025,"07-11-2025 18:13:22.797",-6.11,USD,"Wise Charges for: TRANSFER-1808432866","INV 2025-10 EXP1512",869.36,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
|
||||||
|
TRANSFER-1805800459,06-11-2025,"06-11-2025 04:53:13.751",-19943.51,USD,"Sent money to Siddharth Bose (fee: 56.49 USD)",,875.47,USD,AUD,1.53692,,"Siddharth Bose","(013040) 408556264",,,,,,56.49,30651.58,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-1805800459,06-11-2025,"06-11-2025 04:53:13.750",-56.49,USD,"Wise Charges for: TRANSFER-1805800459",,20818.98,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
CARD-3088194739,06-11-2025,"06-11-2025 04:37:29.830",-45.92,USD,"Card transaction of 40.00 EUR issued by Via Soana 24 ROMA (fee: 0.13 USD)",,20875.47,USD,EUR,0.87104,,,,"Via Soana 24 ROMA",8531,"Siddharth Bose",,,0.13,40.00,DEBIT,CARD
|
||||||
|
FEE-CARD-3088194739,06-11-2025,"06-11-2025 04:37:29.829",-0.13,USD,"Wise Charges for: CARD-3088194739",,20921.39,,,,,,,"Via Soana 24 ROMA",8531,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
CARD-3046253352,25-10-2025,"25-10-2025 15:01:15.513",-12.78,USD,"Card transaction of 10.99 EUR issued by Mega Limited AUCKLAND (fee: 0.03 USD)",,20921.52,USD,EUR,0.86014,,,,"Mega Limited AUCKLAND",4233,"Siddharth Bose",,,0.03,10.99,DEBIT,CARD
|
||||||
|
FEE-CARD-3046253352,25-10-2025,"25-10-2025 15:01:15.512",-0.03,USD,"Wise Charges for: CARD-3046253352",,20934.30,,,,,,,"Mega Limited AUCKLAND",4233,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
TRANSFER-1784386011,24-10-2025,"24-10-2025 10:39:46.041",-6513.98,USD,"Sent money to Siddharth Bose (fee: 18.89 USD)",,20934.33,USD,AUD,1.53516,,"Siddharth Bose","(013040) 408556264",,,,,,18.89,10000.00,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-1784386011,24-10-2025,"24-10-2025 10:39:46.040",-18.89,USD,"Wise Charges for: TRANSFER-1784386011",,27448.31,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
CARD-3027732792,20-10-2025,"20-10-2025 08:58:43.670",549.70,USD,"Card transaction of -549.70 USD issued by Etihad Airw 6072412191144 ABU DHABI",,27467.20,,,,,,,"Etihad Airw 6072412191144 ABU DHABI",4233,"Siddharth Bose",,,0.00,,CREDIT,CARD
|
||||||
|
CARD-3023391491,19-10-2025,"19-10-2025 02:04:15.660",-82.50,USD,"Card transaction of 82.50 USD issued by *Sfo Intl Airport-Itb N SAN FRANCISCO",,26917.50,,,,,,,"*Sfo Intl Airport-Itb N SAN FRANCISCO",8531,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
TRANSFER-1772299455,16-10-2025,"16-10-2025 08:00:56.288",-1500.00,USD,"Sent money to Siddharth Bose",,27000.00,,,,,"Siddharth Bose",68844145,,,,,,0.00,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-1765630070,11-10-2025,"11-10-2025 22:28:23.964",-321.88,USD,"Sent money to Siddharth Bose",,28500.00,,,,,"Siddharth Bose",68844145,,,,,,0.00,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-1755893777,06-10-2025,"06-10-2025 07:59:23.646",-3955.49,USD,"Sent money to Siddharth Bose (fee: 11.74 USD)",,28821.88,USD,AUD,1.51688,,"Siddharth Bose","(033501) 394758",,,,,,11.74,6000.00,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-1755893777,06-10-2025,"06-10-2025 07:59:23.645",-11.74,USD,"Wise Charges for: TRANSFER-1755893777",,32777.37,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
CARD-2962375193,02-10-2025,"02-10-2025 08:41:01.066",4.53,USD,"Card transaction of -4.53 USD issued by Patreon* Membership Internet",,32789.11,,,,,,,"Patreon* Membership Internet",4233,"Siddharth Bose",,,0.00,,CREDIT,CARD
|
||||||
|
TRANSFER-1748256868,01-10-2025,"01-10-2025 18:47:09.850",10790.69,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2025-09 EXP1335","INV 2025-09 EXP1335",32784.58,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
|
||||||
|
FEE-TRANSFER-1748256868,01-10-2025,"01-10-2025 18:47:09.849",-6.11,USD,"Wise Charges for: TRANSFER-1748256868","INV 2025-09 EXP1335",21993.89,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
|
||||||
|
TRANSFER-1742899854,28-09-2025,"28-09-2025 23:23:11.284",-1993.76,USD,"Sent money to Siddharth Bose (fee: 6.24 USD)",,22000.00,USD,AUD,1.52753,,"Siddharth Bose","(939200) 519049940",,,,,,6.24,3045.53,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-1742899854,28-09-2025,"28-09-2025 23:23:11.283",-6.24,USD,"Wise Charges for: TRANSFER-1742899854",,23993.76,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-1738593468,25-09-2025,"25-09-2025 23:12:15.826",-82.39,USD,"Sent money to Siddharth Bose",,24000.00,,,,,"Siddharth Bose",68844145,,,,,,0.00,,DEBIT,TRANSFER
|
||||||
|
CARD-2937450107,25-09-2025,"25-09-2025 14:53:54.340",-12.91,USD,"Card transaction of 10.99 EUR issued by Mega Limited AUCKLAND (fee: 0.03 USD)",,24082.39,USD,EUR,0.85103,,,,"Mega Limited AUCKLAND",4233,"Siddharth Bose",,,0.03,10.99,DEBIT,CARD
|
||||||
|
FEE-CARD-2937450107,25-09-2025,"25-09-2025 14:53:54.339",-0.03,USD,"Wise Charges for: CARD-2937450107",,24095.30,,,,,,,"Mega Limited AUCKLAND",4233,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
CARD-2931031224,23-09-2025,"23-09-2025 19:41:24.887",-549.70,USD,"Card transaction of 549.70 USD issued by Etihad Airw 6072412191144 ABU DHABI",,24095.33,,,,,,,"Etihad Airw 6072412191144 ABU DHABI",4233,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-2929927553,23-09-2025,"23-09-2025 10:05:31.187",-16.50,USD,"Card transaction of 16.50 USD issued by Patreon* Membership Internet",,24645.03,,,,,,,"Patreon* Membership Internet",4233,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-2928693059,23-09-2025,"23-09-2025 01:24:48.519",-4.53,USD,"Card transaction of 4.53 USD issued by Patreon* Membership Internet",,24661.53,,,,,,,"Patreon* Membership Internet",4233,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
TRANSFER-1706024945,04-09-2025,"04-09-2025 18:17:21.211",-2675.24,USD,"Sent money to Siddharth Bose (fee: 8.15 USD)",,24666.06,USD,AUD,1.53257,,"Siddharth Bose","(033501) 394758",,,,,,8.15,4100.00,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-1706024945,04-09-2025,"04-09-2025 18:17:21.210",-8.15,USD,"Wise Charges for: TRANSFER-1706024945",,27341.30,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-1702141390,02-09-2025,"02-09-2025 16:47:10.556",10713.76,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2025-08 EXP1167","INV 2025-08 EXP1167",27349.45,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
|
||||||
|
FEE-TRANSFER-1702141390,02-09-2025,"02-09-2025 16:47:10.555",-6.11,USD,"Wise Charges for: TRANSFER-1702141390","INV 2025-08 EXP1167",16635.69,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
|
||||||
|
TRANSFER-1658801843,04-08-2025,"04-08-2025 23:43:36.588",-4985.39,USD,"Sent money to Siddharth Bose (fee: 14.61 USD)",,16641.80,USD,AUD,1.54261,,"Siddharth Bose","(013040) 408556264",,,,,,14.61,7690.51,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-1658801843,04-08-2025,"04-08-2025 23:43:36.587",-14.61,USD,"Wise Charges for: TRANSFER-1658801843",,21627.19,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-1648433412,29-07-2025,"29-07-2025 17:31:44.230",10789.27,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2025-07 EXP1043","INV 2025-07 EXP1043",21641.80,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
|
||||||
|
FEE-TRANSFER-1648433412,29-07-2025,"29-07-2025 17:31:44.229",-6.11,USD,"Wise Charges for: TRANSFER-1648433412","INV 2025-07 EXP1043",10852.53,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
|
||||||
|
TRANSFER-1626714880,14-07-2025,"14-07-2025 15:47:02.565",10864.75,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2025-05 EXP722","INV 2025-05 EXP722",10858.64,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
|
||||||
|
FEE-TRANSFER-1626714880,14-07-2025,"14-07-2025 15:47:02.564",-6.11,USD,"Wise Charges for: TRANSFER-1626714880","INV 2025-05 EXP722",-6.11,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
|
||||||
|
TRANSFER-1593587151,22-06-2025,"22-06-2025 13:55:31.085",-10827.67,USD,"Sent money to Siddharth Bose (fee: 30.97 USD)","Sent via Wise",0.00,USD,AUD,1.55063,,"Siddharth Bose","(013040) 408556264",,,,,,30.97,16789.71,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-1593587151,22-06-2025,"22-06-2025 13:55:31.084",-30.97,USD,"Wise Charges for: TRANSFER-1593587151","Sent via Wise",10827.67,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-1565423196,02-06-2025,"02-06-2025 20:32:13.789",10864.75,USD,"Received money from HDR GLOBAL SERVICES (BERMUDA) with reference INV 2025-05 EXP722","INV 2025-05 EXP722",10858.64,,,,"HDR GLOBAL SERVICES (BERMUDA)",,,,,,,,6.11,,CREDIT,DEPOSIT
|
||||||
|
FEE-TRANSFER-1565423196,02-06-2025,"02-06-2025 20:32:13.788",-6.11,USD,"Wise Charges for: TRANSFER-1565423196","INV 2025-05 EXP722",-6.11,,,,"HDR GLOBAL SERVICES (BERMUDA)",,,,,,,,0,,DEBIT,DEPOSIT
|
||||||
|
BALANCE-3505957179,31-05-2025,"31-05-2025 10:32:45.687",-10753.05,USD,"Converted 10,783.16 USD to 16,719.38 AUD (fee: 30.11 USD)",,0.00,USD,AUD,1.55485,,,,,,,,,30.11,16719.38,DEBIT,CONVERSION
|
||||||
|
FEE-BALANCE-3505957179,31-05-2025,"31-05-2025 10:32:45.686",-30.11,USD,"Wise Charges for: BALANCE-3505957179",,10753.05,,,,,,,,,,,,0,,DEBIT,CONVERSION
|
||||||
|
TRANSFER-1541924892,16-05-2025,"16-05-2025 17:06:44.192",10789.27,USD,"Received money from HDR GLOBAL SERVICES (BERMUDA) with reference INV 2025-04 EXP617","INV 2025-04 EXP617",10783.16,,,,"HDR GLOBAL SERVICES (BERMUDA)",,,,,,,,6.11,,CREDIT,DEPOSIT
|
||||||
|
FEE-TRANSFER-1541924892,16-05-2025,"16-05-2025 17:06:44.191",-6.11,USD,"Wise Charges for: TRANSFER-1541924892","INV 2025-04 EXP617",-6.11,,,,"HDR GLOBAL SERVICES (BERMUDA)",,,,,,,,0,,DEBIT,DEPOSIT
|
||||||
|
@@ -0,0 +1,6 @@
|
|||||||
|
"TransferWise ID",Date,"Date Time",Amount,Currency,Description,"Payment Reference","Running Balance","Exchange From","Exchange To","Exchange Rate","Payer Name","Payee Name","Payee Account Number",Merchant,"Card Last Four Digits","Card Holder Full Name",Attachment,Note,"Total fees","Exchange To Amount","Transaction Type","Transaction Details Type"
|
||||||
|
TRANSFER-1562381704,31-05-2025,"31-05-2025 10:35:03.049",-16718.38,AUD,"Sent money to Siddharth Bose (fee: 1.00 AUD)","Sent via Wise",0.00,,,,,"Siddharth Bose",408556264,,,,,,1.00,,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-1562381704,31-05-2025,"31-05-2025 10:35:03.048",-1.00,AUD,"Wise Charges for: TRANSFER-1562381704","Sent via Wise",16718.38,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
BALANCE-3505957179,31-05-2025,"31-05-2025 10:32:45.687",16719.38,AUD,"Converted 10,783.16 USD to 16,719.38 AUD",,16719.38,USD,AUD,1.55485,,,,,,,,,0.00,16719.38,CREDIT,CONVERSION
|
||||||
|
"BANK_DETAILS_ORDER_CHECKOUT-invoice-17067107",22-04-2025,"22-04-2025 21:45:08.441",-22.00,AUD,"Wise bank details acquisition",,0.00,,,,,,,,,,,,0.00,,DEBIT,UNKNOWN
|
||||||
|
TRANSFER-1506981660,22-04-2025,"22-04-2025 21:45:07.288",22.00,AUD,"Topped up account",,22.00,,,,,,,,,,,,0.00,,CREDIT,MONEY_ADDED
|
||||||
|
@@ -0,0 +1,4 @@
|
|||||||
|
"TransferWise ID",Date,"Date Time",Amount,Currency,Description,"Payment Reference","Running Balance","Exchange From","Exchange To","Exchange Rate","Payer Name","Payee Name","Payee Account Number",Merchant,"Card Last Four Digits","Card Holder Full Name",Attachment,Note,"Total fees","Exchange To Amount","Transaction Type","Transaction Details Type"
|
||||||
|
CARD-2958747517,01-10-2025,"01-10-2025 09:03:26.018",-30.00,AUD,"Card transaction of 76.00 AUD issued by Air India Limited Gurugram",,0.00,,,,,,,"Air India Limited Gurugram",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
TRANSFER-1738581938,25-09-2025,"25-09-2025 23:10:05.406",30.09,AUD,"Topped up account",,30.00,,,,,,,,,,,,0.09,,CREDIT,MONEY_ADDED
|
||||||
|
FEE-TRANSFER-1738581938,25-09-2025,"25-09-2025 23:10:05.405",-0.09,AUD,"Wise Charges for: TRANSFER-1738581938",,-0.09,,,,,,,,,,,,0,,DEBIT,MONEY_ADDED
|
||||||
|
@@ -0,0 +1,99 @@
|
|||||||
|
"TransferWise ID",Date,"Date Time",Amount,Currency,Description,"Payment Reference","Running Balance","Exchange From","Exchange To","Exchange Rate","Payer Name","Payee Name","Payee Account Number",Merchant,"Card Last Four Digits","Card Holder Full Name",Attachment,Note,"Total fees","Exchange To Amount","Transaction Type","Transaction Details Type"
|
||||||
|
TRANSFER-2268510041,25-07-2026,"25-07-2026 00:00:53.745",-10000.00,USD,"Sent money to Interactive Brokers LLC (fee: 1.13 USD)",547528752,450.41,,,,,"Interactive Brokers LLC","(021000021) 6******02",,,,,,1.13,,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-2268510041,25-07-2026,"25-07-2026 00:00:53.744",-1.13,USD,"Wise Charges for: TRANSFER-2268510041",547528752,10450.41,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-2268507611,24-07-2026,"24-07-2026 23:59:52.113",10000.00,USD,"Received money from Siddharth Bose with reference ",,10451.54,,,,"Siddharth Bose",,,,,,,,0.00,,CREDIT,DEPOSIT
|
||||||
|
CARD-3976207925,27-06-2026,"27-06-2026 10:07:35.409",-16.50,USD,"Card transaction of 16.50 USD issued by Patreon* Membership Internet",,451.54,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3841569125,27-05-2026,"27-05-2026 00:39:20.325",-16.50,USD,"Card transaction of 16.50 USD issued by Patreon* Membership Internet",,468.04,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3805642152,18-05-2026,"18-05-2026 10:23:36.446",-1.10,USD,"Card transaction of 1.10 USD issued by Patreon* Membership Internet",,484.54,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3685714866,18-04-2026,"18-04-2026 10:32:14.560",-1.10,USD,"Card transaction of 1.10 USD issued by Patreon* Membership Internet",,485.64,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
TRANSFER-2075650616,14-04-2026,"14-04-2026 06:21:23.847",-4000.00,USD,"Sent money to Interactive Brokers LLC (fee: 1.13 USD)",,486.74,,,,,"Interactive Brokers LLC","(021000021) 6******02",,,,,,1.13,,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-2075650616,14-04-2026,"14-04-2026 06:21:23.846",-1.13,USD,"Wise Charges for: TRANSFER-2075650616",,4486.74,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-2075646899,14-04-2026,"14-04-2026 06:19:38.179",4000.00,USD,"Received money from Siddharth Bose with reference ",,4487.87,,,,"Siddharth Bose",,,,,,,,0.00,,CREDIT,DEPOSIT
|
||||||
|
CARD-3567908768,18-03-2026,"18-03-2026 10:28:01.251",-1.10,USD,"Card transaction of 1.10 USD issued by Patreon* Membership Internet",,487.87,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
TRANSFER-2009203196,07-03-2026,"07-03-2026 10:05:01.880",-6000.00,USD,"Sent money to Interactive Brokers LLC (fee: 1.13 USD)",499493306,488.97,,,,,"Interactive Brokers LLC","(021000021) 6******02",,,,,,1.13,,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-2009203196,07-03-2026,"07-03-2026 10:05:01.879",-1.13,USD,"Wise Charges for: TRANSFER-2009203196",499493306,6488.97,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-2009199370,07-03-2026,"07-03-2026 10:00:50.551",6000.00,USD,"Received money from Siddharth Bose with reference ",,6490.10,,,,"Siddharth Bose",,,,,,,,0.00,,CREDIT,DEPOSIT
|
||||||
|
CARD-3491856978,25-02-2026,"25-02-2026 10:10:28.993",-3.30,USD,"Card transaction of 3.30 USD issued by Patreon* Membership Internet",,490.10,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3467922012,18-02-2026,"18-02-2026 10:10:19.758",-1.10,USD,"Card transaction of 1.10 USD issued by Patreon* Membership Internet",,493.40,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3453741061,14-02-2026,"14-02-2026 10:15:05.143",-3.30,USD,"Card transaction of 3.30 USD issued by Patreon* Membership Internet",,494.50,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
TRANSFER-1959996575,07-02-2026,"07-02-2026 00:14:08.112",-25000.00,USD,"Sent money to Interactive Brokers LLC (fee: 1.13 USD)",490625745,497.80,,,,,"Interactive Brokers LLC","(021000021) 6******02",,,,,,1.13,,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-1959996575,07-02-2026,"07-02-2026 00:14:08.111",-1.13,USD,"Wise Charges for: TRANSFER-1959996575",490625745,25497.80,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-1960000797,07-02-2026,"07-02-2026 00:12:59.295",25000.00,USD,"Received money from Siddharth Bose with reference ",,25498.93,,,,"Siddharth Bose",,,,,,,,0.00,,CREDIT,DEPOSIT
|
||||||
|
CARD-3410915740,02-02-2026,"02-02-2026 13:08:23.820",-5.50,USD,"Card transaction of 5.50 USD issued by Patreon* Membership Internet",,498.93,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
TRANSFER-1950103478,02-02-2026,"02-02-2026 10:12:26.999",500.00,USD,"Received money from Siddharth Bose with reference ",,504.43,,,,"Siddharth Bose",,,,,,,,0.00,,CREDIT,DEPOSIT
|
||||||
|
CARD-3408417070,01-02-2026,"01-02-2026 21:32:05.683",-1.15,USD,"Card transaction of 1.65 AUD issued by Patreon* Membership Internet (fee: 0.01 USD)",,4.43,USD,AUD,1.43647,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.01,1.65,DEBIT,CARD
|
||||||
|
FEE-CARD-3408417070,01-02-2026,"01-02-2026 21:32:05.682",-0.01,USD,"Wise Charges for: CARD-3408417070",,5.58,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
TRANSFER-1946983328,31-01-2026,"31-01-2026 00:47:24.337",-2440.00,USD,"Sent money to Interactive Brokers LLC (fee: 1.13 USD)",488140942,5.59,,,,,"Interactive Brokers LLC","(021000021) 6******02",,,,,,1.13,,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-1946983328,31-01-2026,"31-01-2026 00:47:24.336",-1.13,USD,"Wise Charges for: TRANSFER-1946983328",488140942,2445.59,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
TRANSFER-1946979742,31-01-2026,"31-01-2026 00:45:46.524",2181.00,USD,"Received money from Siddharth Bose with reference ",,2446.72,,,,"Siddharth Bose",,,,,,,,0.00,,CREDIT,DEPOSIT
|
||||||
|
CARD-3382069917,25-01-2026,"25-01-2026 10:32:05.449",-3.30,USD,"Card transaction of 3.30 USD issued by Patreon* Membership Internet",,265.72,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3374520543,23-01-2026,"23-01-2026 10:25:32.264",-16.50,USD,"Card transaction of 16.50 USD issued by Patreon* Membership Internet",,269.02,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3357157631,18-01-2026,"18-01-2026 10:15:13.990",-1.10,USD,"Card transaction of 1.10 USD issued by Patreon* Membership Internet",,285.52,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3342293626,14-01-2026,"14-01-2026 10:13:23.175",-3.30,USD,"Card transaction of 3.30 USD issued by Patreon* Membership Internet",,286.62,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3299985499,02-01-2026,"02-01-2026 23:06:34.860",-5.29,USD,"Card transaction of 104.51 EUR issued by Sp Nord Studios FORDINGBRIDGE (fee: 0.01 USD)",,289.92,USD,EUR,0.85281,,,,"Sp Nord Studios FORDINGBRIDGE",7824,"Siddharth Bose",,,0.01,4.51,DEBIT,CARD
|
||||||
|
FEE-CARD-3299985499,02-01-2026,"02-01-2026 23:06:34.859",-0.01,USD,"Wise Charges for: CARD-3299985499",,295.21,,,,,,,"Sp Nord Studios FORDINGBRIDGE",7824,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
CARD-3296046085,01-01-2026,"01-01-2026 22:12:53.296",-5.50,USD,"Card transaction of 5.50 USD issued by Patreon* Membership Internet",,295.22,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3296045671,01-01-2026,"01-01-2026 22:12:45.273",-1.10,USD,"Card transaction of 1.65 AUD issued by Patreon* Membership Internet (fee: 0.01 USD)",,300.72,USD,AUD,1.49869,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.01,1.65,DEBIT,CARD
|
||||||
|
FEE-CARD-3296045671,01-01-2026,"01-01-2026 22:12:45.272",-0.01,USD,"Wise Charges for: CARD-3296045671",,301.82,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
CARD-3268020843,25-12-2025,"25-12-2025 05:42:18.806",-3.30,USD,"Card transaction of 3.30 USD issued by Patreon* Membership Internet",,301.83,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3260621879,23-12-2025,"23-12-2025 10:22:35.011",-16.50,USD,"Card transaction of 16.50 USD issued by Patreon* Membership Internet",,305.13,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3241565056,18-12-2025,"18-12-2025 10:16:09.323",-1.10,USD,"Card transaction of 1.10 USD issued by Patreon* Membership Internet",,321.63,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3227480675,14-12-2025,"14-12-2025 10:22:05.979",-3.30,USD,"Card transaction of 3.30 USD issued by Patreon* Membership Internet",,322.73,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3188378869,07-12-2025,"07-12-2025 00:17:33.286",-7.41,USD,"Card transaction of 27.00 QAR issued by Talabat.com DOHA (fee: 0.06 USD)",,326.03,USD,QAR,3.64480,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0.06,27.00,DEBIT,CARD
|
||||||
|
FEE-CARD-3188378869,07-12-2025,"07-12-2025 00:17:33.285",-0.06,USD,"Wise Charges for: CARD-3188378869",,333.44,,,,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
CARD-3188378869,06-12-2025,"06-12-2025 19:20:41.782",7.48,USD,"Card transaction of 27.00 QAR issued by Talabat.com DOHA (fee: 0.06 USD)",,333.50,USD,QAR,3.64115,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0.06,27.00,CREDIT,CARD
|
||||||
|
CARD-3197774302,06-12-2025,"06-12-2025 04:59:25.894",-10.97,USD,"Card transaction of 40.00 QAR issued by Talabat.com DOHA (fee: 0.08 USD)",,326.02,USD,QAR,3.64480,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0.08,40.00,DEBIT,CARD
|
||||||
|
FEE-CARD-3197774302,06-12-2025,"06-12-2025 04:59:25.893",-0.08,USD,"Wise Charges for: CARD-3197774302",,336.99,,,,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
CARD-3195872742,05-12-2025,"05-12-2025 20:14:56.638",-11.52,USD,"Card transaction of 42.00 QAR issued by Talabat.com DOHA (fee: 0.09 USD)",,337.07,USD,QAR,3.64480,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0.09,42.00,DEBIT,CARD
|
||||||
|
FEE-CARD-3195872742,05-12-2025,"05-12-2025 20:14:56.637",-0.09,USD,"Wise Charges for: CARD-3195872742",,348.59,,,,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
CARD-3193767606,05-12-2025,"05-12-2025 04:01:04.690",-12.48,USD,"Card transaction of 45.50 QAR issued by Talabat.com DOHA (fee: 0.09 USD)",,348.68,USD,QAR,3.64490,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0.09,45.50,DEBIT,CARD
|
||||||
|
FEE-CARD-3193767606,05-12-2025,"05-12-2025 04:01:04.689",-0.09,USD,"Wise Charges for: CARD-3193767606",,361.16,,,,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
CARD-3192102297,04-12-2025,"04-12-2025 19:47:04.471",-11.91,USD,"Card transaction of 43.40 QAR issued by Talabat.com DOHA (fee: 0.09 USD)",,361.25,USD,QAR,3.64490,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0.09,43.40,DEBIT,CARD
|
||||||
|
FEE-CARD-3192102297,04-12-2025,"04-12-2025 19:47:04.470",-0.09,USD,"Wise Charges for: CARD-3192102297",,373.16,,,,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
CARD-3190030701,04-12-2025,"04-12-2025 03:27:05.435",-12.35,USD,"Card transaction of 45.00 QAR issued by Talabat.com DOHA (fee: 0.09 USD)",,373.25,USD,QAR,3.64475,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0.09,45.00,DEBIT,CARD
|
||||||
|
FEE-CARD-3190030701,04-12-2025,"04-12-2025 03:27:05.434",-0.09,USD,"Wise Charges for: CARD-3190030701",,385.60,,,,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
CARD-3188378869,03-12-2025,"03-12-2025 19:20:21.222",-7.42,USD,"Card transaction of 27.00 QAR issued by Talabat.com DOHA (fee: 0.06 USD)",,385.69,USD,QAR,3.64115,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0.06,27.00,DEBIT,CARD
|
||||||
|
FEE-CARD-3188378869,03-12-2025,"03-12-2025 19:20:21.221",-0.06,USD,"Wise Charges for: CARD-3188378869",,393.11,,,,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
CARD-3181379836,01-12-2025,"01-12-2025 22:28:23.718",-5.50,USD,"Card transaction of 5.50 USD issued by Patreon* Membership Internet",,393.17,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3181379541,01-12-2025,"01-12-2025 22:28:19.540",-1.08,USD,"Card transaction of 1.65 AUD issued by Patreon* Membership Internet (fee: 0.01 USD)",,398.67,USD,AUD,1.52579,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.01,1.65,DEBIT,CARD
|
||||||
|
FEE-CARD-3181379541,01-12-2025,"01-12-2025 22:28:19.539",-0.01,USD,"Wise Charges for: CARD-3181379541",,399.75,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
CARD-3163094631,27-11-2025,"27-11-2025 01:18:02.955",-15.02,USD,"Card transaction of 15.02 USD issued by Holafly Limited Dublin",,399.76,,,,,,,"Holafly Limited Dublin",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3150513905,23-11-2025,"23-11-2025 10:19:47.934",-16.50,USD,"Card transaction of 16.50 USD issued by Patreon* Membership Internet",,414.78,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
BALANCE-4340683172,19-11-2025,"19-11-2025 02:51:28.940",-115.78,USD,"Converted 116.11 USD to 100.00 EUR (fee: 0.33 USD)",,431.28,USD,EUR,0.86367,,,,,,,,,0.33,100.00,DEBIT,CONVERSION
|
||||||
|
FEE-BALANCE-4340683172,19-11-2025,"19-11-2025 02:51:28.939",-0.33,USD,"Wise Charges for: BALANCE-4340683172",,547.06,,,,,,,,,,,,0,,DEBIT,CONVERSION
|
||||||
|
CARD-3132186281,18-11-2025,"18-11-2025 10:19:06.228",-1.10,USD,"Card transaction of 1.10 USD issued by Patreon* Membership Internet",,547.39,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3127641729,17-11-2025,"17-11-2025 02:39:26.338",-250.84,USD,"Card transaction of 299.70 EUR issued by Bkg*Booking.com Hotel (888)850-3958 (fee: 0.73 USD)",,548.49,USD,EUR,0.86051,,,,"Bkg*Booking.com Hotel (888)850-3958",7824,"Siddharth Bose",,,0.73,215.85,DEBIT,CARD
|
||||||
|
FEE-CARD-3127641729,17-11-2025,"17-11-2025 02:39:26.337",-0.73,USD,"Wise Charges for: CARD-3127641729",,799.33,,,,,,,"Bkg*Booking.com Hotel (888)850-3958",7824,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
CARD-3117684709,14-11-2025,"14-11-2025 10:19:28.530",-3.30,USD,"Card transaction of 3.30 USD issued by Patreon* Membership Internet",,800.06,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
BALANCE-4317152848,14-11-2025,"14-11-2025 06:16:50.965",-407.19,USD,"Converted 408.37 USD to 350.00 EUR (fee: 1.18 USD)",,803.36,USD,EUR,0.85955,,,,,,,,,1.18,350.00,DEBIT,CONVERSION
|
||||||
|
FEE-BALANCE-4317152848,14-11-2025,"14-11-2025 06:16:50.964",-1.18,USD,"Wise Charges for: BALANCE-4317152848",,1210.55,,,,,,,,,,,,0,,DEBIT,CONVERSION
|
||||||
|
CARD-3103414412,10-11-2025,"10-11-2025 09:06:17.274",40.70,USD,"Card transaction of -40.70 USD issued by Etihad Airw 6072412191638 ABU DHABI",,1211.73,,,,,,,"Etihad Airw 6072412191638 ABU DHABI",7824,"Siddharth Bose",,,0.00,,CREDIT,CARD
|
||||||
|
CARD-3072261097,01-11-2025,"01-11-2025 21:01:50.003",-5.50,USD,"Card transaction of 5.50 USD issued by Patreon* Membership Internet",,1171.03,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3072260355,01-11-2025,"01-11-2025 21:01:37.857",-1.08,USD,"Card transaction of 1.65 AUD issued by Patreon* Membership Internet (fee: 0.01 USD)",,1176.53,USD,AUD,1.52788,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.01,1.65,DEBIT,CARD
|
||||||
|
FEE-CARD-3072260355,01-11-2025,"01-11-2025 21:01:37.856",-0.01,USD,"Wise Charges for: CARD-3072260355",,1177.61,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
CARD-3059635570,29-10-2025,"29-10-2025 07:59:17.150",-57.55,USD,"Card transaction of 8,768.00 ETB issued by Ethiopian Ai0714402899536 ETHIOPIA (fee: 0.44 USD)",,1177.62,USD,ETB,152.35500,,,,"Ethiopian Ai0714402899536 ETHIOPIA",7824,"Siddharth Bose",,,0.44,8768.00,DEBIT,CARD
|
||||||
|
FEE-CARD-3059635570,29-10-2025,"29-10-2025 07:59:17.149",-0.44,USD,"Wise Charges for: CARD-3059635570",,1235.17,,,,,,,"Ethiopian Ai0714402899536 ETHIOPIA",7824,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
TRANSFER-1788804021,27-10-2025,"27-10-2025 19:09:13.072",-320.31,USD,"Sent money to USMAN TAHIR (fee: 2.59 USD)",,1235.61,USD,PKR,280.97500,,"USMAN TAHIR","PK64 ALFH 0143 0010 0738 5905",,,,,,2.59,90000.00,DEBIT,TRANSFER
|
||||||
|
FEE-TRANSFER-1788804021,27-10-2025,"27-10-2025 19:09:13.071",-2.59,USD,"Wise Charges for: TRANSFER-1788804021",,1555.92,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
|
||||||
|
CARD-3027275365,27-10-2025,"27-10-2025 05:07:31.020",-6.00,USD,"Card transaction of 6.00 USD issued by Sq *Sf Ferry Building Sui San Francisco",,1558.51,,,,,,,"Sq *Sf Ferry Building Sui San Francisco",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3027275365,25-10-2025,"25-10-2025 04:45:09.096",6.00,USD,"Card transaction of 6.00 USD issued by Sq *Sf Ferry Building Sui San Francisco",,1564.51,,,,,,,"Sq *Sf Ferry Building Sui San Francisco",7824,"Siddharth Bose",,,0.00,,CREDIT,CARD
|
||||||
|
CARD-3038407535,23-10-2025,"23-10-2025 10:08:41.710",-16.50,USD,"Card transaction of 16.50 USD issued by Patreon* Membership Internet",,1558.51,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3037798186,23-10-2025,"23-10-2025 04:35:28.733",-3.00,USD,"Card transaction of 3.00 USD issued by Clipper Systems Mobile #1 CONCORD",,1575.01,,,,,,,"Clipper Systems Mobile #1 CONCORD",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3027416520,20-10-2025,"20-10-2025 05:45:30.898",-22.43,USD,"Card transaction of 22.43 USD issued by Tst* Cholita Linda - Ferr SAN FRANCISCO",,1578.01,,,,,,,"Tst* Cholita Linda - Ferr SAN FRANCISCO",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3027275365,20-10-2025,"20-10-2025 04:42:14.178",-6.00,USD,"Card transaction of 6.00 USD issued by Sq *Sf Ferry Building Sui San Francisco",,1600.44,,,,,,,"Sq *Sf Ferry Building Sui San Francisco",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3027237014,20-10-2025,"20-10-2025 04:26:31.989",-7.61,USD,"Card transaction of 7.61 USD issued by Tst*El Porteno - Ferry B San Francisco",,1606.44,,,,,,,"Tst*El Porteno - Ferry B San Francisco",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3024465363,19-10-2025,"19-10-2025 08:57:38.344",-14.01,USD,"Card transaction of 14.01 USD issued by In-N-Outfishermanswhar SAN FRANCISCO",,1614.05,,,,,,,"In-N-Outfishermanswhar SAN FRANCISCO",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3024374797,19-10-2025,"19-10-2025 08:05:06.371",-15.00,USD,"Card transaction of 15.00 USD issued by Clipper Systems Mobile #1 CONCORD",,1628.06,,,,,,,"Clipper Systems Mobile #1 CONCORD",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3020765939,18-10-2025,"18-10-2025 10:08:02.684",-1.10,USD,"Card transaction of 1.10 USD issued by Patreon* Membership Internet",,1643.06,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
TRANSFER-1772299455,16-10-2025,"16-10-2025 08:00:59.610",1500.00,USD,"Received money from Siddharth Bose with reference ",,1644.16,,,,"Siddharth Bose",,,,,,,,0.00,,CREDIT,DEPOSIT
|
||||||
|
CARD-3006275078,14-10-2025,"14-10-2025 10:08:06.860",-3.30,USD,"Card transaction of 3.30 USD issued by Patreon* Membership Internet",,144.16,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-2997500294,11-10-2025,"11-10-2025 22:30:42.693",-219.70,USD,"Card transaction of 219.70 USD issued by Etihad Airw 6072412191638 ABU DHABI",,147.46,,,,,,,"Etihad Airw 6072412191638 ABU DHABI",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-2997495723,11-10-2025,"11-10-2025 22:29:34.687",1.31,USD,"Card transaction of 2.00 AUD issued by Google *Chrome Temp cc@google.com",,367.16,USD,AUD,1.54381,,,,"Google *Chrome Temp cc@google.com",7824,"Siddharth Bose",,,0.00,2.00,CREDIT,CARD
|
||||||
|
CARD-2997495723,11-10-2025,"11-10-2025 22:29:33.590",-1.31,USD,"Card transaction of 2.00 AUD issued by Google *Chrome Temp cc@google.com",,365.85,USD,AUD,1.54381,,,,"Google *Chrome Temp cc@google.com",7824,"Siddharth Bose",,,0.00,2.00,DEBIT,CARD
|
||||||
|
TRANSFER-1765630070,11-10-2025,"11-10-2025 22:28:27.692",321.88,USD,"Received money from Siddharth Bose with reference ",,367.16,,,,"Siddharth Bose",,,,,,,,0.00,,CREDIT,DEPOSIT
|
||||||
|
CARD-2960780824,01-10-2025,"01-10-2025 22:51:51.843",-1.09,USD,"Card transaction of 1.65 AUD issued by Patreon* Membership Internet (fee: 0.01 USD)",,45.28,USD,AUD,1.51012,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.01,1.65,DEBIT,CARD
|
||||||
|
FEE-CARD-2960780824,01-10-2025,"01-10-2025 22:51:51.842",-0.01,USD,"Wise Charges for: CARD-2960780824",,46.37,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
CARD-2960780366,01-10-2025,"01-10-2025 22:51:44.881",-5.50,USD,"Card transaction of 5.50 USD issued by Patreon* Membership 833-9728766",,46.38,,,,,,,"Patreon* Membership 833-9728766",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-2958747517,01-10-2025,"01-10-2025 09:03:26.059",-30.42,USD,"Card transaction of 76.00 AUD issued by Air India Limited Gurugram (fee: 0.09 USD)",,51.88,USD,AUD,1.51194,,,,"Air India Limited Gurugram",7824,"Siddharth Bose",,,0.09,46.00,DEBIT,CARD
|
||||||
|
FEE-CARD-2958747517,01-10-2025,"01-10-2025 09:03:26.058",-0.09,USD,"Wise Charges for: CARD-2958747517",,82.30,,,,,,,"Air India Limited Gurugram",7824,"Siddharth Bose",,,0,,DEBIT,CARD
|
||||||
|
TRANSFER-1738593468,25-09-2025,"25-09-2025 23:13:04.157",82.39,USD,"Received money from Siddharth Bose with reference ",,82.39,,,,"Siddharth Bose",,,,,,,,0.00,,CREDIT,DEPOSIT
|
||||||
|
@@ -0,0 +1,6 @@
|
|||||||
|
"TransferWise ID",Date,"Date Time",Amount,Currency,Description,"Payment Reference","Running Balance","Exchange From","Exchange To","Exchange Rate","Payer Name","Payee Name","Payee Account Number",Merchant,"Card Last Four Digits","Card Holder Full Name",Attachment,Note,"Total fees","Exchange To Amount","Transaction Type","Transaction Details Type"
|
||||||
|
CARD-3299985499,02-01-2026,"02-01-2026 23:06:34.804",-100.00,EUR,"Card transaction of 104.51 EUR issued by Sp Nord Studios FORDINGBRIDGE",,0.00,,,,,,,"Sp Nord Studios FORDINGBRIDGE",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
BALANCE-4340683172,19-11-2025,"19-11-2025 02:51:28.940",100.00,EUR,"Converted 116.11 USD to 100.00 EUR",,100.00,USD,EUR,0.86367,,,,,,,,,0.00,100.00,CREDIT,CONVERSION
|
||||||
|
CARD-3127641729,17-11-2025,"17-11-2025 02:39:26.289",-83.85,EUR,"Card transaction of 299.70 EUR issued by Bkg*Booking.com Hotel (888)850-3958",,0.00,,,,,,,"Bkg*Booking.com Hotel (888)850-3958",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
CARD-3117227825,14-11-2025,"14-11-2025 06:17:54.371",-266.15,EUR,"Card transaction of 266.15 EUR issued by Bkg*Hotel At Booking.c (888)850-3958",,83.85,,,,,,,"Bkg*Hotel At Booking.c (888)850-3958",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
|
||||||
|
BALANCE-4317152848,14-11-2025,"14-11-2025 06:16:50.965",350.00,EUR,"Converted 408.37 USD to 350.00 EUR",,350.00,USD,EUR,0.85955,,,,,,,,,0.00,350.00,CREDIT,CONVERSION
|
||||||
|
@@ -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",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"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": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.4.2",
|
"@prisma/adapter-pg": "^7.4.2",
|
||||||
@@ -16,7 +21,8 @@
|
|||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"prisma": "^7.4.2",
|
"prisma": "^7.4.2",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"recharts": "^3.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -24,9 +30,12 @@
|
|||||||
"@types/pg": "^8.18.0",
|
"@types/pg": "^8.18.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@vitest/coverage-v8": "^4.1.2",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"tailwindcss": "^4",
|
"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,12 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS rules (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES participants(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
conditions JSONB NOT NULL DEFAULT '[]',
|
||||||
|
actions JSONB NOT NULL DEFAULT '{}',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rules_owner ON rules(owner_id);
|
||||||
@@ -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%';
|
||||||
@@ -7,13 +7,29 @@ datasource db {
|
|||||||
provider = "postgresql"
|
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 {
|
model transaction_overrides {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
transaction_id Int @unique
|
transaction_id Int @unique
|
||||||
merchant_normalized String?
|
merchant_normalized String?
|
||||||
category_override String?
|
category_override String?
|
||||||
notes String?
|
notes String?
|
||||||
|
my_share_percent Decimal? @db.Decimal(5, 2)
|
||||||
updated_at DateTime @default(now()) @updatedAt
|
updated_at DateTime @default(now()) @updatedAt
|
||||||
|
trip_id Int?
|
||||||
|
trip trips? @relation(fields: [trip_id], references: [id], onDelete: SetNull)
|
||||||
}
|
}
|
||||||
|
|
||||||
model participants {
|
model participants {
|
||||||
@@ -23,6 +39,8 @@ model participants {
|
|||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
splits transaction_splits[]
|
splits transaction_splits[]
|
||||||
account_owner_mappings account_owner_mappings[]
|
account_owner_mappings account_owner_mappings[]
|
||||||
|
payments_sent split_payments[] @relation("payments_from")
|
||||||
|
payments_received split_payments[] @relation("payments_to")
|
||||||
}
|
}
|
||||||
|
|
||||||
model account_owner_mappings {
|
model account_owner_mappings {
|
||||||
@@ -49,6 +67,19 @@ model transaction_splits {
|
|||||||
@@unique([transaction_id, participant_id])
|
@@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 {
|
model tags {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
@@ -66,6 +97,19 @@ model transaction_tags {
|
|||||||
@@id([transaction_id, tag_id])
|
@@id([transaction_id, tag_id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model rules {
|
||||||
|
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 {
|
model budgets {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
owner_id Int
|
owner_id Int
|
||||||
@@ -77,3 +121,99 @@ model budgets {
|
|||||||
|
|
||||||
@@unique([owner_id, category, month])
|
@@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 });
|
||||||
|
}
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { queryRaw } from "@/lib/db";
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
const user = await getCurrentUser(req);
|
const user = await getCurrentUser(req);
|
||||||
@@ -16,6 +25,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const startStr = startDate.toISOString().slice(0, 10);
|
const startStr = startDate.toISOString().slice(0, 10);
|
||||||
const endStr = endDate.toISOString().slice(0, 10);
|
const endStr = endDate.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Expenses: debits excluding transfers and investments, split-adjusted
|
||||||
const spendRows = await queryRaw<{
|
const spendRows = await queryRaw<{
|
||||||
month: string;
|
month: string;
|
||||||
category: string;
|
category: string;
|
||||||
@@ -24,14 +34,16 @@ export async function GET(req: NextRequest) {
|
|||||||
}>(
|
}>(
|
||||||
`SELECT
|
`SELECT
|
||||||
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
||||||
COALESCE(o.category_override, t.category) as category,
|
${EFFECTIVE_CATEGORY} as category,
|
||||||
SUM(t.amount)::numeric(12,2) as total_spent,
|
SUM(${mySplitOf(SPEND_SIGNED)})::numeric(12,2) as total_spent,
|
||||||
COUNT(*)::int as transaction_count
|
COUNT(*)::int as transaction_count
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
JOIN statements s ON s.id = t.statement_id
|
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||||
WHERE s.owner_id = $1
|
${STATEMENTS_JOIN}
|
||||||
AND t.transaction_type = 'debit'
|
WHERE ${OWNER_SCOPE} = $1
|
||||||
|
AND ${NET_SPEND_ROWS}
|
||||||
|
AND ${EXCLUDE_NON_SPEND}
|
||||||
AND t.transaction_date >= $2
|
AND t.transaction_date >= $2
|
||||||
AND t.transaction_date < $3
|
AND t.transaction_date < $3
|
||||||
GROUP BY 1, 2
|
GROUP BY 1, 2
|
||||||
@@ -39,10 +51,48 @@ export async function GET(req: NextRequest) {
|
|||||||
[user.id, startStr, endStr]
|
[user.id, startStr, endStr]
|
||||||
);
|
);
|
||||||
|
|
||||||
const budgetRows = await queryRaw<{ month: string; category: string; amount_limit: number }>(
|
// Income: credits/payments categorised as income
|
||||||
`SELECT TO_CHAR(month, 'YYYY-MM') as month, category, amount_limit::numeric
|
const incomeRows = await queryRaw<{
|
||||||
FROM budgets
|
month: string;
|
||||||
WHERE owner_id = $1 AND month >= $2::date AND month < $3::date`,
|
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]
|
[user.id, startStr, endStr]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,50 +106,46 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const spendMap = new Map<string, number>();
|
const spendMap = new Map<string, number>();
|
||||||
const countMap = new Map<string, number>();
|
const countMap = new Map<string, number>();
|
||||||
const budgetMap = new Map<string, number>();
|
const incomeMap = new Map<string, number>();
|
||||||
|
const investMap = new Map<string, number>();
|
||||||
|
|
||||||
for (const r of spendRows) {
|
for (const r of spendRows) {
|
||||||
spendMap.set(`${r.category}:${r.month}`, Number(r.total_spent));
|
spendMap.set(`${r.category}:${r.month}`, Number(r.total_spent));
|
||||||
countMap.set(`${r.category}:${r.month}`, r.transaction_count);
|
countMap.set(`${r.category}:${r.month}`, r.transaction_count);
|
||||||
}
|
}
|
||||||
for (const r of budgetRows) {
|
for (const r of incomeRows) incomeMap.set(r.month, Number(r.total_income));
|
||||||
budgetMap.set(`${r.category}:${r.month}`, Number(r.amount_limit));
|
for (const r of investmentRows) investMap.set(r.month, Number(r.total_invested));
|
||||||
}
|
|
||||||
|
|
||||||
const allCategories = new Set<string>();
|
const allCategories = new Set<string>();
|
||||||
for (const r of spendRows) allCategories.add(r.category);
|
for (const r of spendRows) allCategories.add(r.category);
|
||||||
for (const r of budgetRows) allCategories.add(r.category);
|
|
||||||
|
|
||||||
const rows = Array.from(allCategories)
|
const rows = Array.from(allCategories)
|
||||||
.sort()
|
.sort()
|
||||||
.map((cat) => {
|
.map((cat) => {
|
||||||
const spent: Record<string, number> = {};
|
const spent: Record<string, number> = {};
|
||||||
const budget: Record<string, number> = {};
|
|
||||||
const txCount: Record<string, number> = {};
|
const txCount: Record<string, number> = {};
|
||||||
for (const m of months) {
|
for (const m of months) {
|
||||||
const s = spendMap.get(`${cat}:${m}`);
|
const s = spendMap.get(`${cat}:${m}`);
|
||||||
const b = budgetMap.get(`${cat}:${m}`);
|
|
||||||
const c = countMap.get(`${cat}:${m}`);
|
const c = countMap.get(`${cat}:${m}`);
|
||||||
if (s !== undefined) spent[m] = s;
|
if (s !== undefined) spent[m] = s;
|
||||||
if (b !== undefined) budget[m] = b;
|
|
||||||
if (c !== undefined) txCount[m] = c;
|
if (c !== undefined) txCount[m] = c;
|
||||||
}
|
}
|
||||||
return { category: cat, spent, budget, txCount };
|
return { category: cat, spent, txCount };
|
||||||
});
|
});
|
||||||
|
|
||||||
const totals: Record<string, { spent: number; budget: number }> = {};
|
const totals: Record<string, { spent: number; income: number; investments: number; net: number }> = {};
|
||||||
for (const m of months) {
|
for (const m of months) {
|
||||||
let s = 0;
|
let spent = 0;
|
||||||
let b = 0;
|
for (const row of rows) spent += row.spent[m] || 0;
|
||||||
for (const row of rows) {
|
const income = incomeMap.get(m) || 0;
|
||||||
s += row.spent[m] || 0;
|
const investments = investMap.get(m) || 0;
|
||||||
b += row.budget[m] || 0;
|
|
||||||
}
|
|
||||||
totals[m] = {
|
totals[m] = {
|
||||||
spent: Math.round(s * 100) / 100,
|
spent: Math.round(spent * 100) / 100,
|
||||||
budget: Math.round(b * 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, totals });
|
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,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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { getMerchantSuggestions, getBankNames } from "@/lib/queries";
|
import { getMerchantSuggestions, getBankNames } from "@/lib/queries";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
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 search = req.nextUrl.searchParams.get("search");
|
||||||
const type = req.nextUrl.searchParams.get("type");
|
const type = req.nextUrl.searchParams.get("type");
|
||||||
|
|
||||||
if (type === "banks") {
|
if (type === "banks") {
|
||||||
const banks = await getBankNames();
|
const banks = await getBankNames();
|
||||||
return NextResponse.json(banks.map((b) => b.bank_name));
|
return NextResponse.json(banks);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!search) return NextResponse.json([]);
|
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,45 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { queryRaw, prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
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 body = await req.json();
|
||||||
|
|
||||||
|
const existing = await queryRaw<{ id: number }>(
|
||||||
|
`SELECT id FROM rules WHERE id = $1 AND owner_id = $2`,
|
||||||
|
[Number(id), user.id]
|
||||||
|
);
|
||||||
|
if (!existing.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const updated = await prisma.rules.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data: {
|
||||||
|
...(body.name !== undefined && { name: body.name }),
|
||||||
|
...(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 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 rules WHERE id = $1 AND owner_id = $2`,
|
||||||
|
[Number(id), user.id]
|
||||||
|
);
|
||||||
|
if (!existing.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
await prisma.rules.delete({ where: { id: Number(id) } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const runs = await queryRaw<{
|
||||||
|
id: number;
|
||||||
|
applied_at: string;
|
||||||
|
split_from: string | null;
|
||||||
|
matched: number;
|
||||||
|
transactions_affected: number;
|
||||||
|
reverted_at: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT id, applied_at, split_from, matched, transactions_affected, reverted_at
|
||||||
|
FROM rule_apply_runs WHERE owner_id = $1 ORDER BY applied_at DESC LIMIT 20`,
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(runs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
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; conditions: unknown; actions: unknown }>(
|
||||||
|
ruleId
|
||||||
|
? `SELECT id, conditions, actions FROM rules
|
||||||
|
WHERE owner_id = $1 AND id = $2 AND manual_only = false`
|
||||||
|
: `SELECT id, 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 { conditions, actions } of parsedRules) {
|
||||||
|
for (const tx of transactions) {
|
||||||
|
const allMatch = conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx));
|
||||||
|
if (!allMatch) continue;
|
||||||
|
|
||||||
|
matched++;
|
||||||
|
affectedIds.add(tx.id);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Save run record ---
|
||||||
|
const run = await queryRaw<{ id: number }>(
|
||||||
|
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot)
|
||||||
|
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||||
|
[user.id, splitFrom, matched, affectedIds.size, JSON.stringify(snapshot)]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ id: run[0].id, matched, transactions_affected: affectedIds.size });
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { queryRaw, 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 rows = await queryRaw<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
conditions: unknown;
|
||||||
|
actions: unknown;
|
||||||
|
enabled: boolean;
|
||||||
|
manual_only: boolean;
|
||||||
|
priority: number;
|
||||||
|
created_at: string;
|
||||||
|
}>(
|
||||||
|
`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]
|
||||||
|
);
|
||||||
|
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 { 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({
|
||||||
|
data: {
|
||||||
|
owner_id: user.id,
|
||||||
|
name,
|
||||||
|
conditions: conditions ?? [],
|
||||||
|
actions: actions ?? {},
|
||||||
|
enabled,
|
||||||
|
manual_only,
|
||||||
|
priority,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(rule, { status: 201 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
// A split may be settled by the transaction's effective owner or by the
|
||||||
|
// participant the split belongs to.
|
||||||
|
const SCOPE = `
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM transactions t
|
||||||
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
|
WHERE t.id = transaction_splits.transaction_id
|
||||||
|
AND (COALESCE(t.owner_id, s.owner_id) = $2 OR transaction_splits.participant_id = $2)
|
||||||
|
)`;
|
||||||
|
|
||||||
|
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 { participant_id, split_ids } = body as {
|
||||||
|
participant_id?: number;
|
||||||
|
split_ids?: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (participant_id) {
|
||||||
|
const rows = await queryRaw<{ id: number }>(
|
||||||
|
`UPDATE transaction_splits SET settled = true, settled_at = NOW()
|
||||||
|
WHERE participant_id = $1 AND settled = false ${SCOPE}
|
||||||
|
RETURNING id`,
|
||||||
|
[participant_id, user.id]
|
||||||
|
);
|
||||||
|
return NextResponse.json({ settled: rows.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (split_ids?.length) {
|
||||||
|
const rows = await queryRaw<{ id: number }>(
|
||||||
|
`UPDATE transaction_splits SET settled = true, settled_at = NOW()
|
||||||
|
WHERE id = ANY($1::int[]) AND settled = false ${SCOPE}
|
||||||
|
RETURNING id`,
|
||||||
|
[split_ids, user.id]
|
||||||
|
);
|
||||||
|
return NextResponse.json({ settled: rows.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: "participant_id or split_ids required" }, { status: 400 });
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getStatementById } from "@/lib/queries";
|
import { getStatementById } from "@/lib/queries";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const stmt = await getStatementById(Number(id));
|
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);
|
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { queryRaw } from "@/lib/db";
|
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;
|
const { id } = await params;
|
||||||
await queryRaw(`DELETE FROM tags WHERE id = $1`, [Number(id)]);
|
await queryRaw(`DELETE FROM tags WHERE id = $1`, [Number(id)]);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getTags } from "@/lib/queries";
|
import { getTags } from "@/lib/queries";
|
||||||
import { queryRaw } from "@/lib/db";
|
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();
|
const tags = await getTags();
|
||||||
return NextResponse.json(tags);
|
return NextResponse.json(tags);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
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();
|
const { name, color } = await req.json();
|
||||||
if (!name?.trim()) {
|
if (!name?.trim()) {
|
||||||
return NextResponse.json({ error: "name required" }, { status: 400 });
|
return NextResponse.json({ error: "name required" }, { status: 400 });
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
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 { prisma } from "@/lib/db";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
|
const VALID_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
const { id } = await params;
|
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));
|
const txn = await getTransactionById(Number(id));
|
||||||
if (!txn) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!txn) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
return NextResponse.json(txn);
|
return NextResponse.json(txn);
|
||||||
@@ -16,20 +25,91 @@ export async function PATCH(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const transactionId = Number(id);
|
const transactionId = Number(id);
|
||||||
|
if (!(await canAccessTransactions(user.id, [transactionId]))) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
const body = await req.json();
|
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;
|
category?: string;
|
||||||
merchant_normalized?: string;
|
merchant_normalized?: string;
|
||||||
notes?: 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() };
|
const data: Record<string, unknown> = { updated_at: new Date() };
|
||||||
if (category !== undefined) data.category_override = category;
|
if (category !== undefined) data.category_override = category;
|
||||||
if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized;
|
if (merchant_normalized !== undefined) data.merchant_normalized = merchant_normalized;
|
||||||
if (notes !== undefined) data.notes = notes;
|
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({
|
const override = await prisma.transaction_overrides.upsert({
|
||||||
where: { transaction_id: transactionId },
|
where: { transaction_id: transactionId },
|
||||||
@@ -39,6 +119,8 @@ export async function PATCH(
|
|||||||
category_override: category || null,
|
category_override: category || null,
|
||||||
merchant_normalized: merchant_normalized || null,
|
merchant_normalized: merchant_normalized || null,
|
||||||
notes: notes || 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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { queryRaw } from "@/lib/db";
|
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 }> }) {
|
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 { id } = await params;
|
||||||
|
if (!(await canAccessTransactions(user.id, [Number(id)]))) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
const { tag_id } = await req.json();
|
const { tag_id } = await req.json();
|
||||||
if (!tag_id) return NextResponse.json({ error: "tag_id required" }, { status: 400 });
|
if (!tag_id) return NextResponse.json({ error: "tag_id required" }, { status: 400 });
|
||||||
await queryRaw(
|
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 }> }) {
|
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 { id } = await params;
|
||||||
|
if (!(await canAccessTransactions(user.id, [Number(id)]))) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
const { tag_id } = await req.json();
|
const { tag_id } = await req.json();
|
||||||
if (!tag_id) return NextResponse.json({ error: "tag_id required" }, { status: 400 });
|
if (!tag_id) return NextResponse.json({ error: "tag_id required" }, { status: 400 });
|
||||||
await queryRaw(
|
await queryRaw(
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma, queryRaw } from "@/lib/db";
|
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) {
|
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 body = await req.json();
|
||||||
const { action, ids, category, merchant_normalized, splits, tag_id } = body as {
|
const { action, ids, category, merchant_normalized, splits, tag_id } = body as {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -16,6 +22,10 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "ids required" }, { status: 400 });
|
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) {
|
if (action === "categorize" && category) {
|
||||||
const ops = ids.map((id) =>
|
const ops = ids.map((id) =>
|
||||||
prisma.transaction_overrides.upsert({
|
prisma.transaction_overrides.upsert({
|
||||||
@@ -77,5 +87,42 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ updated: ids.length });
|
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)
|
||||||
|
VALUES ($1, NULL, $2, $3, $4) RETURNING id`,
|
||||||
|
[user.id, ids.length, ids.length, JSON.stringify(snapshot)]
|
||||||
|
);
|
||||||
|
|
||||||
|
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 });
|
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import { getTransactions } from "@/lib/queries";
|
import { getTransactions } from "@/lib/queries";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const user = await getCurrentUser(req);
|
const user = await getCurrentUser(req);
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
const sp = req.nextUrl.searchParams;
|
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, {
|
const result = await getTransactions(user.id, {
|
||||||
from: sp.get("from") || undefined,
|
from: sp.get("from") || undefined,
|
||||||
to: sp.get("to") || undefined,
|
to: sp.get("to") || undefined,
|
||||||
category: sp.get("category") || undefined,
|
categories: parseArr("categories"),
|
||||||
bank_name: sp.get("bank_name") || undefined,
|
bank_names: parseArr("bank_names"),
|
||||||
|
tag_ids: parseArr("tag_ids"),
|
||||||
|
transaction_types: parseArr("transaction_types"),
|
||||||
search: sp.get("search") || undefined,
|
search: sp.get("search") || undefined,
|
||||||
statement_id: sp.get("statement_id") || undefined,
|
statement_id: sp.get("statement_id") || undefined,
|
||||||
tag_id: sp.get("tag_id") || undefined,
|
|
||||||
sort_by: sp.get("sort_by") || undefined,
|
sort_by: sp.get("sort_by") || undefined,
|
||||||
sort_dir: sp.get("sort_dir") || undefined,
|
sort_dir: sp.get("sort_dir") || undefined,
|
||||||
limit: sp.get("limit") ? Number(sp.get("limit")) : undefined,
|
limit: sp.get("limit") ? Number(sp.get("limit")) : undefined,
|
||||||
offset: sp.get("offset") ? Number(sp.get("offset")) : 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);
|
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 });
|
||||||
|
}
|
||||||
+591
-301
@@ -1,377 +1,667 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect, Fragment, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
useBudgets,
|
ComposedChart,
|
||||||
useUpsertBudget,
|
LineChart,
|
||||||
useDeleteBudget,
|
AreaChart,
|
||||||
useMonthlyAnalytics,
|
Area,
|
||||||
} from "@/lib/hooks";
|
Bar,
|
||||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
Line,
|
||||||
|
XAxis,
|
||||||
function formatMonth(m: string): string {
|
YAxis,
|
||||||
const [year, month] = m.split("-");
|
Tooltip,
|
||||||
const date = new Date(Number(year), Number(month) - 1, 1);
|
ResponsiveContainer,
|
||||||
return date.toLocaleString("default", { month: "long", year: "numeric" });
|
Cell,
|
||||||
}
|
ReferenceLine,
|
||||||
|
} from "recharts";
|
||||||
function prevMonth(m: string): string {
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
const [year, month] = m.split("-").map(Number);
|
import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
||||||
const d = new Date(year, month - 2, 1);
|
import { formatCategory, CATEGORIES } from "@/lib/categories";
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
import { CATEGORY_COLORS, CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
|
||||||
}
|
|
||||||
|
|
||||||
function nextMonth(m: string): string {
|
|
||||||
const [year, month] = m.split("-").map(Number);
|
|
||||||
const d = new Date(year, month, 1);
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentMonthStr(): string {
|
function currentMonthStr(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
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)}`; }
|
||||||
|
|
||||||
function barColor(pct: number): string {
|
// ─── Tooltips ────────────────────────────────────────────────────────────────
|
||||||
if (pct > 100) return "bg-red-500";
|
|
||||||
if (pct > 80) return "bg-yellow-400";
|
function ParetoTooltip({ active, payload }: { active?: boolean; payload?: { payload: { category: string; spent: number; pct: number; cumulative: number } }[] }) {
|
||||||
return "bg-emerald-500";
|
if (!active || !payload?.length) return null;
|
||||||
|
const d = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs">
|
||||||
|
<p className="font-medium text-zinc-300 mb-1">{formatCategory(d.category)}</p>
|
||||||
|
<div className="flex justify-between gap-4"><span className="text-zinc-400">Spend</span><span 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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BudgetPage() {
|
function CumulativeTooltip({ active, payload, label }: { active?: boolean; payload?: { dataKey: string; value: number; stroke: string }[]; label?: number }) {
|
||||||
const [selectedMonth, setSelectedMonth] = useState(currentMonthStr);
|
if (!active || !payload?.length) return null;
|
||||||
const [editingCategory, setEditingCategory] = useState<string | null>(null);
|
return (
|
||||||
const [editValue, setEditValue] = useState("");
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { data: budgets = [], isLoading: budgetsLoading } = useBudgets(selectedMonth);
|
// ─── Month spine ─────────────────────────────────────────────────────────────
|
||||||
const { data: analytics, isLoading: analyticsLoading } = useMonthlyAnalytics(6);
|
// Twelve clickable columns, one per month, scaled to that month's spend.
|
||||||
const upsertBudget = useUpsertBudget();
|
// Doubles as period navigation and year-at-a-glance context.
|
||||||
const deleteBudget = useDeleteBudget();
|
|
||||||
|
|
||||||
const budgetMap = new Map(budgets.map((b) => [b.category, b]));
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Categories with spend this month or a budget set
|
// ─── CategoryPanel (drill-down) ──────────────────────────────────────────────
|
||||||
const currentMonthRows = analytics?.rows.filter((r) => r.spent[selectedMonth] !== undefined) || [];
|
|
||||||
const allCategories = new Set<string>([
|
|
||||||
...currentMonthRows.map((r) => r.category),
|
|
||||||
...budgets.map((b) => b.category),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const tableRows = Array.from(allCategories)
|
function CategoryPanel({ category, selectedMonth }: { category: string; selectedMonth: string }) {
|
||||||
.sort()
|
const qc = useQueryClient();
|
||||||
.map((cat) => {
|
const updateTx = useUpdateTransaction();
|
||||||
const analyticsRow = analytics?.rows.find((r) => r.category === cat);
|
const from = `${selectedMonth}-01`;
|
||||||
const spent = analyticsRow?.spent[selectedMonth] || 0;
|
const [year, month] = selectedMonth.split("-").map(Number);
|
||||||
const txCount = analyticsRow?.txCount[selectedMonth] || 0;
|
const nextDate = new Date(year, month, 1);
|
||||||
const budget = budgetMap.get(cat);
|
const to = `${nextDate.getFullYear()}-${String(nextDate.getMonth() + 1).padStart(2, "0")}-01`;
|
||||||
const limit = budget ? Number(budget.amount_limit) : null;
|
const { data, isLoading } = useTransactions({ categories: category ? [category] : [], from, to, limit: 200 });
|
||||||
const remaining = limit !== null ? limit - spent : null;
|
const txns = data?.data || [];
|
||||||
const pct = limit !== null && limit > 0 ? (spent / limit) * 100 : null;
|
|
||||||
return { cat, spent, txCount, budget, limit, remaining, pct };
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalBudgeted = budgets.reduce((s, b) => s + Number(b.amount_limit), 0);
|
|
||||||
const totalSpent = tableRows.reduce((s, r) => s + r.spent, 0);
|
|
||||||
const overBudgetCount = tableRows.filter((r) => r.pct !== null && r.pct > 100).length;
|
|
||||||
|
|
||||||
async function handleBudgetSave(cat: string) {
|
|
||||||
const val = parseFloat(editValue);
|
|
||||||
if (isNaN(val) || val < 0) return;
|
|
||||||
await upsertBudget.mutateAsync({ category: cat, month: selectedMonth, amount_limit: val });
|
|
||||||
setEditingCategory(null);
|
|
||||||
setEditValue("");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<tr>
|
||||||
{/* Month selector */}
|
<td colSpan={4} className="px-0 pb-2 bg-zinc-950/60 border-b border-zinc-800">
|
||||||
<div className="flex items-center justify-between">
|
{isLoading ? (
|
||||||
<h2 className="text-xl font-semibold">Budget</h2>
|
<p className="text-xs text-zinc-500 px-6 py-2">Loading…</p>
|
||||||
<div className="flex items-center gap-3">
|
) : txns.length === 0 ? (
|
||||||
<button
|
<p className="text-xs text-zinc-600 px-6 py-2">No transactions</p>
|
||||||
onClick={() => setSelectedMonth(prevMonth(selectedMonth))}
|
) : (
|
||||||
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"
|
<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) => (
|
||||||
</button>
|
<option key={cat} value={cat}>{formatCategory(cat)}</option>
|
||||||
<span className="text-sm font-medium min-w-36 text-center">{formatMonth(selectedMonth)}</span>
|
))}
|
||||||
<button
|
</select>
|
||||||
onClick={() => setSelectedMonth(nextMonth(selectedMonth))}
|
</td>
|
||||||
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"
|
</tr>
|
||||||
>
|
))}
|
||||||
›
|
</tbody>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary cards */}
|
{/* ── Cashflow strip ── */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<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="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
<div className="px-4 py-3.5">
|
||||||
<p className="text-xs text-zinc-500 mb-1">Total Budgeted</p>
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Income</p>
|
||||||
<p className="text-2xl font-semibold">${totalBudgeted.toFixed(2)}</p>
|
<p className={`text-xl font-mono tabular-nums ${hasIncome ? "text-emerald-400" : "text-zinc-600"}`}>{hasIncome ? fmt(totals.income) : "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
<div className="px-4 py-3.5">
|
||||||
<p className="text-xs text-zinc-500 mb-1">Total Spent</p>
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Expenses</p>
|
||||||
<p
|
<p className="text-xl font-mono tabular-nums text-zinc-100">{fmt(totals.spent)}</p>
|
||||||
className={`text-2xl font-semibold ${
|
|
||||||
totalBudgeted > 0 && totalSpent > totalBudgeted ? "text-red-400" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
${totalSpent.toFixed(2)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
<div className="px-4 py-3.5">
|
||||||
<p className="text-xs text-zinc-500 mb-1">Over Budget</p>
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Invested</p>
|
||||||
<p
|
<p className={`text-xl font-mono tabular-nums ${hasInvestments ? "text-indigo-300" : "text-zinc-600"}`}>{hasInvestments ? fmt(totals.investments) : "—"}</p>
|
||||||
className={`text-2xl font-semibold ${
|
</div>
|
||||||
overBudgetCount > 0 ? "text-red-400" : "text-emerald-400"
|
<div className="px-4 py-3.5">
|
||||||
}`}
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Net cash</p>
|
||||||
>
|
{hasIncome ? (
|
||||||
{overBudgetCount} {overBudgetCount === 1 ? "category" : "categories"}
|
<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>
|
) : (
|
||||||
|
<p className="text-xl font-mono tabular-nums text-zinc-600">—</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Budget table for current month */}
|
{/* ── Top movers vs last month ── */}
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
|
{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">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-800">
|
<tr className="border-b border-zinc-800">
|
||||||
<th className="text-left px-4 py-3 text-xs text-zinc-500 font-medium">Category</th>
|
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Category</th>
|
||||||
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Budget</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-3 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-3 text-xs text-zinc-500 font-medium">Remaining</th>
|
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">% of total</th>
|
||||||
<th className="px-4 py-3 text-xs text-zinc-500 font-medium w-36">Progress</th>
|
|
||||||
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium"># Txns</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{budgetsLoading || analyticsLoading ? (
|
{categoryRows.map(({ category, spent, txCount }) => {
|
||||||
<tr>
|
const isExpanded = expandedCategory === category;
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-zinc-500">
|
return (
|
||||||
Loading...
|
<Fragment key={category}>
|
||||||
</td>
|
<tr
|
||||||
</tr>
|
className={`border-b border-zinc-800/50 cursor-pointer select-none transition-colors ${isExpanded ? "bg-zinc-800/40" : "hover:bg-zinc-800/30"}`}
|
||||||
) : tableRows.length === 0 ? (
|
onClick={() => setExpandedCategory(isExpanded ? null : category)}
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-zinc-500">
|
|
||||||
No spending data for this month. Set a budget for any category below.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
tableRows.map(({ cat, spent, txCount, budget, limit, remaining, pct }) => (
|
|
||||||
<tr key={cat} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
|
||||||
<td className="px-4 py-3 font-medium">{formatCategory(cat)}</td>
|
|
||||||
<td className="px-4 py-3 text-right">
|
|
||||||
{editingCategory === cat ? (
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
value={editValue}
|
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") handleBudgetSave(cat);
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setEditingCategory(null);
|
|
||||||
setEditValue("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-24 bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-right text-sm"
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => handleBudgetSave(cat)}
|
|
||||||
className="text-xs text-emerald-400 hover:text-emerald-300 px-1"
|
|
||||||
>
|
>
|
||||||
✓
|
<td className="px-4 py-2.5 font-medium">
|
||||||
</button>
|
<span className="flex items-center gap-2">
|
||||||
<button
|
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[category] || CHART.axis }} />
|
||||||
onClick={() => {
|
{formatCategory(category)}
|
||||||
setEditingCategory(null);
|
<span className="text-zinc-600 text-xs ml-1">{isExpanded ? "▲" : "▼"}</span>
|
||||||
setEditValue("");
|
|
||||||
}}
|
|
||||||
className="text-xs text-zinc-500 hover:text-zinc-300 px-1"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingCategory(cat);
|
|
||||||
setEditValue(limit?.toString() || "");
|
|
||||||
}}
|
|
||||||
className="text-zinc-300 hover:text-white underline-offset-2 hover:underline"
|
|
||||||
>
|
|
||||||
{limit !== null ? (
|
|
||||||
`$${limit.toFixed(2)}`
|
|
||||||
) : (
|
|
||||||
<span className="text-zinc-600 italic">Set...</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{budget && (
|
|
||||||
<button
|
|
||||||
onClick={() => deleteBudget.mutate(budget.id)}
|
|
||||||
className="text-zinc-600 hover:text-red-400 text-xs ml-1"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right">${spent.toFixed(2)}</td>
|
|
||||||
<td
|
|
||||||
className={`px-4 py-3 text-right ${
|
|
||||||
remaining !== null
|
|
||||||
? remaining < 0
|
|
||||||
? "text-red-400"
|
|
||||||
: "text-emerald-400"
|
|
||||||
: "text-zinc-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{remaining !== null ? `$${remaining.toFixed(2)}` : "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{pct !== null ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 bg-zinc-800 rounded-full h-2 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full ${barColor(pct)}`}
|
|
||||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-zinc-400 w-10 text-right">
|
|
||||||
{pct.toFixed(0)}%
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-zinc-600 text-xs">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right text-zinc-400">{txCount}</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>
|
</tr>
|
||||||
))
|
{isExpanded && <CategoryPanel category={category} selectedMonth={selectedMonth} />}
|
||||||
)}
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add budget for any category not yet shown */}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-zinc-500 mb-2">Set budget for another category:</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{CATEGORIES.filter((c) => !allCategories.has(c)).map((cat) => (
|
|
||||||
<button
|
|
||||||
key={cat}
|
|
||||||
onClick={() => {
|
|
||||||
setEditingCategory(cat);
|
|
||||||
setEditValue("");
|
|
||||||
}}
|
|
||||||
className="px-3 py-1.5 text-xs rounded-lg border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-zinc-200"
|
|
||||||
>
|
|
||||||
{formatCategory(cat)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{editingCategory && !allCategories.has(editingCategory) && (
|
|
||||||
<div className="flex items-center gap-2 mt-3">
|
|
||||||
<span className="text-sm font-medium">{formatCategory(editingCategory)}</span>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
value={editValue}
|
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") handleBudgetSave(editingCategory);
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
setEditingCategory(null);
|
|
||||||
setEditValue("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-28 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
|
||||||
placeholder="Budget amount"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => handleBudgetSave(editingCategory)}
|
|
||||||
className="px-3 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 6-month trend table */}
|
{/* ── 6-month ledger table (heat-tinted) ── */}
|
||||||
{analytics && analytics.months.length > 0 && (
|
{tableMonths.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-zinc-400 mb-3">6-Month Trend</h3>
|
<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-700">
|
<div className="overflow-x-auto rounded-xl border border-zinc-800">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-800 bg-zinc-900">
|
<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">
|
<th className="text-left px-3 py-2 text-zinc-500 font-medium sticky left-0 bg-zinc-900 min-w-32">Category</th>
|
||||||
Category
|
{tableMonths.map((m) => (
|
||||||
</th>
|
|
||||||
{analytics.months.map((m) => (
|
|
||||||
<th
|
<th
|
||||||
key={m}
|
key={m}
|
||||||
className="text-right px-3 py-2 text-zinc-500 font-medium whitespace-nowrap"
|
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)}
|
||||||
>
|
>
|
||||||
{m}
|
{formatShortMonth(m)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{analytics.rows.map((row) => (
|
{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">
|
<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">
|
<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)}
|
{formatCategory(row.category)}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
{analytics.months.map((m) => {
|
{tableMonths.map((m) => {
|
||||||
const spent = row.spent[m];
|
const spent = row.spent[m];
|
||||||
const budget = row.budget[m];
|
const heat = spent !== undefined ? (spent / rowMax) * 0.28 : 0;
|
||||||
const overBudget =
|
|
||||||
spent !== undefined && budget !== undefined && spent > budget;
|
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={m}
|
key={m}
|
||||||
className={`px-3 py-2 text-right tabular-nums ${
|
className={`px-3 py-2 text-right font-mono tabular-nums ${spent === undefined ? "text-zinc-700" : "text-zinc-300"}`}
|
||||||
spent === undefined
|
style={heat > 0.02 ? { background: `rgba(188, 111, 48, ${heat.toFixed(3)})` } : undefined}
|
||||||
? "text-zinc-700"
|
|
||||||
: overBudget
|
|
||||||
? "text-red-300 bg-red-950/40"
|
|
||||||
: "text-zinc-300"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{spent !== undefined ? `$${Number(spent).toFixed(0)}` : "—"}
|
{spent !== undefined ? fmt(spent) : "—"}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</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">
|
<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">Total</td>
|
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Expenses</td>
|
||||||
{analytics.months.map((m) => {
|
{tableMonths.map((m) => {
|
||||||
const t = analytics.totals[m];
|
const t = analytics.totals[m];
|
||||||
const over = t && t.budget > 0 && t.spent > t.budget;
|
|
||||||
return (
|
return (
|
||||||
<td
|
<td key={m} className={`px-3 py-2 text-right font-mono tabular-nums ${m === selectedMonth ? "text-indigo-300" : ""}`}>
|
||||||
key={m}
|
{fmt(t?.spent || 0)}
|
||||||
className={`px-3 py-2 text-right tabular-nums ${over ? "text-red-300" : ""}`}
|
|
||||||
>
|
|
||||||
${(t?.spent || 0).toFixed(0)}
|
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+49
-1
@@ -1,6 +1,54 @@
|
|||||||
@import "tailwindcss";
|
@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-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--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 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 "./globals.css";
|
||||||
import { Providers } from "@/components/providers";
|
import { Providers } from "@/components/providers";
|
||||||
import { Sidebar } from "@/components/sidebar";
|
import { Sidebar } from "@/components/sidebar";
|
||||||
@@ -14,6 +14,12 @@ const geistMono = Geist_Mono({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fraunces = Fraunces({
|
||||||
|
variable: "--font-fraunces",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600"],
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Finance",
|
title: "Finance",
|
||||||
description: "Personal Finance Dashboard",
|
description: "Personal Finance Dashboard",
|
||||||
@@ -27,12 +33,12 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" className="dark">
|
<html lang="en" className="dark">
|
||||||
<body
|
<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>
|
<Providers>
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
<Sidebar />
|
<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>
|
</div>
|
||||||
</Providers>
|
</Providers>
|
||||||
</body>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+553
-2
@@ -1,8 +1,559 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useRuleRuns, useRevertRuleRun, useTags, useParticipants } from "@/lib/hooks";
|
||||||
|
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
|
|
||||||
|
const FIELDS = [
|
||||||
|
{ value: "merchant_normalized", label: "Merchant" },
|
||||||
|
{ value: "description", label: "Description" },
|
||||||
|
{ 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 = [
|
||||||
|
{ value: "contains", label: "contains" },
|
||||||
|
{ value: "equals", label: "equals" },
|
||||||
|
{ value: "starts_with", label: "starts with" },
|
||||||
|
{ value: "not_equals", label: "not equals" },
|
||||||
|
];
|
||||||
|
const AMOUNT_OPS = [
|
||||||
|
{ value: "equals", label: "=" },
|
||||||
|
{ value: "not_equals", label: "≠" },
|
||||||
|
{ 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 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, tagNames?: Map<number, string>): string {
|
||||||
|
const fieldLabel = FIELDS.find((f) => f.value === c.field)?.label || c.field;
|
||||||
|
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>, 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}`);
|
||||||
|
if (a.add_tag_ids?.length) {
|
||||||
|
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() {
|
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 [name, setName] = useState("");
|
||||||
|
const [conditions, setConditions] = useState<Condition[]>([]);
|
||||||
|
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: "" }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCondition(i: number, patch: Partial<Condition>) {
|
||||||
|
setConditions(conditions.map((c, idx) => (idx === i ? { ...c, ...patch } : c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCondition(i: number) {
|
||||||
|
setConditions(conditions.filter((_, idx) => idx !== i));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSplitEntry() {
|
||||||
|
if (!participants.length) return;
|
||||||
|
const existing = actions.apply_split || [];
|
||||||
|
setActions({ ...actions, apply_split: [...existing, { participant_id: participants[0].id, share_percent: 0 }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSplitEntry(i: number, patch: Partial<SplitEntry>) {
|
||||||
|
const entries = (actions.apply_split || []).map((s, idx) => (idx === i ? { ...s, ...patch } : s));
|
||||||
|
setActions({ ...actions, apply_split: entries });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSplitEntry(i: number) {
|
||||||
|
const entries = (actions.apply_split || []).filter((_, idx) => idx !== i);
|
||||||
|
setActions({ ...actions, apply_split: entries.length ? entries : undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
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 (
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<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()}
|
||||||
|
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={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"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{applyResult && (
|
||||||
|
<div className="p-4 bg-emerald-900/30 border border-emerald-700 rounded-lg text-sm">
|
||||||
|
Applied: <strong>{applyResult.matched}</strong> condition matches across{" "}
|
||||||
|
<strong>{applyResult.transactions_affected}</strong> transactions.
|
||||||
|
<button onClick={() => setApplyResult(null)} className="ml-4 text-zinc-400 hover:text-white">
|
||||||
|
dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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">{editingId !== null ? "Edit Rule" : "New Rule"}</h3>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold mb-4">Rules</h2>
|
<label className="block text-xs text-zinc-500 mb-1">Rule Name</label>
|
||||||
<p className="text-zinc-500">Coming soon - auto-classify transactions with rules.</p>
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="e.g. Tag Woolworths as groceries"
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-xs text-zinc-500">Conditions (ALL must match)</label>
|
||||||
|
<button type="button" onClick={addCondition} className="text-xs text-indigo-400 hover:text-indigo-300">
|
||||||
|
+ Add condition
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{conditions.map((cond, i) => {
|
||||||
|
const isAmount = cond.field === "amount";
|
||||||
|
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) => {
|
||||||
|
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) => (
|
||||||
|
<option key={f.value} value={f.value}>
|
||||||
|
{f.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={cond.operator}
|
||||||
|
onChange={(e) => updateCondition(i, { operator: e.target.value })}
|
||||||
|
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{ops.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{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)}
|
||||||
|
className="text-zinc-500 hover:text-red-400 text-lg leading-none px-1"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{conditions.length === 0 && (
|
||||||
|
<p className="text-xs text-zinc-600">No conditions — rule will match ALL transactions.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Set Category (optional)</label>
|
||||||
|
<select
|
||||||
|
value={actions.set_category || ""}
|
||||||
|
onChange={(e) => setActions({ ...actions, set_category: e.target.value || undefined })}
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">— no change —</option>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{formatCategory(c)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Set Merchant (optional)</label>
|
||||||
|
<input
|
||||||
|
value={actions.set_merchant || ""}
|
||||||
|
onChange={(e) => setActions({ ...actions, set_merchant: e.target.value || undefined })}
|
||||||
|
placeholder="Normalized name"
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Add Tags (optional)</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const selected = (actions.add_tag_ids || []).includes(tag.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const ids = actions.add_tag_ids || [];
|
||||||
|
setActions({
|
||||||
|
...actions,
|
||||||
|
add_tag_ids: selected ? ids.filter((id) => id !== tag.id) : [...ids, tag.id],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={`px-2 py-1 rounded text-xs border transition-colors ${
|
||||||
|
selected ? "border-transparent text-white" : "border-zinc-700 text-zinc-400"
|
||||||
|
}`}
|
||||||
|
style={selected ? { backgroundColor: tag.color } : {}}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{tags.length === 0 && <p className="text-xs text-zinc-600">No tags created yet.</p>}
|
||||||
|
</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>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => setPriority(Number(e.target.value))}
|
||||||
|
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={isPending}
|
||||||
|
className="px-6 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isPending ? "Saving..." : editingId !== null ? "Save Changes" : "Create Rule"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{runs.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-zinc-400 mb-2">Apply History</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{runs.map((run) => (
|
||||||
|
<div key={run.id} className={`flex items-center justify-between px-4 py-2.5 rounded-lg border text-sm ${run.reverted_at ? "bg-zinc-900/40 border-zinc-800 opacity-60" : "bg-zinc-900 border-zinc-700"}`}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-zinc-300">{new Date(run.applied_at).toLocaleString()}</span>
|
||||||
|
<span className="text-zinc-500">{run.matched} matches · {run.transactions_affected} transactions</span>
|
||||||
|
{run.split_from && <span className="text-zinc-600 text-xs">splits from {run.split_from}</span>}
|
||||||
|
</div>
|
||||||
|
{run.reverted_at ? (
|
||||||
|
<span className="text-xs text-zinc-500">reverted {new Date(run.reverted_at).toLocaleString()}</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm("Revert this run? This will restore all affected transactions to their state before the rules were applied.")) {
|
||||||
|
revertRun.mutate(run.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={revertRun.isPending}
|
||||||
|
className="text-xs text-amber-400 hover:text-amber-300 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Revert
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-zinc-500 text-sm">Loading rules...</p>
|
||||||
|
) : rules.length === 0 ? (
|
||||||
|
<p className="text-zinc-500 text-sm">No rules yet. Create one to auto-classify transactions.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{rules.map((rule) => {
|
||||||
|
const conds = Array.isArray(rule.conditions) ? rule.conditions : [];
|
||||||
|
const acts =
|
||||||
|
rule.actions && typeof rule.actions === "object" ? (rule.actions as Actions) : {};
|
||||||
|
return (
|
||||||
|
<div key={rule.id} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<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">
|
||||||
|
{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, participantNames)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
{/* No Apply for quick actions: their conditions are empty, so a
|
||||||
|
bulk apply would hit every transaction. They run from the
|
||||||
|
transactions page against a selection instead. */}
|
||||||
|
{!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 ${
|
||||||
|
rule.enabled ? "bg-indigo-600" : "bg-zinc-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${
|
||||||
|
rule.enabled ? "translate-x-4" : "translate-x-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</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);
|
||||||
|
}}
|
||||||
|
className="text-zinc-500 hover:text-red-400 text-sm"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+484
-3
@@ -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 (
|
return (
|
||||||
|
<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>
|
<div>
|
||||||
<h2 className="text-xl font-semibold mb-4">Shared Expenses</h2>
|
<label className="block text-xs text-zinc-500 mb-1">Amount</label>
|
||||||
<p className="text-zinc-500">Coming soon - track shared expenses and splits.</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+234
-38
@@ -1,10 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
import Link from "next/link";
|
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) {
|
function formatDate(d: string | null) {
|
||||||
if (!d) return "-";
|
if (!d) return "—";
|
||||||
return new Date(d).toLocaleDateString("en-AU", {
|
return new Date(d).toLocaleDateString("en-AU", {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "short",
|
month: "short",
|
||||||
@@ -12,64 +19,253 @@ function formatDate(d: string | null) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(amount: number | null, currency = "AUD") {
|
function formatPeriod(start: string | null, end: string | null) {
|
||||||
if (amount === null || amount === undefined) return "-";
|
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", {
|
return new Intl.NumberFormat("en-AU", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency,
|
currency: "AUD",
|
||||||
}).format(amount);
|
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() {
|
export default function StatementsPage() {
|
||||||
const { data: statements, isLoading } = useStatements();
|
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 (
|
return (
|
||||||
<div>
|
<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 ? (
|
{isLoading ? (
|
||||||
<p className="text-zinc-500">Loading...</p>
|
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||||
) : !statements?.length ? (
|
) : !filtered.length ? (
|
||||||
<p className="text-zinc-500">No statements found</p>
|
<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">
|
<div className="border border-zinc-700 rounded-xl overflow-x-auto">
|
||||||
{statements.map((s) => (
|
<table className="w-full text-sm min-w-[800px]">
|
||||||
<div
|
<thead>
|
||||||
key={s.id}
|
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||||||
className="border border-zinc-800 rounded-lg p-4 bg-zinc-900/50 hover:border-zinc-700 transition-colors"
|
<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>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Account</th>
|
||||||
<h3 className="font-medium">{s.bank_name}</h3>
|
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Period</th>
|
||||||
<span className="text-xs text-zinc-500">{s.currency}</span>
|
<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>
|
</div>
|
||||||
{s.card_name && (
|
{s.card_name && (
|
||||||
<p className="text-sm text-zinc-400 mb-2">{s.card_name}</p>
|
<div className="text-xs text-zinc-500 truncate max-w-[160px]">{s.card_name}</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-sm text-zinc-400 space-y-1">
|
{stmtType === "loan" && (s.interest_rate || s.scheduled_repayment) && (
|
||||||
<p>Account: {s.account_number}</p>
|
<div className="text-xs text-zinc-500 truncate max-w-[160px]">
|
||||||
<p>
|
{[
|
||||||
Period: {formatDate(s.billing_start_date)} - {formatDate(s.billing_end_date)}
|
s.interest_rate ? `${Number(s.interest_rate).toFixed(2)}% p.a.` : null,
|
||||||
</p>
|
s.scheduled_repayment
|
||||||
<p>Due: {formatDate(s.payment_due_date)}</p>
|
? `${formatAmount(s.scheduled_repayment)}${
|
||||||
</div>
|
s.repayment_frequency ? ` ${s.repayment_frequency}` : ""
|
||||||
<div className="mt-3 pt-3 border-t border-zinc-800 flex items-center justify-between">
|
}`
|
||||||
<div>
|
: null,
|
||||||
<p className="text-lg font-semibold text-red-400">
|
]
|
||||||
{formatCurrency(s.total_amount_due, s.currency)}
|
.filter(Boolean)
|
||||||
</p>
|
.join(" · ")}
|
||||||
<p className="text-xs text-zinc-500">
|
|
||||||
{s.transaction_count} transactions
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
href={`/transactions?statement_id=${s.id}`}
|
href={`/transactions?statement_id=${s.id}`}
|
||||||
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded text-sm transition-colors"
|
className="sm:hidden text-xs text-indigo-400 hover:text-indigo-300 mt-1 inline-block"
|
||||||
>
|
>
|
||||||
View
|
View →
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+135
-17
@@ -1,21 +1,128 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useTags, useCreateTag, useDeleteTag } from "@/lib/hooks";
|
import { useTags, useCreateTag, useDeleteTag } from "@/lib/hooks";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
const PRESET_COLORS = [
|
const PRESET_COLORS = [
|
||||||
"#6366f1", // indigo
|
"#6366f1", "#8b5cf6", "#ec4899", "#ef4444", "#f97316",
|
||||||
"#8b5cf6", // violet
|
"#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#6b7280",
|
||||||
"#ec4899", // pink
|
|
||||||
"#ef4444", // red
|
|
||||||
"#f97316", // orange
|
|
||||||
"#eab308", // yellow
|
|
||||||
"#22c55e", // green
|
|
||||||
"#14b8a6", // teal
|
|
||||||
"#3b82f6", // blue
|
|
||||||
"#6b7280", // gray
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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() {
|
export default function TagsPage() {
|
||||||
const { data: tags, isLoading } = useTags();
|
const { data: tags, isLoading } = useTags();
|
||||||
const createTag = useCreateTag();
|
const createTag = useCreateTag();
|
||||||
@@ -24,6 +131,7 @@ export default function TagsPage() {
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [color, setColor] = useState(PRESET_COLORS[0]);
|
const [color, setColor] = useState(PRESET_COLORS[0]);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [convertTag, setConvertTag] = useState<{ id: number; name: string; color: string; transaction_count: number } | null>(null);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!name.trim()) return;
|
if (!name.trim()) return;
|
||||||
@@ -38,7 +146,7 @@ export default function TagsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold mb-4">Tags</h2>
|
<h2 className="text-2xl font-display mb-4">Tags</h2>
|
||||||
|
|
||||||
{/* Create form */}
|
{/* Create form */}
|
||||||
<div className="mb-6 p-4 bg-zinc-900/50 border border-zinc-800 rounded-lg">
|
<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) => (
|
{tags.map((tag) => (
|
||||||
<div
|
<div
|
||||||
key={tag.id}
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<span
|
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: tag.color }} />
|
||||||
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-sm font-medium">{tag.name}</span>
|
||||||
<span className="text-xs text-zinc-500">
|
<span className="text-xs text-zinc-500">
|
||||||
{tag.transaction_count} transaction{tag.transaction_count !== 1 ? "s" : ""}
|
{tag.transaction_count} transaction{tag.transaction_count !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<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
|
<button
|
||||||
onClick={() => deleteTag.mutate(tag.id)}
|
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"
|
className="text-xs text-zinc-600 hover:text-red-400 transition-colors px-2 py-1 rounded hover:bg-zinc-800"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{convertTag && (
|
||||||
|
<ConvertModal tag={convertTag} onClose={() => setConvertTag(null)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+754
-58
@@ -1,10 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useRef, useEffect, Suspense } from "react";
|
||||||
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags } from "@/lib/hooks";
|
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 { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
import { SplitModal } from "@/components/split-modal";
|
import { SplitModal } from "@/components/split-modal";
|
||||||
import { TagPicker } from "@/components/tag-picker";
|
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) {
|
function formatDate(d: string) {
|
||||||
return new Date(d).toLocaleDateString("en-AU", {
|
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", {
|
const formatted = new Intl.NumberFormat("en-AU", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "AUD",
|
currency,
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
return type === "debit" ? formatted : `+${formatted}`;
|
return SPEND_TYPES.has(type) ? formatted : `+${formatted}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TypeBadge({ type }: { type: string }) {
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
const colors: Record<string, string> = {
|
|
||||||
debit: "bg-red-900/30 text-red-400",
|
debit: "bg-red-900/30 text-red-400",
|
||||||
credit: "bg-green-900/30 text-green-400",
|
credit: "bg-green-900/30 text-green-400",
|
||||||
payment: "bg-blue-900/30 text-blue-400",
|
payment: "bg-blue-900/30 text-blue-400",
|
||||||
refund: "bg-emerald-900/30 text-emerald-400",
|
refund: "bg-emerald-900/30 text-emerald-400",
|
||||||
fee: "bg-yellow-900/30 text-yellow-400",
|
fee: "bg-yellow-900/30 text-yellow-400",
|
||||||
interest: "bg-orange-900/30 text-orange-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 }) {
|
||||||
return (
|
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}
|
{type}
|
||||||
</span>
|
</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({
|
function InlineEdit({
|
||||||
value,
|
value,
|
||||||
onSave,
|
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() {
|
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({
|
const [filters, setFilters] = useState({
|
||||||
from: "",
|
from: "",
|
||||||
to: "",
|
to: "",
|
||||||
category: "",
|
categories: [] as string[],
|
||||||
bank_name: "",
|
bank_names: [] as string[],
|
||||||
|
tag_ids: [] as string[],
|
||||||
|
transaction_types: [] as string[],
|
||||||
search: "",
|
search: "",
|
||||||
tag_id: "",
|
statement_id: initialStatementId,
|
||||||
sort_by: "transaction_date",
|
sort_by: "transaction_date",
|
||||||
sort_dir: "desc",
|
sort_dir: "desc",
|
||||||
limit: 50,
|
limit: 50,
|
||||||
offset: 0,
|
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 [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
const [bulkCategory, setBulkCategory] = useState("");
|
const [bulkCategory, setBulkCategory] = useState("");
|
||||||
const [bulkTagId, setBulkTagId] = 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, isLoading } = useTransactions(filters);
|
||||||
const { data: banks } = useBanks();
|
const { data: banks } = useBanks();
|
||||||
const { data: tags } = useTags();
|
const { data: tags } = useTags();
|
||||||
|
const { data: me } = useCurrentUser();
|
||||||
|
const { data: statementInfo } = useStatement(parseInt(filters.statement_id) || 0);
|
||||||
const updateTxn = useUpdateTransaction();
|
const updateTxn = useUpdateTransaction();
|
||||||
const bulkAction = useBulkAction();
|
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) => {
|
const toggleSelect = useCallback((id: number) => {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
@@ -159,17 +597,78 @@ export default function TransactionsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 */}
|
{/* Filter bar */}
|
||||||
<div className="flex flex-wrap gap-3 mb-4">
|
<div className="flex flex-wrap gap-3 mb-2">
|
||||||
|
{/* Smart query bar */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search..."
|
value={queryInput}
|
||||||
value={filters.search}
|
onChange={(e) => handleQueryChange(e.target.value)}
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value, offset: 0 }))}
|
placeholder="Search… or >500 <=1500 200-800"
|
||||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm w-48"
|
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
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={filters.from}
|
value={filters.from}
|
||||||
@@ -182,34 +681,55 @@ export default function TransactionsPage() {
|
|||||||
onChange={(e) => setFilters((f) => ({ ...f, to: e.target.value, offset: 0 }))}
|
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"
|
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
|
<select
|
||||||
value={filters.category}
|
value={filters.has_split}
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, offset: 0 }))}
|
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"
|
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm text-zinc-300"
|
||||||
>
|
>
|
||||||
<option value="">All Categories</option>
|
<option value="">All Splits</option>
|
||||||
{CATEGORIES.map((c) => (
|
<option value="yes">Split only</option>
|
||||||
<option key={c} value={c}>{formatCategory(c)}</option>
|
<option value="no">Unsplit only</option>
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
value={filters.bank_name}
|
value={filters.trip_id}
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, bank_name: e.target.value, offset: 0 }))}
|
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"
|
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm text-zinc-300"
|
||||||
>
|
>
|
||||||
<option value="">All Banks</option>
|
<option value="">All Trips</option>
|
||||||
{banks?.map((b) => (
|
<option value="unassigned">No Trip</option>
|
||||||
<option key={b} value={b}>{b}</option>
|
{trips.map((t) => (
|
||||||
))}
|
<option key={t.id} value={String(t.id)}>{t.name}</option>
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={filters.tag_id}
|
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, tag_id: e.target.value, offset: 0 }))}
|
|
||||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
|
|
||||||
>
|
|
||||||
<option value="">All Tags</option>
|
|
||||||
{tags?.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>{t.name}</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,6 +793,56 @@ export default function TransactionsPage() {
|
|||||||
>
|
>
|
||||||
Tag
|
Tag
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setSelected(new Set())}
|
onClick={() => setSelected(new Set())}
|
||||||
className="px-3 py-1 text-zinc-400 hover:text-white text-sm"
|
className="px-3 py-1 text-zinc-400 hover:text-white text-sm"
|
||||||
@@ -282,12 +852,24 @@ export default function TransactionsPage() {
|
|||||||
</div>
|
</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 */}
|
{/* Table */}
|
||||||
<div className="overflow-x-auto border border-zinc-800 rounded-lg">
|
<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>
|
<thead>
|
||||||
<tr className="border-b border-zinc-800 bg-zinc-900/50">
|
<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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={data?.data.length ? selected.size === data.data.length : false}
|
checked={data?.data.length ? selected.size === data.data.length : false}
|
||||||
@@ -296,11 +878,17 @@ export default function TransactionsPage() {
|
|||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<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")}
|
onClick={() => toggleSort("transaction_date")}
|
||||||
>
|
>
|
||||||
Date {filters.sort_by === "transaction_date" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")}
|
Date {filters.sort_by === "transaction_date" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")}
|
||||||
</th>
|
</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">Description</th>
|
||||||
<th className="p-2 text-left">Merchant</th>
|
<th className="p-2 text-left">Merchant</th>
|
||||||
<th
|
<th
|
||||||
@@ -318,9 +906,9 @@ export default function TransactionsPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{isLoading ? (
|
{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 ? (
|
) : !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) => (
|
data.data.map((t) => (
|
||||||
<tr
|
<tr
|
||||||
@@ -329,7 +917,7 @@ export default function TransactionsPage() {
|
|||||||
selected.has(t.id) ? "bg-zinc-800/40" : ""
|
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected.has(t.id)}
|
checked={selected.has(t.id)}
|
||||||
@@ -337,13 +925,22 @@ export default function TransactionsPage() {
|
|||||||
className="accent-blue-600"
|
className="accent-blue-600"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2 whitespace-nowrap">{formatDate(t.transaction_date)}</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 max-w-xs truncate" title={t.description}>{t.description}</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]">
|
<td className="p-2 max-w-[150px]">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<InlineEdit
|
<InlineEdit
|
||||||
value={t.effective_merchant || ""}
|
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 && (
|
{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" />
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={`p-2 text-right whitespace-nowrap font-mono ${
|
<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>
|
||||||
<td className="p-2"><TypeBadge type={t.transaction_type} /></td>
|
|
||||||
<td className="p-2 max-w-[140px]">
|
<td className="p-2 max-w-[140px]">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<InlineEdit
|
<InlineEdit
|
||||||
value={t.effective_category}
|
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"
|
type="select"
|
||||||
options={categoryOptions}
|
options={categoryOptions}
|
||||||
/>
|
/>
|
||||||
@@ -384,14 +994,66 @@ export default function TransactionsPage() {
|
|||||||
<TagPicker transactionId={t.id} currentTags={t.tags ?? []} />
|
<TagPicker transactionId={t.id} currentTags={t.tags ?? []} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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
|
<button
|
||||||
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, transactionIds: undefined })}
|
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount_aud ?? t.amount, description: t.description, merchant: t.effective_merchant || undefined, transactionIds: undefined })}
|
||||||
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
|
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"
|
title="Split this transaction"
|
||||||
>
|
>
|
||||||
Split
|
Split
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
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="Edit this transaction"
|
||||||
|
>
|
||||||
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
@@ -407,10 +1069,44 @@ export default function TransactionsPage() {
|
|||||||
transactionIds={splitModal.transactionIds}
|
transactionIds={splitModal.transactionIds}
|
||||||
amount={splitModal.amount}
|
amount={splitModal.amount}
|
||||||
description={splitModal.description}
|
description={splitModal.description}
|
||||||
|
merchant={splitModal.merchant}
|
||||||
onClose={() => { setSplitModal(null); if (splitModal.transactionIds) setSelected(new Set()); }}
|
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 */}
|
{/* Pagination */}
|
||||||
{data && data.total > filters.limit && (
|
{data && data.total > filters.limit && (
|
||||||
<div className="flex items-center justify-between mt-4">
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
"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", "Total Owed", "Settled", "Unsettled"].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>
|
||||||
|
<td className="px-5 py-3 text-right tabular-nums font-mono text-emerald-500">${Number(p.settled).toFixed(2)}</td>
|
||||||
|
<td className={`px-5 py-3 text-right tabular-nums font-mono ${p.unsettled > 0 ? "text-amber-400" : "text-zinc-600"}`}>
|
||||||
|
${Number(p.unsettled).toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,14 +2,19 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: "/transactions", label: "Transactions", icon: "receipt" },
|
{ href: "/transactions", label: "Transactions", icon: "receipt" },
|
||||||
{ href: "/statements", label: "Statements", icon: "file-text" },
|
{ href: "/statements", label: "Statements", icon: "file-text" },
|
||||||
|
{ href: "/trips", label: "Trips", icon: "map-pin" },
|
||||||
{ href: "/shared", label: "Shared", icon: "users" },
|
{ 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: "/tags", label: "Tags", icon: "tag" },
|
||||||
{ href: "/rules", label: "Rules", icon: "settings" },
|
{ href: "/rules", label: "Rules", icon: "settings" },
|
||||||
|
{ href: "/reconcile", label: "Reconcile", icon: "git-merge" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ICONS: Record<string, React.ReactNode> = {
|
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" />
|
<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>
|
</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: (
|
users: (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</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() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
// Close drawer on route change
|
||||||
<aside className="w-56 bg-zinc-900 border-r border-zinc-800 flex flex-col min-h-screen">
|
useEffect(() => {
|
||||||
<div className="p-4 border-b border-zinc-800">
|
setOpen(false);
|
||||||
<h1 className="text-lg font-semibold text-white">Finance</h1>
|
}, [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>
|
</div>
|
||||||
<nav className="flex-1 p-2">
|
<nav className="flex-1 p-2">
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
@@ -61,10 +110,10 @@ export function Sidebar() {
|
|||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={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
|
active
|
||||||
? "bg-zinc-800 text-white"
|
? "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-white hover:bg-zinc-800/50"
|
: "text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{ICONS[item.icon]}
|
{ICONS[item.icon]}
|
||||||
@@ -73,6 +122,46 @@ export function Sidebar() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
</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 = [
|
export const CATEGORIES = [
|
||||||
"groceries",
|
"groceries",
|
||||||
"dining",
|
"dining",
|
||||||
@@ -14,18 +20,26 @@ export const CATEGORIES = [
|
|||||||
"government",
|
"government",
|
||||||
"education",
|
"education",
|
||||||
"rent",
|
"rent",
|
||||||
|
"home_goods",
|
||||||
|
"home_maintenance",
|
||||||
"transfers",
|
"transfers",
|
||||||
"income",
|
"income",
|
||||||
|
"investment",
|
||||||
|
"loan_interest",
|
||||||
"personal_care",
|
"personal_care",
|
||||||
"pets",
|
"pets",
|
||||||
"gifts",
|
"gifts",
|
||||||
"charity",
|
"charity",
|
||||||
|
"fees",
|
||||||
"other",
|
"other",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Category = (typeof CATEGORIES)[number];
|
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
|
return cat
|
||||||
.split("_")
|
.split("_")
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
.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 []; }
|
||||||
|
}
|
||||||
+505
-16
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
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";
|
import type { CurrentUser } from "./auth";
|
||||||
|
|
||||||
interface TransactionsResponse {
|
interface TransactionsResponse {
|
||||||
@@ -14,21 +15,31 @@ interface TransactionsResponse {
|
|||||||
interface TransactionFilters {
|
interface TransactionFilters {
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
category?: string;
|
categories?: string[];
|
||||||
bank_name?: string;
|
bank_names?: string[];
|
||||||
|
tag_ids?: string[];
|
||||||
|
transaction_types?: string[];
|
||||||
search?: string;
|
search?: string;
|
||||||
statement_id?: string;
|
statement_id?: string;
|
||||||
tag_id?: string;
|
|
||||||
sort_by?: string;
|
sort_by?: string;
|
||||||
sort_dir?: string;
|
sort_dir?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
amount_min?: number;
|
||||||
|
amount_max?: number;
|
||||||
|
has_split?: string;
|
||||||
|
trip_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildParams(filters: TransactionFilters): string {
|
function buildParams(filters: TransactionFilters): string {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
Object.entries(filters).forEach(([key, val]) => {
|
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();
|
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() {
|
export function useUpdateTransaction() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -94,6 +135,13 @@ export function useUpdateTransaction() {
|
|||||||
category?: string;
|
category?: string;
|
||||||
merchant_normalized?: string;
|
merchant_normalized?: string;
|
||||||
notes?: 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}`, {
|
const res = await fetch(`/api/transactions/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -105,6 +153,7 @@ export function useUpdateTransaction() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
qc.invalidateQueries({ queryKey: ["transaction"] });
|
qc.invalidateQueries({ queryKey: ["transaction"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["analytics"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -119,6 +168,7 @@ export function useBulkAction() {
|
|||||||
merchant_normalized?: string;
|
merchant_normalized?: string;
|
||||||
splits?: { participant_id: number; share_percent: number }[];
|
splits?: { participant_id: number; share_percent: number }[];
|
||||||
tag_id?: number;
|
tag_id?: number;
|
||||||
|
rule_id?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const res = await fetch("/api/transactions/bulk", {
|
const res = await fetch("/api/transactions/bulk", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -141,6 +191,16 @@ export function useBulkAction() {
|
|||||||
if (variables.action === "tag" || variables.action === "untag") {
|
if (variables.action === "tag" || variables.action === "untag") {
|
||||||
qc.invalidateQueries({ queryKey: ["tags"] });
|
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 }[]>({
|
return useQuery<{ id: number; name: string; total_owed: number; unsettled_count: number }[]>({
|
||||||
queryKey: ["participant-balances"],
|
queryKey: ["participant-balances", tagIds],
|
||||||
queryFn: async () => {
|
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();
|
return res.json();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSharedTransactions() {
|
export function useSharedTransactions(tagIds?: string[], participantId?: number) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["shared-transactions"],
|
queryKey: ["shared-transactions", tagIds, participantId],
|
||||||
queryFn: async () => {
|
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();
|
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();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (body: { participant_id?: number; split_ids?: number[] }) => {
|
mutationFn: async (body: {
|
||||||
const res = await fetch("/api/splits/settle", {
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -226,8 +323,22 @@ export function useSettleSplits() {
|
|||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
|
|
||||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
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"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -353,6 +464,127 @@ export function useCreateParticipant() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Rules ---
|
||||||
|
|
||||||
|
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; 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRules() {
|
||||||
|
return useQuery<RuleRow[]>({
|
||||||
|
queryKey: ["rules"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/rules");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateRule() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: Omit<RuleRow, "id" | "created_at">) => {
|
||||||
|
const res = await fetch("/api/rules", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to create rule");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateRule() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...data }: Partial<RuleRow> & { id: number }) => {
|
||||||
|
const res = await fetch(`/api/rules/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to update rule");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteRule() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
await fetch(`/api/rules/${id}`, { method: "DELETE" });
|
||||||
|
},
|
||||||
|
onSuccess: () => 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ---
|
// --- Budgets & Analytics ---
|
||||||
|
|
||||||
export interface BudgetRow {
|
export interface BudgetRow {
|
||||||
@@ -372,7 +604,9 @@ export interface MonthlyAnalyticsRow {
|
|||||||
export interface MonthlyAnalytics {
|
export interface MonthlyAnalytics {
|
||||||
months: string[];
|
months: string[];
|
||||||
rows: MonthlyAnalyticsRow[];
|
rows: MonthlyAnalyticsRow[];
|
||||||
totals: Record<string, { spent: number; budget: number }>;
|
income: Record<string, number>;
|
||||||
|
investments: Record<string, number>;
|
||||||
|
totals: Record<string, { spent: number; income: number; investments: number; net: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBudgets(month: string) {
|
export function useBudgets(month: string) {
|
||||||
@@ -421,3 +655,258 @@ export function useMonthlyAnalytics(months?: number) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+709
-50
@@ -8,10 +8,11 @@ export interface TagRow {
|
|||||||
|
|
||||||
export interface TransactionRow {
|
export interface TransactionRow {
|
||||||
id: number;
|
id: number;
|
||||||
statement_id: number;
|
statement_id: number | null;
|
||||||
transaction_date: string;
|
transaction_date: string;
|
||||||
description: string;
|
description: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
amount_aud: number | null;
|
||||||
transaction_type: string;
|
transaction_type: string;
|
||||||
merchant_name: string | null;
|
merchant_name: string | null;
|
||||||
merchant_normalized: string | null;
|
merchant_normalized: string | null;
|
||||||
@@ -21,18 +22,37 @@ export interface TransactionRow {
|
|||||||
category: string;
|
category: string;
|
||||||
row_index: number;
|
row_index: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
// loan repayment split — set only when the lender itemises it (migration 0014)
|
||||||
|
principal_amount: number | null;
|
||||||
|
interest_amount: number | null;
|
||||||
|
// How it was paid (migration 0016). NULL = unknown, treated as reconcilable.
|
||||||
|
// 'cash' is excluded from reconciliation — see notCash().
|
||||||
|
payment_method: string | null;
|
||||||
// override fields
|
// override fields
|
||||||
category_override: string | null;
|
category_override: string | null;
|
||||||
merchant_override: string | null;
|
merchant_override: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
my_share_percent: number | null;
|
||||||
effective_category: string;
|
effective_category: string;
|
||||||
effective_merchant: string;
|
effective_merchant: string;
|
||||||
// statement context
|
// My share of this transaction, resolved server-side so the UI matches analytics.
|
||||||
|
my_share_pct: number;
|
||||||
|
my_amount: number;
|
||||||
|
// statement context (null for manual transactions)
|
||||||
bank_name: string;
|
bank_name: string;
|
||||||
|
// Native currency of the statement this row came from ('AUD' for manual rows).
|
||||||
|
// `amount` is in this currency; `amount_aud` is the converted figure.
|
||||||
|
currency: string;
|
||||||
owner_id: number;
|
owner_id: number;
|
||||||
owner_name: string;
|
owner_name: string;
|
||||||
// tags
|
// tags
|
||||||
tags: TagRow[];
|
tags: TagRow[];
|
||||||
|
// splits
|
||||||
|
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
|
||||||
|
// trip
|
||||||
|
trip_id: number | null;
|
||||||
|
trip_name: string | null;
|
||||||
|
trip_color: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatementRow {
|
export interface StatementRow {
|
||||||
@@ -55,29 +75,48 @@ export interface StatementRow {
|
|||||||
fees_charged: number | null;
|
fees_charged: number | null;
|
||||||
credit_limit: number | null;
|
credit_limit: number | null;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
statement_type: string | null;
|
||||||
|
// Loan statements only (see migration 0014)
|
||||||
|
interest_rate: number | null;
|
||||||
|
scheduled_repayment: number | null;
|
||||||
|
repayment_frequency: string | null;
|
||||||
|
redraw_available: number | null;
|
||||||
|
loan_term_months: number | null;
|
||||||
tier_used: string | null;
|
tier_used: string | null;
|
||||||
owner_id: number;
|
owner_id: number;
|
||||||
owner_name: string;
|
owner_name: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
transaction_count: number;
|
transaction_count: number;
|
||||||
|
// Balance assertion (see BALANCE_DELTA). Null when the statement has no
|
||||||
|
// opening/closing balance to check against.
|
||||||
|
expected_closing: number | null;
|
||||||
|
balance_diff: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TransactionFilters {
|
interface TransactionFilters {
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
category?: string;
|
categories?: string[];
|
||||||
bank_name?: string;
|
bank_names?: string[];
|
||||||
|
tag_ids?: string[];
|
||||||
|
transaction_types?: string[];
|
||||||
search?: string;
|
search?: string;
|
||||||
statement_id?: string;
|
statement_id?: string;
|
||||||
tag_id?: string;
|
|
||||||
sort_by?: string;
|
sort_by?: string;
|
||||||
sort_dir?: string;
|
sort_dir?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
amount_min?: number;
|
||||||
|
amount_max?: number;
|
||||||
|
has_split?: string;
|
||||||
|
trip_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
|
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
|
||||||
const conditions: string[] = [`s.owner_id = $1`];
|
const conditions: string[] = [
|
||||||
|
`(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`,
|
||||||
|
`NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)`,
|
||||||
|
];
|
||||||
const params: unknown[] = [ownerId];
|
const params: unknown[] = [ownerId];
|
||||||
let paramIdx = 2;
|
let paramIdx = 2;
|
||||||
|
|
||||||
@@ -89,16 +128,39 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
|||||||
conditions.push(`t.transaction_date <= $${paramIdx++}`);
|
conditions.push(`t.transaction_date <= $${paramIdx++}`);
|
||||||
params.push(filters.to);
|
params.push(filters.to);
|
||||||
}
|
}
|
||||||
if (filters.category) {
|
if (filters.categories?.length) {
|
||||||
conditions.push(`COALESCE(o.category_override, t.category) = $${paramIdx++}`);
|
conditions.push(`COALESCE(o.category_override, t.category) = ANY($${paramIdx++}::text[])`);
|
||||||
params.push(filters.category);
|
params.push(filters.categories);
|
||||||
}
|
}
|
||||||
if (filters.bank_name) {
|
if (filters.bank_names?.length) {
|
||||||
conditions.push(`s.bank_name = $${paramIdx++}`);
|
const hasManual = filters.bank_names.includes("Manual");
|
||||||
params.push(filters.bank_name);
|
const bankList = filters.bank_names.filter((b) => b !== "Manual");
|
||||||
|
if (hasManual && bankList.length > 0) {
|
||||||
|
conditions.push(`(t.statement_id IS NULL OR s.bank_name = ANY($${paramIdx++}::text[]))`);
|
||||||
|
params.push(bankList);
|
||||||
|
} else if (hasManual) {
|
||||||
|
conditions.push(`t.statement_id IS NULL`);
|
||||||
|
} else {
|
||||||
|
conditions.push(`s.bank_name = ANY($${paramIdx++}::text[])`);
|
||||||
|
params.push(bankList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filters.tag_ids?.length) {
|
||||||
|
const noTags = filters.tag_ids.includes("untagged");
|
||||||
|
const realTagIds = filters.tag_ids.filter((id) => id !== "untagged").map(Number);
|
||||||
|
if (noTags) {
|
||||||
|
conditions.push(`NOT EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id)`);
|
||||||
|
} else if (realTagIds.length > 0) {
|
||||||
|
conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = ANY($${paramIdx++}::int[]))`);
|
||||||
|
params.push(realTagIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filters.transaction_types?.length) {
|
||||||
|
conditions.push(`t.transaction_type = ANY($${paramIdx++}::text[])`);
|
||||||
|
params.push(filters.transaction_types);
|
||||||
}
|
}
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
conditions.push(`(t.description ILIKE $${paramIdx} OR t.merchant_name ILIKE $${paramIdx})`);
|
conditions.push(`(t.description ILIKE $${paramIdx} OR t.merchant_name ILIKE $${paramIdx} OR COALESCE(o.merchant_normalized, t.merchant_normalized) ILIKE $${paramIdx})`);
|
||||||
params.push(`%${filters.search}%`);
|
params.push(`%${filters.search}%`);
|
||||||
paramIdx++;
|
paramIdx++;
|
||||||
}
|
}
|
||||||
@@ -106,14 +168,29 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
|||||||
conditions.push(`t.statement_id = $${paramIdx++}`);
|
conditions.push(`t.statement_id = $${paramIdx++}`);
|
||||||
params.push(Number(filters.statement_id));
|
params.push(Number(filters.statement_id));
|
||||||
}
|
}
|
||||||
if (filters.tag_id) {
|
if (filters.amount_min !== undefined) {
|
||||||
conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = $${paramIdx++})`);
|
conditions.push(`t.amount >= $${paramIdx++}`);
|
||||||
params.push(Number(filters.tag_id));
|
params.push(filters.amount_min);
|
||||||
|
}
|
||||||
|
if (filters.amount_max !== undefined) {
|
||||||
|
conditions.push(`t.amount <= $${paramIdx++}`);
|
||||||
|
params.push(filters.amount_max);
|
||||||
|
}
|
||||||
|
if (filters.has_split === "yes") {
|
||||||
|
conditions.push(`EXISTS (SELECT 1 FROM transaction_splits ts_f WHERE ts_f.transaction_id = t.id)`);
|
||||||
|
} else if (filters.has_split === "no") {
|
||||||
|
conditions.push(`NOT EXISTS (SELECT 1 FROM transaction_splits ts_f WHERE ts_f.transaction_id = t.id)`);
|
||||||
|
}
|
||||||
|
if (filters.trip_id === "unassigned") {
|
||||||
|
conditions.push(`o.trip_id IS NULL`);
|
||||||
|
} else if (filters.trip_id) {
|
||||||
|
conditions.push(`o.trip_id = $${paramIdx++}`);
|
||||||
|
params.push(Number(filters.trip_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = `WHERE ${conditions.join(" AND ")}`;
|
const where = `WHERE ${conditions.join(" AND ")}`;
|
||||||
|
|
||||||
const sortCol = filters.sort_by === "amount" ? "t.amount" : "t.transaction_date";
|
const sortCol = filters.sort_by === "amount" ? "t.amount" : filters.sort_by === "created_at" ? "t.created_at" : "t.transaction_date";
|
||||||
const sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC";
|
const sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC";
|
||||||
const limit = filters.limit || 50;
|
const limit = filters.limit || 50;
|
||||||
const offset = filters.offset || 0;
|
const offset = filters.offset || 0;
|
||||||
@@ -122,7 +199,7 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
|||||||
SELECT COUNT(*)::int as total
|
SELECT COUNT(*)::int as total
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
JOIN statements s ON s.id = t.statement_id
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
${where}
|
${where}
|
||||||
`;
|
`;
|
||||||
const countResult = await queryRaw<{ total: number }>(countSql, params);
|
const countResult = await queryRaw<{ total: number }>(countSql, params);
|
||||||
@@ -130,62 +207,143 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
|||||||
|
|
||||||
const dataSql = `
|
const dataSql = `
|
||||||
SELECT t.*,
|
SELECT t.*,
|
||||||
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
|
||||||
COALESCE(o.category_override, t.category) as effective_category,
|
COALESCE(o.category_override, t.category) as effective_category,
|
||||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||||
s.bank_name, s.owner_id,
|
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||||
|
COALESCE(s.currency, 'AUD') as currency,
|
||||||
|
-- My share, resolved the same way analytics does it (see myShare in
|
||||||
|
-- analytics-sql.ts): explicit split row, then override, then whatever is
|
||||||
|
-- left after everyone else. Computed here so the UI cannot drift from
|
||||||
|
-- the totals it is drilling into.
|
||||||
|
COALESCE(
|
||||||
|
(SELECT x.share_percent FROM transaction_splits x
|
||||||
|
WHERE x.transaction_id = t.id AND x.participant_id = $1),
|
||||||
|
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)
|
||||||
|
)::numeric(5,2) as my_share_pct,
|
||||||
|
(COALESCE(t.amount_aud, t.amount) * COALESCE(
|
||||||
|
(SELECT x.share_percent FROM transaction_splits x
|
||||||
|
WHERE x.transaction_id = t.id AND x.participant_id = $1),
|
||||||
|
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) as my_amount,
|
||||||
|
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||||
p.name as owner_name,
|
p.name as owner_name,
|
||||||
txn_tags.tags
|
COALESCE(src.created_at, t.created_at) as created_at,
|
||||||
|
o.trip_id,
|
||||||
|
tr.name as trip_name,
|
||||||
|
tr.color as trip_color,
|
||||||
|
txn_tags.tags,
|
||||||
|
txn_splits.splits
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
JOIN statements s ON s.id = t.statement_id
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
LEFT JOIN participants p ON p.id = s.owner_id
|
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
|
||||||
|
LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL
|
||||||
|
LEFT JOIN trips tr ON tr.id = o.trip_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags
|
SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags
|
||||||
FROM transaction_tags tt
|
FROM transaction_tags tt
|
||||||
JOIN tags tg ON tg.id = tt.tag_id
|
JOIN tags tg ON tg.id = tt.tag_id
|
||||||
WHERE tt.transaction_id = t.id
|
WHERE tt.transaction_id = t.id
|
||||||
) txn_tags ON true
|
) txn_tags ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COALESCE(json_agg(json_build_object('participant_id', ts.participant_id, 'name', sp.name, 'share_percent', ts.share_percent, 'settled', ts.settled) ORDER BY sp.name), '[]'::json) as splits
|
||||||
|
FROM transaction_splits ts
|
||||||
|
JOIN participants sp ON sp.id = ts.participant_id
|
||||||
|
WHERE ts.transaction_id = t.id
|
||||||
|
) txn_splits ON true
|
||||||
${where}
|
${where}
|
||||||
ORDER BY ${sortCol} ${sortDir}, t.row_index ASC
|
ORDER BY ${sortCol} ${sortDir}, t.row_index ASC
|
||||||
LIMIT $${paramIdx++} OFFSET $${paramIdx++}
|
LIMIT $${paramIdx++} OFFSET $${paramIdx++}
|
||||||
`;
|
`;
|
||||||
params.push(limit, offset);
|
params.push(limit, offset);
|
||||||
|
|
||||||
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[] }>(dataSql, params);
|
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[]; splits: string | TransactionRow["splits"] }>(dataSql, params);
|
||||||
const data = raw.map((r) => ({
|
const data = raw.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []),
|
tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []),
|
||||||
|
splits: typeof r.splits === "string" ? JSON.parse(r.splits) : (r.splits ?? []),
|
||||||
})) as TransactionRow[];
|
})) as TransactionRow[];
|
||||||
|
|
||||||
return { data, total, limit, offset };
|
return { data, total, limit, offset };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A user may act on a transaction they own (directly or via the parent
|
||||||
|
// statement) or one they participate in via a split.
|
||||||
|
export async function canAccessTransactions(ownerId: number, transactionIds: number[]): Promise<boolean> {
|
||||||
|
if (!transactionIds.length) return false;
|
||||||
|
const rows = await queryRaw<{ n: number }>(
|
||||||
|
`SELECT COUNT(*)::int AS n
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
|
WHERE t.id = ANY($2::int[])
|
||||||
|
AND (COALESCE(t.owner_id, s.owner_id) = $1
|
||||||
|
OR EXISTS (SELECT 1 FROM transaction_splits ts WHERE ts.transaction_id = t.id AND ts.participant_id = $1))`,
|
||||||
|
[ownerId, transactionIds]
|
||||||
|
);
|
||||||
|
return rows[0]?.n === transactionIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTransactionById(id: number) {
|
export async function getTransactionById(id: number) {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT t.*,
|
SELECT t.*,
|
||||||
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
|
||||||
COALESCE(o.category_override, t.category) as effective_category,
|
COALESCE(o.category_override, t.category) as effective_category,
|
||||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||||
s.bank_name, s.owner_id,
|
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||||
|
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||||
p.name as owner_name
|
p.name as owner_name
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
JOIN statements s ON s.id = t.statement_id
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
LEFT JOIN participants p ON p.id = s.owner_id
|
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
|
||||||
WHERE t.id = $1
|
WHERE t.id = $1
|
||||||
`;
|
`;
|
||||||
const rows = await queryRaw<TransactionRow>(sql, [id]);
|
const rows = await queryRaw<TransactionRow>(sql, [id]);
|
||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does opening_balance + the period's transactions equal closing_balance?
|
||||||
|
*
|
||||||
|
* The single cheapest check on extraction quality: it catches missed rows,
|
||||||
|
* duplicates, sign errors and transactions filed against the wrong statement,
|
||||||
|
* none of which are visible by eye. Borrowed from double-entry accounting,
|
||||||
|
* where it is called a balance assertion.
|
||||||
|
*
|
||||||
|
* Sign depends on what the balance means. On a liability (credit card, loan)
|
||||||
|
* the balance is what you OWE, so spending increases it and payments reduce it.
|
||||||
|
* On an asset (transaction, savings, offset) the balance is what you HOLD, so
|
||||||
|
* the signs invert.
|
||||||
|
*/
|
||||||
|
export const BALANCE_DELTA = `SUM(CASE
|
||||||
|
WHEN s.statement_type IN ('credit_card', 'loan')
|
||||||
|
THEN CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN t.amount ELSE -t.amount END
|
||||||
|
ELSE CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN -t.amount ELSE t.amount END
|
||||||
|
END)`;
|
||||||
|
|
||||||
export async function getStatements(ownerId: number) {
|
export async function getStatements(ownerId: number) {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT s.*,
|
SELECT s.*,
|
||||||
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count,
|
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count,
|
||||||
p.name as owner_name
|
p.name as owner_name,
|
||||||
|
recon.expected_closing,
|
||||||
|
recon.balance_diff
|
||||||
FROM statements s
|
FROM statements s
|
||||||
LEFT JOIN participants p ON p.id = s.owner_id
|
LEFT JOIN participants p ON p.id = s.owner_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
(s.opening_balance + ${BALANCE_DELTA})::numeric(12,2) as expected_closing,
|
||||||
|
(s.opening_balance + ${BALANCE_DELTA} - s.closing_balance)::numeric(12,2) as balance_diff
|
||||||
|
FROM transactions t
|
||||||
|
WHERE t.statement_id = s.id
|
||||||
|
AND s.opening_balance IS NOT NULL
|
||||||
|
AND s.closing_balance IS NOT NULL
|
||||||
|
) recon ON true
|
||||||
WHERE s.owner_id = $1
|
WHERE s.owner_id = $1
|
||||||
ORDER BY s.billing_end_date DESC NULLS LAST, s.created_at DESC
|
ORDER BY s.billing_end_date DESC NULLS LAST, s.created_at DESC
|
||||||
`;
|
`;
|
||||||
@@ -218,8 +376,13 @@ export async function getMerchantSuggestions(search: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getBankNames() {
|
export async function getBankNames() {
|
||||||
const sql = `SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`;
|
const [bankRows, manualCount] = await Promise.all([
|
||||||
return queryRaw<{ bank_name: string }>(sql);
|
queryRaw<{ bank_name: string }>(`SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`),
|
||||||
|
queryRaw<{ count: number }>(`SELECT COUNT(*)::int as count FROM transactions WHERE statement_id IS NULL`),
|
||||||
|
]);
|
||||||
|
const banks = bankRows.map((r) => r.bank_name);
|
||||||
|
if (manualCount[0]?.count > 0) banks.push("Manual");
|
||||||
|
return banks;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParticipantBalance {
|
export interface ParticipantBalance {
|
||||||
@@ -229,25 +392,269 @@ export interface ParticipantBalance {
|
|||||||
unsettled_count: number;
|
unsettled_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getParticipantBalances(ownerId: number) {
|
export async function getParticipantBalances(ownerId: number, tagIds?: number[]) {
|
||||||
|
const params: unknown[] = [ownerId];
|
||||||
|
let tagFilter = "";
|
||||||
|
if (tagIds?.length) {
|
||||||
|
params.push(tagIds);
|
||||||
|
tagFilter = `AND EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id = ANY($2::int[]))`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payments settle the total relationship between two people, not a specific tag.
|
||||||
|
// Only subtract payments when viewing the unfiltered total; with a tag filter
|
||||||
|
// active, show the raw split amount for that tag context only.
|
||||||
|
const paymentsJoin = tagIds?.length ? "" : `
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
CASE WHEN sp.from_participant_id != $1 THEN sp.from_participant_id ELSE sp.to_participant_id END AS pid,
|
||||||
|
SUM(CASE WHEN sp.to_participant_id = $1 THEN sp.amount ELSE -sp.amount END) AS net_paid
|
||||||
|
FROM split_payments sp
|
||||||
|
WHERE sp.from_participant_id = $1 OR sp.to_participant_id = $1
|
||||||
|
GROUP BY pid
|
||||||
|
) payments ON payments.pid = p.id`;
|
||||||
|
const paymentsSelect = tagIds?.length ? "" : "- COALESCE(payments.net_paid, 0)::numeric(12,2)";
|
||||||
|
const paymentsGroup = tagIds?.length ? "" : ", payments.net_paid";
|
||||||
|
|
||||||
return queryRaw<ParticipantBalance>(`
|
return queryRaw<ParticipantBalance>(`
|
||||||
SELECT p.id, p.name,
|
SELECT p.id, p.name,
|
||||||
COALESCE(SUM(CASE WHEN ts.settled = false THEN t.amount * ts.share_percent / 100 ELSE 0 END), 0)::numeric(12,2) as total_owed,
|
COALESCE(SUM(splits.signed_amount), 0)::numeric(12,2)
|
||||||
COUNT(CASE WHEN ts.settled = false THEN 1 END)::int as unsettled_count
|
${paymentsSelect} AS total_owed,
|
||||||
|
COALESCE(SUM(splits.split_count), 0)::int AS unsettled_count
|
||||||
FROM participants p
|
FROM participants p
|
||||||
LEFT JOIN transaction_splits ts ON ts.participant_id = p.id
|
|
||||||
LEFT JOIN transactions t ON t.id = ts.transaction_id
|
LEFT JOIN (
|
||||||
|
-- They owe me: their splits on transactions I own
|
||||||
|
-- Settle in AUD: on a foreign-currency row the amount column is in its own
|
||||||
|
-- currency, so splitting on it nets a USD figure against AUD ones.
|
||||||
|
SELECT ts.participant_id AS pid,
|
||||||
|
(CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN COALESCE(t.amount_aud, t.amount) ELSE -COALESCE(t.amount_aud, t.amount) END) * ts.share_percent / 100 AS signed_amount,
|
||||||
|
1 AS split_count
|
||||||
|
FROM transaction_splits ts
|
||||||
|
JOIN transactions t ON t.id = ts.transaction_id
|
||||||
LEFT JOIN statements s ON s.id = t.statement_id
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
WHERE (s.owner_id = $1 OR s.id IS NULL)
|
WHERE COALESCE(t.owner_id, s.owner_id) = $1 AND ts.participant_id != $1
|
||||||
GROUP BY p.id, p.name
|
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
|
||||||
|
${tagFilter}
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- I owe them: my splits on transactions they own
|
||||||
|
SELECT COALESCE(t.owner_id, s.owner_id) AS pid,
|
||||||
|
-((CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN COALESCE(t.amount_aud, t.amount) ELSE -COALESCE(t.amount_aud, t.amount) END) * ts.share_percent / 100) AS signed_amount,
|
||||||
|
0 AS split_count
|
||||||
|
FROM transaction_splits ts
|
||||||
|
JOIN transactions t ON t.id = ts.transaction_id
|
||||||
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
|
WHERE ts.participant_id = $1 AND COALESCE(t.owner_id, s.owner_id) != $1
|
||||||
|
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
|
||||||
|
${tagFilter}
|
||||||
|
) splits ON splits.pid = p.id
|
||||||
|
${paymentsJoin}
|
||||||
|
|
||||||
|
WHERE p.id != $1
|
||||||
|
GROUP BY p.id, p.name ${paymentsGroup}
|
||||||
ORDER BY p.name
|
ORDER BY p.name
|
||||||
`, [ownerId]);
|
`, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SharedTransactionRow extends TransactionRow {
|
export interface SharedTransactionRow extends TransactionRow {
|
||||||
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
|
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function ensureTag(name: string, color: string): Promise<number> {
|
||||||
|
const rows = await queryRaw<{ id: number }>(
|
||||||
|
`INSERT INTO tags (name, color) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
|
||||||
|
RETURNING id`,
|
||||||
|
[name, color]
|
||||||
|
);
|
||||||
|
return rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchInsertCSVTransactions(
|
||||||
|
ownerId: number,
|
||||||
|
rows: {
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
transaction_type: string;
|
||||||
|
merchant_name?: string;
|
||||||
|
foreign_currency_amount?: number;
|
||||||
|
foreign_currency_code?: string;
|
||||||
|
category?: string;
|
||||||
|
}[],
|
||||||
|
tagId: number
|
||||||
|
): Promise<number> {
|
||||||
|
if (rows.length === 0) return 0;
|
||||||
|
|
||||||
|
const baseRows = await queryRaw<{ base: number }>(
|
||||||
|
`SELECT COALESCE(MAX(row_index), -1) as base FROM transactions WHERE owner_id = $1 AND statement_id IS NULL`,
|
||||||
|
[ownerId]
|
||||||
|
);
|
||||||
|
const base = Number(baseRows[0].base);
|
||||||
|
|
||||||
|
const valueClauses: string[] = [];
|
||||||
|
const params: unknown[] = [ownerId];
|
||||||
|
let p = 2;
|
||||||
|
rows.forEach((r, i) => {
|
||||||
|
valueClauses.push(`(NULL, $1, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, ${base + 1 + i})`);
|
||||||
|
params.push(r.date, r.description, r.amount, r.transaction_type, r.merchant_name ?? null, r.foreign_currency_amount ?? null, r.foreign_currency_code ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const txIds = await queryRaw<{ id: number }>(
|
||||||
|
`INSERT INTO transactions (statement_id, owner_id, transaction_date, description, amount, transaction_type, merchant_name, foreign_currency_amount, foreign_currency_code, row_index)
|
||||||
|
VALUES ${valueClauses.join(", ")}
|
||||||
|
RETURNING id`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
if (txIds.length > 0) {
|
||||||
|
const tagValueClauses = txIds.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`);
|
||||||
|
const tagParams: unknown[] = txIds.flatMap((r) => [r.id, tagId]);
|
||||||
|
await queryRaw(
|
||||||
|
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ${tagValueClauses.join(", ")} ON CONFLICT DO NOTHING`,
|
||||||
|
tagParams
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return txIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Excludes cash from reconciliation.
|
||||||
|
*
|
||||||
|
* Cash never appears on a statement, so a cash transaction would sit in the
|
||||||
|
* queue forever being offered matches within 3 days and 1% on amount. Accepting
|
||||||
|
* one is silently destructive: reconciled manual rows are filtered out of every
|
||||||
|
* query, so the cash spend vanishes 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, which preserves the behaviour of every pre-existing row.
|
||||||
|
*/
|
||||||
|
export const notCash = (alias = "t") =>
|
||||||
|
`(${alias}.payment_method IS NULL OR ${alias}.payment_method <> 'cash')`;
|
||||||
|
|
||||||
|
export interface PotentialMatch {
|
||||||
|
id: number;
|
||||||
|
transaction_date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
transaction_type: string;
|
||||||
|
effective_merchant: string;
|
||||||
|
effective_category: string;
|
||||||
|
bank_name: string;
|
||||||
|
billing_end_date: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualTxWithMatches extends TransactionRow {
|
||||||
|
matches: PotentialMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPendingReconciliations(ownerId: number): Promise<ManualTxWithMatches[]> {
|
||||||
|
// Fetch all unreconciled manual transactions
|
||||||
|
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[]; splits: string | TransactionRow["splits"] }>(
|
||||||
|
`SELECT t.*,
|
||||||
|
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
|
||||||
|
COALESCE(o.category_override, t.category) as effective_category,
|
||||||
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||||
|
'Manual' as bank_name,
|
||||||
|
t.owner_id,
|
||||||
|
p.name as owner_name,
|
||||||
|
txn_tags.tags,
|
||||||
|
txn_splits.splits
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
LEFT JOIN participants p ON p.id = t.owner_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags
|
||||||
|
FROM transaction_tags tt JOIN tags tg ON tg.id = tt.tag_id
|
||||||
|
WHERE tt.transaction_id = t.id
|
||||||
|
) txn_tags ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COALESCE(json_agg(json_build_object('participant_id', ts.participant_id, 'name', sp.name, 'share_percent', ts.share_percent, 'settled', ts.settled) ORDER BY sp.name), '[]'::json) as splits
|
||||||
|
FROM transaction_splits ts JOIN participants sp ON sp.id = ts.participant_id
|
||||||
|
WHERE ts.transaction_id = t.id
|
||||||
|
) txn_splits ON true
|
||||||
|
WHERE t.statement_id IS NULL AND t.owner_id = $1 AND t.reconciled_with_id IS NULL
|
||||||
|
AND ${notCash("t")}
|
||||||
|
ORDER BY t.transaction_date DESC, t.row_index ASC`,
|
||||||
|
[ownerId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const manualTxs = raw.map((r) => ({
|
||||||
|
...r,
|
||||||
|
tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []),
|
||||||
|
splits: typeof r.splits === "string" ? JSON.parse(r.splits) : (r.splits ?? []),
|
||||||
|
})) as TransactionRow[];
|
||||||
|
|
||||||
|
if (manualTxs.length === 0) return [];
|
||||||
|
|
||||||
|
// Fetch all potential matches in one query using window function
|
||||||
|
const matchRows = await queryRaw<PotentialMatch & { manual_id: number; rn: number }>(
|
||||||
|
`SELECT manual_id, id, transaction_date, description, amount, transaction_type,
|
||||||
|
effective_merchant, effective_category, bank_name, billing_end_date
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
m.id AS manual_id,
|
||||||
|
t.id,
|
||||||
|
t.transaction_date,
|
||||||
|
t.description,
|
||||||
|
t.amount,
|
||||||
|
t.transaction_type,
|
||||||
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, '') AS effective_merchant,
|
||||||
|
COALESCE(o.category_override, t.category, '') AS effective_category,
|
||||||
|
s.bank_name,
|
||||||
|
s.billing_end_date,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY m.id
|
||||||
|
ORDER BY ABS(t.amount - m.amount), ABS(t.transaction_date - m.transaction_date)
|
||||||
|
) AS rn
|
||||||
|
FROM transactions m
|
||||||
|
JOIN transactions t ON t.statement_id IS NOT NULL
|
||||||
|
AND t.transaction_date BETWEEN m.transaction_date - 3 AND m.transaction_date + 3
|
||||||
|
AND t.amount BETWEEN m.amount * 0.99 AND m.amount * 1.01
|
||||||
|
JOIN statements s ON s.id = t.statement_id
|
||||||
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
WHERE m.statement_id IS NULL
|
||||||
|
AND m.owner_id = $1
|
||||||
|
AND m.reconciled_with_id IS NULL
|
||||||
|
AND ${notCash("m")}
|
||||||
|
AND COALESCE(t.owner_id, s.owner_id) = $1
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM transactions mt WHERE mt.reconciled_with_id = t.id
|
||||||
|
)
|
||||||
|
) sq
|
||||||
|
WHERE rn <= 5
|
||||||
|
ORDER BY manual_id, rn`,
|
||||||
|
[ownerId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group matches by manual_id
|
||||||
|
const matchesByManualId = new Map<number, PotentialMatch[]>();
|
||||||
|
for (const row of matchRows) {
|
||||||
|
const list = matchesByManualId.get(row.manual_id) ?? [];
|
||||||
|
list.push({
|
||||||
|
id: row.id,
|
||||||
|
transaction_date: row.transaction_date,
|
||||||
|
description: row.description,
|
||||||
|
amount: row.amount,
|
||||||
|
transaction_type: row.transaction_type,
|
||||||
|
effective_merchant: row.effective_merchant,
|
||||||
|
effective_category: row.effective_category,
|
||||||
|
bank_name: row.bank_name,
|
||||||
|
billing_end_date: row.billing_end_date,
|
||||||
|
});
|
||||||
|
matchesByManualId.set(row.manual_id, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return manualTxs.map((tx) => ({
|
||||||
|
...tx,
|
||||||
|
matches: matchesByManualId.get(tx.id) ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTags() {
|
export async function getTags() {
|
||||||
return queryRaw<TagRow & { transaction_count: number }>(`
|
return queryRaw<TagRow & { transaction_count: number }>(`
|
||||||
SELECT tg.id, tg.name, tg.color,
|
SELECT tg.id, tg.name, tg.color,
|
||||||
@@ -259,14 +666,31 @@ export async function getTags() {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSharedTransactions(ownerId: number) {
|
export async function getSharedTransactions(ownerId: number, tagIds?: number[], noTags?: boolean, participantId?: number) {
|
||||||
|
const params: unknown[] = [ownerId];
|
||||||
|
let tagClause = "";
|
||||||
|
if (noTags) {
|
||||||
|
tagClause = `AND NOT EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id)`;
|
||||||
|
} else if (tagIds?.length) {
|
||||||
|
params.push(tagIds);
|
||||||
|
tagClause = `AND EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id = ANY($2::int[]))`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let participantClause = "";
|
||||||
|
if (participantId) {
|
||||||
|
params.push(participantId);
|
||||||
|
participantClause = `AND EXISTS (SELECT 1 FROM transaction_splits ts_p WHERE ts_p.transaction_id = t.id AND ts_p.participant_id = $${params.length})`;
|
||||||
|
}
|
||||||
|
|
||||||
const rows = await queryRaw<TransactionRow & { split_data: string }>(`
|
const rows = await queryRaw<TransactionRow & { split_data: string }>(`
|
||||||
SELECT t.*,
|
SELECT t.*,
|
||||||
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
||||||
COALESCE(o.category_override, t.category) as effective_category,
|
COALESCE(o.category_override, t.category) as effective_category,
|
||||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||||
s.bank_name, s.owner_id,
|
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||||
|
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||||
p_owner.name as owner_name,
|
p_owner.name as owner_name,
|
||||||
|
COALESCE(src.created_at, t.created_at) as created_at,
|
||||||
json_agg(json_build_object(
|
json_agg(json_build_object(
|
||||||
'split_id', ts.id,
|
'split_id', ts.id,
|
||||||
'participant_id', ts.participant_id,
|
'participant_id', ts.participant_id,
|
||||||
@@ -278,20 +702,255 @@ export async function getSharedTransactions(ownerId: number) {
|
|||||||
JOIN transaction_splits ts ON ts.transaction_id = t.id
|
JOIN transaction_splits ts ON ts.transaction_id = t.id
|
||||||
JOIN participants p ON p.id = ts.participant_id
|
JOIN participants p ON p.id = ts.participant_id
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
JOIN statements s ON s.id = t.statement_id
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
LEFT JOIN participants p_owner ON p_owner.id = s.owner_id
|
LEFT JOIN participants p_owner ON p_owner.id = COALESCE(t.owner_id, s.owner_id)
|
||||||
WHERE s.owner_id = $1
|
LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL
|
||||||
AND EXISTS (
|
WHERE (
|
||||||
SELECT 1 FROM transaction_splits ts2
|
(
|
||||||
JOIN participants p2 ON p2.id = ts2.participant_id
|
COALESCE(t.owner_id, s.owner_id) = $1
|
||||||
WHERE ts2.transaction_id = t.id AND p2.name != 'Me'
|
AND EXISTS (SELECT 1 FROM transaction_splits ts2 WHERE ts2.transaction_id = t.id AND ts2.participant_id != $1)
|
||||||
|
) OR (
|
||||||
|
COALESCE(t.owner_id, s.owner_id) != $1
|
||||||
|
AND EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1)
|
||||||
)
|
)
|
||||||
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name
|
)
|
||||||
|
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
|
||||||
|
${tagClause}
|
||||||
|
${participantClause}
|
||||||
|
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name, src.created_at
|
||||||
ORDER BY t.transaction_date DESC
|
ORDER BY t.transaction_date DESC
|
||||||
`, [ownerId]);
|
`, params);
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
splits: typeof r.split_data === "string" ? JSON.parse(r.split_data) : r.split_data,
|
splits: typeof r.split_data === "string" ? JSON.parse(r.split_data) : r.split_data,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Trips ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TripRow {
|
||||||
|
id: number;
|
||||||
|
owner_id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
color: string;
|
||||||
|
archived: boolean;
|
||||||
|
created_at: string;
|
||||||
|
total_spend: number;
|
||||||
|
transaction_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TripAnalytics {
|
||||||
|
trip: TripRow;
|
||||||
|
total_spend: number;
|
||||||
|
transaction_count: number;
|
||||||
|
num_days: number;
|
||||||
|
daily_average: number;
|
||||||
|
category_breakdown: { category: string; amount: number; count: number }[];
|
||||||
|
daily_spend: { date: string; amount: number }[];
|
||||||
|
top_merchants: { merchant: string; amount: number; count: number }[];
|
||||||
|
tag_breakdown: { tag_id: number; name: string; color: string; amount: number; count: number }[];
|
||||||
|
participant_splits: { participant_id: number; name: string; owed: number; settled: number; unsettled: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTrips(ownerId: number): Promise<TripRow[]> {
|
||||||
|
return queryRaw<TripRow>(`
|
||||||
|
SELECT
|
||||||
|
t.*,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE WHEN tx.transaction_type IN ('debit','fee','interest')
|
||||||
|
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment')
|
||||||
|
THEN COALESCE(tx.amount_aud, tx.amount) ELSE 0 END
|
||||||
|
), 0)::float AS total_spend,
|
||||||
|
COUNT(o.transaction_id)::int AS transaction_count
|
||||||
|
FROM trips t
|
||||||
|
LEFT JOIN transaction_overrides o ON o.trip_id = t.id
|
||||||
|
LEFT JOIN transactions tx ON tx.id = o.transaction_id
|
||||||
|
WHERE t.owner_id = $1
|
||||||
|
GROUP BY t.id
|
||||||
|
ORDER BY t.created_at DESC
|
||||||
|
`, [ownerId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTripById(id: number, ownerId: number): Promise<TripRow | null> {
|
||||||
|
const rows = await queryRaw<TripRow>(`
|
||||||
|
SELECT
|
||||||
|
t.*,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE WHEN tx.transaction_type IN ('debit','fee','interest')
|
||||||
|
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment')
|
||||||
|
THEN COALESCE(tx.amount_aud, tx.amount) ELSE 0 END
|
||||||
|
), 0)::float AS total_spend,
|
||||||
|
COUNT(o.transaction_id)::int AS transaction_count
|
||||||
|
FROM trips t
|
||||||
|
LEFT JOIN transaction_overrides o ON o.trip_id = t.id
|
||||||
|
LEFT JOIN transactions tx ON tx.id = o.transaction_id
|
||||||
|
WHERE t.id = $1 AND t.owner_id = $2
|
||||||
|
GROUP BY t.id
|
||||||
|
`, [id, ownerId]);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTripAnalytics(tripId: number, ownerId: number): Promise<TripAnalytics> {
|
||||||
|
const trip = await getTripById(tripId, ownerId);
|
||||||
|
if (!trip) throw new Error("Trip not found");
|
||||||
|
|
||||||
|
const [categoryRows, dailyRows, merchantRows, tagRows, splitRows] = await Promise.all([
|
||||||
|
queryRaw<{ category: string; amount: number; count: number }>(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(o.category_override, tx.category, 'other') AS category,
|
||||||
|
SUM(COALESCE(tx.amount_aud, tx.amount))::float AS amount,
|
||||||
|
COUNT(*)::int AS count
|
||||||
|
FROM transaction_overrides o
|
||||||
|
JOIN transactions tx ON tx.id = o.transaction_id
|
||||||
|
WHERE o.trip_id = $1
|
||||||
|
AND tx.transaction_type IN ('debit','fee','interest')
|
||||||
|
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment')
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 2 DESC
|
||||||
|
`, [tripId]),
|
||||||
|
|
||||||
|
queryRaw<{ date: string; amount: number }>(`
|
||||||
|
SELECT
|
||||||
|
tx.transaction_date::text AS date,
|
||||||
|
SUM(COALESCE(tx.amount_aud, tx.amount))::float AS amount
|
||||||
|
FROM transaction_overrides o
|
||||||
|
JOIN transactions tx ON tx.id = o.transaction_id
|
||||||
|
WHERE o.trip_id = $1
|
||||||
|
AND tx.transaction_type IN ('debit','fee','interest')
|
||||||
|
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment')
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1
|
||||||
|
`, [tripId]),
|
||||||
|
|
||||||
|
queryRaw<{ merchant: string; amount: number; count: number }>(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(o.merchant_normalized, tx.merchant_normalized, tx.merchant_name, tx.description) AS merchant,
|
||||||
|
SUM(COALESCE(tx.amount_aud, tx.amount))::float AS amount,
|
||||||
|
COUNT(*)::int AS count
|
||||||
|
FROM transaction_overrides o
|
||||||
|
JOIN transactions tx ON tx.id = o.transaction_id
|
||||||
|
WHERE o.trip_id = $1
|
||||||
|
AND tx.transaction_type IN ('debit','fee','interest')
|
||||||
|
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment')
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 2 DESC
|
||||||
|
LIMIT 10
|
||||||
|
`, [tripId]),
|
||||||
|
|
||||||
|
queryRaw<{ tag_id: number; name: string; color: string; amount: number; count: number }>(`
|
||||||
|
SELECT
|
||||||
|
tg.id AS tag_id, tg.name, tg.color,
|
||||||
|
SUM(COALESCE(tx.amount_aud, tx.amount))::float AS amount,
|
||||||
|
COUNT(DISTINCT tx.id)::int AS count
|
||||||
|
FROM transaction_overrides o
|
||||||
|
JOIN transactions tx ON tx.id = o.transaction_id
|
||||||
|
JOIN transaction_tags tt ON tt.transaction_id = tx.id
|
||||||
|
JOIN tags tg ON tg.id = tt.tag_id
|
||||||
|
WHERE o.trip_id = $1
|
||||||
|
AND tx.transaction_type IN ('debit','fee','interest')
|
||||||
|
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment')
|
||||||
|
GROUP BY tg.id
|
||||||
|
ORDER BY 4 DESC
|
||||||
|
`, [tripId]),
|
||||||
|
|
||||||
|
queryRaw<{ participant_id: number; name: string; owed: number; settled: number; unsettled: number }>(`
|
||||||
|
SELECT
|
||||||
|
p.id AS participant_id,
|
||||||
|
p.name,
|
||||||
|
SUM(ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount))::float AS owed,
|
||||||
|
SUM(CASE WHEN ts.settled THEN ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount) ELSE 0 END)::float AS settled,
|
||||||
|
SUM(CASE WHEN NOT ts.settled THEN ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount) ELSE 0 END)::float AS unsettled
|
||||||
|
FROM transaction_overrides o
|
||||||
|
JOIN transactions tx ON tx.id = o.transaction_id
|
||||||
|
JOIN transaction_splits ts ON ts.transaction_id = tx.id
|
||||||
|
JOIN participants p ON p.id = ts.participant_id
|
||||||
|
WHERE o.trip_id = $1
|
||||||
|
AND tx.transaction_type IN ('debit','fee','interest')
|
||||||
|
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment')
|
||||||
|
GROUP BY p.id
|
||||||
|
ORDER BY 3 DESC
|
||||||
|
`, [tripId]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const num_days = (trip.start_date && trip.end_date)
|
||||||
|
? Math.max(1, Math.round((new Date(trip.end_date).getTime() - new Date(trip.start_date).getTime()) / 86400000) + 1)
|
||||||
|
: Math.max(dailyRows.length, 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
trip,
|
||||||
|
total_spend: trip.total_spend,
|
||||||
|
transaction_count: trip.transaction_count,
|
||||||
|
num_days,
|
||||||
|
daily_average: trip.total_spend / num_days,
|
||||||
|
category_breakdown: categoryRows,
|
||||||
|
daily_spend: dailyRows,
|
||||||
|
top_merchants: merchantRows,
|
||||||
|
tag_breakdown: tagRows,
|
||||||
|
participant_splits: splitRows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTrip(
|
||||||
|
ownerId: number,
|
||||||
|
data: { name: string; description?: string | null; start_date?: string | null; end_date?: string | null; color?: string }
|
||||||
|
): Promise<TripRow> {
|
||||||
|
const rows = await queryRaw<TripRow>(`
|
||||||
|
INSERT INTO trips (owner_id, name, description, start_date, end_date, color)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *, 0::float AS total_spend, 0::int AS transaction_count
|
||||||
|
`, [ownerId, data.name, data.description ?? null, data.start_date ?? null, data.end_date ?? null, data.color ?? '#6366f1']);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTrip(
|
||||||
|
id: number,
|
||||||
|
ownerId: number,
|
||||||
|
data: Partial<{ name: string; description: string | null; start_date: string | null; end_date: string | null; color: string; archived: boolean }>
|
||||||
|
): Promise<TripRow | null> {
|
||||||
|
const setClauses: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
if (data.name !== undefined) { setClauses.push(`name = $${idx++}`); params.push(data.name); }
|
||||||
|
if ('description' in data) { setClauses.push(`description = $${idx++}`); params.push(data.description ?? null); }
|
||||||
|
if ('start_date' in data) { setClauses.push(`start_date = $${idx++}`); params.push(data.start_date ?? null); }
|
||||||
|
if ('end_date' in data) { setClauses.push(`end_date = $${idx++}`); params.push(data.end_date ?? null); }
|
||||||
|
if (data.color !== undefined) { setClauses.push(`color = $${idx++}`); params.push(data.color); }
|
||||||
|
if (data.archived !== undefined) { setClauses.push(`archived = $${idx++}`); params.push(data.archived); }
|
||||||
|
if (!setClauses.length) return getTripById(id, ownerId);
|
||||||
|
params.push(id, ownerId);
|
||||||
|
const rows = await queryRaw<TripRow>(`
|
||||||
|
UPDATE trips SET ${setClauses.join(', ')}
|
||||||
|
WHERE id = $${idx++} AND owner_id = $${idx}
|
||||||
|
RETURNING *, 0::float AS total_spend, 0::int AS transaction_count
|
||||||
|
`, params);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTrip(id: number, ownerId: number): Promise<void> {
|
||||||
|
await queryRaw(`DELETE FROM trips WHERE id = $1 AND owner_id = $2`, [id, ownerId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignTransactionsToTrip(
|
||||||
|
tripId: number | null,
|
||||||
|
transactionIds: number[]
|
||||||
|
): Promise<void> {
|
||||||
|
if (!transactionIds.length) return;
|
||||||
|
await queryRaw(`
|
||||||
|
INSERT INTO transaction_overrides (transaction_id, trip_id)
|
||||||
|
SELECT unnest($1::int[]), $2
|
||||||
|
ON CONFLICT (transaction_id)
|
||||||
|
DO UPDATE SET trip_id = EXCLUDED.trip_id
|
||||||
|
`, [transactionIds, tripId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTagTransactionIds(tagId: number): Promise<number[]> {
|
||||||
|
const rows = await queryRaw<{ transaction_id: number }>(
|
||||||
|
`SELECT transaction_id FROM transaction_tags WHERE tag_id = $1`,
|
||||||
|
[tagId]
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.transaction_id);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Applying a rule's `actions` to transactions, and snapshotting the before-state
|
||||||
|
* so a run can be reverted.
|
||||||
|
*
|
||||||
|
* Shared by the two callers that write rule actions:
|
||||||
|
* - `POST /api/rules/apply` — condition-matched bulk run
|
||||||
|
* - `POST /api/transactions/bulk` with `action: "apply_rule"` — quick action
|
||||||
|
* fired against a hand-picked selection (conditions ignored)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
import type { Actions } from "@/lib/rules";
|
||||||
|
|
||||||
|
export 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 }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply `actions` to one transaction.
|
||||||
|
*
|
||||||
|
* Tags are additive (ON CONFLICT DO NOTHING) — re-running never duplicates and
|
||||||
|
* never removes tags the rule doesn't mention. Splits replace the participant
|
||||||
|
* set but upsert shares, so `settled` flags survive a re-run; a plain
|
||||||
|
* delete+reinsert would silently un-settle reconciled splits.
|
||||||
|
*/
|
||||||
|
export async function applyRuleActions(
|
||||||
|
transactionId: number,
|
||||||
|
actions: Actions,
|
||||||
|
opts: { skipSplit?: boolean } = {}
|
||||||
|
): Promise<void> {
|
||||||
|
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()`,
|
||||||
|
[transactionId, 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`,
|
||||||
|
[transactionId, tagId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actions.apply_split?.length && !opts.skipSplit) {
|
||||||
|
await queryRaw(
|
||||||
|
`DELETE FROM transaction_splits WHERE transaction_id = $1 AND participant_id != ALL($2::int[])`,
|
||||||
|
[transactionId, actions.apply_split.map((s) => s.participant_id)]
|
||||||
|
);
|
||||||
|
for (const s of actions.apply_split) {
|
||||||
|
await queryRaw(
|
||||||
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (transaction_id, participant_id)
|
||||||
|
DO UPDATE SET share_percent = EXCLUDED.share_percent`,
|
||||||
|
[transactionId, s.participant_id, s.share_percent]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capture the overrides / tags / splits of `ids` before a run mutates them. */
|
||||||
|
export async function captureSnapshot(ids: number[]): Promise<SnapshotEntry[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
|
||||||
|
const overrides = await queryRaw<{
|
||||||
|
transaction_id: number;
|
||||||
|
category_override: string | null;
|
||||||
|
merchant_normalized: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT transaction_id, category_override, merchant_normalized
|
||||||
|
FROM transaction_overrides WHERE transaction_id = ANY($1::int[])`,
|
||||||
|
[ids]
|
||||||
|
);
|
||||||
|
const overrideMap = new Map(overrides.map((o) => [o.transaction_id, o]));
|
||||||
|
|
||||||
|
const tagRows = await queryRaw<{ transaction_id: number; tag_id: number }>(
|
||||||
|
`SELECT transaction_id, tag_id FROM transaction_tags WHERE transaction_id = ANY($1::int[])`,
|
||||||
|
[ids]
|
||||||
|
);
|
||||||
|
const tagMap = new Map<number, number[]>();
|
||||||
|
for (const row of tagRows) {
|
||||||
|
if (!tagMap.has(row.transaction_id)) tagMap.set(row.transaction_id, []);
|
||||||
|
tagMap.get(row.transaction_id)!.push(row.tag_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitRows = await queryRaw<{
|
||||||
|
transaction_id: number;
|
||||||
|
participant_id: number;
|
||||||
|
share_percent: number;
|
||||||
|
settled: boolean;
|
||||||
|
}>(
|
||||||
|
`SELECT transaction_id, participant_id, share_percent, settled
|
||||||
|
FROM transaction_splits WHERE transaction_id = ANY($1::int[])`,
|
||||||
|
[ids]
|
||||||
|
);
|
||||||
|
const splitMap = new Map<number, { participant_id: number; share_percent: number; settled: boolean }[]>();
|
||||||
|
for (const row of splitRows) {
|
||||||
|
if (!splitMap.has(row.transaction_id)) splitMap.set(row.transaction_id, []);
|
||||||
|
splitMap.get(row.transaction_id)!.push({
|
||||||
|
participant_id: row.participant_id,
|
||||||
|
share_percent: row.share_percent,
|
||||||
|
settled: row.settled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids.map((id) => {
|
||||||
|
const ov = overrideMap.get(id);
|
||||||
|
return {
|
||||||
|
transaction_id: id,
|
||||||
|
had_override: !!ov,
|
||||||
|
prev_category_override: ov?.category_override ?? null,
|
||||||
|
prev_merchant_normalized: ov?.merchant_normalized ?? null,
|
||||||
|
prev_tag_ids: tagMap.get(id) ?? [],
|
||||||
|
prev_splits: splitMap.get(id) ?? [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
export interface Condition {
|
||||||
|
field:
|
||||||
|
| "merchant_normalized"
|
||||||
|
| "description"
|
||||||
|
| "category"
|
||||||
|
| "bank_name"
|
||||||
|
| "amount"
|
||||||
|
| "transaction_type"
|
||||||
|
| "tag";
|
||||||
|
operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SplitEntry {
|
||||||
|
participant_id: number;
|
||||||
|
share_percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Actions {
|
||||||
|
set_category?: string;
|
||||||
|
add_tag_ids?: number[];
|
||||||
|
set_merchant?: string;
|
||||||
|
apply_split?: SplitEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TxFields {
|
||||||
|
effective_category: string;
|
||||||
|
effective_merchant: string;
|
||||||
|
description: string;
|
||||||
|
bank_name: string;
|
||||||
|
amount: number;
|
||||||
|
transaction_type: string;
|
||||||
|
tags: { id: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cond.field === "tag") {
|
||||||
|
const tagId = Number(cond.value);
|
||||||
|
const hasTag = tx.tags.some((t) => t.id === tagId);
|
||||||
|
return cond.operator === "not_equals" ? !hasTag : hasTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
case "transaction_type": fieldVal = tx.transaction_type || ""; 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user