Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1103397397 | ||
|
|
a9e251d969 | ||
|
|
c82a22767f | ||
|
|
33db7d05ef | ||
|
|
6d3b6e1a9d | ||
|
|
9f168cf4c8 | ||
|
|
775e5cc08f | ||
|
|
bbb90238e4 | ||
|
|
d06088fe34 | ||
|
|
c465742635 | ||
|
|
3f04cbd5e7 | ||
|
|
ba87ff86e7 | ||
|
|
4b7a7e5d9c | ||
|
|
3778bfe836 | ||
|
|
cc852e7c6f | ||
|
|
31a8177958 | ||
|
|
030490efa3 | ||
|
|
d6b4ec84f6 | ||
|
|
ab00f8c592 | ||
|
|
02495e4173 | ||
|
|
315bc06d0d | ||
|
|
76db9dddb4 | ||
|
|
25ef504574 | ||
|
|
e0b0fc91e0 | ||
|
|
856e1a51ab | ||
|
|
831d30669b | ||
|
|
99af10f9ea | ||
|
|
0b40924af0 | ||
|
|
48ec151c15 | ||
|
|
b706973fb5 | ||
|
|
710e089c4f | ||
|
|
b2a8a56c7b | ||
|
|
d384cc96f6 | ||
|
|
5df164e9a5 | ||
|
|
b8cd1b0f89 | ||
|
|
ce67e38d77 | ||
|
|
b8296b6e29 | ||
|
|
0c1f88ed9c | ||
|
|
4a49add277 | ||
|
|
07b8c1ef16 | ||
|
|
1b561af9e9 | ||
|
|
1296555f17 | ||
|
|
7491e70a15 | ||
|
|
0a1f6b48a2 | ||
|
|
ef73a9cea0 | ||
|
|
d53d3106f2 | ||
|
|
02ac136e19 | ||
|
|
084b8764e3 | ||
|
|
281f0d3782 | ||
|
|
85e7801407 | ||
|
|
5206388958 | ||
|
|
8076d1a949 | ||
|
|
aeaca84cc7 | ||
|
|
278e57354c | ||
|
|
9f90d8726f | ||
|
|
859043f5a5 | ||
|
|
fc22a61a43 | ||
|
|
0985c38be8 | ||
|
|
af4c64bba7 | ||
|
|
a8743ba7df | ||
|
|
7b3fd4b65f | ||
|
|
dd11019fdf | ||
|
|
714c5a9b25 | ||
|
|
2a10450c3e | ||
|
|
e72d3ad9e5 | ||
|
|
a7461ff83b | ||
|
|
c1d031511a | ||
|
|
7379437cc3 | ||
|
|
8bd7d77a8a | ||
|
|
f90ba332bd | ||
|
|
e3aa17acdd | ||
|
|
1eff0f9337 | ||
|
|
3cf67f6e2a | ||
|
|
90d8db4abe | ||
|
|
d1a0eedf03 | ||
|
|
5dbeb0cb87 | ||
|
|
30a7857d13 | ||
|
|
1e79ada6d8 | ||
|
|
be85822cc7 | ||
|
|
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
|
||||||
@@ -41,3 +42,6 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
|
|
||||||
|
# Raw statement exports — real financial data, never commit
|
||||||
|
dump/
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
creation_rules:
|
||||||
|
- path_regex: \.env$
|
||||||
|
age: age1tw6wsyxgxa465cc0wx32u7xuw5675pyz35cuzey4huz69hc54v4qp0pvgn
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Guidance for Claude Code when working in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Personal finance tracker. Bank statements are ingested via an N8N workflow (in the smarthome repo at `docker/automation/workflows/cc-statement-processor-paperless.json`) that sends PDFs to Gemini 2.5 Flash for extraction, then inserts into PostgreSQL.
|
||||||
|
|
||||||
|
- **App**: Next.js 16 App Router, TypeScript, Tailwind CSS
|
||||||
|
- **DB**: PostgreSQL container `postgres-personal`, database `personal`, user `personal`
|
||||||
|
- **Auth**: `X-Forwarded-User` header (email) set by Traefik → `participants.email`. In dev/fallback: participant id=1 ("Me")
|
||||||
|
- **Runs at**: port 3000 inside container, exposed on host port 4100, proxied at `https://finance.bosecamp.com`
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
**Deployment is push-to-deploy via Komodo** (since 2026-07-19): pushing to `main` on
|
||||||
|
Gitea triggers the `deploy-finance` Procedure, which runs DeployStack `--build` on the
|
||||||
|
`finance` stack (files_on_host over `docker/finance/` in the smarthome repo). Just
|
||||||
|
commit and push — no manual deploy needed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Manual fallback only (from smarthome repo root), e.g. if Komodo is down
|
||||||
|
docker compose --env-file docker/common.env --env-file docker/finance/.env \
|
||||||
|
-f docker/finance/docker-compose.yml up -d --build
|
||||||
|
|
||||||
|
# IMPORTANT: docker restart does NOT pick up a new image — push to main (or use the compose command above)
|
||||||
|
|
||||||
|
# DB access
|
||||||
|
docker exec postgres-personal psql -U personal -d personal
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker logs finance -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `src/lib/db.ts` | `queryRaw<T>()` — the only DB query function; uses `pg` directly |
|
||||||
|
| `src/lib/queries.ts` | All SQL query functions (no ORM); import `queryRaw` from `@/lib/db` |
|
||||||
|
| `src/lib/hooks.ts` | TanStack Query hooks for all API calls |
|
||||||
|
| `src/lib/auth.ts` | `getCurrentUser()` — reads `X-Forwarded-User` header |
|
||||||
|
| `src/lib/categories.ts` | Canonical category list (`CATEGORIES` array + `formatCategory()`) |
|
||||||
|
| `src/app/api/*/route.ts` | API route handlers |
|
||||||
|
| `src/components/` | Shared UI components |
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
- All queries in `src/lib/queries.ts` use raw SQL via `queryRaw` from `src/lib/db.ts`
|
||||||
|
- API routes call query functions and return `NextResponse.json()`
|
||||||
|
- Frontend uses hooks from `src/lib/hooks.ts` (TanStack Query) — never fetches directly
|
||||||
|
- Auth is always checked first in every API route: `const user = await getCurrentUser(req)`
|
||||||
|
|
||||||
|
### Owner Scoping
|
||||||
|
|
||||||
|
All data is scoped by `owner_id`. The effective owner of a transaction is:
|
||||||
|
```sql
|
||||||
|
COALESCE(t.owner_id, s.owner_id)
|
||||||
|
```
|
||||||
|
- Statement-linked transactions: owner comes from `statements.owner_id`
|
||||||
|
- Manual transactions: `statement_id IS NULL`, owner stored directly in `transactions.owner_id`
|
||||||
|
|
||||||
|
The effective merchant and category always prefer overrides:
|
||||||
|
```sql
|
||||||
|
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) -- merchant
|
||||||
|
COALESCE(o.category_override, t.category) -- category
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Schema inspection
|
||||||
|
docker exec postgres-personal psql -U personal -d personal -c "\d transactions"
|
||||||
|
|
||||||
|
# Apply a migration SQL file
|
||||||
|
docker exec postgres-personal psql -U personal -d personal < prisma/migrations/<name>/migration.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Tables
|
||||||
|
|
||||||
|
- `statements` — one row per billing period per bank account
|
||||||
|
- `transactions` — line items; `statement_id` is nullable (NULL = manual entry); `reconciled_with_id` links a manual tx to its matched statement tx; `payment_method` (migration 0016) is `card | cash | bank_transfer | other`, NULL = unknown
|
||||||
|
|
||||||
|
### Cash and reconciliation
|
||||||
|
|
||||||
|
`payment_method = 'cash'` excludes a transaction from reconciliation via the
|
||||||
|
`notCash()` fragment in `queries.ts`. Cash never appears on a statement, so
|
||||||
|
without it a cash entry sits in the pending queue forever being offered matches
|
||||||
|
within 3 days and 1% on amount — and accepting one is silently destructive:
|
||||||
|
reconciled manual rows are filtered out of every query, so the cash spend
|
||||||
|
disappears while the card transaction it matched claims to be that same spend.
|
||||||
|
|
||||||
|
Only cash is excluded. Bank transfers *do* appear on a statement now that
|
||||||
|
transaction accounts are imported, and NULL means unknown — both stay
|
||||||
|
candidates, preserving the behaviour of every pre-existing row.
|
||||||
|
|
||||||
|
ATM withdrawals stay categorised as spend rather than `transfers`. Treating them
|
||||||
|
as transfers only works if every cash purchase is logged; with partial logging
|
||||||
|
it silently deletes the unlogged remainder from spend totals.
|
||||||
|
- `transaction_overrides` — user corrections to AI-extracted data (category, merchant, notes)
|
||||||
|
- `transaction_splits` — shared expense tracking (participant, share_percent, settled)
|
||||||
|
- `split_payments` — recorded cash settlements between participants
|
||||||
|
- `transaction_tags` — many-to-many join to `tags`
|
||||||
|
- `rules` — auto-categorisation rules (JSONB conditions + actions)
|
||||||
|
- `rule_apply_runs` — audit log of bulk rule-apply runs with full snapshot for revert
|
||||||
|
- `expense_metadata` — enrichment from email receipts; `transaction_id` nullable until reconciled
|
||||||
|
- `participants` — people; `id=1` is "Me" (the primary user)
|
||||||
|
- `account_owner_mappings` — persists bank+account → owner assignments
|
||||||
|
|
||||||
|
### Shared expenses and settlement — read before touching
|
||||||
|
|
||||||
|
The model is under active redesign. See `docs/shared-expenses-design.md` for the
|
||||||
|
proposal and what is already decided. Three traps:
|
||||||
|
|
||||||
|
**`transaction_splits.settled` is dead data.** It is `false` on every row. Its
|
||||||
|
only writer was `/api/splits/settle`, removed in `3f04cbd` because nothing called
|
||||||
|
it and one request could mark all of a participant's splits settled. Do not build
|
||||||
|
on this flag until settlement contexts exist.
|
||||||
|
|
||||||
|
**`getParticipantBalances` computes `splits − payments` and is correct.** Do not
|
||||||
|
"fix" it to exclude settled splits — the payments that settled them are still
|
||||||
|
subtracted, so you would double-count. The two settlement models (running tab vs
|
||||||
|
per-split flag) must not be mixed.
|
||||||
|
|
||||||
|
**Settlement cannot be attributed per trip.** `split_payments` records only
|
||||||
|
from/to/amount/date. Any per-trip settled/unsettled figure is fabricated; the
|
||||||
|
trip view used to show one and always reported 100% unsettled. Trips show share
|
||||||
|
only, and point at `/shared` for real balances.
|
||||||
|
|
||||||
|
Also: settlements already exist twice. Four of eight `split_payments` match an
|
||||||
|
offset-account credit exactly on amount and date, with `linked_transaction_id`
|
||||||
|
populated on only one. And Sonu's loan contributions (`…emi` in the offset
|
||||||
|
account, 39 rows, $37,980.24) are categorised `transfers`, indistinguishable from
|
||||||
|
ordinary internal transfers.
|
||||||
|
|
||||||
|
**Partial split coverage inside a category is usually correct, not a gap.** Only
|
||||||
|
*shared* items are split. `utilities` sits at 69% yours because Globird, OVO, GWW
|
||||||
|
and home telecoms are split while Telstra, Vodafone, Optus and JB Hi-Fi Mobile
|
||||||
|
are personal. `subscriptions` is 91% because Uber One, Amazon Prime and OnePass
|
||||||
|
are shared while Claude, OpenAI, Anthropic, OpenRouter, You.com, LinkedIn, Xero,
|
||||||
|
Billdu, Spotify and Patreon are not. `fees` and `charity` are 100% yours and
|
||||||
|
correct. Check the merchants before concluding a rule was never applied — a
|
||||||
|
category-level ratio that "looks wrong" usually is not.
|
||||||
|
|
||||||
|
Splits exist in this app from **2026-01-09** only; earlier splits lived in
|
||||||
|
SplitMyExpenses. So a trailing-12-month per-person series splices six months of
|
||||||
|
gross onto six months of net. Use Feb–Jun 2026 for anything per-person.
|
||||||
|
|
||||||
|
### The shared loan
|
||||||
|
|
||||||
|
The loan is a **separate ledger**, not a shared expense and not a settlement
|
||||||
|
context — a contribution must never be able to settle a dinner. Sonu's obligation
|
||||||
|
is a fixed 50% of the repayment; actual contributions vary, and the difference is
|
||||||
|
a tracked receivable ($4,000.00 over 2025-07 → 2026-06).
|
||||||
|
|
||||||
|
Do not derive the share from actual payments. During her leave the obligation did
|
||||||
|
not change, only the payment did — a percentage-of-actual model would silently
|
||||||
|
redefine her share as 30% and make the shortfall vanish.
|
||||||
|
|
||||||
|
Loan interest reconciles exactly: `repayments − interest − fees = balance
|
||||||
|
reduction`. It stays categorised `loan_interest` and counts as spend — over 12
|
||||||
|
months $63,500 of cash left and debt fell $44,127.36, and the $16,523.64
|
||||||
|
difference bought nothing. Excluding it would leave the balance sheet unable to
|
||||||
|
reconcile cash out against equity gained.
|
||||||
|
|
||||||
|
**The repayment is voluntarily above contracted, and the gap is the largest
|
||||||
|
flexible cost in the whole picture.** Contracted is $1,190.54/fortnight
|
||||||
|
($2,579.50/mo annualised); the actual direct debit is $2,500.00/fortnight
|
||||||
|
($5,416.67/mo). That is $2,837.17/mo of overpayment, and it is *not* sunk — it
|
||||||
|
shows up as `statements.redraw_available`, which grew $62,387.17 → $81,017.42
|
||||||
|
across the two most recent loan statements. Sonu returned to $1,250/fortnight in
|
||||||
|
July 2026 after the reduced $750 period during her leave.
|
||||||
|
|
||||||
|
Treat the repayment as two figures whenever asking "what does this cost me":
|
||||||
|
the contracted floor and the actual. `scheduled_repayment` holds the actual
|
||||||
|
($2,500), not the contracted minimum — the contracted figure is not in the DB at
|
||||||
|
all. See `docs/expense-baseline.md`.
|
||||||
|
|
||||||
|
### Import Date (`created_at`)
|
||||||
|
|
||||||
|
`transactions.created_at` is the import timestamp (DB default `now()`). In the transactions and shared views, the "Imported" column shows:
|
||||||
|
- For statement transactions: when the statement was processed by N8N
|
||||||
|
- For reconciled transactions: the `created_at` of the original manual/CSV transaction (via `LEFT JOIN transactions src ON src.reconciled_with_id = t.id`) — so the original import date is preserved post-reconciliation
|
||||||
|
|
||||||
|
Use `created_at` (not `transaction_date`) to answer "what was added since the last settlement?". Sort by `created_at` is supported server-side in `getTransactions` and client-side in the shared view.
|
||||||
|
|
||||||
|
### Rules System
|
||||||
|
|
||||||
|
Conditions are AND-evaluated. Fields: `merchant_normalized`, `description`, `category`, `bank_name`, `amount`, `transaction_type`. Operators: `contains`, `equals`, `starts_with`, `gt`, `lt`, `not_equals`. Actions: `set_category`, `set_merchant`, `add_tag_ids`, `apply_split`.
|
||||||
|
|
||||||
|
`contains` and `equals` operators are case-insensitive (both sides `.toLowerCase()`).
|
||||||
|
|
||||||
|
**A rule with zero conditions matches every transaction.** Both apply paths use
|
||||||
|
`conditions.length === 0 || conditions.every(...)`. Rule 43 "Home 50/50 Sonu" has
|
||||||
|
no conditions and a 50/50 split action — applying it blindly would split all
|
||||||
|
~3,700 transactions with another participant. That is what `manual_only` is for:
|
||||||
|
those rules are excluded from bulk runs and fire from the transactions page
|
||||||
|
against a hand-picked selection.
|
||||||
|
|
||||||
|
### Previewing a rule before applying it
|
||||||
|
|
||||||
|
`GET /api/rules/[id]/matches` is a dry run — it writes nothing and returns only
|
||||||
|
the transactions a rule would actually *change*, with already-correct rows
|
||||||
|
summarised as a count. The Preview button on the rules page uses it.
|
||||||
|
|
||||||
|
Apply then goes through `POST /api/transactions/bulk` with `action: "apply_rule"`
|
||||||
|
and **explicit transaction ids**, not the conditions. That is the safety
|
||||||
|
property: a rule whose conditions are too broad cannot reach further than what
|
||||||
|
the preview showed and the user ticked.
|
||||||
|
|
||||||
|
Prefer this over auto-applying rules on ingestion. It fails safe, works
|
||||||
|
retroactively, and tells you which rules are consistent enough to automate later.
|
||||||
|
|
||||||
|
### Rule apply history
|
||||||
|
|
||||||
|
`rule_apply_runs` snapshots the before-state so a run can be reverted, and since
|
||||||
|
migration 0017 also records `rule_id`, `rule_name` and `source`
|
||||||
|
(`all` | `rule` | `selection`). `rule_name` is denormalised deliberately and
|
||||||
|
there is no FK to `rules` — history must stay readable after a rule is renamed or
|
||||||
|
deleted, and deleting a rule must not cascade away the audit trail.
|
||||||
|
|
||||||
|
`GET /api/rules/runs/[id]` diffs that snapshot against current values. Rows
|
||||||
|
changed by something else since the run are flagged, because reverting restores
|
||||||
|
the pre-run value and discards the later edit.
|
||||||
|
|
||||||
|
### Trusting extracted statement data
|
||||||
|
|
||||||
|
**Balance assertions are the check that works.** `getStatements` computes
|
||||||
|
`opening + movement − closing`; the statements page flags any statement that does
|
||||||
|
not reconcile. Sign depends on what the balance means — on a credit card or loan
|
||||||
|
it is what you owe, so spending increases it; on a transaction or offset account
|
||||||
|
it is what you hold. 11 pre-existing statements currently fail, ~$4,177
|
||||||
|
unexplained, including two adjacent ANZ statements off by exactly ±$230.38 (a
|
||||||
|
transaction filed against the wrong one).
|
||||||
|
|
||||||
|
**Do not derive `opening_balance` from `closing − movement`.** It is an accounting
|
||||||
|
identity, so every statement would reconcile and the check would go permanently
|
||||||
|
green. A null that reads "unverified" is worth more than a number that is right
|
||||||
|
by construction. For the same reason, do not add a totals assertion comparing
|
||||||
|
`total_debits` to the summed rows — those totals are now *computed* from the rows
|
||||||
|
(see the N8N `Parse Gemini Result` node), so that check can never fail.
|
||||||
|
|
||||||
|
**Gemini invents summary fields the statement does not print.** Wise PDFs show
|
||||||
|
only a closing balance; asked for an opening balance anyway, the model produced
|
||||||
|
11,277.08 against a truth of 0.00, and on another statement read the running
|
||||||
|
balance of the oldest row. Every transaction was extracted perfectly in both
|
||||||
|
cases — verified row for row against the CSV exports. When a balance assertion
|
||||||
|
fails, suspect the summary before the transactions.
|
||||||
|
|
||||||
|
**Gemini drops rows silently on long tables.** `finishReason` was `STOP`, not
|
||||||
|
`MAX_TOKENS`, so raising `maxOutputTokens` does not help. This did *not* actually
|
||||||
|
occur on the Wise imports (that was the summary bug above), but it is why an
|
||||||
|
empty statement must not throw: a document that errors never gets tagged, so it
|
||||||
|
is re-fetched every poll forever and blocks everything behind it in the queue
|
||||||
|
(`ordering=-created`, `page_size=1`).
|
||||||
|
|
||||||
|
**FX is per transaction date**, via Frankfurter (ECB daily, free, no key), with
|
||||||
|
weekends resolving to the prior publication. A single spot rate across a 15-month
|
||||||
|
statement is wrong by up to 20%. Wise's own rates are more accurate in principle
|
||||||
|
but differ by only 0.05% and exist on 44 of 194 rows, so mixing bases is not
|
||||||
|
worth it.
|
||||||
|
|
||||||
|
**When comparing CSV exports to extracted data, order by full timestamp
|
||||||
|
including milliseconds.** Two of one statement's rows are 1ms apart; dropping the
|
||||||
|
fraction reversed them and produced a bogus opening balance.
|
||||||
|
|
||||||
|
## Development Patterns
|
||||||
|
|
||||||
|
### Adding a new API route
|
||||||
|
|
||||||
|
1. Create `src/app/api/<resource>/route.ts`
|
||||||
|
2. Always call `getCurrentUser(req)` first; return 403 if null
|
||||||
|
3. Write SQL in `src/lib/queries.ts` using `queryRaw`
|
||||||
|
4. Add a TanStack Query hook in `src/lib/hooks.ts`
|
||||||
|
|
||||||
|
### Adding a new condition field to rules
|
||||||
|
|
||||||
|
Two files only:
|
||||||
|
- `src/app/api/rules/apply/route.ts` — add to `Condition.field` union, `TxFields` interface, and `evaluateCondition()` switch
|
||||||
|
- `src/app/rules/page.tsx` — add to `FIELDS` array; add special rendering if needed (e.g. enum dropdown for `transaction_type`)
|
||||||
|
|
||||||
|
### Modifying queries
|
||||||
|
|
||||||
|
- All JOINs to `statements` must be `LEFT JOIN` (manual transactions have no statement)
|
||||||
|
- Owner filter pattern: `WHERE COALESCE(t.owner_id, s.owner_id) = $1`
|
||||||
|
- Bank name pattern: `COALESCE(s.bank_name, 'Manual') as bank_name`
|
||||||
|
|
||||||
|
Analytics queries must import the fragments from `src/lib/analytics-sql.ts`
|
||||||
|
(`STATEMENTS_JOIN`, `OWNER_SCOPE`, `EFFECTIVE_CATEGORY`, `EXCLUDE_NON_SPEND`)
|
||||||
|
rather than hand-rolling them. Two failure modes they exist to prevent:
|
||||||
|
|
||||||
|
- An `INNER JOIN statements` + `WHERE s.owner_id = $1` silently drops every
|
||||||
|
manual/CSV transaction (`statement_id IS NULL`).
|
||||||
|
- Spend must exclude the `transfers` and `investment` categories. Once bank
|
||||||
|
statements are imported alongside card statements, a credit-card payment
|
||||||
|
appears twice — as a debit leaving the bank account and as the underlying
|
||||||
|
purchases on the card statement. Excluding `transfers` is what nets it out.
|
||||||
|
Use the `EXCLUDE_NON_SPEND` fragment: a bare `category NOT IN (...)` evaluates
|
||||||
|
to NULL for uncategorised rows and drops them from totals.
|
||||||
|
|
||||||
|
### Statement types
|
||||||
|
|
||||||
|
`statements.statement_type` is constrained to `credit_card | transaction |
|
||||||
|
savings | loan | offset | investment | other`. Migration 0013 added a
|
||||||
|
`normalize_statement_type()` SQL function plus a BEFORE INSERT/UPDATE trigger, so
|
||||||
|
the N8N workflow can keep sending raw free text (`'ACCESS ADVANTAGE'`, `'Business
|
||||||
|
Card'`) and the DB normalises it on write. The raw extracted value is preserved in
|
||||||
|
`account_type`.
|
||||||
|
|
||||||
|
The TypeScript mirror is `src/lib/statement-types.ts` — keep the list, the SQL
|
||||||
|
function, and the CHECK constraint in sync when adding a type.
|
||||||
|
|
||||||
|
### Loans
|
||||||
|
|
||||||
|
A loan repayment is **not** an expense. It is part principal (equity, a
|
||||||
|
balance-sheet move) and part interest (the only part that is spend). Migration
|
||||||
|
0014 adds:
|
||||||
|
|
||||||
|
- `transactions.principal_amount` / `interest_amount` — populated only when the
|
||||||
|
lender itemises the split on the repayment row itself
|
||||||
|
- `statements.interest_rate`, `scheduled_repayment`, `repayment_frequency`,
|
||||||
|
`redraw_available`, `loan_term_months`
|
||||||
|
|
||||||
|
Two statement shapes, both handled:
|
||||||
|
|
||||||
|
1. **Separate rows** (the common Australian case) — the loan statement lists
|
||||||
|
repayments and "Interest Charged" separately. `transaction_type` alone is
|
||||||
|
enough: `interest` rows count as spend, `payment` rows don't. No split columns
|
||||||
|
needed.
|
||||||
|
2. **Itemised repayment row** — some lenders print principal and interest on the
|
||||||
|
repayment line. That row is typed `payment`, so it would be skipped entirely
|
||||||
|
and its interest lost. The `SPEND_ROWS` / `SPEND_BASE` fragments in
|
||||||
|
`analytics-sql.ts` handle it: a row with a non-null `interest_amount` counts
|
||||||
|
as spend, valued at `interest_amount` rather than `amount`.
|
||||||
|
|
||||||
|
The N8N `Parse Gemini Result` node only accepts a split when both parts are
|
||||||
|
present *and* they sum to the row amount (±2c) — a half-extracted split would
|
||||||
|
silently misreport spend, so it is discarded rather than trusted.
|
||||||
|
|
||||||
|
Loan interest uses the `loan_interest` category; principal repayments use
|
||||||
|
`investment` (excluded from spend, surfaced on the investments line in monthly
|
||||||
|
analytics).
|
||||||
|
|
||||||
|
### Prisma
|
||||||
|
|
||||||
|
The schema at `prisma/schema.prisma` covers all tables. The generated client (gitignored) must be regenerated after schema changes:
|
||||||
|
```bash
|
||||||
|
cd /mnt/m2cache/appdata/finance-app && npx prisma generate
|
||||||
|
```
|
||||||
|
Docker builds run `npx prisma generate` automatically. Do not commit `src/generated/prisma/` — it is gitignored.
|
||||||
|
|
||||||
|
## Agent / MCP Access
|
||||||
|
|
||||||
|
Agents read this DB through the read-only `postgres-personal` MCP server (lives in the
|
||||||
|
`personal-agent-gateway` repo, not here): `agent_ro` role, SELECT-only, SQLGlot guardrail,
|
||||||
|
100-row cap, every call audited to `mcp_query_log`. See `docs/agent-access.md` for the tool
|
||||||
|
list, the five analysis views, and per-client setup (Claude Code, Codex, Hermes).
|
||||||
|
|
||||||
|
Two things to remember when changing the schema: the agent views are created by
|
||||||
|
`smarthome/personal-agent/migrations/006_agent_read_role_views.sql` (not Prisma) and read
|
||||||
|
`transactions`/`statements`/`expense_metadata` columns directly — rename a column and they
|
||||||
|
break or go stale. And the views are **not** owner-scoped and do **not** merge
|
||||||
|
`transaction_overrides`, so agent numbers can differ from the UI.
|
||||||
|
|
||||||
|
## Known Gaps / TODOs
|
||||||
|
|
||||||
|
See `README.md` → **Known Gaps / TODOs** for full details.
|
||||||
|
|
||||||
|
**Payment provider tracking**: `merchant_normalized` currently conflates payment provider (PayPal, Afterpay, Zip) with the actual merchant. Plan: add `payment_provider` column, update Gemini prompt to extract it separately, backfill from `merchant_name` patterns, surface in UI filters.
|
||||||
|
|
||||||
|
### Open as of 2026-07-26
|
||||||
|
|
||||||
|
- **Shared expenses redesign** — `docs/shared-expenses-design.md`. Phase 0 done;
|
||||||
|
Phases 1–4 unbuilt. Deliberately paused to live with the current behaviour
|
||||||
|
before committing to a model designed in one session.
|
||||||
|
- **Expense baseline / emergency reserve** — `docs/expense-baseline.md`. One-off
|
||||||
|
analysis, nothing built. Records four data corrections the raw numbers need
|
||||||
|
(misfiled Raiz/super/brokerage debits, `other` credits read as negative spend,
|
||||||
|
`government` conflating ATO with rates/rego, `fees` being mostly annual) and
|
||||||
|
why only Feb–Jun 2026 is trustworthy for per-person figures.
|
||||||
|
- **11 statements fail the balance assertion**, ~$4,177 unexplained. Predates
|
||||||
|
this work. One ANZ statement is off by exactly $0.50, traced to a misread digit
|
||||||
|
in fee rows ($5.00 vs $5.50).
|
||||||
|
- **28 Up Bank debits are categorised `other`** ($3,760.74). Up only categorised
|
||||||
|
16 of 88 rows. The `Payee` field is populated throughout, so merchant rules plus
|
||||||
|
the rule preview should clear most of it.
|
||||||
|
- **Up item sales are categorised `income`** ($4,238.04 across 19 credits — iPad,
|
||||||
|
drone, camera). Correct in that they are excluded from spend, but it mixes
|
||||||
|
asset disposals into the income line alongside salary.
|
||||||
|
- **`payment_method` is not shown in the transactions list** — settable on create
|
||||||
|
and edit only. Worth a column or filter if cash becomes routine.
|
||||||
|
- **Raw statement exports live in `dump/`**, gitignored since `31a8177`. They were
|
||||||
|
committed by accident in `030490e` and remain in that commit's history; the repo
|
||||||
|
has no GitHub remote, so exposure is limited to the local Gitea. Purging history
|
||||||
|
was offered and not actioned.
|
||||||
@@ -1,36 +1,409 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# Finance App
|
||||||
|
|
||||||
## Getting Started
|
Personal finance tracker built on Next.js 16 (App Router), PostgreSQL, and Prisma. Bank statements are ingested automatically from Paperless-NGX via an N8N workflow that uses Gemini to extract structured data from PDF statements.
|
||||||
|
|
||||||
First, run the development server:
|
## Stack
|
||||||
|
|
||||||
```bash
|
- **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,211 @@
|
|||||||
|
# Monthly expense baseline and emergency reserve
|
||||||
|
|
||||||
|
Analysis run 2026-07-26. **One-off analysis, not a feature** — nothing in the app
|
||||||
|
computes these numbers. Read "Reproducing this" before trusting a restated figure.
|
||||||
|
|
||||||
|
The question: how much should be held in reserve to cover 6–12 months of expenses?
|
||||||
|
|
||||||
|
## Answer
|
||||||
|
|
||||||
|
| Scenario | $/mo | 6 months | 12 months |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| Survival — contracted loan repayment, essentials only | 3,040 | 18,250 | 36,500 |
|
||||||
|
| **Realistic — contracted loan, + dining and charity** | **4,140** | **24,800** | **49,600** |
|
||||||
|
| Status quo — keep overpaying the loan, normal life ex-travel | 5,557 | 33,500 | 67,000 |
|
||||||
|
|
||||||
|
Use the middle row. The survival row assumes dining is cut to zero and stays
|
||||||
|
there, which is not a plan anyone executes for six months.
|
||||||
|
|
||||||
|
Against that, liquidity already available (statements 133 and 131, 2026-06-30):
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---:|
|
||||||
|
| Loan redraw | 81,017.42 |
|
||||||
|
| Offset balance | 8,753.00 |
|
||||||
|
| **Accessible** | **89,770.42** |
|
||||||
|
|
||||||
|
That is 3.6× the six-month target and 1.8× the twelve-month one. Redraw grew
|
||||||
|
$62,387.17 → $81,017.42 across the last two loan statements, matching the
|
||||||
|
overpayment rate — the money spent killing the loan faster is still reachable.
|
||||||
|
|
||||||
|
**Caveat on counting redraw as the reserve.** It is available at AMP's
|
||||||
|
discretion, and lenders reduce or freeze it exactly when a borrower looks
|
||||||
|
distressed — which is when it would be needed. The rate also moved 5.54% → 6.29%
|
||||||
|
between the two statements, so redrawn funds cost more than they did. Hold some
|
||||||
|
genuine cash; it does not need to be $50,000.
|
||||||
|
|
||||||
|
## This is *your* outgoings, not household spend
|
||||||
|
|
||||||
|
The app only sees accounts that get imported. Sonu's own spending on the
|
||||||
|
household is invisible to it. Grocery spend reads as ~$300/mo gross on 4–10
|
||||||
|
transactions, which is implausible for a household and is partly explained by her
|
||||||
|
paying from her own account.
|
||||||
|
|
||||||
|
For "how much reserve do **I** need" that blind spot does not matter — your own
|
||||||
|
outgoings is the correct measure. Do not relabel these figures as household
|
||||||
|
totals; they are not, and they would be wrong by an unknown amount.
|
||||||
|
|
||||||
|
## The loan has two floors
|
||||||
|
|
||||||
|
This is the largest single lever and the reason there are three scenarios.
|
||||||
|
|
||||||
|
| | $/fortnight | $/mo annualised | Your 50% |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| Contracted minimum | 1,190.54 | 2,579.50 | **1,290** |
|
||||||
|
| Actual direct debit | 2,500.00 | 5,416.67 | **2,708** |
|
||||||
|
| Voluntary overpayment | 1,309.46 | 2,837.17 | 1,419 |
|
||||||
|
|
||||||
|
Dropping to contracted cuts your loan cost by $1,418/mo. Sonu's obligation is a
|
||||||
|
fixed 50% of the repayment (see CLAUDE.md → "The shared loan"), so it falls with
|
||||||
|
it. Her rate returned to $1,250/fortnight in July 2026 after the reduced $750
|
||||||
|
period during her leave.
|
||||||
|
|
||||||
|
## Baseline composition
|
||||||
|
|
||||||
|
Built from **Feb–Jun 2026** — the months where split data is trustworthy — with
|
||||||
|
annual items annualised over 12 rather than divided by the 5-month sample.
|
||||||
|
|
||||||
|
| Essential | $/mo | Note |
|
||||||
|
|---|---:|---|
|
||||||
|
| Loan (contracted, your 50%) | 1,290 | 2,708 at the current actual rate |
|
||||||
|
| Transport | 326 | |
|
||||||
|
| Insurance | 280 | annualised; your 55% |
|
||||||
|
| Utilities | 278 | shared energy/water + personal mobile |
|
||||||
|
| Subscriptions | 204 | shared household + personal/AI |
|
||||||
|
| Groceries | 200 | see the blind-spot note above |
|
||||||
|
| Card + package fees | 175 | annualised — see below |
|
||||||
|
| Health | 167 | |
|
||||||
|
| Rates + rego | 122 | annualised, your share |
|
||||||
|
| **Essential** | **3,042** | |
|
||||||
|
| + dining 675, charity 422 | 4,139 | charity is a Smith Family sponsorship commitment |
|
||||||
|
| + typical shopping | ~4,800 | median 657, **not** the 1,709 mean |
|
||||||
|
|
||||||
|
**Travel is excluded throughout.** At $3,153/mo of your share even post-cutover it
|
||||||
|
would roughly double every figure, and it is the first thing that stops.
|
||||||
|
|
||||||
|
## Four corrections the raw data needed
|
||||||
|
|
||||||
|
Any restatement that skips these will be wrong. None are fixed in the data yet.
|
||||||
|
|
||||||
|
**Micro-investing counted as spend.** Raiz ($9,406 / 27 rows), Vanguard Super
|
||||||
|
($500) and moomoo ($300) sit in `other` as debits — $10,206/yr, ~$850/mo of
|
||||||
|
phantom spend. These belong in `investment`, which is already excluded.
|
||||||
|
|
||||||
|
**Incoming money counted as negative spend.** 17 rows in `other` typed `credit`
|
||||||
|
($5,622 in the window). `SPEND_SIGNED` negates credits so refunds cancel
|
||||||
|
purchases, but these are not refunds — they are money arriving. June 2025 shows
|
||||||
|
*minus* $7,814 of total spend because two Wise credits of ~$16.7k each landed in
|
||||||
|
`other`.
|
||||||
|
|
||||||
|
**`government` is two unrelated things.** $25,554 of ATO payments (one annual
|
||||||
|
bill, routed through Zen B2B and RewardPay to earn points) versus $2,054 council
|
||||||
|
rates and $875 rego. Tax is not a monthly living cost and falls with income
|
||||||
|
anyway; rates and rego are non-negotiable. Splitting them moves this line from
|
||||||
|
$2,411/mo to $244/mo.
|
||||||
|
|
||||||
|
**`fees` is mostly annual.** Of $2,599 post-cutover, $1,750 is an annual card fee
|
||||||
|
and $349 a loan package annual fee. Recurring is ~$175/mo annualised, not the
|
||||||
|
$520/mo the 5-month mean implies.
|
||||||
|
|
||||||
|
## Splits: what is trustworthy and what is not
|
||||||
|
|
||||||
|
Splits exist in this app from **2026-01-09** only; before that they were tracked
|
||||||
|
in SplitMyExpenses. So a trailing-12-month per-person series splices six months
|
||||||
|
of *gross* onto six months of *net* and is not a series at all. Use Feb–Jun 2026.
|
||||||
|
|
||||||
|
**Partial split coverage within a category is usually correct, not a gap.** This
|
||||||
|
was misdiagnosed once during the analysis. Verified composition:
|
||||||
|
|
||||||
|
| Category | Your share | Split | Unsplit |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| utilities | 69% | Globird, OVO, GWW, home telecoms | Telstra, Vodafone, Optus, JB Hi-Fi Mobile |
|
||||||
|
| subscriptions | 91% | Uber One, Amazon Prime, OnePass | Claude, OpenAI, Anthropic, OpenRouter, You.com, LinkedIn, Xero, Billdu, Spotify, Patreon |
|
||||||
|
| fees | 100% | — | credit card fees are personal |
|
||||||
|
| charity | 100% | — | personal commitment |
|
||||||
|
|
||||||
|
Only *shared* utilities and subscriptions are split. Mobile bills, AI
|
||||||
|
subscriptions and card fees are personal and correctly sit at 100%. Do not
|
||||||
|
"fix" these ratios.
|
||||||
|
|
||||||
|
## Known weak spots
|
||||||
|
|
||||||
|
- **`other` is $424–773/mo of your share and unclassified.** The single biggest
|
||||||
|
lever on accuracy. Clearing it with the rule preview
|
||||||
|
(`GET /api/rules/[id]/matches`) improves every other view at the same time.
|
||||||
|
- **Five months is a short sample**, and it contains the Europe trip and the ATO
|
||||||
|
bill. Both are excluded, but they crowd out the ordinary months. The
|
||||||
|
SplitMyExpenses CSVs would stretch it to 18 months — worth having, not
|
||||||
|
blocking, since only an *aggregate ratio per category* is needed, not row
|
||||||
|
matching, so the combined-transaction problem does not bite.
|
||||||
|
- **`/api/analytics/monthly` does not filter `reconciled_with_id IS NOT NULL`.**
|
||||||
|
48 rows are double-counted app-wide. Small, but real, and unrelated to this
|
||||||
|
analysis. The queries below do filter it.
|
||||||
|
|
||||||
|
## Reproducing this
|
||||||
|
|
||||||
|
Working queries are not checked in; they were run ad hoc against
|
||||||
|
`postgres-personal`. The shape that matters:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Per-category monthly distribution, your share, Feb-Jun 2026.
|
||||||
|
-- Mirrors src/lib/analytics-sql.ts, plus the corrections above.
|
||||||
|
WITH s AS (
|
||||||
|
SELECT to_char(t.transaction_date,'YYYY-MM') m,
|
||||||
|
COALESCE(o.category_override, t.category, 'other') cat,
|
||||||
|
SUM((CASE WHEN t.transaction_type IN ('refund','credit') THEN -1 ELSE 1 END)
|
||||||
|
* (CASE WHEN t.interest_amount IS NOT NULL
|
||||||
|
THEN t.interest_amount ELSE COALESCE(t.amount_aud, t.amount) END)
|
||||||
|
* COALESCE(ts.share_percent, o.my_share_percent,
|
||||||
|
100 - COALESCE((SELECT SUM(x.share_percent) FROM transaction_splits x
|
||||||
|
WHERE x.transaction_id = t.id
|
||||||
|
AND x.participant_id <> 1), 0)) / 100)::numeric(12,2) amt
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = 1
|
||||||
|
LEFT JOIN statements st ON st.id = t.statement_id
|
||||||
|
WHERE COALESCE(t.owner_id, st.owner_id) = 1
|
||||||
|
AND t.reconciled_with_id IS NULL -- see weak spots
|
||||||
|
AND ((t.transaction_type IN ('debit','fee','interest')
|
||||||
|
OR t.interest_amount IS NOT NULL)
|
||||||
|
OR t.transaction_type IN ('refund','credit'))
|
||||||
|
AND COALESCE(o.category_override, t.category, 'other')
|
||||||
|
NOT IN ('transfers','investment','income')
|
||||||
|
-- correction 1: misfiled investments
|
||||||
|
AND COALESCE(t.merchant_normalized, t.merchant_name, '')
|
||||||
|
!~* '^(Raiz|Vanguard Super|moomoo)'
|
||||||
|
-- correction 2: incoming money miscategorised as 'other' credits
|
||||||
|
AND NOT (COALESCE(o.category_override, t.category, 'other') = 'other'
|
||||||
|
AND t.transaction_type = 'credit')
|
||||||
|
AND t.transaction_date >= '2026-02-01' AND t.transaction_date < '2026-07-01'
|
||||||
|
GROUP BY 1, 2
|
||||||
|
)
|
||||||
|
SELECT cat, count(*) mths, round(avg(amt),0) mean,
|
||||||
|
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY amt)::numeric, 0) median
|
||||||
|
FROM s GROUP BY cat ORDER BY mean DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the **median** for anything recurring monthly and the **annualised total** for
|
||||||
|
lumpy-but-certain items (insurance, rates, rego, annual card fees). Means are
|
||||||
|
badly skewed here — shopping is 1,709 mean against 657 median.
|
||||||
|
|
||||||
|
Corrections 3 and 4 (`government`, `fees`) are not expressible as filters; they
|
||||||
|
need the category split by merchant, done by hand above.
|
||||||
|
|
||||||
|
## If this becomes a feature
|
||||||
|
|
||||||
|
The blocker is `other`, not the split model — the split data held up under
|
||||||
|
scrutiny. Sequence:
|
||||||
|
|
||||||
|
1. Recategorise Raiz / Vanguard Super / moomoo to `investment` (27 rows,
|
||||||
|
unambiguous) via the rule preview.
|
||||||
|
2. Split `government` so rates and rego separate from ATO. Probably a new
|
||||||
|
category or a tag; `government` conflates a recurring obligation with an
|
||||||
|
annual tax bill.
|
||||||
|
3. Work `other` down with the rule preview.
|
||||||
|
4. Then a baseline panel on Insights, which already carries the
|
||||||
|
Regular/Occasional split this analysis is a stricter version of
|
||||||
|
(`REGULAR_CATEGORIES` in `src/lib/categories.ts`).
|
||||||
|
|
||||||
|
A **liquidity vs baseline** view would be genuinely new rather than a restatement:
|
||||||
|
`statements.redraw_available`, offset closing balance, and the
|
||||||
|
contracted-vs-actual repayment gap are all in the schema and nothing reads them.
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# Shared expenses, settlement, and the shared loan — design proposal
|
||||||
|
|
||||||
|
Status: **proposal, nothing built**. Written 2026-07-26 for review.
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
Three questions have no answer in the current model:
|
||||||
|
|
||||||
|
1. Which splits does a settlement payment settle?
|
||||||
|
2. Is the Europe trip settled, separately from the ongoing household tab?
|
||||||
|
3. Whose expense is a $2,500 loan repayment when Sonu funds part of it?
|
||||||
|
|
||||||
|
They look like three problems. They are one: **the app records money moving, and
|
||||||
|
separately records who owes whom, and the two never meet.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is actually broken
|
||||||
|
|
||||||
|
### Two settlement models, neither finished
|
||||||
|
|
||||||
|
| Model | Where | State |
|
||||||
|
|---|---|---|
|
||||||
|
| Running tab | `split_payments` (from, to, amount, date) | **in use** — 8 payments, $37,881.10 |
|
||||||
|
| Per-split flag | `transaction_splits.settled` | **never used** — all 673 splits are `false` |
|
||||||
|
|
||||||
|
They are honoured inconsistently:
|
||||||
|
|
||||||
|
- `getParticipantBalances` (the shared page) ignores `settled` entirely
|
||||||
|
- `/api/participants/[id]/balance` filters on `settled = false`
|
||||||
|
- `getTripAnalytics` reports settled/unsettled **from the unused flag**
|
||||||
|
|
||||||
|
The third is a live bug. Every trip shows 100% unsettled forever, even though
|
||||||
|
Molina has paid $20,782.79 against $19,556.07 of splits and is square.
|
||||||
|
|
||||||
|
### Settlements exist twice, unlinked
|
||||||
|
|
||||||
|
Four of the eight recorded payments match an offset-account credit exactly:
|
||||||
|
|
||||||
|
| Payment date | From | Amount | Offset transaction |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| 2026-02-02 | Molina | 7,500.00 | `Transfer from - MEGHALEE BOSE mummy Pa…` |
|
||||||
|
| 2026-04-12 | Sonu | 3,779.33 | `Transfer from - MEGHALEE BOSE transfer` |
|
||||||
|
| 2026-04-21 | Molina | 1,685.24 | `Transfer from - MEGHALEE BOSE mummy split` |
|
||||||
|
| 2026-05-16 | Sonu | 4,794.06 | `Transfer from - MEGHALEE BOSE transfer` |
|
||||||
|
|
||||||
|
The same money is a `split_payments` row *and* a `transactions` row.
|
||||||
|
`split_payments.linked_transaction_id` exists but only 1 of 8 rows uses it. So a
|
||||||
|
settlement is bookkeeping that happens to resemble a bank credit, rather than
|
||||||
|
being that credit.
|
||||||
|
|
||||||
|
### The shared loan is invisible
|
||||||
|
|
||||||
|
Sonu's contributions are already in the ledger and unrecognised:
|
||||||
|
|
||||||
|
| Pattern in offset credits | Rows | Total | Meaning |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| `…emi` | 39 | $37,980.24 | Sonu's loan contribution |
|
||||||
|
| `…mummy…` | 6 | $29,721.24 | Molina's money, forwarded by Sonu |
|
||||||
|
| other Meghalee | 15 | $71,130.27 | Sonu's own settlements |
|
||||||
|
|
||||||
|
All are categorised `transfers` — correct for spend purposes, but it means a
|
||||||
|
loan contribution and an expense settlement are indistinguishable.
|
||||||
|
|
||||||
|
Meanwhile the loan itself, over the 12 imported months:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---:|
|
||||||
|
| Principal repaid (`investment`, excluded from spend) | $63,500.00 |
|
||||||
|
| Interest charged (`loan_interest`, the only part counted as spend) | $16,523.64 |
|
||||||
|
|
||||||
|
At roughly $25,000/year of `emi` against ~$80,000 of annual repayments, Sonu
|
||||||
|
funds about **31%** — of both the equity being built and the interest being paid.
|
||||||
|
Today 100% of the interest counts as your spend and 100% of the equity as yours.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The core problem
|
||||||
|
|
||||||
|
The model conflates two different things:
|
||||||
|
|
||||||
|
- **Money movement** — a credit landed in the offset account
|
||||||
|
- **Obligation** — someone owed someone else, and now owes less
|
||||||
|
|
||||||
|
A settlement is both. A loan contribution is both. Right now movement lives in
|
||||||
|
`transactions` and obligation lives in `transaction_splits` / `split_payments`,
|
||||||
|
with nothing joining them. That is why a payment cannot say what it settles: it
|
||||||
|
was never attached to anything in the first place.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed model
|
||||||
|
|
||||||
|
### 1. Settlement contexts
|
||||||
|
|
||||||
|
Splits belong to something that is settled **as a unit**. Payments name which
|
||||||
|
unit they settle. Balance is computed per context, not globally.
|
||||||
|
|
||||||
|
| Context | Splits | Settled by | State |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Household (default) | ongoing | periodic payments | running tab |
|
||||||
|
| Europe 2026 | trip-bound | lump sum | closeable |
|
||||||
|
| Pre-2026 (SplitMyExpenses) | historical | settled elsewhere | **born closed** |
|
||||||
|
|
||||||
|
A closed context still contributes to analytics — you see your true share — but
|
||||||
|
contributes nothing to what anyone owes.
|
||||||
|
|
||||||
|
This answers all three opening questions, and it dissolves the `splitFrom` date
|
||||||
|
cutoff: pre-2026 splits can be applied retroactively **because they are born
|
||||||
|
into a closed context**, so they fix the analytics without creating debt. No date
|
||||||
|
guard needed, no risk of resurrecting settled obligations.
|
||||||
|
|
||||||
|
Mechanically: `settlement_contexts` table; `transaction_splits.context_id`;
|
||||||
|
`split_payments.context_id`. `transaction_splits.settled` becomes derived
|
||||||
|
("is my context closed?") or is dropped.
|
||||||
|
|
||||||
|
### 2. Payments are transactions, not a side table
|
||||||
|
|
||||||
|
A settlement is the offset-account credit. `split_payments` becomes a thin
|
||||||
|
attribution layer over a real transaction rather than a parallel record of it:
|
||||||
|
|
||||||
|
- Populate `linked_transaction_id` on all existing payments where a match exists
|
||||||
|
- On ingestion, an incoming credit that looks like a settlement is *proposed* as
|
||||||
|
one for confirmation, rather than silently becoming `transfers`
|
||||||
|
- A payment with no matching transaction (cash, or an account not imported)
|
||||||
|
stays as a manual row — the model must tolerate that
|
||||||
|
|
||||||
|
### 3. The shared loan — a separate ledger
|
||||||
|
|
||||||
|
Not a settlement context. The loan is a jointly funded asset with its own
|
||||||
|
obligation, and mixing it with expense settlement would let a contribution
|
||||||
|
accidentally settle a dinner.
|
||||||
|
|
||||||
|
- `emi` credits are recognised as **contributions**, not generic transfers
|
||||||
|
- A **contribution schedule** states what is owed per period (50% of the
|
||||||
|
repayment), independent of what was actually paid
|
||||||
|
- The running difference is a **receivable** — currently $4,000.00
|
||||||
|
|
||||||
|
The schedule matters: during Sonu's leave the obligation did not change, only the
|
||||||
|
payment did. A percentage-of-actual model would silently redefine her share as
|
||||||
|
30% and make the shortfall disappear.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration path
|
||||||
|
|
||||||
|
### Phase 0 — DONE (2026-07-26, commit `3f04cbd`)
|
||||||
|
|
||||||
|
Stop the trip view reporting a settlement breakdown it cannot compute.
|
||||||
|
|
||||||
|
The original plan was "make `getTripAnalytics` and `getParticipantBalances`
|
||||||
|
agree". **That plan was wrong** and reading the code before building is what
|
||||||
|
caught it:
|
||||||
|
|
||||||
|
- `getParticipantBalances` is *not* buggy. It computes `splits − payments`,
|
||||||
|
which is coherent. Excluding settled splits there while still subtracting the
|
||||||
|
payments that settled them would have double-counted and broken a working page.
|
||||||
|
- The real defect was narrower: the trip view showed Settled/Unsettled from
|
||||||
|
`transaction_splits.settled`, which nothing sets. A correct per-trip figure is
|
||||||
|
not computable at all, because `split_payments` has no trip attribution.
|
||||||
|
|
||||||
|
So the fix was **subtractive**: the trip view now shows each participant's share
|
||||||
|
and points at Shared for what is actually owed.
|
||||||
|
|
||||||
|
Also removed `/api/splits/settle` — unreachable from the UI but live on its URL,
|
||||||
|
where one call with `participant_id` would mark every one of that person's splits
|
||||||
|
settled, writing a flag nothing reads.
|
||||||
|
|
||||||
|
`transaction_splits.settled` / `settled_at` still exist and are now pure dead
|
||||||
|
data. Phase 1 either repurposes them ("is my context closed?") or drops them.
|
||||||
|
|
||||||
|
### Phase 1 — settlement contexts
|
||||||
|
|
||||||
|
Add contexts; put every existing split in "Household"; every payment likewise.
|
||||||
|
Balance queries group by context. Touches `queries.ts` (both balance CTEs),
|
||||||
|
`shared/page.tsx`, `trips/[id]/page.tsx`, `split-payments/route.ts`. ~1 day.
|
||||||
|
|
||||||
|
### Phase 2 — link payments to transactions
|
||||||
|
|
||||||
|
Backfill `linked_transaction_id` for the four exact matches; flag the other four
|
||||||
|
for manual linking. On ingestion, propose a matching credit as a settlement
|
||||||
|
rather than silently categorising it `transfers`. ~half a day.
|
||||||
|
|
||||||
|
### Phase 3 — retroactive pre-2026 split
|
||||||
|
|
||||||
|
Create the "Pre-2026" closed context. Apply household split rules into it via the
|
||||||
|
rule preview (`/api/rules/[id]/matches`, built 2026-07-26) — fixes ~$97,627 of
|
||||||
|
the trailing 12 months currently shown as 100% yours. Then delete the `splitFrom`
|
||||||
|
cutoff entirely.
|
||||||
|
|
||||||
|
**Validate the ratio first.** This assumes today's 50/50 held through 2025. The
|
||||||
|
SplitMyExpenses CSVs should be used to *check* that assumption — not to
|
||||||
|
reconcile, since transactions were sometimes combined and exact matching is
|
||||||
|
impossible.
|
||||||
|
|
||||||
|
### Phase 4 — loan ledger
|
||||||
|
|
||||||
|
Contribution schedule, contributions recognised from `emi` credits, running
|
||||||
|
receivable. Independent of contexts — the loan is a separate ledger. ~1–2 days.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions taken (2026-07-26)
|
||||||
|
|
||||||
|
### The loan is separate from shared expenses
|
||||||
|
|
||||||
|
Different obligations, different rhythms, different nature: one funds an asset,
|
||||||
|
the other funds consumption. They do not share a settlement context and a
|
||||||
|
contribution is never a settlement.
|
||||||
|
|
||||||
|
### The share is 50/50, fixed — with the shortfall tracked
|
||||||
|
|
||||||
|
Not derived from actual payments, which fluctuate. Sonu's obligation is half the
|
||||||
|
repayment; the difference between obligation and actual is a **receivable**, and
|
||||||
|
it is the interesting number.
|
||||||
|
|
||||||
|
Over 2025-07-01 → 2026-06-30:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---:|
|
||||||
|
| Repayments | $63,500.00 |
|
||||||
|
| Sonu's 50% obligation | $31,750.00 |
|
||||||
|
| Actually contributed (26 payments) | $27,750.00 |
|
||||||
|
| **Shortfall** | **$4,000.00** |
|
||||||
|
|
||||||
|
She never missed a fortnight; the rate changed:
|
||||||
|
|
||||||
|
| Rate | Payments | Period |
|
||||||
|
|---|---:|---|
|
||||||
|
| $1,250 | 15 | Aug 2025 – Feb 2026 (the correct 50%) |
|
||||||
|
| $1,000 | 3 | Jul 2025 (pre-adjustment) |
|
||||||
|
| $750 | 8 | Mar – Jun 2026 (leave) |
|
||||||
|
|
||||||
|
So the model needs a **contribution schedule** (expected per period) alongside
|
||||||
|
actual contributions, with the running difference as a tracked balance. A flat
|
||||||
|
percentage cannot express "obligation unchanged, payment temporarily reduced,
|
||||||
|
difference owed".
|
||||||
|
|
||||||
|
### Interest: recommended as expense, pending final call
|
||||||
|
|
||||||
|
The mechanics are as described — interest is debited to the loan and repayments
|
||||||
|
pay down the combined balance. Reconciles exactly:
|
||||||
|
|
||||||
|
134: 31,000.00 − 8,553.27 = 22,446.73 = balance reduction
|
||||||
|
133: 32,500.00 − 7,970.37 − 2,849.00 = 21,680.63 = balance reduction
|
||||||
|
|
||||||
|
But mechanics are not the same as economics. Over 12 months $63,500 of cash left
|
||||||
|
and debt fell by $44,127.36. The $16,523.64 difference bought nothing and is not
|
||||||
|
recoverable — that is an expense by definition. Excluding it leaves the balance
|
||||||
|
sheet unable to reconcile cash out against equity gained, and understates annual
|
||||||
|
cost by ~10%.
|
||||||
|
|
||||||
|
The legitimate concern is that interest is **non-discretionary**. The answer to
|
||||||
|
that is a fixed-commitments grouping alongside rent, insurance and utilities —
|
||||||
|
a presentation change, not an exclusion.
|
||||||
|
|
||||||
|
**Recommendation: keep `loan_interest` as spend, add a fixed/discretionary
|
||||||
|
split.** Flagged rather than settled: it is a judgement about what "spend" means
|
||||||
|
in your own reporting.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. **Does equity need tracking per person?** If Sonu accrues a share of the
|
||||||
|
principal, that is a balance-sheet item the app has no concept of. Probably
|
||||||
|
belongs in the net-worth view rather than here.
|
||||||
|
|
||||||
|
4. **Attribution of forwarded payments.** `mummy` in the description reliably
|
||||||
|
marks Molina's money in all six known cases, but it is a description match on
|
||||||
|
a free-text field. Acceptable as a *suggestion* requiring confirmation; not as
|
||||||
|
an automatic rule.
|
||||||
|
|
||||||
|
5. **Retroactive split ratios.** Applying today's household rules to 2025
|
||||||
|
assumes the arrangement has not changed. The SplitMyExpenses CSVs could give
|
||||||
|
real historical shares, but transactions were sometimes combined, so matching
|
||||||
|
is imperfect. Recommendation: use today's ratios, accept the approximation —
|
||||||
|
the goal is a truer analytics picture, not a restated ledger.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What I would not do
|
||||||
|
|
||||||
|
- **Do not** restate history from the SplitMyExpenses CSVs. The combining problem
|
||||||
|
makes exact reconciliation impossible, and the value is low: those balances are
|
||||||
|
settled and will not change.
|
||||||
|
- **Do not** make the loan a shared *expense*. It is a funded asset. Modelling it
|
||||||
|
as a recurring split would put $2,500 a fortnight of principal into spend,
|
||||||
|
which is the error migration 0014 was written to prevent.
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
# Europe 2026 — Expense Report
|
||||||
|
|
||||||
|
**Trip:** 22 days · Mar 21 – Apr 12, 2026
|
||||||
|
**Route:** Rome → Venice → Ortisei (Dolomites) → Varenna (Lake Como) → St Moritz → Lucerne → Paris
|
||||||
|
**Group:** 5 travellers — Siddharth & Meghalee (Melbourne), Molina, Sibnath & Rumna (Delhi)
|
||||||
|
**Data:** 212 tagged transactions across all cards
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Group Breakdown — AU vs India
|
||||||
|
|
||||||
|
The two groups fly different routes and airlines. Everything else (accommodation, trains, on-trip spending) is shared across all 5 travellers.
|
||||||
|
|
||||||
|
| | AU Group (Siddharth + Meghalee) | India Group (Molina + Sibnath + Rumna) |
|
||||||
|
|---|---:|---:|
|
||||||
|
| Flights | $2,164 | $6,151 |
|
||||||
|
| Share of shared costs | $12,577 | $18,866 |
|
||||||
|
| **Group total** | **$14,741** | **$25,017** |
|
||||||
|
| Per person | $7,371 | $8,339 |
|
||||||
|
|
||||||
|
**Shared costs (accommodation + trains + all on-trip):** $31,443
|
||||||
|
Apportioned 2:3 by headcount — AU group (2 pax) = 40% · India group (3 pax) = 60%
|
||||||
|
|
||||||
|
| Scenario | Amount |
|
||||||
|
|---|---:|
|
||||||
|
| Total trip | $39,757 |
|
||||||
|
| **Excl. all flights** | **$31,443** |
|
||||||
|
| Excl. AU flights only (what India group is responsible for) | $37,593 |
|
||||||
|
| Excl. India flights only (what AU group is responsible for) | $33,607 |
|
||||||
|
|
||||||
|
> Australia group flights: China Eastern MEL→PVG→FCO × 2 ($1,885) + Qatar cancellation fee ($279) = **$2,164**
|
||||||
|
> India group flights: ITA Airways DEL→FCO × 3 (Azwebin, $2,696) + Air India CDG→DEL→GAU × 3 ($3,454) = **$6,151**
|
||||||
|
> India group flights are ~3× the AU group total despite flying a similar number of legs — driven by 3 pax vs 2, plus the ITA outbound on top of Air India return.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overall Summary
|
||||||
|
|
||||||
|
| | AUD |
|
||||||
|
|---|---:|
|
||||||
|
| Gross charged | $42,696.73 |
|
||||||
|
| Refunds received | −$2,939.22 |
|
||||||
|
| **Net total spend** | **$39,757.51** |
|
||||||
|
|
||||||
|
| Phase | Net Spend | % of Total |
|
||||||
|
|---|---:|---:|
|
||||||
|
| Pre-trip bookings | $23,730.02 | 60% |
|
||||||
|
| On-trip (22 days) | $16,027.49 | 40% |
|
||||||
|
|
||||||
|
**On-trip daily average:** $729/day for the group · $146/person/day
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refunds & Cancellations
|
||||||
|
|
||||||
|
| Merchant | Charged | Refunded | Net | Detail |
|
||||||
|
|---|---:|---:|---:|---|
|
||||||
|
| Qatar Airways | $2,396.90 | −$2,118.10 | **$278.80** | Cancelled. Replaced with China Eastern MEL→PVG→FCO. |
|
||||||
|
| Luxury Escapes | $3,283.80 | −$820.95 | **$2,462.85** | Partial refund on Paris Adagio Montmartre. |
|
||||||
|
| FreeNow | $43.32 | −$0.17 | $43.15 | Hold reversal. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-Trip Bookings — $23,730 net
|
||||||
|
|
||||||
|
### Flights
|
||||||
|
|
||||||
|
| Carrier | Passengers | Gross | Refund | Net |
|
||||||
|
|---|---|---:|---:|---:|
|
||||||
|
| ITA Airways (via Azwebin) | Delhi group (3) DEL → FCO | $2,696.34 | — | $2,696.34 |
|
||||||
|
| Air India | Delhi group (3) CDG → DEL → GAU (return) | $3,454.32 | — | $3,454.32 |
|
||||||
|
| China Eastern | Siddharth MEL → PVG → FCO | $849.05 | — | $849.05 |
|
||||||
|
| China Eastern | Meghalee MEL → PVG → FCO | $849.05 | — | $849.05 |
|
||||||
|
| China Eastern (ancillary) | Seat/baggage fees | $187.14 | — | $187.14 |
|
||||||
|
| Qatar Airways | Siddharth + Meghalee (cancelled) | $2,396.90 | −$2,118.10 | $278.80 |
|
||||||
|
| **Flights total** | | **$10,432.80** | **−$2,118.10** | **$8,314.70** |
|
||||||
|
|
||||||
|
> ITA Airways (billed as Azwebin Fiumicino) — 3 outbound tickets Delhi → Rome plus 3 sets of ancillary fees, all charged Jan 17.
|
||||||
|
> Qatar was booked first (Jan 17) and then cancelled; refund of $2,118.10 processed Mar 24 (during Rome stay). Net cancellation penalty: **$278.80**. China Eastern replaced it at $1,885.24 total — slightly cheaper.
|
||||||
|
|
||||||
|
### Accommodation
|
||||||
|
|
||||||
|
| Property | Destination | Nights | Booked via | Net Cost |
|
||||||
|
|---|---|---|---|---:|
|
||||||
|
| Fontana di Trevi area (Vrbo HA-XTP24R) | Rome | 4 | Vrbo | $2,221.66 |
|
||||||
|
| Apartment Moro 4 — Rialto | Venice | 3 | Direct | $1,410.11 |
|
||||||
|
| 174A Apartments Murata | Ortisei | 4 | Muse Holiday | $1,815.06 |
|
||||||
|
| In the heart of Varenna (Vrbo HA7B9C9J) | Varenna | 2 | Vrbo | $2,274.46 |
|
||||||
|
| Cozy Central Duplex | St Moritz | 2 | Agoda | $2,560.45 |
|
||||||
|
| Hotel Luzernerhof | Lucerne | 3 | Agoda | $1,930.07 |
|
||||||
|
| Adagio Paris Montmartre | Paris | 4 | Luxury Escapes | $2,745.24 |
|
||||||
|
| **Accommodation total** | | **22 nights** | | **$14,957.05** |
|
||||||
|
|
||||||
|
> Paris: Luxury Escapes $3,283.80 − $820.95 refund + $282.39 extra charge = $2,745.24 net.
|
||||||
|
> **Average cost per night (5 pax):** $680 · **per person per night:** $136
|
||||||
|
|
||||||
|
### Pre-booked Transport
|
||||||
|
|
||||||
|
| Date | Merchant | Route | Amount |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| Jan 26 | Trenitalia (Frecciarossa Business) | Rome → Venice (5 pax) | $468.55 |
|
||||||
|
| Mar 4 | Rail Europe | Various European routes | $372.40 |
|
||||||
|
| | **Total** | | **$840.95** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## On-Trip Spending — $16,027 net
|
||||||
|
|
||||||
|
### By Destination
|
||||||
|
|
||||||
|
| Destination | Dates | Net Spend | Txns | Daily Avg |
|
||||||
|
|---|---|---:|---:|---:|
|
||||||
|
| Rome *(inc. transit days Mar 19–20)* | Mar 19–24 | $2,955 | 46 | $493 |
|
||||||
|
| Venice | Mar 25–27 | $960 | 21 | $320 |
|
||||||
|
| Ortisei / Dolomites | Mar 28–31 | $3,595 | 19 | $899 |
|
||||||
|
| Varenna / Lake Como | Apr 1–2 | $863 | 16 | $432 |
|
||||||
|
| St Moritz | Apr 3–4 | $355 | 10 | $178 |
|
||||||
|
| Lucerne | Apr 5–7 | $3,422 | 22 | $1,141 |
|
||||||
|
| Paris | Apr 8–12 | $3,878 | 46 | $970 |
|
||||||
|
| **Total on-trip** | **22 days** | **$16,027** | **180** | **$729** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rome & Transit (Mar 19–24) — $2,955
|
||||||
|
|
||||||
|
**Mar 19–20 (transit via Shanghai → Milan → Rome):**
|
||||||
|
- Glovo Milan × 4 deliveries: $93 — feeding the group across airport transit days
|
||||||
|
- Shanghai Sunny restaurant: $16 (CNY 78)
|
||||||
|
- Todis Roma groceries: $55 (manual, likely night of arrival)
|
||||||
|
|
||||||
|
**On ground in Rome:**
|
||||||
|
|
||||||
|
| Category | Key Items | Total |
|
||||||
|
|---|---|---:|
|
||||||
|
| Dining | Lione 21 EUR 90 ($148) · Suresh EUR 52 ($85) · Glovo × 4 ($179) · Faro Café ($40) · Bap Vaschette ($29) · various | ~$780 |
|
||||||
|
| Transport | FreeNow × 2 ($43) · Uber × 6 ($151) · ATAC bus ($2) · Radiotaxi ($11) | ~$210 |
|
||||||
|
| Travel | Trenitalia local ($23) · Roma Termini station tickets EUR 300 ($493) · Trenitalia EUR 275 ($452) · Trenitalia EUR 165 ($274) | ~$1,242 |
|
||||||
|
| Entertainment | St Peter's Basilica tickets: $58 | $58 |
|
||||||
|
| Groceries | Todis × 2 ($12) · F.Lli Casali ($17) · SumUp ($12) | $41 |
|
||||||
|
| Shopping | Scudieri International Florence EUR 51: $84 | $84 |
|
||||||
|
| Cash | Mar 21 cash transactions lump sum | $337 |
|
||||||
|
|
||||||
|
> **Florence day trip (Mar 22):** Trenitalia tickets $452 + $274 bought at Roma Termini ($493) and online, plus Scudieri shopping EUR 51.
|
||||||
|
> The two large Trenitalia charges on Mar 22–23 suggest additional train legs beyond Rome–Venice (possibly Florence or Naples day trip and ticket changes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Venice (Mar 25–27) — $960
|
||||||
|
|
||||||
|
| Item | Amount |
|
||||||
|
|---|---:|
|
||||||
|
| Water taxi Ferrovia → accommodation | $79 |
|
||||||
|
| Caffe Rialto Venezia (dinner) | $109 |
|
||||||
|
| Fresh pasta restaurant (Sum Dal) | $96 |
|
||||||
|
| Baci Pasta | $70 |
|
||||||
|
| Dolcemente Salato (pastries/café) | $67 |
|
||||||
|
| Farini Pizza × 3 visits | $92 |
|
||||||
|
| Tvm Rialto Diretti 2 — shopping EUR 125 | $209 |
|
||||||
|
| Muse Holiday extra charge EUR 60 | $100 |
|
||||||
|
| Uber × 2 | $48 |
|
||||||
|
| Despar groceries | $19 |
|
||||||
|
| Gelatoteca Suso + Bar Americano | $17 |
|
||||||
|
| Pasticceria Ballarin + Bar Filovia × 2 | $28 |
|
||||||
|
|
||||||
|
> Grand Canal water taxi arrival ($79) was the planned "wow moment".
|
||||||
|
> Tvm Rialto Diretti 2 (EUR 125) — likely Murano glass or quality Venetian craft souvenirs, the largest single on-trip discretionary purchase outside of gear/activities.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ortisei / Dolomites (Mar 28–31) — $3,595
|
||||||
|
|
||||||
|
The most expensive on-trip leg. Five major items account for $2,857 of it:
|
||||||
|
|
||||||
|
| Item | Amount | Detail |
|
||||||
|
|---|---:|---|
|
||||||
|
| Erich Perathon (gear shop, Val Gardena) | **$1,477** | EUR 880 — outdoor/ski clothing or equipment in Santa Cristina |
|
||||||
|
| Funivie Seceda cable car | **$441** | EUR 236+27 — the centrepiece cable car to 2,519m above Ortisei |
|
||||||
|
| Trenitalia Venice → Bolzano (5 pax) | **$528** | EUR 259.50 + EUR 55 split across two bookings |
|
||||||
|
| Taxi Demez Gregor (into valley) | **$238** | EUR 142 — private taxi from Bolzano to Ortisei (no public option for 5 with bags) |
|
||||||
|
| Funivia Lagazuoi, Cortina | **$222** | EUR 132.50 — Cortina d'Ampezzo day trip cable car |
|
||||||
|
| Despar Dolomiti (groceries) | **$201** | EUR 97.59 + EUR 22.30 across two shops |
|
||||||
|
| Ristorante Meuble Pont, Cortina | **$180** | EUR 107.50 — group dinner on Cortina day trip |
|
||||||
|
| Turonda Pizza Ortisei | $93 | |
|
||||||
|
| Istanbul Kebab + Buffet Bolzano × 2 | $111 | Transit + local meals |
|
||||||
|
| Lago di Braies chocolate stop | $21 | |
|
||||||
|
| Café Adler + Caffe Corso | $34 | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Varenna / Lake Como (Apr 1–2) — $863
|
||||||
|
|
||||||
|
| Item | Amount |
|
||||||
|
|---|---:|
|
||||||
|
| Taxi Demez Gregor (out of valley) | $239 |
|
||||||
|
| Trenitalia (onward journey) | $101 |
|
||||||
|
| Pizza e Sfizi × 2 dinners | $113 |
|
||||||
|
| Mr Panino Bellagio (lunch) | $67 |
|
||||||
|
| Bernasconi Claudia Varenna | $55 |
|
||||||
|
| Varenna 1 shop EUR 55 | $92 |
|
||||||
|
| Valli Mara Bellagio souvenirs | $71 |
|
||||||
|
| Bistro di Corti × 2 | $50 |
|
||||||
|
| Lake Bakery × 2 | $30 |
|
||||||
|
| Ristorante Liberty, Tirano | $44 |
|
||||||
|
| Groceries (Despar, Macelleria × 2) | $49 |
|
||||||
|
| Gelato + bakery | $28 |
|
||||||
|
|
||||||
|
> Bellagio day trip from Varenna was a highlight — lunch at Mr Panino, ice cream at Gilardoni Stefania, souvenirs.
|
||||||
|
> Taxi Demez again for the exit from Val Gardena (charged Apr 1, same driver/company as arrival).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### St Moritz (Apr 3–4) — $355
|
||||||
|
|
||||||
|
Almost entirely self-catered:
|
||||||
|
|
||||||
|
| Item | Amount |
|
||||||
|
|---|---:|
|
||||||
|
| Coop supermarket (big shop) | $114 |
|
||||||
|
| Pizzaway × 2 orders | $89 |
|
||||||
|
| Bäckerei Bad × 3 visits | $58 |
|
||||||
|
| Coop (additional) | $14 |
|
||||||
|
| Bus und Service | $22 |
|
||||||
|
| Taxi St Moritz CHF 30 | $55 |
|
||||||
|
| SBB WC | $3 |
|
||||||
|
|
||||||
|
> Despite being the highest accommodation cost per night ($1,280/night), almost nothing was spent on-trip. The group self-catered using Coop and relied on the bakery.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Lucerne (Apr 5–7) — $3,422
|
||||||
|
|
||||||
|
Three large purchases dominate:
|
||||||
|
|
||||||
|
| Item | Amount | Detail |
|
||||||
|
|---|---:|---|
|
||||||
|
| Rail Europe (last-minute) | **$1,526** | EUR 911.95 — Swiss Travel Pass or multi-city rail tickets; bought while already in Lucerne |
|
||||||
|
| Daytrip.com private tour | **$1,078** | EUR 642 — private guided day tour (description: "Prague" but likely Rhine Falls or day excursion from Lucerne) |
|
||||||
|
| Rigi Bahnen (cog railway) | **$844** | CHF 461.60 — historic Rigi mountain railway, 5 pax round trip (charged Apr 10 but relates to Lucerne stay) |
|
||||||
|
| Hotel Rigi Kaltbad dining | $47 | Lunch at mountain hotel summit |
|
||||||
|
| Rigi Dorfladen shop | $37 | CHF 20 at Rigi summit village |
|
||||||
|
| Uber Eats | $94 | CHF 51.23 |
|
||||||
|
| WAL*TENZ GMBH Zurich | $82 | CHF 45.10 — dining/café in Zurich |
|
||||||
|
| QUZGYXOO-2 | $150 | CHF 82.50 — unidentified Lucerne charge |
|
||||||
|
| GO4T Luzern | $114 | CHF 62.40 — likely a guided activity or boat tour |
|
||||||
|
| Amorino Gelato + Alpineum Café | $51 | |
|
||||||
|
| Juice Paradise bubble tea | $29 | |
|
||||||
|
| SBB toilets × 4 | $11 | CHF 1.50 each — Swiss public toilets |
|
||||||
|
|
||||||
|
> The Rail Europe purchase last-minute cost $1,526 vs booking pre-trip where Swiss Travel Passes are generally cheaper. Worth pre-purchasing on future Swiss trips.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Paris (Apr 8–12) — $3,878
|
||||||
|
|
||||||
|
The return leg via Basel with 4 nights in Paris:
|
||||||
|
|
||||||
|
**Transit Basel (Apr 8):**
|
||||||
|
Bahn Mi Pho CHF 69 ($126) + McDonald's CHF 51 ($93) + Weber1s bagels ($66) + Kaffeemacher coffee ($17) + Confiseur Bachmann ($22) + Acento Argentino ($26) = **$350 in Basel** for the group during travel day
|
||||||
|
|
||||||
|
**Paris spending:**
|
||||||
|
|
||||||
|
| Item | Amount | Detail |
|
||||||
|
|---|---:|---|
|
||||||
|
| Viator/TripAdvisor guided tour | $296 | USD 203.70 — skip-the-line or guided Versailles/Paris tour |
|
||||||
|
| Palace of Versailles tickets | $292 | Full group |
|
||||||
|
| Louvre Museum tickets | $277 | Full group |
|
||||||
|
| Adagio Paris hotel extra charge | $282 | EUR 169 — extra night or room upgrade |
|
||||||
|
| FC Soleil restaurant | $207 | EUR 125 — group dinner |
|
||||||
|
| Uber airport transfer (Apr 12) | $126 | EUR 75.98 — departure to CDG |
|
||||||
|
| Deliveroo × 2 | $123 | EUR 52.49 + EUR 21.90 |
|
||||||
|
| Yak and Yeti restaurant | $83 | EUR 49.90 |
|
||||||
|
| Uber Eats | $65 | |
|
||||||
|
| Wild Deer restaurant | $64 | EUR 38.20 |
|
||||||
|
| Uber trips × 5 | $192 | Multiple rides across Paris |
|
||||||
|
| Fric Frac Montmartre | $55 | EUR 33 |
|
||||||
|
| Mitao | $56 | |
|
||||||
|
| Navigo transit pass | $61 | Group metro/bus |
|
||||||
|
| Little Versailles coffeeshop | $36 | |
|
||||||
|
| Pain Pain bakery × 2 | $29 | |
|
||||||
|
| Sarl by Orsel (groceries) × 2 | $35 | |
|
||||||
|
| Various small | $49 | |
|
||||||
|
|
||||||
|
> Museums + guided tour = **$865 on Paris attractions** (Versailles, Louvre, Viator).
|
||||||
|
> Uber was heavily used in Paris — 5+ trips totalling $254 including the CDG airport transfer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spend by Category (Net)
|
||||||
|
|
||||||
|
| Category | Net Spend | Txns | Notes |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| Travel | $31,290 | 51 | Flights, accommodation, trains, cable cars |
|
||||||
|
| Dining | $4,241 | 95 | 95 restaurant/café/delivery transactions |
|
||||||
|
| Transport | $2,636 | 35 | Uber, taxis, buses, metro passes |
|
||||||
|
| Shopping | $2,053 | 6 | Erich Perathon $1,477 + Venice/Varenna/Florence |
|
||||||
|
| Entertainment | $698 | 4 | Versailles, Louvre, Basilica, Bellagio souvenirs |
|
||||||
|
| Groceries | $589 | 15 | Despar, Coop, Todis, Macelleria |
|
||||||
|
| Other (net) | −$1,751 | 4 | Qatar refund exceeds "other" debits |
|
||||||
|
| Uncategorised | $42 | 2 | Residual |
|
||||||
|
| **Total** | **$39,757** | **212** | |
|
||||||
|
|
||||||
|
> The negative "Other" figure reflects the Qatar Airways refund ($2,118) offsetting the ITA/Azwebin ancillary fees charged in the same category.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Top 15 On-Trip Transactions
|
||||||
|
|
||||||
|
| Date | Merchant | AUD | Category |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| Mar 28 | Erich Perathon, Val Gardena | $1,477 | Shopping — EUR 880 gear |
|
||||||
|
| Apr 6 | Rail Europe | $1,526 | Travel — EUR 912 rail passes |
|
||||||
|
| Apr 7 | Daytrip.com | $1,078 | Transport — EUR 642 private tour |
|
||||||
|
| Apr 10 | Rigi Bahnen | $844 | Travel — CHF 462 mountain railway |
|
||||||
|
| Mar 22 | Roma Termini (train tickets) | $493 | Travel — EUR 300 |
|
||||||
|
| Mar 22 | Trenitalia | $452 | Travel — EUR 275 |
|
||||||
|
| Mar 28 | Trenitalia | $435 | Travel — EUR 260 |
|
||||||
|
| Mar 30 | Funivie Seceda | $396 | Travel — EUR 236 cable car |
|
||||||
|
| Apr 9 | Viator/TripAdvisor | $296 | Travel — USD 204 Paris tour |
|
||||||
|
| Apr 8 | Palace of Versailles | $292 | Entertainment |
|
||||||
|
| Apr 10 | Adagio Paris extra | $282 | Travel |
|
||||||
|
| Apr 8 | Louvre Museum | $277 | Entertainment |
|
||||||
|
| Mar 23 | Trenitalia | $274 | Travel — EUR 165 |
|
||||||
|
| Mar 21 | Cash transactions (Rome) | $337 | Various |
|
||||||
|
| Apr 1 | Taxi Demez Gregor | $239 | Transport — EUR 142 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Observations
|
||||||
|
|
||||||
|
**Flight changes cost $278.80.** Qatar was booked first (Jan 17, $2,396.90) and later cancelled; the refund of $2,118.10 came through during the Rome stay on Mar 24. China Eastern (MEL→PVG→FCO) replaced it at $1,885.24 — marginally cheaper but via Shanghai with a longer travel time.
|
||||||
|
|
||||||
|
**60% of spend was locked in before leaving Australia.** Pre-trip bookings (flights for all 5, accommodation across 7 cities, and pre-purchased trains) totalled $23,730 vs $16,027 spent on the ground.
|
||||||
|
|
||||||
|
**Dolomites was the most expensive on-trip leg** despite only 4 days — Erich Perathon EUR 880 gear purchase in Val Gardena accounts for $1,477 alone. Without it, Dolomites would be the cheapest destination per day.
|
||||||
|
|
||||||
|
**Rail spend ballooned in Lucerne.** Rail Europe EUR 912 ($1,526) bought last-minute in Lucerne was the single largest on-trip travel cost. Swiss Travel Passes are considerably cheaper when purchased outside Switzerland before travel.
|
||||||
|
|
||||||
|
**Dining: 95 transactions, $4,241 net.** The group averaged $193/day on food across 22 days ($39/person/day). Glovo delivery was the primary dining mode during transit days and in Rome (10+ orders). Paris had the most sit-down restaurant spending — FC Soleil EUR 125, Yak and Yeti EUR 50, plus multiple delivery orders.
|
||||||
|
|
||||||
|
**St Moritz: highest accommodation cost, lowest on-trip spend.** Agoda charged $2,560 for 2 nights but the group spent only $355 on-trip — almost entirely groceries and the local bakery.
|
||||||
|
|
||||||
|
**Paris Uber spend: $254 across 6+ trips** including a $126 CDG airport transfer (EUR 76). Combined with 2 Deliveroo orders and Uber Eats, rideshare/delivery accounted for ~$380 of Paris on-trip costs.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
Generated
+1644
-7
File diff suppressed because it is too large
Load Diff
+12
-3
@@ -6,7 +6,12 @@
|
|||||||
"dev": "next dev",
|
"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%';
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Record which rule a run came from.
|
||||||
|
--
|
||||||
|
-- rule_apply_runs stored only counts ("13 matches · 13 transactions"), which is
|
||||||
|
-- not enough to decide whether to revert: you cannot tell a merchant rename from
|
||||||
|
-- a 50/50 split of your entire history. The snapshot column already holds the
|
||||||
|
-- before-state, but nothing said what was applied or why.
|
||||||
|
--
|
||||||
|
-- rule_name is denormalised deliberately. A run must stay readable after the
|
||||||
|
-- rule it came from is edited or deleted -- the history is a record of what
|
||||||
|
-- happened, not a pointer to what the rule says today.
|
||||||
|
|
||||||
|
ALTER TABLE rule_apply_runs
|
||||||
|
ADD COLUMN IF NOT EXISTS rule_id INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS rule_name TEXT,
|
||||||
|
-- 'all' = bulk run over every enabled rule; 'rule' = one rule by conditions;
|
||||||
|
-- 'selection' = one rule against hand-picked transactions (preview → apply).
|
||||||
|
ADD COLUMN IF NOT EXISTS source TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE rule_apply_runs DROP CONSTRAINT IF EXISTS rule_apply_runs_source_check;
|
||||||
|
ALTER TABLE rule_apply_runs ADD CONSTRAINT rule_apply_runs_source_check
|
||||||
|
CHECK (source IS NULL OR source IN ('all', 'rule', 'selection'));
|
||||||
|
|
||||||
|
-- No FK to rules: deleting a rule must not cascade away the audit trail.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rule_apply_runs_rule
|
||||||
|
ON rule_apply_runs (rule_id) WHERE rule_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Existing rows stay NULL. There is no way to recover which rule they ran;
|
||||||
|
-- the UI shows them as "unknown rule" rather than guessing.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- Create order_reviews table
|
||||||
|
CREATE TABLE IF NOT EXISTS order_reviews (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
transaction_id INTEGER NOT NULL UNIQUE REFERENCES transactions(id) ON DELETE CASCADE,
|
||||||
|
rating TEXT,
|
||||||
|
order_again BOOLEAN,
|
||||||
|
note TEXT,
|
||||||
|
item_verdicts JSONB NOT NULL DEFAULT '[]',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add source_message_id to expense_metadata
|
||||||
|
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS source_message_id TEXT;
|
||||||
|
|
||||||
|
-- 1. Admit 'credits' as a payment method.
|
||||||
|
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','credits','other'));
|
||||||
|
|
||||||
|
-- 2. Idempotency key (I7). Partial: rows without an order_reference
|
||||||
|
-- (manual entries) are unaffected.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_expense_source_order
|
||||||
|
ON expense_metadata (source, order_reference)
|
||||||
|
WHERE order_reference IS NOT NULL;
|
||||||
|
|
||||||
|
-- 3. I1 as a database-level guard, not just workflow logic.
|
||||||
|
-- Scoped to pipeline-created rows so manual/statement rows are untouched.
|
||||||
|
ALTER TABLE transactions DROP CONSTRAINT IF EXISTS chk_ingested_orders_after_cutover;
|
||||||
|
ALTER TABLE transactions ADD CONSTRAINT chk_ingested_orders_after_cutover
|
||||||
|
CHECK (
|
||||||
|
payment_method IS DISTINCT FROM 'credits'
|
||||||
|
OR transaction_date >= DATE '2026-01-09'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. Constrain the review verdict (§6.1).
|
||||||
|
ALTER TABLE order_reviews DROP CONSTRAINT IF EXISTS chk_order_review_rating;
|
||||||
|
ALTER TABLE order_reviews ADD CONSTRAINT chk_order_review_rating
|
||||||
|
CHECK (rating IS NULL OR rating IN ('again','fine','never'));
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Deferred card reconciliation.
|
||||||
|
--
|
||||||
|
-- An order paid "MasterCard Ending in 8032 and/or credits" does not state the
|
||||||
|
-- split. The split is recoverable from the card statement -- but for a live
|
||||||
|
-- order that statement is weeks away, so the split cannot be resolved at ingest
|
||||||
|
-- time. These columns let an order be parked and revisited.
|
||||||
|
|
||||||
|
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS card_last4 TEXT;
|
||||||
|
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS currency TEXT;
|
||||||
|
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS flags JSONB NOT NULL DEFAULT '[]';
|
||||||
|
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS reconciled_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- The pending set: provenance recorded, no transaction yet, still waiting on a
|
||||||
|
-- statement line. Partial so it stays small regardless of table growth.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_expense_metadata_pending
|
||||||
|
ON expense_metadata (transaction_date)
|
||||||
|
WHERE transaction_id IS NULL AND reconciled_at IS NULL;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Which statement line settled an order's card leg.
|
||||||
|
--
|
||||||
|
-- Without this, reconcileCardLeg has no way to know a charge has already been
|
||||||
|
-- consumed, so two orders on the same card inside the match window both bind to
|
||||||
|
-- it and each books its own credits remainder -- double-counting spend.
|
||||||
|
ALTER TABLE expense_metadata
|
||||||
|
ADD COLUMN IF NOT EXISTS matched_transaction_id INTEGER
|
||||||
|
REFERENCES transactions(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- One statement line settles at most one order.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_expense_matched_txn
|
||||||
|
ON expense_metadata (matched_transaction_id)
|
||||||
|
WHERE matched_transaction_id IS NOT NULL;
|
||||||
@@ -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,115 @@ 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?
|
||||||
|
order_review order_reviews?
|
||||||
|
}
|
||||||
|
|
||||||
|
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?
|
||||||
|
source_message_id 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)
|
||||||
|
|
||||||
|
@@unique([source, order_reference], name: "uq_expense_source_order")
|
||||||
|
}
|
||||||
|
|
||||||
|
model order_reviews {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
transaction_id Int @unique
|
||||||
|
rating String?
|
||||||
|
order_again Boolean?
|
||||||
|
note String?
|
||||||
|
item_verdicts Json @default("[]")
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
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,549 @@
|
|||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml">
|
||||||
|
<head><!--[if gte mso 9]><xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml><![endif]-->
|
||||||
|
<title>DoorDash</title>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0 ">
|
||||||
|
<meta name="format-detection" content="telephone=no">
|
||||||
|
<style type="text/css">body {
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-text-size-adjust: 100%!important;
|
||||||
|
-ms-text-size-adjust: 100%!important;
|
||||||
|
-webkit-font-smoothing: antialiased!important;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
border: 0!important;
|
||||||
|
outline: none!important;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
Margin: 0px!important;
|
||||||
|
Padding: 0px!important;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-table-lspace: 0px;
|
||||||
|
mso-table-rspace: 0px;
|
||||||
|
}
|
||||||
|
td, a, span {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-line-height-rule: exactly;
|
||||||
|
}
|
||||||
|
.ExternalClass * {
|
||||||
|
line-height: 100%;
|
||||||
|
}
|
||||||
|
.em_defaultlink a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a[x-apple-data-detectors], u+.em_body a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
@media only screen and (max-width:667px) {
|
||||||
|
.em_main_table {
|
||||||
|
width: 100%!important;
|
||||||
|
}
|
||||||
|
.em_wrapper {
|
||||||
|
width: 100%!important;
|
||||||
|
}
|
||||||
|
.em_hide {
|
||||||
|
display: none!important;
|
||||||
|
}
|
||||||
|
.em_hauto {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
.em_full_img img {
|
||||||
|
width: 100%!important;
|
||||||
|
height: auto!important;
|
||||||
|
}
|
||||||
|
.em_pad1 {
|
||||||
|
padding-right: 10px!important;
|
||||||
|
}
|
||||||
|
.em_hauto {
|
||||||
|
height: auto!important;
|
||||||
|
}
|
||||||
|
.em_side15 {
|
||||||
|
width: 40px!important;
|
||||||
|
}
|
||||||
|
.em_h20 {
|
||||||
|
height: 40px!important;
|
||||||
|
font-size: 1px!important;
|
||||||
|
line-height: 1px!important;
|
||||||
|
}
|
||||||
|
.em_h10 {
|
||||||
|
height: 10px!important;
|
||||||
|
font-size: 1px!important;
|
||||||
|
line-height: 1px!important;
|
||||||
|
}
|
||||||
|
.em_h30 {
|
||||||
|
height: 30px!important;
|
||||||
|
}
|
||||||
|
u+.em_body .em_full_wrap {
|
||||||
|
width: 100%!important;
|
||||||
|
width: 100vw!important;
|
||||||
|
}
|
||||||
|
.em_side30 {
|
||||||
|
width: 26px!important;
|
||||||
|
}
|
||||||
|
.em_cta {
|
||||||
|
width: 190px !important;
|
||||||
|
height: 40px!important;
|
||||||
|
}
|
||||||
|
.em_cta a {
|
||||||
|
font-size: 17px !important;
|
||||||
|
line-height: 40px!important;
|
||||||
|
}
|
||||||
|
.em_h90 {
|
||||||
|
height: 140px !important;
|
||||||
|
}
|
||||||
|
.em_font_58 {
|
||||||
|
font-size: 40px!important;
|
||||||
|
line-height: 44px!important;
|
||||||
|
}
|
||||||
|
.em_pad1 {
|
||||||
|
padding: 0px 15px !important;
|
||||||
|
}
|
||||||
|
.en_icon {
|
||||||
|
width: 30px !important;
|
||||||
|
padding-bottom:10px !important;
|
||||||
|
}
|
||||||
|
.em_rounded {
|
||||||
|
border-top-left-radius: 25px !important;
|
||||||
|
border-top-right-radius: 25px !important;
|
||||||
|
}
|
||||||
|
.em_bold {
|
||||||
|
letter-spacing: -1px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (max-width:480px) {
|
||||||
|
.em_side30 {
|
||||||
|
width: 26px!important;
|
||||||
|
}
|
||||||
|
.ft_16 {
|
||||||
|
font-size: 14px!important;
|
||||||
|
line-height: 18px!important;
|
||||||
|
}
|
||||||
|
.em_side15 {
|
||||||
|
width: 40px!important;
|
||||||
|
}
|
||||||
|
.em_font_58 {
|
||||||
|
font-size: 35px!important;
|
||||||
|
line-height: 42px!important;
|
||||||
|
}
|
||||||
|
.em_cta {
|
||||||
|
width: 165px !important;
|
||||||
|
height: 38px!important;
|
||||||
|
}
|
||||||
|
.em_cta a {
|
||||||
|
font-size: 15px !important;
|
||||||
|
line-height: 38px!important;
|
||||||
|
}
|
||||||
|
.em_h90 {
|
||||||
|
height: 105px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (max-width:374px) {
|
||||||
|
.ft_16 {
|
||||||
|
font-size: 12px!important;
|
||||||
|
line-height: 16px!important;
|
||||||
|
}
|
||||||
|
.em_side15 {
|
||||||
|
width: 40px!important;
|
||||||
|
}
|
||||||
|
.em_side30 {
|
||||||
|
width: 20px!important;
|
||||||
|
}
|
||||||
|
.em_font_58 {
|
||||||
|
font-size: 30px!important;
|
||||||
|
line-height: 38px!important;
|
||||||
|
}
|
||||||
|
.em_cta {
|
||||||
|
width: 160px !important;
|
||||||
|
height: 38px!important;
|
||||||
|
}
|
||||||
|
.em_cta a {
|
||||||
|
font-size: 15px !important;
|
||||||
|
line-height: 38px!important;
|
||||||
|
}
|
||||||
|
.em_h90 {
|
||||||
|
height: 95px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen {
|
||||||
|
@font-face {
|
||||||
|
font-family: 'TTNorms-Regular';
|
||||||
|
src: url('https://typography.doordash.com/TTNorms-Regular.woff') format('woff'), url('https://typography.doordash.com/TTNorms-Regular.ttf') format('truetype');
|
||||||
|
font-weight: normal !important;
|
||||||
|
font-style: normal !important;
|
||||||
|
mso-font-alt: 'Arial'
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'TTNorms-Medium';
|
||||||
|
src: url('https://typography.doordash.com/TTNorms-Medium.woff') format('woff'), url('https://typography.doordash.com/TTNorms-Medium.ttf') format('truetype');
|
||||||
|
font-weight: normal !important;
|
||||||
|
font-style: normal !important;
|
||||||
|
mso-font-alt: 'Arial'
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'TTNorms-Bold';
|
||||||
|
src: url('https://typography.doordash.com/TTNorms-Bold.woff') format('woff'), url('https://typography.doordash.com/TTNorms-Bold.ttf') format('truetype');
|
||||||
|
font-weight: normal !important;
|
||||||
|
font-style: normal !important;
|
||||||
|
mso-font-alt: 'Arial'
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'TTNorms-ExtraBold';
|
||||||
|
src: url('https://typography.doordash.com/TTNorms-ExtraBold.woff') format('woff'), url('https://typography.doordash.com/TTNorms-ExtraBold.ttf') format('truetype');
|
||||||
|
font-weight: normal !important;
|
||||||
|
font-style: normal !important;
|
||||||
|
mso-font-alt: 'Arial'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body bgcolor="#ffffff" class="em_body" data-gr-c-s-loaded="true" style="margin:0px auto; padding:0px;">
|
||||||
|
<span style="color:transparent;visibility:hidden;display:none;opacity:0;height:0;width:0;font-size:0;"></span> <!-- == Body Section == -->
|
||||||
|
<table bgcolor="#ffffff" border="0" cellpadding="0" cellspacing="0" class="em_full_wrap" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="em_main_table" style="width:700px;" width="700">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top"><!---->
|
||||||
|
<table align="center" bgcolor="#ededed" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h30" height="62" style="height:62px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr> <!-- banner Section -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#ededed" class="em_hauto" valign="top"><!---->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="em_side30" style="width:60px;" width="60"></td>
|
||||||
|
<td align="center" class="em_hauto" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top"><a style="text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAEkIYH_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniDohab0lVqyyqLUGGGtDfKri8s0BPbLw2Kta64VgtBfCarELB9jSoY9mQzB-2FDOSk7-2FtMWjohoN3uf5f2rLrFhl33mtjWZ53lXvtDO4wQsmVDJSCEfVON02MQaDh-2BmTLPN5Vxq7DH8UlIbETnDVF4Df7l7njadHPDZB2aHMoBR9nS93-2B-2FrbnWmdM0cdfetMj3RGMa8Yk-2Fgyfj6ZKM2CqlUxqNZhbHVjZo1bFR4YvyxsTPc56A8ujeF0vzPIUM4yXtdY-2BtGKNvO-2F2momIcFB8TCK6k39zWYllF-2FQub6BRW64qF3OwcOA8Qi4U05aRe966x3Bpi5mkgpGuXH0X2CslH23bljSHOl2uOmwPSt23EvYS0CPbrX4YRtgLnEmHgYYbokWIuo5wjdIr3VkCFPni8ollM9GFO6AXsUeSwZAvhq6oZY-2FetynINJjWZRmZA3FqSomogDxHnQi04e6CHFKy-2BGOn5XT6stPkmBsFDgIMFmy1Jd3aVxMduJiqTN-2FrgtJfvcZfTHhj5HcBZymhPVxScgd1TCe4E2-2FYMFqXzB9Or-2FAP5yIgF2yNJhy8OA3CMlV8LdA8Rd-2Fsxt1caI8cPn9hyYypY-2FLckUNAxRzwuBCAQ1buubQlldewgQiuToSUsejOOxk-3D" universal="true"><img alt="DOORDASH" border="0" class="en_icon" style="display:block; max-width:45px;font-family:Arial, sans-serif;font-size:20px; line-height:30px; color:#ee3623; font-weight:bold;" width="45" src="https://assets.doordash.team/m/835d1d775f776ef/original/-04_April-MX_Winback_Campaign-logo_img.png"> </a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="50" style="height:50px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink em_font_58 em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:51px; line-height:60px; font-weight: bold;" valign="top"><!---->Thanks for your<br> order, Siddharth<!----></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h10" height="20" style="height:20px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#000000;font-size:16px; line-height:20px;" valign="top"><!---->The estimated delivery time for your order<br class="em_hide"> is <strong>2:00 pm - 2:15 pm</strong>. Track your order in<br class="em_hide"> the DoorDash app or website.<!----></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h10" height="20" style="height:20px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" class="em_cta" style="width:220px; max-width:220px;" width="220">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" class="em_defaultlink em_cta em_bold" height="45" style="font-family:'TTNorms-Bold', Arial, sans-serif;color:#ffffff;font-size:18px; background-color:#eb1700; border-radius:25px; font-weight: bold; " valign="middle"><a style="text-decoration:none; display:block; color:#ffffff; line-height:45px;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiUYpjiz5uS6IJixGXj3UHbTD93pKefx1-2F0xnbLGDGm-2F-2BQiJu-2BjCmmrEKOYlPr7srvk-3D8BvI_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniDohab0lVqyyqLUGGGtDfKri8s0BPbLw2Kta64VgtBfCarELB9jSoY9mQzB-2FDOSk7-2FtMWjohoN3uf5f2rLrFhl33mtjWZ53lXvtDO4wQsmVDJSCEfVON02MQaDh-2BmTLPN5Vxq7DH8UlIbETnDVF4Df7l7njadHPDZB2aHMoBR9nS93-2B-2FrbnWmdM0cdfetMj3RGMa8Yk-2Fgyfj6ZKM2CqlUxqNZhbHVjZo1bFR4YvyxsTPc56A8ujeF0vzPIUM4yXtdY-2BtGKNvO-2F2momIcFB8TCK6k39zWYllF-2FQub6BRW64qF3OwcOA8Qi4U05aRe966x3Bpi5mkgpGuXH0X2CslH23bljSHOl2uOmwPSt23EvYS0CPbrX4YRtgLnEmHgYYbokWIuo5wjdIr3VkCFPni8ollM9GFO6AXsUeSwZAvhq6oZY-2FetynINJjWZRmZA3FqSomogDxHnQi04e6CHFKy-2BGOn5XT6stPkmBsFDgIMFmy1JaOMC6-2F6zNNGOii9obOCSUz236yIB4gCzgF-2BEE7DFN-2BzWJDOZETyK2c-2BdBr5QJMquOChmyoid171OpGSki94P-2Br-2FT5mkOXMUdSuXDA9B2zx9bfVlZsW1KYw6NBNiJ3c-2F9p-2FTFh6SVp-2Ferm15pDsGPoc-3D" universal="true">Track Your Order</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
<td class="em_side15" style="width:20px;" width="20"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!----><!--Illustration 1-->
|
||||||
|
<tr>
|
||||||
|
<td align="center" class="em_full_img" valign="top"><a style="text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAEGC0g_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniDohab0lVqyyqLUGGGtDfKri8s0BPbLw2Kta64VgtBfCarELB9jSoY9mQzB-2FDOSk7-2FtMWjohoN3uf5f2rLrFhl33mtjWZ53lXvtDO4wQsmVDJSCEfVON02MQaDh-2BmTLPN5Vxq7DH8UlIbETnDVF4Df7l7njadHPDZB2aHMoBR9nS93-2B-2FrbnWmdM0cdfetMj3RGMa8Yk-2Fgyfj6ZKM2CqlUxqNZhbHVjZo1bFR4YvyxsTPc56A8ujeF0vzPIUM4yXtdY-2BtGKNvO-2F2momIcFB8TCK6k39zWYllF-2FQub6BRW64qF3OwcOA8Qi4U05aRe966x3Bpi5mkgpGuXH0X2CslH23bljSHOl2uOmwPSt23EvYS0CPbrX4YRtgLnEmHgYYbokWIuo5wjdIr3VkCFPni8ollM9GFO6AXsUeSwZAvhq6oZY-2FetynINJjWZRmZA3FqSomogDxHnQi04e6CHFKy-2BGOn5XT6stPkmBsFDgIMFmy1JaZrmPxM5tO3MVqwpeuZtxlipSYowDluqjEhtng6OZ1tiReEtrMZSXfyizE8Z-2BoYl-2FOhh2mVbKh8Ag8sj-2FY0Dr5fXcralZiuXdtrszdPSlbYpbbdCWJgW2Mvx4JTup-2Be7s2F323Q0hdUfWlDu1kXlV0-3D" universal="true"><img alt="" border="0" class="em_full_img" style="display:block; max-width:700px; font-family:Arial, sans-serif; font-size:22px; line-height:25px; color:#ffffff; font-weight:bold;" width="700" src="https://assets.doordash.team/m/2f9c7fde7cfed840/original/-template-OrderConfirmation-foodbag.png"></a></td>
|
||||||
|
</tr> <!--//Illustration 1--><!----><!-- //banner Section -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:6%;" width="6%"></td>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" class="em_rounded" style="border-top-left-radius: 40px; border-top-right-radius: 40px; background-color: #ffffff;" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="50" style="height:50px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px;" valign="top">Paid with credits<br> Mad Mex</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px; font-weight:bold; color:#000000;" valign="top">Total: $14.64</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="35" style="height:35px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:26px; line-height:36px; font-weight: bold;" valign="top">Your receipt</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:21px;" valign="top">19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="45" style="height:45px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px;" valign="top"><font size="2" color="#666666"><b>- For: Siddharth Bose -</b></font><br><br>
|
||||||
|
<table width="100%" style="margin: auto; margin-bottom: 20px">
|
||||||
|
<tbody>
|
||||||
|
<tr style="text-align: left;">
|
||||||
|
<td valign="top" width="10%" style="color: #666666; font-size: 18px; line-height: 24px">1x</td>
|
||||||
|
<td valign="top" width="75%" style="color: #666666; font-size: 18px; line-height: 24px"><b>Burrito</b> (Mains)<br><font color="dimgrey">• Slow Cooked Beef (GF)</font><br><font color="dimgrey">• Fresh Guacamole (GF, VG)</font><br><font color="dimgrey">• Spicy Salsa</font><br><font color="dimgrey">• No Beans (GF,V)</font><br><br></td>
|
||||||
|
<td valign="top" width="15%" style="color: #666666; font-size: 18px; line-height: 24px text-align: right">$22.10</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="22" style="height:22px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#ffffff" class="em_pad1" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="em_wrapper" style="width:530px;" width="530">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#e5e5e5" height="2" style="line-height:0px; font-size:0px; height: 2px;"><img alt="" border="0" height="1" style="display:block;" width="1" src="https://assets.doordash.team/m/1b5c04bd5b887a06/original/-05_May-90D_Resurrection_Campaign_Refresh_T2-spacer.gif"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="background-color: #ffffff;" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="10" style="height:10px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr><!---->
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Subtotal</td> <!---->
|
||||||
|
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$22.10</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr><!---->
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Taxes</td> <!---->
|
||||||
|
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$0.00</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!----><!---->
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Delivery Fee</td>
|
||||||
|
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$0.00</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Service Fee</td>
|
||||||
|
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$1.99</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Tip</td>
|
||||||
|
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$0.00</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!---->
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Discounts</td>
|
||||||
|
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">-$24.09</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!---->
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="18" style="height:18px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#ffffff" class="em_pad1" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="em_wrapper" style="width:530px;" width="530">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#e5e5e5" height="2" style="line-height:0px; font-size:0px; height: 2px;"><img alt="" border="0" height="1" style="display:block;" width="1" src="https://assets.doordash.team/m/1b5c04bd5b887a06/original/-05_May-90D_Resurrection_Campaign_Refresh_T2-spacer.gif"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="background-color: #ffffff;" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="12" style="height:12px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr><!---->
|
||||||
|
<td align="left" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:17px; line-height:24px; font-weight: bold;" valign="top">Total Charged</td> <!---->
|
||||||
|
<td align="right" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:17px; line-height:24px; font-weight: bold;" valign="top">$14.64</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!----><!----><!----><!----><!----><!----><!----> <!---->
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="15" style="height:15px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#ff2f07;font-size:14px; line-height:21px; font-weight: bold;" valign="top"><a style="color:#ff2f07; text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiUYpjiz5uS6IJixGXj3UHbTD93pKefx1-2F0xnbLGDGm-2F-2BQiJu-2BjCmmrEKOYlPr7srvk-3DZM8O_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniDohab0lVqyyqLUGGGtDfKri8s0BPbLw2Kta64VgtBfCarELB9jSoY9mQzB-2FDOSk7-2FtMWjohoN3uf5f2rLrFhl33mtjWZ53lXvtDO4wQsmVDJSCEfVON02MQaDh-2BmTLPN5Vxq7DH8UlIbETnDVF4Df7l7njadHPDZB2aHMoBR9nS93-2B-2FrbnWmdM0cdfetMj3RGMa8Yk-2Fgyfj6ZKM2CqlUxqNZhbHVjZo1bFR4YvyxsTPc56A8ujeF0vzPIUM4yXtdY-2BtGKNvO-2F2momIcFB8TCK6k39zWYllF-2FQub6BRW64qF3OwcOA8Qi4U05aRe966x3Bpi5mkgpGuXH0X2CslH23bljSHOl2uOmwPSt23EvYS0CPbrX4YRtgLnEmHgYYbokWIuo5wjdIr3VkCFPni8ollM9GFO6AXsUeSwZAvhq6oZY-2FetynINJjWZRmZA3FqSomogDxHnQi04e6CHFKy-2BGOn5XT6stPkmBsFDgIMFmy1JaVyVX-2BN0rfrZb4gqsyh-2FPhwDCNS7-2Fr19T2GWsMfiRQ3UNw3buP-2FdHaE5XAo30Zx73XG0x2-2Bj9CkYTdbTkYtPKndOo8C-2Frr-2BJqyRFJZMfQUP1ZV-2F2Ey3WNBzbwVr6SxafeujsUnxXrbM6VJ3DMYac3E-3D" universal="true">Get Order Help</a></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="58" style="height:58px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!-- == //Body Section == --><!-- == Footer Section == --><!-- == //Footer Section == --></td>
|
||||||
|
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
<td style="width:6%;" width="6%"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top"><!--[if (gte mso 9)|(IE)]>
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:620px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<![endif]-->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" id="Footer" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 700px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 0 24px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width:100%;max-width:572px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 48px 0;border-bottom: 1px solid #E7E7E7;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="color: #191919; padding: 32px 0 16px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;">©2026 <a style="color: #9A9A9A; text-decoration: none;">DoorDash Technologies Australia Pty Ltd <br>401 Collins St. <br>Melbourne, VIC 3000 Australia</a></p> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYCzx-2Br1lrYaof6JiBiFLOVfEOgDjXh7OOs7yReUnPIfAQpor-2FGsJy8J81hADh-2FLb3M-3Dcz1D_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniDohab0lVqyyqLUGGGtDfKri8s0BPbLw2Kta64VgtBfCarELB9jSoY9mQzB-2FDOSk7-2FtMWjohoN3uf5f2rLrFhl33mtjWZ53lXvtDO4wQsmVDJSCEfVON02MQaDh-2BmTLPN5Vxq7DH8UlIbETnDVF4Df7l7njadHPDZB2aHMoBR9nS93-2B-2FrbnWmdM0cdfetMj3RGMa8Yk-2Fgyfj6ZKM2CqlUxqNZhbHVjZo1bFR4YvyxsTPc56A8ujeF0vzPIUM4yXtdY-2BtGKNvO-2F2momIcFB8TCK6k39zWYllF-2FQub6BRW64qF3OwcOA8Qi4U05aRe966x3Bpi5mkgpGuXH0X2CslH23bljSHOl2uOmwPSt23EvYS0CPbrX4YRtgLnEmHgYYbokWIuo5wjdIr3VkCFPni8ollM9GFO6AXsUeSwZAvhq6oZY-2FetynINJjWZRmZA3FqSomogDxHnQi04e6CHFKy-2BGOn5XT6stPkmBsFDgIMFmy1JRYFdI4rWGqZbwcwPjA-2FfGSpVNEJe6-2BhKqFbWrIPgphG2UaqzMu-2FrPVVEEnOy-2F7K7l-2Fiy9DJuVosOT1N6tV6enGQ9g8qH2bVpDkJgmaqxnNTS4xf9s7LwM3H-2FEquZm-2By-2FmUkLGnDPlpUJFzu1om-2F4mY-3D" target="_blank" style="color: #9A9A9A; text-decoration: none;">Privacy Policy</a></p></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="color: #191919; padding: 0 0 24px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:500;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYC9Fbd7OtaSznXr4XXA8cVP-2BJHCjKWkCwgvETCnlfYzgA-3D-3D9VgN_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniDohab0lVqyyqLUGGGtDfKri8s0BPbLw2Kta64VgtBfCarELB9jSoY9mQzB-2FDOSk7-2FtMWjohoN3uf5f2rLrFhl33mtjWZ53lXvtDO4wQsmVDJSCEfVON02MQaDh-2BmTLPN5Vxq7DH8UlIbETnDVF4Df7l7njadHPDZB2aHMoBR9nS93-2B-2FrbnWmdM0cdfetMj3RGMa8Yk-2Fgyfj6ZKM2CqlUxqNZhbHVjZo1bFR4YvyxsTPc56A8ujeF0vzPIUM4yXtdY-2BtGKNvO-2F2momIcFB8TCK6k39zWYllF-2FQub6BRW64qF3OwcOA8Qi4U05aRe966x3Bpi5mkgpGuXH0X2CslH23bljSHOl2uOmwPSt23EvYS0CPbrX4YRtgLnEmHgYYbokWIuo5wjdIr3VkCFPni8ollM9GFO6AXsUeSwZAvhq6oZY-2FetynINJjWZRmZA3FqSomogDxHnQi04e6CHFKy-2BGOn5XT6stPkmBsFDgIMFmy1JePrOHq5c5aW6evBo1DEdQJNXu-2BX-2FLP1H7ZXkSsghQ0EUu5Q6HYBtkMij3hyHvfxKdfDvfPKun4porDdzU27HkbjUwffVpDDk5SZimtlGs-2B8re-2FwuFPMI9TFYPc8JMXjGAWJTpoqf-2BcFT1RgPBXiW4Y-3D" target="_blank" style="color:#9A9A9A;text-decoration:none;">Help Center</a></p></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]--></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="em_hide" style="line-height:1px;min-width:700px;background-color:#f4f4f4;"><img alt="" border="0" height="1" style="max-height:1px; min-height:1px; display:block; width:700px; min-width:700px;" width="700" src="https://assets.doordash.team/m/1b5c04bd5b887a06/original/-05_May-90D_Resurrection_Campaign_Refresh_T2-spacer.gif"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<img src="https://tracksg.doordash.com/wf/open?upn=u001.EBL2ug8kstebd25Xirrl3olMckTI261ldPjJ39bNHcC7U6EKGAOA4dSVB97lcv3b8qsQRq6LwkHED6F3X4gqaODB2b1wZFk4or4JbrThItr6lxxhe0ghxdjbmj40V9NR2q6sVlDPfArvtaJbtCejz-2BJxmNDpj6cBZjf16jHN3-2B2-2FBNoONAVXEROMy-2FebOh-2BiIcflH-2B33fKp22eXYo6U8vHb6sK-2FhKm1eTZq8fP2ZliSW-2FDJ4KPJHZKnoxb-2FEyRvVNj8NRt3Z7VhrxSD83L-2BD-2Fg-2BYQJkcfBPsJzteuA7jtfPftcKLG1twN4g1gQQZHw9OGtsD1rLq9iXJ6ZzaVdWLEzmjDcZ7g3fTMixrMzH4oIIOEair3v0qOMD-2BV-2BHU7ncYtO-2FnD0bk1O9XI94PBy9e9JXZDgi2OxIT84h7X7b83MwQfHv6Y0eFjKVrvu0wzSlch7hpGzIv6v9RQGcCePKn-2BQ-2B85mf3KcKtUiyQ-2Bcvh-2BNe6ZHrHZAucV3pF0Fdo4Yn1-2Fie3uN1peu4VputGOf9gYSRqzfDIxJ-2BpD9KcAQghrRgZ08n7rFpdtIeHrI0J-2BsvQ0YqnprMwzKBgarmyWY4Ll6vo-2B-2FQLHQVXWlGWqomUtaBIY1wIPtH3sUn48aYCsuzz9tPifFXGPqgruVpD2mpi3C41MG8rFgUFQwfp4xV7SLCp8axKXY3t7WsYtRFw04WcnX-2FJMSRUrn8bq8PbEHq3Hjikf10MnkiyW7GobtuKhHdBL4F6ntalNVUHk3neSbhFBHGlJJQUA2Kfxt1bsI5eAQoX-2FRsU2BYtChgxaMUD53E-3D" alt="" width="1" height="1" border="0" style="height:1px !important;width:1px !important;border-width:0 !important;margin-top:0 !important;margin-bottom:0 !important;margin-right:0 !important;margin-left:0 !important;padding-top:0 !important;padding-bottom:0 !important;padding-right:0 !important;padding-left:0 !important;"/></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,817 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" dir="ltr" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1 user-scalable=yes">
|
||||||
|
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
|
||||||
|
<meta name="x-apple-disable-message-reformatting">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<meta name="supported-color-schemes" content="light dark">
|
||||||
|
<title>DoorDash</title> <!-- WEB FONTS --> <!--[if !mso]>-->
|
||||||
|
<style type="text/css">
|
||||||
|
@font-face{font-family:'TTNorms';font-style:normal;font-weight:700;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Bold.woff2')format('woff2');}
|
||||||
|
@font-face{font-family:'TTNorms';font-style:normal;font-weight:600;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-DemiBold.woff2')format('woff2');}
|
||||||
|
@font-face{font-family:'TTNorms';font-style:normal;font-weight:500;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Medium.woff2')format('woff2');}
|
||||||
|
@font-face{font-family:'TTNorms';font-style:normal;font-weight:450;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Normal.woff2')format('woff2');}
|
||||||
|
@font-face{font-family:'TTNorms';font-style:normal;font-weight:400;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Regular.woff2')format('woff2');}
|
||||||
|
</style> <!--<![endif]--> <!-- STYLE RESETS -->
|
||||||
|
<style type="text/css">a[href^="tel"],a[href^="sms"]{color:inherit;cursor:default;font-weight:inherit;text-decoration:none}body{-ms-text-size-adjust:100%;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;mso-line-height-rule:exactly;}html,body{width:100%;margin:0;padding:0}img{border:0;display:block;height:auto;line-height:100%;outline:none;text-decoration:none}table{border:0 !important;padding:0 !important; border-collapse:collapse !important;mso-table-lspace:0pt;mso-table-rspace:0pt;}u + .body a{color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;line-height:inherit}u ~ img + div > div{display:none;}u + .body{width:100%;}.body a[x-apple-data-detectors=true]{color:inherit!important;text-decoration:inherit!important}span.MsoHyperlink{color:inherit !important;mso-style-priority:99 !important}span.MsoHyperlinkFollowed{color:inherit !important;mso-style-priority:99 !important}
|
||||||
|
</style> <!-- BACKGROUND COLORS -->
|
||||||
|
<style type="text/css">
|
||||||
|
body,#MainTable{background-color:#F4F4F4;}
|
||||||
|
#Basic000{background-color:#FEFFFF;}
|
||||||
|
#Sky200{background-color:#DDF4F4;}
|
||||||
|
</style> <!-- FONT STYLES -->
|
||||||
|
<style type="text/css">
|
||||||
|
h1,h2,h3,h4,h5,h6{font-family:'TTNorms',system-ui,sans-serif;font-weight:700;margin:0 0 8px 0;}
|
||||||
|
p,ol,ul{font-family:'TTNorms',system-ui,sans-serif;font-weight:450;margin:0 0 8px 0;}
|
||||||
|
ul,ol{padding:0 0 0 20px;}
|
||||||
|
li{font-weight:450;margin:0 0 8px 0;}
|
||||||
|
h1{font-size:50px;line-height:50px;letter-spacing:-0.03em;}
|
||||||
|
h2{font-size:40px;line-height:40px;letter-spacing:-0.02em;}
|
||||||
|
h3{font-size:32px;line-height:32px;letter-spacing:-0.02em;}
|
||||||
|
h4{font-size:24px;line-height:24px;letter-spacing:-0.01em;}
|
||||||
|
h5{font-size:20px;line-height:22px;letter-spacing:-0.01em;}
|
||||||
|
h6{font-size:16px;line-height:18px;}
|
||||||
|
p.p1{font-size:20px;line-height:26px;}
|
||||||
|
p.p2{font-size:16px;line-height:22px;}
|
||||||
|
p.p4{font-size:12px;line-height:14px;}
|
||||||
|
sup{font-size:11px;line-height:11px;}
|
||||||
|
</style> <!-- FONT COLORS -->
|
||||||
|
<style type="text/css">
|
||||||
|
#MainTable table td{color:#191919;}
|
||||||
|
#MainTable table td a{color:inherit;}
|
||||||
|
#MainTable table td p a{text-decoration:underline;}
|
||||||
|
.Red200{color:#EB1700 !important;}
|
||||||
|
</style> <!-- CTAs --> <!-- MOBILE STYLES -->
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width:699px){
|
||||||
|
#MainTable > table {max-width:410px!important;}
|
||||||
|
.full{width:100%!important;height:auto!important;}
|
||||||
|
.pad0{padding-left:0!important;padding-right:0!important;}
|
||||||
|
.pad8{padding-left:8px!important;padding-right:8px!important;}
|
||||||
|
.pad24{padding-left:24px!important;padding-right:24px!important;}
|
||||||
|
.logo{padding-top:40px!important;padding-bottom:40px!important;}
|
||||||
|
h1{font-size:40px!important;line-height:40px!important;letter-spacing:-0.02em!important;}
|
||||||
|
h2{font-size:32px!important;line-height:32px!important;}
|
||||||
|
h3{font-size:24px!important;line-height:24px!important;letter-spacing:-0.01em!important;}
|
||||||
|
}
|
||||||
|
</style> <!-- DARK MODE STYLES -->
|
||||||
|
<style type="text/css">
|
||||||
|
@media (prefers-color-scheme:dark){
|
||||||
|
body,#MainTable,#Footer{background-color:#000000!important;background-image:linear-gradient(#000000,#000000)!important;}
|
||||||
|
table[id^="Basic"]{background-color:#191919!important;background-image:linear-gradient(#191919,#191919)!important;}
|
||||||
|
table[id^="Sky"],table[id^="Blue"]{background-color:#002629!important;background-image:linear-gradient(#002629,#002629)!important;}
|
||||||
|
#MainTable table td{color:#FFFFFF!important;}
|
||||||
|
#MainTable #Footer table td a{color:#FFFFFF!important;}
|
||||||
|
#MainTable .Red200{color:#FF3008!important;}
|
||||||
|
#MainTable .label span{color:#494949!important;background-color:#FEFFFF!important;}
|
||||||
|
#MainTable .grayCopy p{color:#A6A6A6!important;}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
:root{color-scheme:light dark;supported-color-schemes:light dark;}
|
||||||
|
</style> <!-- GMAIL APP DARK MODE FIX --> <!-- OUTLOOK SPECIFIC CSS --> <!--[if gte mso 9]>
|
||||||
|
<style type="text/css">
|
||||||
|
#MainTable td a{color:#191919;}
|
||||||
|
ol,ul{margin-left:20px !important;}
|
||||||
|
li{text-indent:-1em;}
|
||||||
|
</style>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body class="body" style="width:100%;margin:0;padding:0;">
|
||||||
|
<span style="color:transparent;visibility:hidden;display:none;opacity:0;height:0;width:0;font-size:0;"></span>
|
||||||
|
<div role="article" aria-roledescription="email" aria-label="DoorDash Email" lang="en" dir="ltr" style="font-size:medium; font-size:max(16px, 1rem);">
|
||||||
|
<div style="display: none; max-height: 0px; overflow: hidden;">
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
</div>
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" id="MainTable" style="background-color:#F4F4F4;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="full" id="Sky200" role="presentation" style="width:700px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="pad24" style="padding: 0 64px;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="logo" style="padding: 48px 0;"><a href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAEerk2_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dT8hiOQ5EDRx3fHM-2BeYkU4qHqDyPFxUwZuSfyRj0SIOTkg-2B18lVSaIyNV-2FYADATgkv5vXH4igP0Invu4xOPQBpgg16ckbsIS8IRV3mGaj1X5KhMT-2FuLlWkyJ0vYYtn7DRdGkRIV8GNdbKAWhFWQmTP4s1sfO-2BmfVk1PxO2rjtA0ZfcEcAKhCAzKFEEbQ9BWgK39Qvkt47rpQjykYZ9iiQAGWQLAjcs6P4DIPoAa8QmGrMb0TkMbrHUuWzG3I6o6QTpKFhzWbXT1TXbl0tj9rUZeGPY7Gxx-2FjDpfuEqSvOXz4YCMEoeSAPy7cIWp2wjaTin6ux0N-2Fsxe4v-2FsCd7o-2F1uCwW4EEDetpthuIuYYkeIXQlzypqy8rrJ5Czz-2F9QxvlX7Rurh5UPO-2FSnnEWnQNj-2Bby-2BmuZiFMapcD3VfIVucgG0YgY0q8krauzK38sC-2Bo3p3uvmDObIONy9moqGQopWgFpL5Oy8bXmdxo1-2FRjNsrDbsLgM1km0RQLa4WV7Y99oaIhdT35ahysXpZwozejK1qEgOqrm8WZjgms1TsVagTaVDuAieGpWb-2FQlcU9t3Vh8TCZEmOz2Loi3CvHDdReh6E5oaG4UkH-2BbB80dFEw5AwJF-2B0-3D" target="_blank" universal="true"><img alt="DoorDash" border="0" src="https://assets.doordash.team/m/5e68fa5cbbc50c32/original/DoorDash-Logo-Red100.png" style="color: #FF3008;font-family:'TTNorms',system-ui,sans-serif;font-weight:bold;font-size:18px;text-decoration:none;" width="50"></a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="pad0" style="padding:0 64px 0 0;"><h1 style="margin:0 0 16px 0;">There are adjustments to your order.</h1></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="right" style="padding: 0 0 40px 0;"><img alt="" src="https://img.cdn4dd.com/s/convenience/images/adjustments_eml_grocery.png" width="380" style="width:100%;max-width:380px;"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="pad8" style="padding: 0 40px 40px;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" id="Basic000" role="presentation" style="width:100%;border-radius:25px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="pad24" style="padding:40px 40px 0;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 32px 0;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 24px 0;"><!----> <p class="p2" style="margin:0 0 4px 0;">Paid with credits</p> <p class="p2" style="margin:0 0 4px 0;">ALDI</p> <p class="p2" style="margin:0 0 4px 0;"></p> <p class="p2"><strong>Total: $0.00</strong></p></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 24px 0;border-bottom:1px solid #C4C4C4;"><h4>Your receipt</h4> <!----> <p class="p2" style="margin:0 0 4px 0;"></p> <!----> <p class="p2"><a href="" style="color:#191919;text-decoration:none;">19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia</a></p></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!---->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 16px 0;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left"><h4>Items that were adjusted</h4></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left"><!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="label" style="padding: 0 0 8px 0;"><!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <span style="font-family:'TTNorms',system-ui,sans-serif;font-size:12px;line-height:18px;color:#FEFFFF;background-color:#494949;display:inline-block;padding:1px 4px;border-radius:4px;font-weight:700;white-space:nowrap;">Out of Stock</span></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class="grayCopy"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:#767676;"><strong>5x</strong> Coca-Cola Coke Zero Sugar Soft Drink (1.5 L)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: line-through; margin: 0 0 4px 0;color:#767676;white-space:nowrap;"> $16.45 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="label" style="padding: 0 0 8px 0;"><!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <span style="font-family:'TTNorms',system-ui,sans-serif;font-size:12px;line-height:18px;color:#FEFFFF;background-color:#494949;display:inline-block;padding:1px 4px;border-radius:4px;font-weight:700;white-space:nowrap;">Substituted</span></td>
|
||||||
|
</tr> <!-- --> <!-- --> <!-- -->
|
||||||
|
<tr>
|
||||||
|
<td align="left"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class="grayCopy"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color: #767676;"><strong>2x</strong> Specially Selected Beef Wagyu Burger (150 g)</p></td>
|
||||||
|
<td align="right" valign="top" class="grayCopy" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: line-through; margin: 0 0 4px 0;color: #767676;white-space:nowrap;"> $8.58 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="color: #191919;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; margin: 0 0 8px 0; font-size: 16px; line-height: 20px; font-weight: 700;">Substituted with:</p></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" style="color: #191919;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;"><strong>1x</strong> Ready, Set...Cook! Wagyu Beef Burgers (400 g)</p></td>
|
||||||
|
<td align="right" valign="top" style="color: #191919; padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;white-space:nowrap;">$9.39</p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: none;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="label" style="padding: 0 0 8px 0;"><!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <span style="font-family:'TTNorms',system-ui,sans-serif;font-size:12px;line-height:18px;color:#FEFFFF;background-color:#494949;display:inline-block;padding:1px 4px;border-radius:4px;font-weight:700;white-space:nowrap;">Substituted</span></td>
|
||||||
|
</tr> <!-- --> <!-- --> <!-- -->
|
||||||
|
<tr>
|
||||||
|
<td align="left"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class="grayCopy"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color: #767676;"><strong>1x</strong> Bon Appetit Sliced Brioche Burger Buns with Sesame Seeds (200 g)</p></td>
|
||||||
|
<td align="right" valign="top" class="grayCopy" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: line-through; margin: 0 0 4px 0;color: #767676;white-space:nowrap;"> $3.89 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="color: #191919;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; margin: 0 0 8px 0; font-size: 16px; line-height: 20px; font-weight: 700;">Substituted with:</p></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" style="color: #191919;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;"><strong>1x</strong> Bon Appetit Sliced Brioche Burger Buns (200 g)</p></td>
|
||||||
|
<td align="right" valign="top" style="color: #191919; padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;white-space:nowrap;">$3.89</p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!---->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 32px 0;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left"><h4>Items you ordered</h4></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 16px 0;border-bottom:1px solid #C4C4C4;"><!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Ironbark Pork Belly Pack</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $24.21 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" align="left" class="grayCopy"><p class="para-md" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 14px; line-height: 18px; margin: 0 0 4px 0;color: #767676;">$18.99/kg • Purchased 1.275 kg</p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Green Capsicum Loose (each) (each)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $1.54 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" align="left" class="grayCopy"><p class="para-md" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 14px; line-height: 18px; margin: 0 0 4px 0;color: #767676;">$5.99/kg • Purchased 0.257 kg</p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>2x</strong> Broccoli Loose (each)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $3.08 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" align="left" class="grayCopy"><p class="para-md" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 14px; line-height: 18px; margin: 0 0 4px 0;color: #767676;">$4.49/kg • Purchased 0.685 kg</p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Ginger Loose (each)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $4.47 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" align="left" class="grayCopy"><p class="para-md" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 14px; line-height: 18px; margin: 0 0 4px 0;color: #767676;">$29.99/kg • Purchased 0.149 kg</p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Berg Streaky Bacon (200 g)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $4.69 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Emporium Selection Burrata (150 g)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $7.09 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Anco Soft Anco Soft Fabric Softener Concentrate - Cashmere Touch (1 L)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $3.99 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Pure Vita Canola Oil (2 L)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $6.49 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> The Herb Garden Jalapeno Chillies (80 g)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $3.49 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Farmdale Thickened Cream (300 ml)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $3.69 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Mandarins (1 kg)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $3.49 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Sweet Valley Fruit Salad in Syrup (825 g)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $3.89 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>3x</strong> Hass Avocado</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $5.37 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Spring Onion Bunch</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $2.69 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: none;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> The Herb Garden Coriander Bunch</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $2.99 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!---->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 32px 0;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 24px 0;border-bottom:1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left"><p class="p2">Subtotal</p></td>
|
||||||
|
<td align="right" valign="bottom"><p class="p2">$94.45</p></td>
|
||||||
|
</tr> <!----> <!---->
|
||||||
|
<tr>
|
||||||
|
<td align="left"><p class="p2">Bag Fee</p></td>
|
||||||
|
<td align="right" valign="top"><p class="p2">$0.75</p></td>
|
||||||
|
</tr> <!----> <!----> <!---->
|
||||||
|
<tr>
|
||||||
|
<td align="left"><p class="p2">Tax</p></td>
|
||||||
|
<td align="right" valign="top"><p class="p2">$0.00</p></td>
|
||||||
|
</tr> <!----> <!----> <!---->
|
||||||
|
<tr>
|
||||||
|
<td align="left"><p class="p2">Delivery fee</p></td>
|
||||||
|
<td align="right" valign="top"><p class="p2">$0.00</p></td>
|
||||||
|
</tr> <!----> <!---->
|
||||||
|
<tr>
|
||||||
|
<td align="left"><p class="p2">Service fee</p></td>
|
||||||
|
<td align="right" valign="top"><p class="p2">$8.95</p></td>
|
||||||
|
</tr> <!----> <!---->
|
||||||
|
<tr>
|
||||||
|
<td align="left"><p class="p2">Dasher tip</p></td>
|
||||||
|
<td align="right" valign="top"><p class="p2">$0.00</p></td>
|
||||||
|
</tr> <!----> <!---->
|
||||||
|
<tr>
|
||||||
|
<td align="left"><p class="p2">Discount</p></td>
|
||||||
|
<td align="right" valign="top"><p class="p2">-$25.00</p></td>
|
||||||
|
</tr> <!----> <!----> <!----> <!---->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left"><p class="p1" style="font-weight:700;">Final total charged</p></td>
|
||||||
|
<td align="right" valign="bottom"><p class="p1" style="font-weight:700;">$0.00</p></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:16px 0 24px 0;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left"><!----> <!----> <!----> <p class="p4" style="margin:0 0 16px;font-weight:400;">This email confirms revisions made to your original DoorDash order and reflects the final amount charged. The new total cost of your order is above and includes all taxes and fees. Payment processing adjustments to the original charge may take up to 5-7 business days to process.</p> <!----> <p class="p2" style="margin:0 0 16px;"><a class="Red200" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiUYUIJyXMOWcKUkQoS6w1cidTUBltD9gsdDsG54KhCqi0TpTxXlRftLSD-2F0zchmY726xMeqb-2BYjhquRL7EwEUa-2B-C1a_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dT8hiOQ5EDRx3fHM-2BeYkU4qHqDyPFxUwZuSfyRj0SIOTkg-2B18lVSaIyNV-2FYADATgkv5vXH4igP0Invu4xOPQBpgg16ckbsIS8IRV3mGaj1X5KhMT-2FuLlWkyJ0vYYtn7DRdGkRIV8GNdbKAWhFWQmTP4s1sfO-2BmfVk1PxO2rjtA0ZfcEcAKhCAzKFEEbQ9BWgK39Qvkt47rpQjykYZ9iiQAGWQLAjcs6P4DIPoAa8QmGrMb0TkMbrHUuWzG3I6o6QTpKFhzWbXT1TXbl0tj9rUZeGPY7Gxx-2FjDpfuEqSvOXz4YCMEoeSAPy7cIWp2wjaTin6ux0N-2Fsxe4v-2FsCd7o-2F1uCwW4EEDetpthuIuYYkeIXQlzypqy8rrJ5Czz-2F9QxvlX7Rurh5UPO-2FSnnEWnQNj-2Bby-2BmuZiFMapcD3VfIVucgG0YgY0q8krauzK38sC-2Bo3p3uvmDObIONy9moqGQopWgFpL5Oy8bXmdxo1-2FRjNsrDbsIzcz91FpNoAfjDMq4Z1wpXHGTMPHfVoKcReXA-2FJ6jaKo-2B6xGAG-2Bp5h6Him0z2xNQVc2hNO8YatcJsnFfudME5OtlTe61-2Bq439PkYJf0JqaFj1iwRjZNlhThyTmY7ccTBU-3D" target="_blank" style="font-weight:700;text-decoration:none;" universal="true">Get Order Help</a></p></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="full" id="Footer" role="presentation" style="width:700px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td valign="top" align="center" style="padding:0 0 24px 0;"><!--[if (gte mso 9)|(IE)]>
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:620px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<![endif]-->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" id="Footer" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 700px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 0 24px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width:100%;max-width:572px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 48px 0;border-bottom: 1px solid #E7E7E7;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="color: #191919; padding: 32px 0 16px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;">©2026 <a style="color: #9A9A9A; text-decoration: none;">DoorDash Technologies Australia Pty Ltd <br>401 Collins St. <br>Melbourne, VIC 3000 Australia</a></p> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYAqF1KGCKTTTznL6MvOfulOsmFa2kqsuG7LjgY0AllZKBKWegcAPOx5sr25l15pnWbLZL6VnVCDFc3hEnP4sDnrjJH9_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dT8hiOQ5EDRx3fHM-2BeYkU4qHqDyPFxUwZuSfyRj0SIOTkg-2B18lVSaIyNV-2FYADATgkv5vXH4igP0Invu4xOPQBpgg16ckbsIS8IRV3mGaj1X5KhMT-2FuLlWkyJ0vYYtn7DRdGkRIV8GNdbKAWhFWQmTP4s1sfO-2BmfVk1PxO2rjtA0ZfcEcAKhCAzKFEEbQ9BWgK39Qvkt47rpQjykYZ9iiQAGWQLAjcs6P4DIPoAa8QmGrMb0TkMbrHUuWzG3I6o6QTpKFhzWbXT1TXbl0tj9rUZeGPY7Gxx-2FjDpfuEqSvOXz4YCMEoeSAPy7cIWp2wjaTin6ux0N-2Fsxe4v-2FsCd7o-2F1uCwW4EEDetpthuIuYYkeIXQlzypqy8rrJ5Czz-2F9QxvlX7Rurh5UPO-2FSnnEWnQNj-2Bby-2BmuZiFMapcD3VfIVucgG0YgY0q8krauzK38sC-2Bo3p3uvmDObIONy9moqGQopWgFpL5Oy8bXmdxo1-2FRjNsrDbsIjwoUCSEKvO5n-2Bj16PIPm9vylVUKXBAg-2Fy62EoW7wAeLOkv7ljadn2lJdO1Z8YyTZVsg50XPfub07LBUsUSDmpWsvorrlalMazpGr6pIsnVp6l5lDVPbZ82aaASmCqGBc-3D" target="_blank" style="color: #9A9A9A; text-decoration: none;">Privacy Policy</a></p></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="color: #191919; padding: 0 0 24px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:500;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYABnncKv1afIihr5Gt6bkMj2LIo_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dT8hiOQ5EDRx3fHM-2BeYkU4qHqDyPFxUwZuSfyRj0SIOTkg-2B18lVSaIyNV-2FYADATgkv5vXH4igP0Invu4xOPQBpgg16ckbsIS8IRV3mGaj1X5KhMT-2FuLlWkyJ0vYYtn7DRdGkRIV8GNdbKAWhFWQmTP4s1sfO-2BmfVk1PxO2rjtA0ZfcEcAKhCAzKFEEbQ9BWgK39Qvkt47rpQjykYZ9iiQAGWQLAjcs6P4DIPoAa8QmGrMb0TkMbrHUuWzG3I6o6QTpKFhzWbXT1TXbl0tj9rUZeGPY7Gxx-2FjDpfuEqSvOXz4YCMEoeSAPy7cIWp2wjaTin6ux0N-2Fsxe4v-2FsCd7o-2F1uCwW4EEDetpthuIuYYkeIXQlzypqy8rrJ5Czz-2F9QxvlX7Rurh5UPO-2FSnnEWnQNj-2Bby-2BmuZiFMapcD3VfIVucgG0YgY0q8krauzK38sC-2Bo3p3uvmDObIONy9moqGQopWgFpL5Oy8bXmdxo1-2FRjNsrDbsIrFQJW5Yztgt2Jh362zEdlWVE33xdgG5SV98hQgmujo0E4JvhCRaOchZGxbbbqba5s49iSoOH1uA1snY7iHXvSUNUHWVknLjLn6FaOweBoSS4nrQXEl0UQpbIiLKqwLeM-3D" target="_blank" style="color:#9A9A9A;text-decoration:none;">Help Center</a></p></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]--></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<img src="https://tracksg.doordash.com/wf/open?upn=u001.EBL2ug8kstebd25Xirrl3olMckTI261ldPjJ39bNHcC7U6EKGAOA4dSVB97lcv3b8qsQRq6LwkHED6F3X4gqaFe59-2BHz9QfrDL7jp-2BN3jX4WgiXvlTBIakpalIO3JdserrDEfSsXb7VYNi8XmPfj32D9Xcsg-2BKnH54BOn83XeKSYsMOiCB2O7NlyiRLKTOp-2FBccW4vEHOV-2FHaLf-2FAjylTtey6rU4sqCD635svGzB8KNIeJlLUHQHFm36Lriv1GKTHI0TRmME0InLq-2F8D2jWE2PvL5vl2AuTCSyofMbb8v6F0-2Fp2tKFfRS7SLHGABa-2BiIEO0Cj-2B51-2BCj8vI17Ej73lSLlLpOQjZQ5w6Xjpy94GXTJoyFOgOCkMe1dYoozgKu053kwmU-2FQgzvTvZvWljLEAh8AKZgpttD0qf9fwmzT-2FfbmEACAx1lYnUbMQSEnKVrq-2BN93H5GudoxrcNAH5xj7CeRBdn-2B-2Fm2uq78KkVh9j7d2poYBFM-2F0phg4yfzMJ8HenyMNPoDsxTYF2uwr9FPYHeHB92FdN-2FyMNtbhWu3f2Bj2Jp32hssVlE8ypjMbIy3F8Z4K8hxnzYgtXhQn9f7NJRAIR6ZxzgEYOW9gIQ1FDvzuTPT-2BOgXirIYJd7VNW1ls-2F33I09NUJXDzuQt6FpCbkzrw5rMFL9uAjGdsCzWAwAdtI0Vpj9QHVbFrQ7V0Km2o1xng4pDAf2zzOoVcOfy6Llv7NL5VR958SiCNPk-2FivSDJKGbqADXQIiUAHJEWxjJh9" alt="" width="1" height="1" border="0" style="height:1px !important;width:1px !important;border-width:0 !important;margin-top:0 !important;margin-bottom:0 !important;margin-right:0 !important;margin-left:0 !important;padding-top:0 !important;padding-bottom:0 !important;padding-right:0 !important;padding-left:0 !important;"/></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" dir="ltr" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1 user-scalable=yes">
|
||||||
|
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
|
||||||
|
<meta name="x-apple-disable-message-reformatting">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<meta name="supported-color-schemes" content="light dark">
|
||||||
|
<title>DoorDash</title> <!-- WEB FONTS --> <!--[if !mso]>-->
|
||||||
|
<style type="text/css">
|
||||||
|
@font-face{font-family:'TTNorms';font-style:normal;font-weight:700;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Bold.woff2')format('woff2');}
|
||||||
|
@font-face{font-family:'TTNorms';font-style:normal;font-weight:600;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-DemiBold.woff2')format('woff2');}
|
||||||
|
@font-face{font-family:'TTNorms';font-style:normal;font-weight:500;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Medium.woff2')format('woff2');}
|
||||||
|
@font-face{font-family:'TTNorms';font-style:normal;font-weight:450;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Normal.woff2')format('woff2');}
|
||||||
|
@font-face{font-family:'TTNorms';font-style:normal;font-weight:400;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Regular.woff2')format('woff2');}
|
||||||
|
</style> <!--<![endif]--> <!-- STYLE RESETS -->
|
||||||
|
<style type="text/css">a[href^="tel"],a[href^="sms"]{color:inherit;cursor:default;font-weight:inherit;text-decoration:none}body{-ms-text-size-adjust:100%;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;mso-line-height-rule:exactly;}html,body{width:100%;margin:0;padding:0}img{border:0;display:block;height:auto;line-height:100%;outline:none;text-decoration:none}table{border:0 !important;padding:0 !important; border-collapse:collapse !important;mso-table-lspace:0pt;mso-table-rspace:0pt;}u + .body a{color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;line-height:inherit}u ~ img + div > div{display:none;}u + .body{width:100%;}.body a[x-apple-data-detectors=true]{color:inherit!important;text-decoration:inherit!important}span.MsoHyperlink{color:inherit !important;mso-style-priority:99 !important}span.MsoHyperlinkFollowed{color:inherit !important;mso-style-priority:99 !important}
|
||||||
|
</style> <!-- BACKGROUND COLORS -->
|
||||||
|
<style type="text/css">
|
||||||
|
body,#MainTable{background-color:#F4F4F4;}
|
||||||
|
#Basic000{background-color:#FEFFFF;}
|
||||||
|
#Sky200{background-color:#DDF4F4;}
|
||||||
|
</style> <!-- FONT STYLES -->
|
||||||
|
<style type="text/css">
|
||||||
|
h1,h2,h3,h4,h5,h6{font-family:'TTNorms',system-ui,sans-serif;font-weight:700;margin:0 0 8px 0;}
|
||||||
|
p,ol,ul{font-family:'TTNorms',system-ui,sans-serif;font-weight:450;margin:0 0 8px 0;}
|
||||||
|
ul,ol{padding:0 0 0 20px;}
|
||||||
|
li{font-weight:450;margin:0 0 8px 0;}
|
||||||
|
h1{font-size:50px;line-height:50px;letter-spacing:-0.03em;}
|
||||||
|
h2{font-size:40px;line-height:40px;letter-spacing:-0.02em;}
|
||||||
|
h3{font-size:32px;line-height:32px;letter-spacing:-0.02em;}
|
||||||
|
h4{font-size:24px;line-height:24px;letter-spacing:-0.01em;}
|
||||||
|
h5{font-size:20px;line-height:22px;letter-spacing:-0.01em;}
|
||||||
|
h6{font-size:16px;line-height:18px;}
|
||||||
|
p.p1{font-size:20px;line-height:26px;}
|
||||||
|
p.p2{font-size:16px;line-height:22px;}
|
||||||
|
p.p4{font-size:12px;line-height:14px;}
|
||||||
|
sup{font-size:11px;line-height:11px;}
|
||||||
|
</style> <!-- FONT COLORS -->
|
||||||
|
<style type="text/css">
|
||||||
|
#MainTable table td{color:#191919;}
|
||||||
|
#MainTable table td a{color:inherit;}
|
||||||
|
#MainTable table td p a{text-decoration:underline;}
|
||||||
|
.Red200{color:#EB1700 !important;}
|
||||||
|
</style> <!-- CTAs --> <!-- MOBILE STYLES -->
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width:699px){
|
||||||
|
#MainTable > table {max-width:410px!important;}
|
||||||
|
.full{width:100%!important;height:auto!important;}
|
||||||
|
.pad0{padding-left:0!important;padding-right:0!important;}
|
||||||
|
.pad8{padding-left:8px!important;padding-right:8px!important;}
|
||||||
|
.pad24{padding-left:24px!important;padding-right:24px!important;}
|
||||||
|
.logo{padding-top:40px!important;padding-bottom:40px!important;}
|
||||||
|
h1{font-size:40px!important;line-height:40px!important;letter-spacing:-0.02em!important;}
|
||||||
|
h2{font-size:32px!important;line-height:32px!important;}
|
||||||
|
h3{font-size:24px!important;line-height:24px!important;letter-spacing:-0.01em!important;}
|
||||||
|
}
|
||||||
|
</style> <!-- DARK MODE STYLES -->
|
||||||
|
<style type="text/css">
|
||||||
|
@media (prefers-color-scheme:dark){
|
||||||
|
body,#MainTable,#Footer{background-color:#000000!important;background-image:linear-gradient(#000000,#000000)!important;}
|
||||||
|
table[id^="Basic"]{background-color:#191919!important;background-image:linear-gradient(#191919,#191919)!important;}
|
||||||
|
table[id^="Sky"],table[id^="Blue"]{background-color:#002629!important;background-image:linear-gradient(#002629,#002629)!important;}
|
||||||
|
#MainTable table td{color:#FFFFFF!important;}
|
||||||
|
#MainTable #Footer table td a{color:#FFFFFF!important;}
|
||||||
|
#MainTable .Red200{color:#FF3008!important;}
|
||||||
|
#MainTable .label span{color:#494949!important;background-color:#FEFFFF!important;}
|
||||||
|
#MainTable .grayCopy p{color:#A6A6A6!important;}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
:root{color-scheme:light dark;supported-color-schemes:light dark;}
|
||||||
|
</style> <!-- GMAIL APP DARK MODE FIX --> <!-- OUTLOOK SPECIFIC CSS --> <!--[if gte mso 9]>
|
||||||
|
<style type="text/css">
|
||||||
|
#MainTable td a{color:#191919;}
|
||||||
|
ol,ul{margin-left:20px !important;}
|
||||||
|
li{text-indent:-1em;}
|
||||||
|
</style>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body class="body" style="width:100%;margin:0;padding:0;">
|
||||||
|
<span style="color:transparent;visibility:hidden;display:none;opacity:0;height:0;width:0;font-size:0;"></span>
|
||||||
|
<div role="article" aria-roledescription="email" aria-label="DoorDash Email" lang="en" dir="ltr" style="font-size:medium; font-size:max(16px, 1rem);">
|
||||||
|
<div style="display: none; max-height: 0px; overflow: hidden;">
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
</div>
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" id="MainTable" style="background-color:#F4F4F4;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="full" id="Sky200" role="presentation" style="width:700px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="pad24" style="padding: 0 64px;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="logo" style="padding: 48px 0;"><a href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAEa1Qb_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dTef2rlCVIu36es-2FxRXYDPOLHXG7qitIbeuwICQOtiAiJyF0XW5mYCg4EenXQa5FiPOrlqnzXCXafNC5mlJGvxOlPnGz4KG6-2FibY4F4HrGTkxRoVpyf9CEBWQB3h4B6-2F1vV34umJj7-2FLlLpcjkRso-2FZrw9zD-2FDCCnEzAJ9b7RCYUIm4jyKucNAH6Fj2kRAnALL6-2FL0QY3vFFztXGwYqrzLSreyQBjHZ9aQwUSam7UJYCAaq48Y50TUgS9EuM1bSw4Twf5p6fp3H4FoRGPMueME-2FSTdpWyR6zn70BPVqAFezHiorOMilHHsOq6nk1-2FqORI-2F0veiu3RW2zrCwnVrabBGigtiGnhurr7p9nOs1AjdyZa-2Fy5Uh8bX3CPB4wzCMXE7O-2FOmAxPQkFoB0-2BgXl8YJlxRNDOaueR-2BgqPA2uYZxr4-2BQtI4L0YynseA3NMscHGJNGN88RJHrH4CqPbPBkbpXelO-2Ftz4qkPn2adhNtmCgMrl4mfF5lxevUpoJQJ7UmMxollJfoLEGLjLhrl7sdZrHH5YHZ8O6My471AKWPRD9-2Fi1Gj5kDczANohQUl0hqEmYQa58AnEUM4SbTghHAzOx2-2FdIewmuvSy5w0lyALMIuqj5OPNSb0LQsZWNIEFVG8SDXp" target="_blank" universal="true"><img alt="DoorDash" border="0" src="https://assets.doordash.team/m/5e68fa5cbbc50c32/original/DoorDash-Logo-Red100.png" style="color: #FF3008;font-family:'TTNorms',system-ui,sans-serif;font-weight:bold;font-size:18px;text-decoration:none;" width="50"></a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="pad0" style="padding:0 64px 0 0;"><h1 style="margin:0 0 16px 0;">Final receipt.</h1></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="right" style="padding: 0 0 40px 0;"><img alt="" src="https://img.cdn4dd.com/s/convenience/images/adjustments_eml_grocery.png" width="380" style="width:100%;max-width:380px;"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="pad8" style="padding: 0 40px 40px;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" id="Basic000" role="presentation" style="width:100%;border-radius:25px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="pad24" style="padding:40px 40px 0;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 32px 0;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 24px 0;"><!--
|
||||||
|
|
||||||
|
--> <p class="p2" style="margin:0 0 4px 0;">Paid with MasterCard Ending in 8032 and/or credits</p> <p class="p2" style="margin:0 0 4px 0;">Woolworths</p> <p class="p2" style="margin:0 0 4px 0;"></p> <p class="p2"><strong>Total: $60.93</strong></p></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 24px 0;border-bottom:1px solid #C4C4C4;"><h4>Your receipt</h4> <!----> <p class="p2" style="margin:0 0 4px 0;"></p> <!----> <p class="p2"><a href="" style="color:#191919;text-decoration:none;">19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia</a></p></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!---->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 32px 0;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left"><h4>Items you ordered</h4></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 16px 0;border-bottom:1px solid #C4C4C4;"><!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Woolworths Corned Beef Silverside (each)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $15.54 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" align="left" class="grayCopy"><p class="para-md" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 14px; line-height: 18px; margin: 0 0 4px 0;color: #767676;">$11.50/kg • Purchased 1.351 kg</p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>2x</strong> Jalapeno Chilli (1 ea)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $2.30 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>2x</strong> Coca-Cola Zero Sugar Soft Drink Bottle (2 L)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $9.00 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Kewpie Sriracha Mayonnaise (300 g)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $6.11 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Woolworths Angus Quarter Pound Beef Burgers (454 g × 4 pk)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $9.00 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Green Capsicum (1 ea)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $1.40 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Brioche Gourmet Sesame Brioche Burger Buns (4 pk)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $7.30 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>2x</strong> Hass Avocado</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $4.10 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Helga's Light Rye Bread (680 g)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $5.95 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Red Capsicum (1 ea)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $1.40 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Garlic Head</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $2.15 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----> <!-- -->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 16px 0 0 0; border-bottom: none;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!-- --> <!-- -->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<!--
|
||||||
|
|
||||||
|
-->
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Lee Kum Kee Premium Dark Soy Sauce (250 ml)</p></td>
|
||||||
|
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;"> $3.65 </p></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!-- --> <!-- -->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!----></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!---->
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 32px 0;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 24px 0;border-bottom:1px solid #C4C4C4;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left"><p class="p2">Subtotal</p></td>
|
||||||
|
<td align="right" valign="bottom"><p class="p2">$67.90</p></td>
|
||||||
|
</tr> <!----> <!---->
|
||||||
|
<tr>
|
||||||
|
<td align="left"><p class="p2">Bag Fee</p></td>
|
||||||
|
<td align="right" valign="top"><p class="p2">$0.50</p></td>
|
||||||
|
</tr> <!----> <!----> <!---->
|
||||||
|
<tr>
|
||||||
|
<td align="left"><p class="p2">Tax</p></td>
|
||||||
|
<td align="right" valign="top"><p class="p2">$0.00</p></td>
|
||||||
|
</tr> <!----> <!----> <!---->
|
||||||
|
<tr>
|
||||||
|
<td align="left"><p class="p2">Delivery fee</p></td>
|
||||||
|
<td align="right" valign="top"><p class="p2">$0.00</p></td>
|
||||||
|
</tr> <!----> <!---->
|
||||||
|
<tr>
|
||||||
|
<td align="left"><p class="p2">Service fee</p></td>
|
||||||
|
<td align="right" valign="top"><p class="p2">$6.11</p></td>
|
||||||
|
</tr> <!----> <!---->
|
||||||
|
<tr>
|
||||||
|
<td align="left"><p class="p2">Dasher tip</p></td>
|
||||||
|
<td align="right" valign="top"><p class="p2">$0.00</p></td>
|
||||||
|
</tr> <!----> <!---->
|
||||||
|
<tr>
|
||||||
|
<td align="left"><p class="p2">Discount</p></td>
|
||||||
|
<td align="right" valign="top"><p class="p2">-$13.58</p></td>
|
||||||
|
</tr> <!----> <!----> <!----> <!---->
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left"><p class="p1" style="font-weight:700;">Final total charged</p></td>
|
||||||
|
<td align="right" valign="bottom"><p class="p1" style="font-weight:700;">$60.93</p></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:16px 0 24px 0;">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left"><!----> <!----> <!----> <p class="p4" style="margin:0 0 16px;font-weight:400;">This email confirms revisions made to your original DoorDash order and reflects the final amount charged. The new total cost of your order is above and includes all taxes and fees. Payment processing adjustments to the original charge may take up to 5-7 business days to process.</p> <!----> <p class="p2" style="margin:0 0 16px;"><a class="Red200" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiXhYhQ4ra2libvRVipt1sfv4E-2FKwlbgukGbMndo1ZsJhMUiuAxeGb86p06pmk6V-2FQsCkNG42P38jfRm6zgoux4YpHAP_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dTef2rlCVIu36es-2FxRXYDPOLHXG7qitIbeuwICQOtiAiJyF0XW5mYCg4EenXQa5FiPOrlqnzXCXafNC5mlJGvxOlPnGz4KG6-2FibY4F4HrGTkxRoVpyf9CEBWQB3h4B6-2F1vV34umJj7-2FLlLpcjkRso-2FZrw9zD-2FDCCnEzAJ9b7RCYUIm4jyKucNAH6Fj2kRAnALL6-2FL0QY3vFFztXGwYqrzLSreyQBjHZ9aQwUSam7UJYCAaq48Y50TUgS9EuM1bSw4Twf5p6fp3H4FoRGPMueME-2FSTdpWyR6zn70BPVqAFezHiorOMilHHsOq6nk1-2FqORI-2F0veiu3RW2zrCwnVrabBGigtiGnhurr7p9nOs1AjdyZa-2Fy5Uh8bX3CPB4wzCMXE7O-2FOmAxPQkFoB0-2BgXl8YJlxRNDOaueR-2BgqPA2uYZxr4-2BQtI4L0YynseA3NMscHGJNGN88RJHrH4CqPbPBkbpXelO-2Ftz4qkPn2adhNtmCgMrl40rFinK7NkUBQ-2Fl8tQtwvq8JTmf6gxu6dY07cNv4deM6rPu-2BQ-2FvHH4q00LtMFjDuuQ3oUFkbnbQHKhXkIUv3eSN4o7Kl6vrZHuHXulUntzFpaf0-2BlkucXK2YwHzC7HliOAyEF6-2FBa1FHUj7-2B2nt5St" target="_blank" style="font-weight:700;text-decoration:none;" universal="true">Get Order Help</a></p></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="full" id="Footer" role="presentation" style="width:700px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td valign="top" align="center" style="padding:0 0 24px 0;"><!--[if (gte mso 9)|(IE)]>
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:620px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<![endif]-->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" id="Footer" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 700px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 0 24px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width:100%;max-width:572px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 48px 0;border-bottom: 1px solid #E7E7E7;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="color: #191919; padding: 32px 0 16px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;">©2026 <a style="color: #9A9A9A; text-decoration: none;">DoorDash Technologies Australia Pty Ltd <br>401 Collins St. <br>Melbourne, VIC 3000 Australia</a></p> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYAqF1KGCKTTTznL6MvOfulOsmFa2kqsuG7LjgY0AllZKBKWegcAPOx5sr25l15pnWbLZL6VnVCDFc3hEnP4sDnr_qFX_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dTef2rlCVIu36es-2FxRXYDPOLHXG7qitIbeuwICQOtiAiJyF0XW5mYCg4EenXQa5FiPOrlqnzXCXafNC5mlJGvxOlPnGz4KG6-2FibY4F4HrGTkxRoVpyf9CEBWQB3h4B6-2F1vV34umJj7-2FLlLpcjkRso-2FZrw9zD-2FDCCnEzAJ9b7RCYUIm4jyKucNAH6Fj2kRAnALL6-2FL0QY3vFFztXGwYqrzLSreyQBjHZ9aQwUSam7UJYCAaq48Y50TUgS9EuM1bSw4Twf5p6fp3H4FoRGPMueME-2FSTdpWyR6zn70BPVqAFezHiorOMilHHsOq6nk1-2FqORI-2F0veiu3RW2zrCwnVrabBGigtiGnhurr7p9nOs1AjdyZa-2Fy5Uh8bX3CPB4wzCMXE7O-2FOmAxPQkFoB0-2BgXl8YJlxRNDOaueR-2BgqPA2uYZxr4-2BQtI4L0YynseA3NMscHGJNGN88RJHrH4CqPbPBkbpXelO-2Ftz4qkPn2adhNtmCgMrl47hiO5jAJKuIZrUFfimDnpntNrXHRtriBc5B6j34phPs9t-2FTsNtprBO5gereYjazskrJGz4kTq383Phk6ev5l-2FwkKCZAvVCUn589YJbi-2FoyqrxbStev5iE7PJ2Us6bPgQpdvcUQSEkpYDzIyI-2Blcws" target="_blank" style="color: #9A9A9A; text-decoration: none;">Privacy Policy</a></p></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="color: #191919; padding: 0 0 24px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:500;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYABnncKv1afIihr5Gt6bkMjesVR_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dTef2rlCVIu36es-2FxRXYDPOLHXG7qitIbeuwICQOtiAiJyF0XW5mYCg4EenXQa5FiPOrlqnzXCXafNC5mlJGvxOlPnGz4KG6-2FibY4F4HrGTkxRoVpyf9CEBWQB3h4B6-2F1vV34umJj7-2FLlLpcjkRso-2FZrw9zD-2FDCCnEzAJ9b7RCYUIm4jyKucNAH6Fj2kRAnALL6-2FL0QY3vFFztXGwYqrzLSreyQBjHZ9aQwUSam7UJYCAaq48Y50TUgS9EuM1bSw4Twf5p6fp3H4FoRGPMueME-2FSTdpWyR6zn70BPVqAFezHiorOMilHHsOq6nk1-2FqORI-2F0veiu3RW2zrCwnVrabBGigtiGnhurr7p9nOs1AjdyZa-2Fy5Uh8bX3CPB4wzCMXE7O-2FOmAxPQkFoB0-2BgXl8YJlxRNDOaueR-2BgqPA2uYZxr4-2BQtI4L0YynseA3NMscHGJNGN88RJHrH4CqPbPBkbpXelO-2Ftz4qkPn2adhNtmCgMrl4Xvmy9Ho7hVCvflBaTaT7GIPK-2Bx-2FVY0eWPUYOIl9ze-2FigbiVg7zeZtEGGvUnrFsPyYMZvNqAmnc5awn0lwnuo6T2MwPVn7itLe2lk679GxtX4NcwYfkPFJXWYwEqreqmpCX0cU2u5EQKvkeFL5tyNJ" target="_blank" style="color:#9A9A9A;text-decoration:none;">Help Center</a></p></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]--></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<img src="https://tracksg.doordash.com/wf/open?upn=u001.EBL2ug8kstebd25Xirrl3olMckTI261ldPjJ39bNHcC7U6EKGAOA4dSVB97lcv3b8qsQRq6LwkHED6F3X4gqaFe59-2BHz9QfrDL7jp-2BN3jX4WgiXvlTBIakpalIO3Jdse0BvY-2BpzLRd3B7D76PRNnOdtT8H1IzuhLRJrs8Ejc-2BQ4LU1ObPOGMj6kj4yuf9wvF-2BNU8yHnNyO0aMIUyf7Vw-2FvxkWQyIhy6jsNc64aO9QhWMEzgvfqcwerkGC0infSMLleveIQg1DJBFCa1l1sbtqm8v4JdVcD3tIswbkSwUBel2KQ6xwvitDjmSW4JqAVQjU2bnGfsVtQIUlNm-2FVkiKuTOkUSpSv-2BlmuRSRwJBxspIW-2FHL-2Fizzna0UNWeoxCQEouEyMXVglI9wvbdjxbE2fJhnbSO5BQgiIaJi6Z1Agc9UNZnRJQfmuKx-2BvkOjjEvTyMkvt-2F1GvutzvpeXgVGLKtJPwmIIEYfS4bk0NKmMQHCG0VBFaOwtyWNr32tHHB6clMeOlpiIJ9B-2FV6NK2NOKOn7nF2XPwImzV33xN-2B0bIZZeeDZf0piG4s3LMX9IoAqChWag-2BAHkcHaYx0ULMfGzIdlJCvGzkUFqfvXEkO7EloGLJ7Ir77VX8C-2BK1PF-2Bev7l0-2F1IBaXJUUw185DM9Yga0FDlihJgZGc0DBp8ml0RmuDjeqZPfpMHZgCXSm-2BXpoAOZg8Jjnj0I8Xr3SrabOHhH5IyYeOz-2FFmDwDDEXGh7pvKTOmsNosAQAHnmTCzcE1ABT" alt="" width="1" height="1" border="0" style="height:1px !important;width:1px !important;border-width:0 !important;margin-top:0 !important;margin-bottom:0 !important;margin-right:0 !important;margin-left:0 !important;padding-top:0 !important;padding-bottom:0 !important;padding-right:0 !important;padding-left:0 !important;"/></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml">
|
||||||
|
<head><!--[if gte mso 9]><xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml><![endif]-->
|
||||||
|
<title>DoorDash</title>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0 ">
|
||||||
|
<meta name="format-detection" content="telephone=no">
|
||||||
|
<style type="text/css">body {
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-text-size-adjust: 100%!important;
|
||||||
|
-ms-text-size-adjust: 100%!important;
|
||||||
|
-webkit-font-smoothing: antialiased!important;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
border: 0!important;
|
||||||
|
outline: none!important;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
Margin: 0px!important;
|
||||||
|
Padding: 0px!important;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-table-lspace: 0px;
|
||||||
|
mso-table-rspace: 0px;
|
||||||
|
}
|
||||||
|
td, a, span {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-line-height-rule: exactly;
|
||||||
|
}
|
||||||
|
.ExternalClass * {
|
||||||
|
line-height: 100%;
|
||||||
|
}
|
||||||
|
.em_defaultlink a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a[x-apple-data-detectors], u+.em_body a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
@media only screen and (max-width:667px) {
|
||||||
|
.em_main_table {
|
||||||
|
width: 100%!important;
|
||||||
|
}
|
||||||
|
.em_wrapper {
|
||||||
|
width: 100%!important;
|
||||||
|
}
|
||||||
|
.em_hide {
|
||||||
|
display: none!important;
|
||||||
|
}
|
||||||
|
.em_hauto {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
.em_full_img img {
|
||||||
|
width: 100%!important;
|
||||||
|
height: auto!important;
|
||||||
|
}
|
||||||
|
.em_pad1 {
|
||||||
|
padding-right: 10px!important;
|
||||||
|
}
|
||||||
|
.em_hauto {
|
||||||
|
height: auto!important;
|
||||||
|
}
|
||||||
|
.em_side15 {
|
||||||
|
width: 40px!important;
|
||||||
|
}
|
||||||
|
.em_h20 {
|
||||||
|
height: 40px!important;
|
||||||
|
font-size: 1px!important;
|
||||||
|
line-height: 1px!important;
|
||||||
|
}
|
||||||
|
.em_h10 {
|
||||||
|
height: 10px!important;
|
||||||
|
font-size: 1px!important;
|
||||||
|
line-height: 1px!important;
|
||||||
|
}
|
||||||
|
.em_h30 {
|
||||||
|
height: 30px!important;
|
||||||
|
}
|
||||||
|
u+.em_body .em_full_wrap {
|
||||||
|
width: 100%!important;
|
||||||
|
width: 100vw!important;
|
||||||
|
}
|
||||||
|
.em_side30 {
|
||||||
|
width: 26px!important;
|
||||||
|
}
|
||||||
|
.em_cta {
|
||||||
|
width: 190px !important;
|
||||||
|
height: 40px!important;
|
||||||
|
}
|
||||||
|
.em_cta a {
|
||||||
|
font-size: 17px !important;
|
||||||
|
line-height: 40px!important;
|
||||||
|
}
|
||||||
|
.em_h90 {
|
||||||
|
height: 140px !important;
|
||||||
|
}
|
||||||
|
.em_font_58 {
|
||||||
|
font-size: 40px!important;
|
||||||
|
line-height: 44px!important;
|
||||||
|
}
|
||||||
|
.em_pad1 {
|
||||||
|
padding: 0px 15px !important;
|
||||||
|
}
|
||||||
|
.en_icon {
|
||||||
|
width: 30px !important;
|
||||||
|
padding-bottom:10px !important;
|
||||||
|
}
|
||||||
|
.em_rounded {
|
||||||
|
border-top-left-radius: 25px !important;
|
||||||
|
border-top-right-radius: 25px !important;
|
||||||
|
}
|
||||||
|
.em_bold {
|
||||||
|
letter-spacing: -1px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (max-width:480px) {
|
||||||
|
.em_side30 {
|
||||||
|
width: 26px!important;
|
||||||
|
}
|
||||||
|
.ft_16 {
|
||||||
|
font-size: 14px!important;
|
||||||
|
line-height: 18px!important;
|
||||||
|
}
|
||||||
|
.em_side15 {
|
||||||
|
width: 40px!important;
|
||||||
|
}
|
||||||
|
.em_font_58 {
|
||||||
|
font-size: 35px!important;
|
||||||
|
line-height: 42px!important;
|
||||||
|
}
|
||||||
|
.em_cta {
|
||||||
|
width: 165px !important;
|
||||||
|
height: 38px!important;
|
||||||
|
}
|
||||||
|
.em_cta a {
|
||||||
|
font-size: 15px !important;
|
||||||
|
line-height: 38px!important;
|
||||||
|
}
|
||||||
|
.em_h90 {
|
||||||
|
height: 105px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (max-width:374px) {
|
||||||
|
.ft_16 {
|
||||||
|
font-size: 12px!important;
|
||||||
|
line-height: 16px!important;
|
||||||
|
}
|
||||||
|
.em_side15 {
|
||||||
|
width: 40px!important;
|
||||||
|
}
|
||||||
|
.em_side30 {
|
||||||
|
width: 20px!important;
|
||||||
|
}
|
||||||
|
.em_font_58 {
|
||||||
|
font-size: 30px!important;
|
||||||
|
line-height: 38px!important;
|
||||||
|
}
|
||||||
|
.em_cta {
|
||||||
|
width: 160px !important;
|
||||||
|
height: 38px!important;
|
||||||
|
}
|
||||||
|
.em_cta a {
|
||||||
|
font-size: 15px !important;
|
||||||
|
line-height: 38px!important;
|
||||||
|
}
|
||||||
|
.em_h90 {
|
||||||
|
height: 95px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen {
|
||||||
|
@font-face {
|
||||||
|
font-family: 'TTNorms-Regular';
|
||||||
|
src: url('https://typography.doordash.com/TTNorms-Regular.woff') format('woff'), url('https://typography.doordash.com/TTNorms-Regular.ttf') format('truetype');
|
||||||
|
font-weight: normal !important;
|
||||||
|
font-style: normal !important;
|
||||||
|
mso-font-alt: 'Arial'
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'TTNorms-Medium';
|
||||||
|
src: url('https://typography.doordash.com/TTNorms-Medium.woff') format('woff'), url('https://typography.doordash.com/TTNorms-Medium.ttf') format('truetype');
|
||||||
|
font-weight: normal !important;
|
||||||
|
font-style: normal !important;
|
||||||
|
mso-font-alt: 'Arial'
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'TTNorms-Bold';
|
||||||
|
src: url('https://typography.doordash.com/TTNorms-Bold.woff') format('woff'), url('https://typography.doordash.com/TTNorms-Bold.ttf') format('truetype');
|
||||||
|
font-weight: normal !important;
|
||||||
|
font-style: normal !important;
|
||||||
|
mso-font-alt: 'Arial'
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'TTNorms-ExtraBold';
|
||||||
|
src: url('https://typography.doordash.com/TTNorms-ExtraBold.woff') format('woff'), url('https://typography.doordash.com/TTNorms-ExtraBold.ttf') format('truetype');
|
||||||
|
font-weight: normal !important;
|
||||||
|
font-style: normal !important;
|
||||||
|
mso-font-alt: 'Arial'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body bgcolor="#ffffff" class="em_body" data-gr-c-s-loaded="true" style="margin:0px auto; padding:0px;">
|
||||||
|
<span style="color:transparent;visibility:hidden;display:none;opacity:0;height:0;width:0;font-size:0;"></span> <!-- == Body Section == -->
|
||||||
|
<table bgcolor="#ffffff" border="0" cellpadding="0" cellspacing="0" class="em_full_wrap" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="em_main_table" style="width:700px;" width="700">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top"><!---->
|
||||||
|
<table align="center" bgcolor="#ededed" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h30" height="62" style="height:62px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr> <!-- banner Section -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#ededed" class="em_hauto" valign="top"><!---->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="em_side30" style="width:60px;" width="60"></td>
|
||||||
|
<td align="center" class="em_hauto" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top"><a style="text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAEYEgU_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniCObtF-2F77Rftwzxtvch3D6ixsFu8SYfu5xBceQTH8-2FBodTZxiewjgPzbEdFl3FPmC8yd8up2svP69bkXzPP1EgX9eD-2FyUn-2FEnfP2x2wq7ZOBUoARCeqqlFkP6W7Y5dGla8nNiQlX7AucOtqmpWXoN748jOggntsFT6RKIRx0tqjjl8YZ9wNtw1lwTOb8kFmOgsOcVVtLje8EbuOzP3zqIlcuQWgqqYb-2BV3lOeDIxWBtKbxqWC5a-2FCRgVj5lY1Bv7imoZhcA0AGsBJcII2E70CeIU8WfbTYM4M37ZPgsh2cLUb3V4Aejx-2FWVUWAWH2KDEs5dv-2B4PKgCJ8Acse3h-2BQekWDD3i77HE0-2BDrWvpiQ0PqKJUExxn5P-2FL0klhkr7BaNj8Rk-2F1Chu5ZAE5-2BwUiki7JUuLsWW9OOERb4e0qR3cSg4LijaO28HYiLjXFPAYOjdZeQOd-2FBnPtUPMDVpU9gYUKfSgsagIJguyAqpdjLfY6G8VVr86jIoxtluUxhWGTXIjaVcoCCZMHwuVy8TqEvwJR2SqSwWKk68wYn83-2BGVzp0Q96Il8ghSJk4VqSWMBaTqTd0KosKTV3a2OQBYPn6Cz3rrGJtTstNW0ohwZ27PImy8rSHU9BerWxzrGv35FfsQbw-3D" universal="true"><img alt="DOORDASH" border="0" class="en_icon" style="display:block; max-width:45px;font-family:Arial, sans-serif;font-size:20px; line-height:30px; color:#ee3623; font-weight:bold;" width="45" src="https://assets.doordash.team/m/835d1d775f776ef/original/-04_April-MX_Winback_Campaign-logo_img.png"> </a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="50" style="height:50px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink em_font_58 em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:51px; line-height:60px; font-weight: bold;" valign="top"><!---->Thanks for your<br> order, Siddharth<!----></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h10" height="20" style="height:20px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#000000;font-size:16px; line-height:20px;" valign="top"><!---->The estimated delivery time for your order<br class="em_hide"> is <strong>12:56 pm - 1:06 pm</strong>. Track your order in<br class="em_hide"> the DoorDash app or website.<!----></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h10" height="20" style="height:20px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" valign="top">
|
||||||
|
<table align="left" border="0" cellpadding="0" cellspacing="0" class="em_cta" style="width:220px; max-width:220px;" width="220">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" class="em_defaultlink em_cta em_bold" height="45" style="font-family:'TTNorms-Bold', Arial, sans-serif;color:#ffffff;font-size:18px; background-color:#eb1700; border-radius:25px; font-weight: bold; " valign="middle"><a style="text-decoration:none; display:block; color:#ffffff; line-height:45px;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiVnjyPy8cqQ7jo-2BNF2Wc6ScBomnSDcwnk7ZiV0pRP-2F7pg2J7U-2BGioomJQf-2FXVlW43g-3Dvyw__gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniCObtF-2F77Rftwzxtvch3D6ixsFu8SYfu5xBceQTH8-2FBodTZxiewjgPzbEdFl3FPmC8yd8up2svP69bkXzPP1EgX9eD-2FyUn-2FEnfP2x2wq7ZOBUoARCeqqlFkP6W7Y5dGla8nNiQlX7AucOtqmpWXoN748jOggntsFT6RKIRx0tqjjl8YZ9wNtw1lwTOb8kFmOgsOcVVtLje8EbuOzP3zqIlcuQWgqqYb-2BV3lOeDIxWBtKbxqWC5a-2FCRgVj5lY1Bv7imoZhcA0AGsBJcII2E70CeIU8WfbTYM4M37ZPgsh2cLUb3V4Aejx-2FWVUWAWH2KDEs5dv-2B4PKgCJ8Acse3h-2BQekWDD3i77HE0-2BDrWvpiQ0PqKJUExxn5P-2FL0klhkr7BaNj8Rk-2F1Chu5ZAE5-2BwUiki7JUuLsWW9OOERb4e0qR3cSg4LijaO28HYiLjXFPAYOjdZeQOd-2FBnPtUPMDVpU9gYUKfSgsagIJguyAqpdjLfY6G8c11Xrs5mrwxYvXOjM-2B31YOhRV-2FVn8biClWfjAQE-2FFKhuO4Omylt1Br9cDjiGoEk5b7gncfkgZItoZ0uVSEVcQFQl4dKCCcWTsNznKpdwNr7hzrx4kK9eLK5xE8TC9mvmLuZcj8FiuGDz2TIKo2aVMk-3D" universal="true">Track Your Order</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
<td class="em_side15" style="width:20px;" width="20"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!----><!----><!--Illustration 2-->
|
||||||
|
<tr>
|
||||||
|
<td align="center" class="em_full_img" valign="top"><a style="text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAEvLxo_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniCObtF-2F77Rftwzxtvch3D6ixsFu8SYfu5xBceQTH8-2FBodTZxiewjgPzbEdFl3FPmC8yd8up2svP69bkXzPP1EgX9eD-2FyUn-2FEnfP2x2wq7ZOBUoARCeqqlFkP6W7Y5dGla8nNiQlX7AucOtqmpWXoN748jOggntsFT6RKIRx0tqjjl8YZ9wNtw1lwTOb8kFmOgsOcVVtLje8EbuOzP3zqIlcuQWgqqYb-2BV3lOeDIxWBtKbxqWC5a-2FCRgVj5lY1Bv7imoZhcA0AGsBJcII2E70CeIU8WfbTYM4M37ZPgsh2cLUb3V4Aejx-2FWVUWAWH2KDEs5dv-2B4PKgCJ8Acse3h-2BQekWDD3i77HE0-2BDrWvpiQ0PqKJUExxn5P-2FL0klhkr7BaNj8Rk-2F1Chu5ZAE5-2BwUiki7JUuLsWW9OOERb4e0qR3cSg4LijaO28HYiLjXFPAYOjdZeQOd-2FBnPtUPMDVpU9gYUKfSgsagIJguyAqpdjLfY6G8WRCD-2FqRcT3qYaTZhzIDIiyJa5PtObUnCjY4SczlOveif0uLWX8gst2iXvDBVuJze7WmSiLEzmN-2Bdnt8ToATmeJperQEu3d9WnvS8-2BknB4MEeK-2BwIQhdsw6X9lhJzohufq-2BYaSEg2vrnOSJed7mMlcQ-3D" universal="true"><img alt="" border="0" class="em_full_img" style="display:block; max-width:700px; font-family:Arial, sans-serif; font-size:22px; line-height:25px; color:#ffffff; font-weight:bold;" width="700" src="https://assets.doordash.team/m/188a590491f6c3c9/original/-template-OrderConfirmation-dancingfood.png"></a></td>
|
||||||
|
</tr> <!--//Illustration 2--><!----><!----><!-- //banner Section -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:6%;" width="6%"></td>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" class="em_rounded" style="border-top-left-radius: 40px; border-top-right-radius: 40px; background-color: #ffffff;" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="50" style="height:50px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px;" valign="top">Paid with MasterCard Ending in 8032<br> Subway</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px; font-weight:bold; color:#000000;" valign="top">Total: $29.08</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="35" style="height:35px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:26px; line-height:36px; font-weight: bold;" valign="top">Your receipt</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:21px;" valign="top">19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="45" style="height:45px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px;" valign="top"><font size="2" color="#666666"><b>- For: Siddharth Bose -</b></font><br><br>
|
||||||
|
<table width="100%" style="margin: auto; margin-bottom: 20px">
|
||||||
|
<tbody>
|
||||||
|
<tr style="text-align: left;">
|
||||||
|
<td valign="top" width="10%" style="color: #666666; font-size: 18px; line-height: 24px">1x</td>
|
||||||
|
<td valign="top" width="75%" style="color: #666666; font-size: 18px; line-height: 24px"><b>Italian B.M.T.®</b> (All Subs)<br><font color="dimgrey">• Subway Footlong ®</font><br><font color="dimgrey">• Italian Herb & Cheese Bread</font><br><font color="dimgrey">• Toasted</font><br><font color="dimgrey">• Old English Style Cheese</font><br><font color="dimgrey">• Double Cheese</font><br><font color="dimgrey">• Spinach</font><br><font color="dimgrey">• Tomato</font><br><font color="dimgrey">• Cucumber</font><br><font color="dimgrey">• Capsicum</font><br><font color="dimgrey">• Onions</font><br><font color="dimgrey">• Jalapenos</font><br><font color="dimgrey">• Carrots</font><br><font color="dimgrey">• Honey Mustard Sauce</font><br><font color="dimgrey">• Sweet Onion Dressing</font><br><font color="dimgrey">• Pepper</font><br><font color="dimgrey">• Sea Salt</font><br><br></td>
|
||||||
|
<td valign="top" width="15%" style="color: #666666; font-size: 18px; line-height: 24px text-align: right">$19.85</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="text-align: left;">
|
||||||
|
<td valign="top" width="10%" style="color: #666666; font-size: 18px; line-height: 24px">1x</td>
|
||||||
|
<td valign="top" width="75%" style="color: #666666; font-size: 18px; line-height: 24px"><b>Italian Meatball</b> (All Subs)<br><font color="dimgrey">• Subway 6-Inch ®</font><br><font color="dimgrey">• Italian Herb & Cheese Bread</font><br><font color="dimgrey">• Toasted</font><br><font color="dimgrey">• Mozzarella</font><br><font color="dimgrey">• Double Meat (Selected Meat only)</font><br><font color="dimgrey">• Double Cheese</font><br><font color="dimgrey">• Cucumber</font><br><font color="dimgrey">• Pickles</font><br><font color="dimgrey">• Capsicum</font><br><font color="dimgrey">• Onions</font><br><font color="dimgrey">• Ranch Dressing</font><br><font color="dimgrey">• Garlic Aioli</font><br><font color="dimgrey">• Pepper</font><br><font color="dimgrey">• Sea Salt</font><br><br></td>
|
||||||
|
<td valign="top" width="15%" style="color: #666666; font-size: 18px; line-height: 24px text-align: right">$16.00</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="22" style="height:22px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#ffffff" class="em_pad1" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="em_wrapper" style="width:530px;" width="530">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#e5e5e5" height="2" style="line-height:0px; font-size:0px; height: 2px;"><img alt="" border="0" height="1" style="display:block;" width="1" src="https://assets.doordash.team/m/1b5c04bd5b887a06/original/-05_May-90D_Resurrection_Campaign_Refresh_T2-spacer.gif"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="background-color: #ffffff;" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="10" style="height:10px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr><!---->
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Subtotal</td> <!---->
|
||||||
|
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$35.85</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr><!---->
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Taxes</td> <!---->
|
||||||
|
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$0.00</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!----><!---->
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Delivery Fee</td>
|
||||||
|
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$0.00</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Service Fee</td>
|
||||||
|
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$3.23</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Tip</td>
|
||||||
|
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$0.00</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!---->
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Discounts</td>
|
||||||
|
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">-$26.02</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!---->
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="18" style="height:18px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#ffffff" class="em_pad1" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" class="em_wrapper" style="width:530px;" width="530">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#e5e5e5" height="2" style="line-height:0px; font-size:0px; height: 2px;"><img alt="" border="0" height="1" style="display:block;" width="1" src="https://assets.doordash.team/m/1b5c04bd5b887a06/original/-05_May-90D_Resurrection_Campaign_Refresh_T2-spacer.gif"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="background-color: #ffffff;" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="12" style="height:12px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr><!---->
|
||||||
|
<td align="left" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:17px; line-height:24px; font-weight: bold;" valign="top">Total Charged</td> <!---->
|
||||||
|
<td align="right" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:17px; line-height:24px; font-weight: bold;" valign="top">$29.08</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr> <!----><!----><!----><!----><!----><!----><!----> <!---->
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="15" style="height:15px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#ff2f07;font-size:14px; line-height:21px; font-weight: bold;" valign="top"><a style="color:#ff2f07; text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiVnjyPy8cqQ7jo-2BNF2Wc6ScBomnSDcwnk7ZiV0pRP-2F7pg2J7U-2BGioomJQf-2FXVlW43g-3D6wBj_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniCObtF-2F77Rftwzxtvch3D6ixsFu8SYfu5xBceQTH8-2FBodTZxiewjgPzbEdFl3FPmC8yd8up2svP69bkXzPP1EgX9eD-2FyUn-2FEnfP2x2wq7ZOBUoARCeqqlFkP6W7Y5dGla8nNiQlX7AucOtqmpWXoN748jOggntsFT6RKIRx0tqjjl8YZ9wNtw1lwTOb8kFmOgsOcVVtLje8EbuOzP3zqIlcuQWgqqYb-2BV3lOeDIxWBtKbxqWC5a-2FCRgVj5lY1Bv7imoZhcA0AGsBJcII2E70CeIU8WfbTYM4M37ZPgsh2cLUb3V4Aejx-2FWVUWAWH2KDEs5dv-2B4PKgCJ8Acse3h-2BQekWDD3i77HE0-2BDrWvpiQ0PqKJUExxn5P-2FL0klhkr7BaNj8Rk-2F1Chu5ZAE5-2BwUiki7JUuLsWW9OOERb4e0qR3cSg4LijaO28HYiLjXFPAYOjdZeQOd-2FBnPtUPMDVpU9gYUKfSgsagIJguyAqpdjLfY6G8bOKlnu7vSFB-2BIQ-2FSa0IlYx-2F9RNR0nsTokQzCNSqlpkomb9IWoemjq9we5J1OThMwqfb3jx6ATLRRe0hZPiX7urz31ND8Ku6OkcqBpF9xCGYZ-2B8b-2FruvvcHPSbTGjapxW5zZugpMbHsegIXXCMVBkNg-3D" universal="true">Get Order Help</a></td>
|
||||||
|
</tr> <!-- -->
|
||||||
|
<tr>
|
||||||
|
<td class="em_h20" height="58" style="height:58px; line-height:0px; font-size:0px;"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!-- == //Body Section == --><!-- == Footer Section == --><!-- == //Footer Section == --></td>
|
||||||
|
<td class="em_side15" style="width: 40px;" width="40"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
<td style="width:6%;" width="6%"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top"><!--[if (gte mso 9)|(IE)]>
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:620px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<![endif]-->
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" id="Footer" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 700px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 0 24px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width:100%;max-width:572px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding:0 0 48px 0;border-bottom: 1px solid #E7E7E7;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="color: #191919; padding: 32px 0 16px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;">©2026 <a style="color: #9A9A9A; text-decoration: none;">DoorDash Technologies Australia Pty Ltd <br>401 Collins St. <br>Melbourne, VIC 3000 Australia</a></p> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYAqF1KGCKTTTznL6MvOfulOsmFa2kqsuG7LjgY0AllZKBKWegcAPOx5sr25l15pnWbLZL6VnVCDFc3hEnP4sDnrBpZV_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniCObtF-2F77Rftwzxtvch3D6ixsFu8SYfu5xBceQTH8-2FBodTZxiewjgPzbEdFl3FPmC8yd8up2svP69bkXzPP1EgX9eD-2FyUn-2FEnfP2x2wq7ZOBUoARCeqqlFkP6W7Y5dGla8nNiQlX7AucOtqmpWXoN748jOggntsFT6RKIRx0tqjjl8YZ9wNtw1lwTOb8kFmOgsOcVVtLje8EbuOzP3zqIlcuQWgqqYb-2BV3lOeDIxWBtKbxqWC5a-2FCRgVj5lY1Bv7imoZhcA0AGsBJcII2E70CeIU8WfbTYM4M37ZPgsh2cLUb3V4Aejx-2FWVUWAWH2KDEs5dv-2B4PKgCJ8Acse3h-2BQekWDD3i77HE0-2BDrWvpiQ0PqKJUExxn5P-2FL0klhkr7BaNj8Rk-2F1Chu5ZAE5-2BwUiki7JUuLsWW9OOERb4e0qR3cSg4LijaO28HYiLjXFPAYOjdZeQOd-2FBnPtUPMDVpU9gYUKfSgsagIJguyAqpdjLfY6G8Q8l21R-2BUnujFyqLh7dgRALyW1uXR-2FN8S0g00dvJLLfcmlzcQZxW44cVyDjeLcQdT7oXKMmfaQcqjpUqJASXPTpcyojzmAR7AJNx0XL1wVst-2FulVzDY1gZlfxA5V-2BU-2F-2B4tKqztf0gKzb-2BVk9xtu1bac-3D" target="_blank" style="color: #9A9A9A; text-decoration: none;">Privacy Policy</a></p></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="color: #191919; padding: 0 0 24px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:500;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYABnncKv1afIihr5Gt6bkMjMbSD_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniCObtF-2F77Rftwzxtvch3D6ixsFu8SYfu5xBceQTH8-2FBodTZxiewjgPzbEdFl3FPmC8yd8up2svP69bkXzPP1EgX9eD-2FyUn-2FEnfP2x2wq7ZOBUoARCeqqlFkP6W7Y5dGla8nNiQlX7AucOtqmpWXoN748jOggntsFT6RKIRx0tqjjl8YZ9wNtw1lwTOb8kFmOgsOcVVtLje8EbuOzP3zqIlcuQWgqqYb-2BV3lOeDIxWBtKbxqWC5a-2FCRgVj5lY1Bv7imoZhcA0AGsBJcII2E70CeIU8WfbTYM4M37ZPgsh2cLUb3V4Aejx-2FWVUWAWH2KDEs5dv-2B4PKgCJ8Acse3h-2BQekWDD3i77HE0-2BDrWvpiQ0PqKJUExxn5P-2FL0klhkr7BaNj8Rk-2F1Chu5ZAE5-2BwUiki7JUuLsWW9OOERb4e0qR3cSg4LijaO28HYiLjXFPAYOjdZeQOd-2FBnPtUPMDVpU9gYUKfSgsagIJguyAqpdjLfY6G8aanhk3H7M0AmyPIeD-2FSytmK5nE5elBgAZcjX3JsIKz28vuZG4HM2bl-2FxI9FZWifPR-2FNBQ-2FOmTQqLo0YK3jcUZRxpOUh8EYeWnkDHAf9CUnJYhfBCXrPHy8-2BIouWsFADv6rViHCrwIlJq7PKIdjhsQU-3D" target="_blank" style="color:#9A9A9A;text-decoration:none;">Help Center</a></p></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table> <!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]--></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="em_hide" style="line-height:1px;min-width:700px;background-color:#f4f4f4;"><img alt="" border="0" height="1" style="max-height:1px; min-height:1px; display:block; width:700px; min-width:700px;" width="700" src="https://assets.doordash.team/m/1b5c04bd5b887a06/original/-05_May-90D_Resurrection_Campaign_Refresh_T2-spacer.gif"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<img src="https://tracksg.doordash.com/wf/open?upn=u001.EBL2ug8kstebd25Xirrl3olMckTI261ldPjJ39bNHcC7U6EKGAOA4dSVB97lcv3b8qsQRq6LwkHED6F3X4gqaODB2b1wZFk4or4JbrThItr6lxxhe0ghxdjbmj40V9NR2q6sVlDPfArvtaJbtCejz-2BJxmNDpj6cBZjf16jHN3-2B0iC1V4tFBTMLc-2BJwfmofzVTZ93Zr38yBFeE9QeTx2HIPl8aahBdGSAhck5E0FopTVrs3ftPac-2BjBZQ-2F-2BOtO9AAatRdgybtYfdgGvULGP9PgScKi7bqhiEsMHtYK5BiFCtgHDd3wSmmSQEmLY5CxOdBaxC6hncHaAEfQICV5PV5RYoWJUn59AMV6iCj-2FAzsFmZDmbIos3SveyBNTIRxoQ9cPRYnGUJvcAO2C-2FN1QxbjuL3Y8ELK5gWiOUqwAeJaoIqwImrrWGzkrtF6Iu6lpAYw2yIml9u8FnfV-2BNo6dypylz5AbWbO4wm5AnTe6e7WmglLSPh-2FBr7RLUmgVl05JkvNjJmFrJUL-2FOA3VD8xCUTqdI8CiSkwhJgeP4PUUSKU6CPRXiEhpXbnj4JBARqbiZEvMCv3cChoIFQg20r6bMddokffgXp-2BiFWCvNIrp4WNUFzUegA1y-2FApKsMQemjXy5KM5DhVuyriQDcI1uKZbNH2L4o9QR7SSsEbRXbj-2F5-2FuRPshsLYfjHnGoPt11ACguPrpPaB5Kmke1gWd0UvceKwobel8MH3YRSgU2gvLJEJ000ZGfHr9Mj0j30BzHFnFaYfPzegN2Y2l5rXh6a3zR9Dsv0mUWU0mjRXrYJif2o5NEVM-3D" alt="" width="1" height="1" border="0" style="height:1px !important;width:1px !important;border-width:0 !important;margin-top:0 !important;margin-bottom:0 !important;margin-right:0 !important;margin-left:0 !important;padding-top:0 !important;padding-bottom:0 !important;padding-right:0 !important;padding-left:0 !important;"/></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "19f89664ea7b3aeb",
|
||||||
|
"date": "Date: Wed, 22 Jul 2026 10:36:50 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Homey Meals",
|
||||||
|
"len": 38394
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19f68fd09cd2c3f1",
|
||||||
|
"date": "Date: Thu, 16 Jul 2026 03:34:00 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Mad Mex",
|
||||||
|
"len": 37448
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19f656a298f42858",
|
||||||
|
"date": "Date: Wed, 15 Jul 2026 10:54:42 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Carl's Jr.",
|
||||||
|
"len": 37970
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19f605de9ab82216",
|
||||||
|
"date": "Date: Tue, 14 Jul 2026 11:23:14 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Delhi Nights - Sweets & Indian\r\n Cuisine",
|
||||||
|
"len": 38707
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19f2b6390a95b665",
|
||||||
|
"date": "Date: Sat, 04 Jul 2026 04:29:32 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from TEG KEBABS & BIRYANI",
|
||||||
|
"len": 38005
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19f2256c51714b9c",
|
||||||
|
"date": "Date: Thu, 02 Jul 2026 10:18:58 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Sri Dwaraka",
|
||||||
|
"len": 37817
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19f1d0b003834626",
|
||||||
|
"date": "Date: Wed, 01 Jul 2026 09:38:07 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Taco Bell",
|
||||||
|
"len": 38019
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19f1c301dda7332c",
|
||||||
|
"date": "Date: Wed, 01 Jul 2026 05:39:01 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Woolworths",
|
||||||
|
"len": 38753
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19f0c5624707ae34",
|
||||||
|
"date": "Date: Sun, 28 Jun 2026 03:46:39 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from ALDI",
|
||||||
|
"len": 68206
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19f0c0d0c33bf5c9",
|
||||||
|
"date": "Date: Sun, 28 Jun 2026 02:26:49 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from ALDI",
|
||||||
|
"len": 47419
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19eed89eee79b624",
|
||||||
|
"date": "Date: Mon, 22 Jun 2026 04:14:59 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Woolworths",
|
||||||
|
"len": 48653
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19eed498e3fb7fce",
|
||||||
|
"date": "Date: Mon, 22 Jun 2026 03:04:39 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Woolworths",
|
||||||
|
"len": 44104
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19eed38ca77047a1",
|
||||||
|
"date": "Date: Mon, 22 Jun 2026 02:46:21 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Oporto",
|
||||||
|
"len": 38675
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19ee9b9b1b7073c9",
|
||||||
|
"date": "Date: Sun, 21 Jun 2026 10:28:39 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||||
|
"len": 37871
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19e95be74e216594",
|
||||||
|
"date": "Date: Fri, 05 Jun 2026 03:05:46 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||||
|
"len": 37817
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19e854cdbf552dcf",
|
||||||
|
"date": "Date: Mon, 01 Jun 2026 22:27:45 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from TOMKINS BAKERY",
|
||||||
|
"len": 37741
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19e8020eb0027109",
|
||||||
|
"date": "Date: Sun, 31 May 2026 22:21:39 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Tonmax Bakery",
|
||||||
|
"len": 37439
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19e16b64b3f6dbad",
|
||||||
|
"date": "Date: Mon, 11 May 2026 11:05:04 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Woolworths",
|
||||||
|
"len": 39750
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19e16b1a2d9526f8",
|
||||||
|
"date": "Date: Mon, 11 May 2026 11:00:00 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Burger Road",
|
||||||
|
"len": 38230
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19e11390ea456222",
|
||||||
|
"date": "Date: Sun, 10 May 2026 09:30:11 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||||
|
"len": 37847
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19cdfa1d25f194a8",
|
||||||
|
"date": "Date: Thu, 12 Mar 2026 01:20:48 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Kesari Indian Kitchen",
|
||||||
|
"len": 37922
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19cc62db0460b8a0",
|
||||||
|
"date": "Date: Sat, 07 Mar 2026 02:43:28 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||||
|
"len": 37884
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19ca1f0fa493f0ca",
|
||||||
|
"date": "Date: Sat, 28 Feb 2026 01:50:49 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||||
|
"len": 37862
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19c9c18ad4bf22e1",
|
||||||
|
"date": "Date: Thu, 26 Feb 2026 22:36:27 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Hungry Jacks",
|
||||||
|
"len": 37596
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19c8ef02c0a0f590",
|
||||||
|
"date": "Date: Tue, 24 Feb 2026 09:17:09 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Schnitz",
|
||||||
|
"len": 38107
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19c6e7cb5565afd5",
|
||||||
|
"date": "Date: Wed, 18 Feb 2026 02:03:12 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||||
|
"len": 37418
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19c4c4b1a0efa1e0",
|
||||||
|
"date": "Date: Wed, 11 Feb 2026 10:41:56 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from lahori fish n tikka",
|
||||||
|
"len": 37166
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19c4013c5a7fac93",
|
||||||
|
"date": "Date: Mon, 09 Feb 2026 01:46:02 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Subway",
|
||||||
|
"len": 39212
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19c30b95bf183a2b",
|
||||||
|
"date": "Date: Fri, 06 Feb 2026 02:12:59 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Souvlaki GR",
|
||||||
|
"len": 37776
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19c30b773199c2fb",
|
||||||
|
"date": "Date: Fri, 06 Feb 2026 02:10:54 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Souvlaki GR",
|
||||||
|
"len": 37309
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19c27f770fae04f2",
|
||||||
|
"date": "Date: Wed, 04 Feb 2026 09:24:13 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||||
|
"len": 37440
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19c21316b26e066f",
|
||||||
|
"date": "Date: Tue, 03 Feb 2026 01:50:11 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Punjabi bai sweets & Indian\r\n cuisine",
|
||||||
|
"len": 39289
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19bf2e0fd25facda",
|
||||||
|
"date": "Date: Sun, 25 Jan 2026 01:59:48 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||||
|
"len": 38432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19bb18096ebd9bf5",
|
||||||
|
"date": "Date: Mon, 12 Jan 2026 09:19:12 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Woolworths",
|
||||||
|
"len": 42464
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19bac527ef5fb1d4",
|
||||||
|
"date": "Date: Sun, 11 Jan 2026 09:10:45 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||||
|
"len": 37833
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "19baad6dd7f80a9f",
|
||||||
|
"date": "Date: Sun, 11 Jan 2026 02:16:04 +0000 (UTC)",
|
||||||
|
"from": "From: DoorDash Order <no-reply@doordash.com>",
|
||||||
|
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
|
||||||
|
"len": 37334
|
||||||
|
}
|
||||||
|
]
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,205 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"i": 0,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkeckxXAAAA",
|
||||||
|
"date": "2026-07-22T03:41:19Z",
|
||||||
|
"subject": "Your Wednesday afternoon order with Uber Eats",
|
||||||
|
"len": 57778
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 1,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkd32TzAAAA",
|
||||||
|
"date": "2026-07-21T04:25:09Z",
|
||||||
|
"subject": "Your Tuesday afternoon order with Uber Eats",
|
||||||
|
"len": 61532
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 2,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkd32TsAAAA",
|
||||||
|
"date": "2026-07-20T23:17:05Z",
|
||||||
|
"subject": "Your Tuesday morning order with Uber Eats",
|
||||||
|
"len": 59110
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 3,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkdK0voAAAA",
|
||||||
|
"date": "2026-07-20T04:23:40Z",
|
||||||
|
"subject": "Your Monday afternoon order with Uber Eats",
|
||||||
|
"len": 66211
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 4,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0aigAAAA",
|
||||||
|
"date": "2026-07-07T15:31:27Z",
|
||||||
|
"subject": "[Family] Your Tuesday evening order with Uber Eats",
|
||||||
|
"len": 80279
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 5,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0aicAAAA",
|
||||||
|
"date": "2026-07-07T08:58:43Z",
|
||||||
|
"subject": "Your Tuesday evening order with Uber Eats",
|
||||||
|
"len": 73592
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 6,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0aibAAAA",
|
||||||
|
"date": "2026-07-07T08:50:06Z",
|
||||||
|
"subject": "[Family] Your Tuesday afternoon order with Uber Eats",
|
||||||
|
"len": 78782
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 7,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0aiaAAAA",
|
||||||
|
"date": "2026-07-07T08:46:17Z",
|
||||||
|
"subject": "Your Tuesday evening order with Uber Eats",
|
||||||
|
"len": 65886
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 8,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0ah6AAAA",
|
||||||
|
"date": "2026-07-05T15:51:11Z",
|
||||||
|
"subject": "[Family] Your Sunday evening order with Uber Eats",
|
||||||
|
"len": 82383
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 9,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0ah5AAAA",
|
||||||
|
"date": "2026-07-05T15:33:01Z",
|
||||||
|
"subject": "[Family] Your Sunday evening order with Uber Eats",
|
||||||
|
"len": 88958
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 10,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0ahTAAAA",
|
||||||
|
"date": "2026-07-03T10:04:15Z",
|
||||||
|
"subject": "Your Friday evening order with Uber Eats",
|
||||||
|
"len": 61485
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 11,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0ahFAAAA",
|
||||||
|
"date": "2026-07-02T22:57:18Z",
|
||||||
|
"subject": "Your Friday morning order with Uber Eats",
|
||||||
|
"len": 60751
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 12,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkOIIcBAAAA",
|
||||||
|
"date": "2026-06-27T04:21:08Z",
|
||||||
|
"subject": "Your Saturday afternoon order with Uber Eats",
|
||||||
|
"len": 60720
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 13,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zVTAAAA",
|
||||||
|
"date": "2026-06-22T10:19:49Z",
|
||||||
|
"subject": "Your Monday evening order with Uber Eats",
|
||||||
|
"len": 58303
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 14,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zUwAAAA",
|
||||||
|
"date": "2026-06-20T12:28:26Z",
|
||||||
|
"subject": "Your Saturday afternoon order with Uber Eats",
|
||||||
|
"len": 60856
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 15,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zURAAAA",
|
||||||
|
"date": "2026-06-18T18:43:06Z",
|
||||||
|
"subject": "Your Thursday evening order with Uber Eats",
|
||||||
|
"len": 57881
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 16,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkGJuBoAAAA",
|
||||||
|
"date": "2026-06-15T19:32:17Z",
|
||||||
|
"subject": "Your Monday evening order with Uber Eats",
|
||||||
|
"len": 60838
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 17,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAj9_fg8AAAA",
|
||||||
|
"date": "2026-06-02T11:27:03Z",
|
||||||
|
"subject": "Your Tuesday evening order with Uber Eats",
|
||||||
|
"len": 55147
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 18,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAj7_L67AAAA",
|
||||||
|
"date": "2026-06-02T03:11:33Z",
|
||||||
|
"subject": "Your Tuesday afternoon order with Uber Eats",
|
||||||
|
"len": 60716
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 19,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAj66TeuAAAA",
|
||||||
|
"date": "2026-05-29T02:47:06Z",
|
||||||
|
"subject": "Your Friday afternoon order with Uber Eats",
|
||||||
|
"len": 55127
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 20,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAj19QCWNwAAAA==",
|
||||||
|
"date": "2026-05-23T03:00:14Z",
|
||||||
|
"subject": "Your Saturday afternoon order with Uber Eats",
|
||||||
|
"len": 65562
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 21,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjzoSJtAAAA",
|
||||||
|
"date": "2026-05-18T10:50:25Z",
|
||||||
|
"subject": "Your Monday evening order with Uber Eats",
|
||||||
|
"len": 68144
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 22,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjy8e6sAAAA",
|
||||||
|
"date": "2026-05-17T03:13:05Z",
|
||||||
|
"subject": "Your Sunday afternoon order with Uber Eats",
|
||||||
|
"len": 62439
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 23,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjw8ZyAAAAA",
|
||||||
|
"date": "2026-05-15T09:45:15Z",
|
||||||
|
"subject": "Your Friday evening order with Uber Eats",
|
||||||
|
"len": 55113
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 24,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjw8ZxwAAAA",
|
||||||
|
"date": "2026-05-14T15:02:24Z",
|
||||||
|
"subject": "Your Thursday afternoon order with Uber Eats",
|
||||||
|
"len": 58231
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 25,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjw8ZxtAAAA",
|
||||||
|
"date": "2026-05-14T11:24:43Z",
|
||||||
|
"subject": "Your Thursday evening order with Uber Eats",
|
||||||
|
"len": 60745
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 26,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjV0eF4AAAA",
|
||||||
|
"date": "2026-04-08T05:54:03Z",
|
||||||
|
"subject": "Your Tuesday evening order with Uber Eats",
|
||||||
|
"len": 68338
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 27,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjV0eFyAAAA",
|
||||||
|
"date": "2026-04-07T22:40:52Z",
|
||||||
|
"subject": "Your Tuesday afternoon order with Uber Eats",
|
||||||
|
"len": 52600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 28,
|
||||||
|
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjV0eFcAAAA",
|
||||||
|
"date": "2026-04-06T05:23:40Z",
|
||||||
|
"subject": "Your Sunday evening order with Uber Eats",
|
||||||
|
"len": 59440
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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,106 @@
|
|||||||
|
import { describe, it, expect, beforeAll } from "vitest";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { resolve } from "path";
|
||||||
|
import { queryRaw } from "../../lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HTTP path had no tests at all — which is how three defects reached the
|
||||||
|
* branch through a suite of 105 green ones. These exercise the route handler
|
||||||
|
* directly (no server needed) so the auth gate and the error taxonomy are
|
||||||
|
* actually covered.
|
||||||
|
*/
|
||||||
|
const dir = resolve(__dirname, "../fixtures/orders/real");
|
||||||
|
const html = (f: string) => readFileSync(resolve(dir, `${f}.html`), "utf-8");
|
||||||
|
|
||||||
|
const TOKEN = "test-ingest-token";
|
||||||
|
let POST: any;
|
||||||
|
|
||||||
|
const req = (body: unknown, token: string | null = TOKEN) =>
|
||||||
|
({
|
||||||
|
headers: { get: (h: string) => (h === "x-ingest-token" ? token : null) },
|
||||||
|
json: async () => body,
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
process.env.ORDER_INGEST_TOKEN = TOKEN;
|
||||||
|
({ POST } = await import("../../app/api/orders/ingest/route"));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ingest API — auth", () => {
|
||||||
|
it("rejects a missing token", async () => {
|
||||||
|
const res = await POST(req({ html: "x", meta: {} }, null));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a wrong token", async () => {
|
||||||
|
const res = await POST(req({ html: "x", meta: {} }, "nope"));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed body", async () => {
|
||||||
|
const res = await POST(req({ html: "only html" }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ingest API — error taxonomy", () => {
|
||||||
|
const meta = (over = {}) => ({
|
||||||
|
messageId: `api-${Math.random().toString(36).slice(2)}`,
|
||||||
|
subject: "Order Confirmation for Siddharth from Mad Mex",
|
||||||
|
receivedAt: "2026-07-16T03:34:00Z",
|
||||||
|
sender: "DoorDash Order <no-reply@doordash.com>",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a non-receipt is 200 and silent — it must not alert", async () => {
|
||||||
|
// A newsletter: real traffic, correctly ignored.
|
||||||
|
const res = await POST(req({ html: html("dd-01"), meta: meta({ subject: "Newsletter", sender: "promo@example.com" }) }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((await res.json()).kind).toBe("skipped");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an adjustment notice is 200 and silent", async () => {
|
||||||
|
const res = await POST(req({
|
||||||
|
html: html("dd-08"),
|
||||||
|
meta: meta({ subject: "Order Confirmation for Siddharth from ALDI" }),
|
||||||
|
}));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((await res.json()).kind).toBe("skipped");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a receipt that cannot be parsed is 422 so it ALERTS", async () => {
|
||||||
|
// A DoorDash receipt with its totals stripped out — i.e. what a provider
|
||||||
|
// template change looks like. Previously this returned 200 and vanished.
|
||||||
|
const broken = html("dd-01")
|
||||||
|
.replace(/Total Charged/g, "Gesamtbetrag")
|
||||||
|
.replace(/Total:/g, "Summe:");
|
||||||
|
const res = await POST(req({ html: broken, meta: meta() }));
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.kind).toBe("parse_failed");
|
||||||
|
expect(body.reason).toMatch(/total/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a refund is routed to the amendment path, not ingestion", async () => {
|
||||||
|
const res = await POST(req({
|
||||||
|
html: html("ue-05"),
|
||||||
|
meta: meta({ subject: "Your Tuesday evening order with Uber Eats", sender: "uber.com" }),
|
||||||
|
dryRun: true,
|
||||||
|
}));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.kind).toBe("amendment");
|
||||||
|
expect(body.amendment.new_total).toBeCloseTo(45.73, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a good receipt ingests", async () => {
|
||||||
|
await queryRaw(`DELETE FROM expense_metadata WHERE source = 'email'`);
|
||||||
|
await queryRaw(`DELETE FROM transactions WHERE description LIKE 'Order - %'`);
|
||||||
|
const res = await POST(req({ html: html("dd-01"), meta: meta({ messageId: "api-ok-1" }) }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.kind).toBe("order");
|
||||||
|
expect(body.total).toBe(14.64);
|
||||||
|
expect(body.transactionId).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { resolve } from "path";
|
||||||
|
import { queryRaw, queryRow } from "../../lib/db";
|
||||||
|
import {
|
||||||
|
parseOrderHTML,
|
||||||
|
validateOrderTotals,
|
||||||
|
processOrderIngestion,
|
||||||
|
reconcilePendingOrders,
|
||||||
|
parseOrderAmendment,
|
||||||
|
applyOrderAmendment,
|
||||||
|
OrderParseError,
|
||||||
|
NotAReceiptError,
|
||||||
|
type MessageMeta,
|
||||||
|
} from "../../lib/order-ingestion";
|
||||||
|
import { EXCLUDE_NON_SPEND } from "../../lib/analytics-sql";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These run against REAL captured receipts, not synthetic fixtures. The earlier
|
||||||
|
* suite passed 31/31 against fixtures written to satisfy the parser, while the
|
||||||
|
* parser could not read a single real email. Fixtures live in
|
||||||
|
* __tests__/fixtures/orders/real/ and are unmodified message bodies.
|
||||||
|
*/
|
||||||
|
const dir = resolve(__dirname, "../fixtures/orders/real");
|
||||||
|
const html = (f: string) => readFileSync(resolve(dir, `${f}.html`), "utf-8");
|
||||||
|
|
||||||
|
const meta = (over: Partial<MessageMeta> = {}): MessageMeta => ({
|
||||||
|
messageId: `test-${Math.random().toString(36).slice(2)}`,
|
||||||
|
subject: "Order Confirmation for Siddharth from Mad Mex",
|
||||||
|
receivedAt: "2026-07-16T03:34:00Z",
|
||||||
|
sender: "DoorDash Order <no-reply@doordash.com>",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Order parsing — real receipts", () => {
|
||||||
|
it("reads DoorDash totals structurally, not by flattening (I9)", () => {
|
||||||
|
const p = parseOrderHTML(html("dd-01"), meta());
|
||||||
|
expect(p.merchant_name).toBe("Mad Mex");
|
||||||
|
expect(p.totals.total_charged).toBe(14.64);
|
||||||
|
expect(p.payment.credits_amount).toBe(14.64);
|
||||||
|
expect(p.line_items).toHaveLength(1);
|
||||||
|
expect(p.line_items[0].description).toBe("Burrito (Mains)");
|
||||||
|
expect(p.line_items[0].options).toContain("Slow Cooked Beef (GF)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT gate on DoorDash's fee breakdown, which genuinely does not reconcile", () => {
|
||||||
|
// Real receipt: subtotal 22.10 + service 1.99 - Discounts 24.09 = 0.00,
|
||||||
|
// against a stated total of 14.64. DoorDash prints this; it is not a parse
|
||||||
|
// artefact. Recorded here so nobody "fixes" the parser to force it to sum.
|
||||||
|
const p = parseOrderHTML(html("dd-01"), meta());
|
||||||
|
expect(p.totals.subtotal).toBe(22.10);
|
||||||
|
expect(p.totals.discounts).toBe(24.09);
|
||||||
|
expect(validateOrderTotals(p, html("dd-01")).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives order_reference from the message, never randomly (I7)", () => {
|
||||||
|
const m = meta({ messageId: "abc123" });
|
||||||
|
const a = parseOrderHTML(html("dd-01"), m);
|
||||||
|
const b = parseOrderHTML(html("dd-01"), m);
|
||||||
|
expect(a.order_reference).toBe(b.order_reference);
|
||||||
|
expect(a.order_reference).toBe("msg:abc123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses Uber's embedded order UUID as the reference", () => {
|
||||||
|
const p = parseOrderHTML(
|
||||||
|
html("ue-00"),
|
||||||
|
meta({ subject: "Your Wednesday afternoon order with Uber Eats", sender: "uber.com" })
|
||||||
|
);
|
||||||
|
expect(p.order_reference).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
expect(p.platform).toBe("ubereats");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the order date from the message, not a body string", () => {
|
||||||
|
const p = parseOrderHTML(html("dd-01"), meta({ receivedAt: "2026-07-16T03:34:00Z" }));
|
||||||
|
expect(p.order_datetime.slice(0, 10)).toBe("2026-07-16");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects [Family] from the subject prefix, not a body substring (I11)", () => {
|
||||||
|
const fam = parseOrderHTML(
|
||||||
|
html("ue-04"),
|
||||||
|
meta({ subject: "[Family] Your Tuesday evening order with Uber Eats", sender: "uber.com" })
|
||||||
|
);
|
||||||
|
expect(fam.is_family).toBe(true);
|
||||||
|
const notFam = parseOrderHTML(html("dd-01"), meta());
|
||||||
|
expect(notFam.is_family).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads [Family] orders as LKR, not dollars", () => {
|
||||||
|
const p = parseOrderHTML(
|
||||||
|
html("ue-04"),
|
||||||
|
meta({ subject: "[Family] Your Tuesday evening order with Uber Eats", sender: "uber.com" })
|
||||||
|
);
|
||||||
|
expect(p.currency).toBe("LKR");
|
||||||
|
expect(p.totals.total_charged).toBeCloseTo(3783.20, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads Swiss orders as CHF", () => {
|
||||||
|
const p = parseOrderHTML(
|
||||||
|
html("ue-26"),
|
||||||
|
meta({ subject: "Your Tuesday evening order with Uber Eats", sender: "uber.com" })
|
||||||
|
);
|
||||||
|
expect(p.currency).toBe("CHF");
|
||||||
|
expect(p.totals.total_charged).toBeCloseTo(51.23, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a failed payment attempt and takes the successful one", () => {
|
||||||
|
// ue-09: "Visa ••••8841 LKR 4,267.01 ... Failed" then "LKR 3,757.01".
|
||||||
|
const p = parseOrderHTML(
|
||||||
|
html("ue-09"),
|
||||||
|
meta({ subject: "[Family] Your Sunday evening order with Uber Eats", sender: "uber.com" })
|
||||||
|
);
|
||||||
|
expect(p.totals.total_charged).toBeCloseTo(3757.01, 2);
|
||||||
|
expect(validateOrderTotals(p, html("ue-09")).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an order-adjustment notice rather than booking $0.00", () => {
|
||||||
|
// NotAReceiptError, not OrderParseError: this is expected traffic, so it
|
||||||
|
// must be skipped silently. Only a receipt that fails to parse should
|
||||||
|
// alert — see the ingest API's error taxonomy.
|
||||||
|
expect(() =>
|
||||||
|
parseOrderHTML(html("dd-08"), meta({ subject: "Order Confirmation for Siddharth from ALDI" }))
|
||||||
|
).toThrow(NotAReceiptError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a refund notice rather than inserting a duplicate order", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseOrderHTML(html("ue-05"), meta({ subject: "Your Tuesday evening order with Uber Eats", sender: "uber.com" }))
|
||||||
|
).toThrow(/refund/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a grocery Final receipt that has no Total Charged row", () => {
|
||||||
|
const p = parseOrderHTML(
|
||||||
|
html("dd-10"),
|
||||||
|
meta({ subject: "Order Confirmation for Siddharth from Woolworths" })
|
||||||
|
);
|
||||||
|
expect(p.totals.total_charged).toBeCloseTo(60.93, 2);
|
||||||
|
expect(p.payment.ambiguous).toBe(true); // "8032 and/or credits"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Order ingestion — invariants", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await queryRaw(`DELETE FROM expense_metadata WHERE source = 'email'`);
|
||||||
|
await queryRaw(`DELETE FROM transactions WHERE description LIKE 'Order - %'`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("I6: a credits order creates one transaction at face value", async () => {
|
||||||
|
const p = parseOrderHTML(html("dd-01"), meta());
|
||||||
|
const res = await processOrderIngestion(p);
|
||||||
|
expect(res.transactionId).not.toBeNull();
|
||||||
|
const txn = await queryRow<{ amount: string; payment_method: string; category: string }>(
|
||||||
|
`SELECT amount::text, payment_method, category FROM transactions WHERE id = $1`,
|
||||||
|
[res.transactionId]
|
||||||
|
);
|
||||||
|
expect(Number(txn!.amount)).toBe(14.64);
|
||||||
|
expect(txn!.payment_method).toBe("credits");
|
||||||
|
expect(txn!.category).toBe("dining");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("I7: re-ingesting the same receipt creates nothing new", async () => {
|
||||||
|
const p = parseOrderHTML(html("dd-01"), meta({ messageId: "dedupe-1" }));
|
||||||
|
const a = await processOrderIngestion(p);
|
||||||
|
const b = await processOrderIngestion(parseOrderHTML(html("dd-01"), meta({ messageId: "dedupe-1" })));
|
||||||
|
expect(b.skipped).toBe("already_ingested");
|
||||||
|
expect(b.metadataId).toBe(a.metadataId);
|
||||||
|
const n = await queryRow<{ c: string }>(
|
||||||
|
`SELECT count(*)::text c FROM transactions WHERE description = 'Order - Mad Mex'`
|
||||||
|
);
|
||||||
|
expect(Number(n!.c)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("I1: a credits order before the cutover is refused", async () => {
|
||||||
|
const p = parseOrderHTML(html("dd-01"), meta({ receivedAt: "2025-11-15T12:00:00Z" }));
|
||||||
|
const res = await processOrderIngestion(p);
|
||||||
|
expect(res.skipped).toBe("pre_cutover");
|
||||||
|
expect(res.transactionId).toBeNull();
|
||||||
|
await expect(
|
||||||
|
queryRaw(
|
||||||
|
`INSERT INTO transactions (transaction_date, amount, payment_method) VALUES ('2025-11-15', 20.00, 'credits')`
|
||||||
|
)
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("I11: a [Family] order is imported, tagged, and excluded from spend", async () => {
|
||||||
|
const p = parseOrderHTML(
|
||||||
|
html("ue-04"),
|
||||||
|
meta({ subject: "[Family] Your Tuesday evening order with Uber Eats", sender: "uber.com", receivedAt: "2026-07-07T10:08:00Z" })
|
||||||
|
);
|
||||||
|
const res = await processOrderIngestion(p);
|
||||||
|
expect(res.transactionId).not.toBeNull();
|
||||||
|
expect(res.flags).toContain("family_payment_assumed_credits");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a foreign-currency order records the original amount and code", async () => {
|
||||||
|
const p = parseOrderHTML(
|
||||||
|
html("ue-26"),
|
||||||
|
meta({ subject: "Your Tuesday evening order with Uber Eats", sender: "uber.com", receivedAt: "2026-04-07T08:53:00Z" })
|
||||||
|
);
|
||||||
|
// Card-settled Swiss order: no credits leg, so no transaction (I5).
|
||||||
|
const res = await processOrderIngestion(p);
|
||||||
|
expect(res.transactionId).toBeNull();
|
||||||
|
const meta_ = await queryRow<{ currency: string }>(
|
||||||
|
`SELECT currency FROM expense_metadata WHERE id = $1`,
|
||||||
|
[res.metadataId]
|
||||||
|
);
|
||||||
|
expect(meta_!.currency).toBe("CHF");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parks an unresolvable split instead of guessing, then resolves it once the statement lands", async () => {
|
||||||
|
const p = parseOrderHTML(
|
||||||
|
html("dd-10"),
|
||||||
|
meta({ subject: "Order Confirmation for Siddharth from Woolworths", receivedAt: "2026-03-02T12:00:00Z" })
|
||||||
|
);
|
||||||
|
const res = await processOrderIngestion(p);
|
||||||
|
expect(res.transactionId).toBeNull();
|
||||||
|
expect(res.flags).toContain("awaiting_card_statement");
|
||||||
|
|
||||||
|
// Statement arrives: card 8032 took 40.93 of the 60.93 order.
|
||||||
|
const st = await queryRow<{ id: number }>(
|
||||||
|
`INSERT INTO statements (bank_name, account_number, filename)
|
||||||
|
VALUES ('Westpac','5163103015778032','test-westpac-2026-03.pdf') RETURNING id`
|
||||||
|
);
|
||||||
|
await queryRaw(
|
||||||
|
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type)
|
||||||
|
VALUES ($1, '2026-03-02', 'DD *DOORDASH WOOLWORTHS MELBOURNE AUS', 40.93, 'debit')`,
|
||||||
|
[st!.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const out = await reconcilePendingOrders();
|
||||||
|
expect(out.resolved).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(out.created).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
const credits = await queryRow<{ amount: string }>(
|
||||||
|
`SELECT t.amount::text FROM transactions t
|
||||||
|
JOIN expense_metadata em ON em.transaction_id = t.id
|
||||||
|
WHERE em.id = $1`,
|
||||||
|
[res.metadataId]
|
||||||
|
);
|
||||||
|
expect(Number(credits!.amount)).toBeCloseTo(20.00, 2); // 60.93 - 40.93
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconciliation is idempotent — a second pass creates nothing", async () => {
|
||||||
|
const before = await queryRow<{ c: string }>(`SELECT count(*)::text c FROM transactions`);
|
||||||
|
const out = await reconcilePendingOrders();
|
||||||
|
const after = await queryRow<{ c: string }>(`SELECT count(*)::text c FROM transactions`);
|
||||||
|
expect(out.created).toBe(0);
|
||||||
|
expect(after!.c).toBe(before!.c);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("EXCLUDE_NON_SPEND removes family-tagged rows", async () => {
|
||||||
|
const txn = await queryRow<{ id: number }>(
|
||||||
|
`INSERT INTO transactions (transaction_date, description, amount, category, transaction_type)
|
||||||
|
VALUES ('2026-03-01','Order - Family Test', 50.00, 'dining', 'debit') RETURNING id`
|
||||||
|
);
|
||||||
|
const tag = await queryRow<{ id: number }>(
|
||||||
|
`INSERT INTO tags (name, color) VALUES ('family','#ef4444')
|
||||||
|
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`
|
||||||
|
);
|
||||||
|
await queryRaw(`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1,$2)`, [txn!.id, tag!.id]);
|
||||||
|
|
||||||
|
const visible = await queryRaw(
|
||||||
|
`SELECT t.id FROM transactions t
|
||||||
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
WHERE t.id = $1 AND (${EXCLUDE_NON_SPEND})`,
|
||||||
|
[txn!.id]
|
||||||
|
);
|
||||||
|
expect(visible).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Refund amendments", () => {
|
||||||
|
const ueMeta = (over = {}) => meta({
|
||||||
|
subject: "Your Tuesday evening order with Uber Eats",
|
||||||
|
sender: "uber.com",
|
||||||
|
receivedAt: "2026-07-07T08:17:00Z",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a refund notice into an amendment, not an order", () => {
|
||||||
|
const a = parseOrderAmendment(html("ue-05"), ueMeta());
|
||||||
|
expect(a.previous_total).toBeCloseTo(49.94, 2);
|
||||||
|
expect(a.refund_amount).toBeCloseTo(4.21, 2);
|
||||||
|
expect(a.new_total).toBeCloseTo(45.73, 2);
|
||||||
|
expect(a.order_reference).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reduces the original transaction instead of adding a second row", async () => {
|
||||||
|
// Seed the original order this amendment refers to.
|
||||||
|
const a = parseOrderAmendment(html("ue-05"), ueMeta());
|
||||||
|
const txn = await queryRow<{ id: number }>(
|
||||||
|
`INSERT INTO transactions (transaction_date, description, amount, amount_aud, category, payment_method, transaction_type)
|
||||||
|
VALUES ('2026-07-07','Order - Coles (Wyndham Vale)', 49.94, 49.94, 'groceries', 'credits', 'debit')
|
||||||
|
RETURNING id`
|
||||||
|
);
|
||||||
|
await queryRaw(
|
||||||
|
`INSERT INTO expense_metadata (transaction_id, source, order_reference, amount, transaction_date, flags)
|
||||||
|
VALUES ($1,'email',$2, 49.94, '2026-07-07', '[]'::jsonb)`,
|
||||||
|
[txn!.id, a.order_reference]
|
||||||
|
);
|
||||||
|
|
||||||
|
const before = await queryRow<{ c: string }>(`SELECT count(*)::text c FROM transactions`);
|
||||||
|
const res = await applyOrderAmendment(a);
|
||||||
|
const after = await queryRow<{ c: string }>(`SELECT count(*)::text c FROM transactions`);
|
||||||
|
|
||||||
|
expect(res.matched).toBe(true);
|
||||||
|
expect(after!.c).toBe(before!.c); // amended in place, no second row
|
||||||
|
const updated = await queryRow<{ amount: string }>(
|
||||||
|
`SELECT amount::text FROM transactions WHERE id = $1`,
|
||||||
|
[txn!.id]
|
||||||
|
);
|
||||||
|
expect(Number(updated!.amount)).toBeCloseTo(45.73, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invents nothing when the original order was never ingested", async () => {
|
||||||
|
const a = parseOrderAmendment(html("ue-05"), ueMeta());
|
||||||
|
await queryRaw(`DELETE FROM expense_metadata WHERE order_reference = $1`, [a.order_reference]);
|
||||||
|
const res = await applyOrderAmendment(a);
|
||||||
|
expect(res.matched).toBe(false);
|
||||||
|
expect(res.transactionId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("[Family] orders import rather than park", () => {
|
||||||
|
it("records a family order as credits and tags it", async () => {
|
||||||
|
const p = parseOrderHTML(
|
||||||
|
html("ue-04"),
|
||||||
|
meta({ subject: "[Family] Your Tuesday evening order with Uber Eats", sender: "uber.com", receivedAt: "2026-07-07T10:08:00Z" })
|
||||||
|
);
|
||||||
|
expect(p.flags).toContain("family_payment_assumed_credits");
|
||||||
|
|
||||||
|
const res = await processOrderIngestion(p);
|
||||||
|
expect(res.transactionId).not.toBeNull();
|
||||||
|
|
||||||
|
const tag = await queryRow<{ name: string }>(
|
||||||
|
`SELECT tg.name FROM transaction_tags tt JOIN tags tg ON tg.id = tt.tag_id
|
||||||
|
WHERE tt.transaction_id = $1`,
|
||||||
|
[res.transactionId]
|
||||||
|
);
|
||||||
|
expect(tag!.name).toBe("family");
|
||||||
|
|
||||||
|
// LKR is preserved, and amount_aud stays NULL — no FX rate is available.
|
||||||
|
const txn = await queryRow<{ foreign_currency_code: string; amount_aud: string | null }>(
|
||||||
|
`SELECT foreign_currency_code, amount_aud::text FROM transactions WHERE id = $1`,
|
||||||
|
[res.transactionId]
|
||||||
|
);
|
||||||
|
expect(txn!.foreign_currency_code).toBe("LKR");
|
||||||
|
expect(txn!.amount_aud).toBeNull();
|
||||||
|
|
||||||
|
// And it must not reach spend.
|
||||||
|
const visible = await queryRaw(
|
||||||
|
`SELECT t.id FROM transactions t
|
||||||
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
WHERE t.id = $1 AND (${EXCLUDE_NON_SPEND})`,
|
||||||
|
[res.transactionId]
|
||||||
|
);
|
||||||
|
expect(visible).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { readFileSync } from "fs";
|
||||||
|
import { resolve } from "path";
|
||||||
|
import {
|
||||||
|
parseOrderHTML,
|
||||||
|
validateOrderTotals,
|
||||||
|
resolveCategory,
|
||||||
|
OrderParseError,
|
||||||
|
NotAReceiptError,
|
||||||
|
type MessageMeta,
|
||||||
|
type ParsedOrder,
|
||||||
|
} from "../../lib/order-ingestion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewritten 2026-07-26. The previous unit suite exercised synthetic fixtures
|
||||||
|
* built to satisfy the parser, so it passed while the parser could not read a
|
||||||
|
* real email. These run against unmodified captured receipts.
|
||||||
|
*/
|
||||||
|
const dir = resolve(__dirname, "../fixtures/orders/real");
|
||||||
|
const html = (f: string) => readFileSync(resolve(dir, `${f}.html`), "utf-8");
|
||||||
|
const meta = (over: Partial<MessageMeta> = {}): MessageMeta => ({
|
||||||
|
messageId: "unit-1",
|
||||||
|
subject: "Order Confirmation for Siddharth from Mad Mex",
|
||||||
|
receivedAt: "2026-07-16T03:34:00Z",
|
||||||
|
sender: "DoorDash Order <no-reply@doordash.com>",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("payment detection", () => {
|
||||||
|
it("credits-only", () => {
|
||||||
|
const p = parseOrderHTML(html("dd-01"), meta());
|
||||||
|
expect(p.payment.credits_amount).toBe(14.64);
|
||||||
|
expect(p.payment.card_last4).toBeNull();
|
||||||
|
expect(p.payment.ambiguous).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("card-only produces no credits figure", () => {
|
||||||
|
const p = parseOrderHTML(
|
||||||
|
html("dd-27"),
|
||||||
|
meta({ subject: "Order Confirmation for Siddharth from Subway" })
|
||||||
|
);
|
||||||
|
expect(p.payment.card_last4).toBe("8032");
|
||||||
|
expect(p.payment.credits_amount).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'and/or credits' is ambiguous, not silently credits", () => {
|
||||||
|
// Regression: an earlier regex delimited the payment line on a double
|
||||||
|
// space, which whitespace collapsing removes. Every card and mixed receipt
|
||||||
|
// fell through to the credits branch — this one booked the whole $60.93 as
|
||||||
|
// credits spend that never happened.
|
||||||
|
const p = parseOrderHTML(
|
||||||
|
html("dd-10"),
|
||||||
|
meta({ subject: "Order Confirmation for Siddharth from Woolworths" })
|
||||||
|
);
|
||||||
|
expect(p.payment.ambiguous).toBe(true);
|
||||||
|
expect(p.payment.card_last4).toBe("8032");
|
||||||
|
expect(p.payment.credits_amount).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateOrderTotals", () => {
|
||||||
|
const base = (over: Partial<ParsedOrder> = {}): ParsedOrder => ({
|
||||||
|
order_reference: "x",
|
||||||
|
platform: "doordash",
|
||||||
|
merchant_name: "M",
|
||||||
|
order_datetime: "2026-03-01T00:00:00Z",
|
||||||
|
currency: "AUD",
|
||||||
|
payment: { credits_amount: 10, card_amount: null, card_last4: null, ambiguous: false },
|
||||||
|
totals: {
|
||||||
|
subtotal: null, taxes: null, delivery_fee: null,
|
||||||
|
service_fee: null, tip: null, discounts: null, total_charged: 10,
|
||||||
|
},
|
||||||
|
line_items: [],
|
||||||
|
is_family: false,
|
||||||
|
flags: [],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-positive total", () => {
|
||||||
|
const o = base();
|
||||||
|
o.totals.total_charged = 0;
|
||||||
|
expect(validateOrderTotals(o).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects payments that do not account for the total", () => {
|
||||||
|
const r = validateOrderTotals(
|
||||||
|
base({ payment: { credits_amount: 5, card_amount: null, card_last4: null, ambiguous: false } })
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toMatch(/payments sum/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a matching total", () => {
|
||||||
|
expect(validateOrderTotals(base()).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not gate on DoorDash's non-reconciling fee breakdown", () => {
|
||||||
|
const p = parseOrderHTML(html("dd-01"), meta());
|
||||||
|
expect(p.totals.subtotal).toBe(22.10);
|
||||||
|
expect(p.totals.discounts).toBe(24.09); // 22.10 + 1.99 — genuinely printed
|
||||||
|
expect(validateOrderTotals(p, html("dd-01")).ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveCategory", () => {
|
||||||
|
const o = (merchant: string, platform: ParsedOrder["platform"] = "doordash") =>
|
||||||
|
({ merchant_name: merchant, platform }) as ParsedOrder;
|
||||||
|
|
||||||
|
it("maps grocers to groceries", () => {
|
||||||
|
expect(resolveCategory(o("Woolworths"))).toBe("groceries");
|
||||||
|
expect(resolveCategory(o("ALDI"))).toBe("groceries");
|
||||||
|
expect(resolveCategory(o("GLOMARK Kandana", "ubereats"))).toBe("groceries");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps restaurants to dining rather than 'other'", () => {
|
||||||
|
// The earlier six-merchant allowlist sent every one of these to `other`.
|
||||||
|
for (const m of ["Carl's Jr.", "Taco Bell", "Chilli India", "Oporto", "Schnitz", "Souvlaki GR"]) {
|
||||||
|
expect(resolveCategory(o(m))).toBe("dining");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps rides to transport", () => {
|
||||||
|
expect(resolveCategory(o("Uber Trip", "uber"))).toBe("transport");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parse guards", () => {
|
||||||
|
it("throws on a body too short to be a receipt", () => {
|
||||||
|
expect(() => parseOrderHTML("<html></html>", meta())).toThrow(NotAReceiptError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws rather than inventing a platform", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseOrderHTML(html("dd-01"), meta({ subject: "Newsletter", sender: "someone@example.com" }))
|
||||||
|
).toThrow(/platform/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("order_reference anchoring", () => {
|
||||||
|
it("takes Uber's tripReference, not the first UUID in the document", () => {
|
||||||
|
const p = parseOrderHTML(
|
||||||
|
html("ue-00"),
|
||||||
|
meta({ subject: "Your Wednesday afternoon order with Uber Eats", sender: "uber.com" })
|
||||||
|
);
|
||||||
|
// The UUID the PDF redirect actually resolves to for this receipt.
|
||||||
|
expect(p.order_reference).toBe("34d6b4ee-da8f-5029-8d14-bd359617c8e9");
|
||||||
|
expect(p.flags).not.toContain("order_uuid_ambiguous");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is stable across repeated parses of the same message", () => {
|
||||||
|
const m = meta({ subject: "Your Wednesday afternoon order with Uber Eats", sender: "uber.com" });
|
||||||
|
const a = parseOrderHTML(html("ue-00"), m).order_reference;
|
||||||
|
const b = parseOrderHTML(html("ue-00"), m).order_reference;
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags ambiguity only when several UUIDs and no anchor", () => {
|
||||||
|
// Strip the anchor from a receipt that carries multiple UUIDs (ue-09 has 6).
|
||||||
|
const stripped = html("ue-09").replace(/tripReference/gi, "notTheAnchor");
|
||||||
|
const p = parseOrderHTML(
|
||||||
|
stripped,
|
||||||
|
meta({ subject: "[Family] Your Sunday evening order with Uber Eats", sender: "uber.com" })
|
||||||
|
);
|
||||||
|
expect(p.flags).toContain("order_uuid_ambiguous");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,117 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
parseOrderHTML,
|
||||||
|
parseOrderAmendment,
|
||||||
|
isAmendment,
|
||||||
|
validateOrderTotals,
|
||||||
|
processOrderIngestion,
|
||||||
|
applyOrderAmendment,
|
||||||
|
reconcilePendingOrders,
|
||||||
|
OrderParseError,
|
||||||
|
NotAReceiptError,
|
||||||
|
type MessageMeta,
|
||||||
|
} from "@/lib/order-ingestion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Machine ingest endpoint for order receipts.
|
||||||
|
*
|
||||||
|
* n8n polls the two mailboxes and POSTs each message here. The parsing lives in
|
||||||
|
* the app, not in an n8n Code node, because the n8n sandbox has no `require`
|
||||||
|
* and no filesystem — a parser there could not be unit-tested against the real
|
||||||
|
* fixture corpus, which is the whole reason this one is trustworthy.
|
||||||
|
*
|
||||||
|
* Auth is a shared secret, not the Traefik `x-forwarded-user` header: this is
|
||||||
|
* called machine-to-machine and there is no browser session to forward.
|
||||||
|
*/
|
||||||
|
function authorised(req: NextRequest): boolean {
|
||||||
|
const expected = process.env.ORDER_INGEST_TOKEN;
|
||||||
|
if (!expected) return false; // fail closed when unconfigured
|
||||||
|
const got = req.headers.get("x-ingest-token");
|
||||||
|
return !!got && got === expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
if (!authorised(req)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { html?: string; meta?: MessageMeta; dryRun?: boolean };
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { html, meta, dryRun } = body;
|
||||||
|
if (!html || !meta?.messageId || !meta?.subject || !meta?.receivedAt) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "html and meta{messageId,subject,receivedAt} are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Amendments restate an existing order; they are not receipts.
|
||||||
|
if (isAmendment(html)) {
|
||||||
|
const amendment = parseOrderAmendment(html, meta);
|
||||||
|
if (dryRun) return NextResponse.json({ kind: "amendment", amendment });
|
||||||
|
const applied = await applyOrderAmendment(amendment);
|
||||||
|
return NextResponse.json({ kind: "amendment", amendment, applied });
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = parseOrderHTML(html, meta);
|
||||||
|
|
||||||
|
const check = validateOrderTotals(order, html);
|
||||||
|
if (!check.ok) {
|
||||||
|
// Refuse rather than record a number we cannot stand behind.
|
||||||
|
return NextResponse.json(
|
||||||
|
{ kind: "rejected", reason: check.reason, order_reference: order.order_reference },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) return NextResponse.json({ kind: "order", order });
|
||||||
|
|
||||||
|
const result = await processOrderIngestion(order, { messageId: meta.messageId });
|
||||||
|
return NextResponse.json({
|
||||||
|
kind: "order",
|
||||||
|
order_reference: order.order_reference,
|
||||||
|
merchant: order.merchant_name,
|
||||||
|
total: order.totals.total_charged,
|
||||||
|
currency: order.currency,
|
||||||
|
is_family: order.is_family,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Not a receipt: promotions, delivery updates, adjustment and refund
|
||||||
|
// notices. Expected traffic — 200 and silent, or the alert channel fills
|
||||||
|
// with noise and stops being read.
|
||||||
|
if (e instanceof NotAReceiptError) {
|
||||||
|
return NextResponse.json({ kind: "skipped", reason: e.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// IS a receipt, could not be parsed. This is the failure that matters and
|
||||||
|
// it must be loud: a provider template change breaks every order at once,
|
||||||
|
// and the only other symptom is spend quietly ceasing to appear. Returning
|
||||||
|
// 200 here — as this route originally did — made the most likely
|
||||||
|
// production failure completely invisible.
|
||||||
|
if (e instanceof OrderParseError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ kind: "parse_failed", reason: e.message, messageId: e.messageId },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Statement-import hook: resolve orders parked awaiting a card statement. */
|
||||||
|
export async function PATCH(req: NextRequest) {
|
||||||
|
if (!authorised(req)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const out = await reconcilePendingOrders();
|
||||||
|
return NextResponse.json(out);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
interface BalanceRow {
|
||||||
|
participant_id: number;
|
||||||
|
name: string;
|
||||||
|
total_owed: number;
|
||||||
|
transaction_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const rows = await queryRaw<BalanceRow>(
|
||||||
|
`SELECT ts.participant_id, p.name,
|
||||||
|
SUM(COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100)::numeric(12,2) as total_owed,
|
||||||
|
COUNT(*)::int as transaction_count
|
||||||
|
FROM transaction_splits ts
|
||||||
|
JOIN transactions t ON t.id = ts.transaction_id
|
||||||
|
JOIN participants p ON p.id = ts.participant_id
|
||||||
|
WHERE ts.participant_id = $1 AND ts.settled = false
|
||||||
|
GROUP BY ts.participant_id, p.name`,
|
||||||
|
[Number(id)]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
rows[0] ?? { participant_id: Number(id), total_owed: 0, transaction_count: 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getParticipantBalances } from "@/lib/queries";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const tagParam = req.nextUrl.searchParams.get("tag_ids");
|
||||||
|
const tagIds = tagParam ? tagParam.split(",").map(Number).filter(Boolean) : undefined;
|
||||||
|
const balances = await getParticipantBalances(user.id, tagIds);
|
||||||
|
return NextResponse.json(balances);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
const participants = await prisma.participants.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
if (user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
participants.map((p) => (p.id === user.id ? { ...p, name: "Me" } : p))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(participants);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { name, email } = await req.json();
|
||||||
|
if (!name?.trim()) {
|
||||||
|
return NextResponse.json({ error: "name required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const participant = await prisma.participants.create({
|
||||||
|
data: { name: name.trim(), email: email?.trim() || null },
|
||||||
|
});
|
||||||
|
return NextResponse.json(participant, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { getPendingReconciliations } from "@/lib/queries";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const data = await getPendingReconciliations(user.id);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
import { getTransactions } from "@/lib/queries";
|
||||||
|
import { evaluateCondition, type Condition, type Actions } from "@/lib/rules";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dry run: which transactions would this rule touch, and what would change?
|
||||||
|
*
|
||||||
|
* Nothing is written. This exists so a rule can be inspected before it is
|
||||||
|
* applied — the apply path (POST /api/transactions/bulk with action
|
||||||
|
* "apply_rule") takes an explicit list of ids, so the flow is preview, pick,
|
||||||
|
* then apply.
|
||||||
|
*
|
||||||
|
* Matches are split into two groups because they need different attention:
|
||||||
|
* `changes` are rows the rule would actually alter, `noops` already have the
|
||||||
|
* value the rule would set. A merchant-normalisation rule matching 400 rows
|
||||||
|
* where 380 are already correct is 20 changes and 380 rows of noise.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Change {
|
||||||
|
field: "category" | "merchant" | "tags" | "split";
|
||||||
|
from: string | null;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const rules = await queryRaw<{
|
||||||
|
id: number; name: string; conditions: unknown; actions: unknown;
|
||||||
|
}>(
|
||||||
|
`SELECT id, name, conditions, actions FROM rules WHERE id = $1 AND owner_id = $2`,
|
||||||
|
[Number(id), user.id]
|
||||||
|
);
|
||||||
|
if (!rules.length) return NextResponse.json({ error: "Rule not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const rule = rules[0];
|
||||||
|
const conditions = (typeof rule.conditions === "string"
|
||||||
|
? JSON.parse(rule.conditions) : rule.conditions) as Condition[];
|
||||||
|
const actions = (typeof rule.actions === "string"
|
||||||
|
? JSON.parse(rule.actions) : rule.actions) as Actions;
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const statementId = searchParams.get("statement_id");
|
||||||
|
const limit = Math.min(2000, Math.max(1, Number(searchParams.get("limit") || "500")));
|
||||||
|
|
||||||
|
const { data: transactions } = await getTransactions(user.id, {
|
||||||
|
limit: 100000,
|
||||||
|
offset: 0,
|
||||||
|
...(statementId ? { statement_id: statementId } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// A rule with no conditions matches every transaction. That is how the apply
|
||||||
|
// endpoint behaves too, so the preview must show it rather than hide it —
|
||||||
|
// seeing "matches 3,721" is the point.
|
||||||
|
const matchAll = conditions.length === 0;
|
||||||
|
|
||||||
|
const tagIds = new Set(actions.add_tag_ids ?? []);
|
||||||
|
const splitTarget = actions.apply_split ?? [];
|
||||||
|
|
||||||
|
const changes: unknown[] = [];
|
||||||
|
let noops = 0;
|
||||||
|
|
||||||
|
for (const tx of transactions) {
|
||||||
|
if (!matchAll && !conditions.every((c) => evaluateCondition(c, tx))) continue;
|
||||||
|
|
||||||
|
const diff: Change[] = [];
|
||||||
|
|
||||||
|
if (actions.set_category && tx.effective_category !== actions.set_category) {
|
||||||
|
diff.push({ field: "category", from: tx.effective_category ?? null, to: actions.set_category });
|
||||||
|
}
|
||||||
|
if (actions.set_merchant && tx.effective_merchant !== actions.set_merchant) {
|
||||||
|
diff.push({ field: "merchant", from: tx.effective_merchant ?? null, to: actions.set_merchant });
|
||||||
|
}
|
||||||
|
if (tagIds.size) {
|
||||||
|
const have = new Set((tx.tags ?? []).map((t) => t.id));
|
||||||
|
const missing = [...tagIds].filter((t) => !have.has(t));
|
||||||
|
if (missing.length) {
|
||||||
|
diff.push({ field: "tags", from: null, to: missing.join(",") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (splitTarget.length) {
|
||||||
|
// Compare the participant/share set the rule would write against what is
|
||||||
|
// already there; a re-run that changes nothing should not look like work.
|
||||||
|
const have = new Map((tx.splits ?? []).map((s) => [s.participant_id, Number(s.share_percent)]));
|
||||||
|
const differs = splitTarget.length !== have.size
|
||||||
|
|| splitTarget.some((s) => have.get(s.participant_id) !== Number(s.share_percent));
|
||||||
|
if (differs) {
|
||||||
|
diff.push({
|
||||||
|
field: "split",
|
||||||
|
from: [...have.entries()].map(([p, s]) => `${p}:${s}%`).join(" ") || null,
|
||||||
|
to: splitTarget.map((s) => `${s.participant_id}:${s.share_percent}%`).join(" "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diff.length === 0) { noops++; continue; }
|
||||||
|
|
||||||
|
changes.push({
|
||||||
|
id: tx.id,
|
||||||
|
transaction_date: tx.transaction_date,
|
||||||
|
description: tx.description,
|
||||||
|
amount: tx.amount,
|
||||||
|
amount_aud: tx.amount_aud,
|
||||||
|
currency: tx.currency,
|
||||||
|
bank_name: tx.bank_name,
|
||||||
|
effective_merchant: tx.effective_merchant,
|
||||||
|
effective_category: tx.effective_category,
|
||||||
|
changes: diff,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
rule: { id: rule.id, name: rule.name, matches_everything: matchAll },
|
||||||
|
total_matched: changes.length + noops,
|
||||||
|
would_change: changes.length,
|
||||||
|
already_correct: noops,
|
||||||
|
transactions: changes.slice(0, limit),
|
||||||
|
truncated: changes.length > limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,107 @@
|
|||||||
|
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;
|
||||||
|
rule_id: number | null;
|
||||||
|
rule_name: string | null;
|
||||||
|
source: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT id, applied_at, split_from, matched, transactions_affected, reverted_at,
|
||||||
|
rule_id, rule_name, source
|
||||||
|
FROM rule_apply_runs WHERE owner_id = $1 ORDER BY applied_at DESC LIMIT 20`,
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
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; name: string; conditions: unknown; actions: unknown }>(
|
||||||
|
ruleId
|
||||||
|
? `SELECT id, name, conditions, actions FROM rules
|
||||||
|
WHERE owner_id = $1 AND id = $2 AND manual_only = false`
|
||||||
|
: `SELECT id, name, conditions, actions FROM rules
|
||||||
|
WHERE owner_id = $1 AND enabled = true AND manual_only = false
|
||||||
|
ORDER BY priority DESC`,
|
||||||
|
ruleId ? [user.id, ruleId] : [user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rules.length) return NextResponse.json({ matched: 0, transactions_affected: 0 });
|
||||||
|
|
||||||
|
const { data: transactions } = await getTransactions(user.id, { limit: 100000, offset: 0 });
|
||||||
|
|
||||||
|
// --- Pre-pass: find all transactions that will match any rule ---
|
||||||
|
const parsedRules = rules.map((r) => ({
|
||||||
|
conditions: (typeof r.conditions === "string" ? JSON.parse(r.conditions) : r.conditions) as Condition[],
|
||||||
|
actions: (typeof r.actions === "string" ? JSON.parse(r.actions) : r.actions) as Actions,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const matchedIds = new Set<number>();
|
||||||
|
for (const tx of transactions) {
|
||||||
|
for (const { conditions } of parsedRules) {
|
||||||
|
if (conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx))) {
|
||||||
|
matchedIds.add(tx.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Capture before-state for all matched transactions (batched) ---
|
||||||
|
const snapshot = await captureSnapshot(Array.from(matchedIds));
|
||||||
|
|
||||||
|
// --- Apply rules ---
|
||||||
|
let matched = 0;
|
||||||
|
const affectedIds = new Set<number>();
|
||||||
|
|
||||||
|
for (const { 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,
|
||||||
|
rule_id, rule_name, source)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
|
||||||
|
[user.id, splitFrom, matched, affectedIds.size, JSON.stringify(snapshot),
|
||||||
|
ruleId, ruleId ? (rules[0]?.name ?? null) : null, ruleId ? "rule" : "all"]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ id: run[0].id, matched, transactions_affected: affectedIds.size });
|
||||||
|
}
|
||||||
@@ -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,134 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a rule run actually did — the before-state from its snapshot set against
|
||||||
|
* the values now.
|
||||||
|
*
|
||||||
|
* The run list only had counts, which is not enough to decide whether to revert:
|
||||||
|
* "13 matches · 13 transactions" reads the same whether it renamed a merchant or
|
||||||
|
* split your history with someone. Reverting is destructive, so the detail has
|
||||||
|
* to be visible before the button is pressed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SnapshotEntry {
|
||||||
|
transaction_id: number;
|
||||||
|
had_override: boolean;
|
||||||
|
prev_category_override: string | null;
|
||||||
|
prev_merchant_normalized: string | null;
|
||||||
|
prev_tag_ids: number[];
|
||||||
|
prev_splits: { participant_id: number; share_percent: number; settled: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const runs = await queryRaw<{
|
||||||
|
id: number; applied_at: string; reverted_at: string | null;
|
||||||
|
split_from: string | null; matched: number; transactions_affected: number;
|
||||||
|
rule_id: number | null; rule_name: string | null; source: string | null;
|
||||||
|
snapshot: unknown;
|
||||||
|
}>(
|
||||||
|
`SELECT id, applied_at, reverted_at, split_from, matched, transactions_affected,
|
||||||
|
rule_id, rule_name, source, snapshot
|
||||||
|
FROM rule_apply_runs WHERE id = $1 AND owner_id = $2`,
|
||||||
|
[Number(id), user.id]
|
||||||
|
);
|
||||||
|
if (!runs.length) return NextResponse.json({ error: "Run not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const run = runs[0];
|
||||||
|
const snapshot = (typeof run.snapshot === "string"
|
||||||
|
? JSON.parse(run.snapshot) : run.snapshot) as SnapshotEntry[];
|
||||||
|
const byId = new Map(snapshot.map((s) => [s.transaction_id, s]));
|
||||||
|
const ids = snapshot.map((s) => s.transaction_id);
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return NextResponse.json({ run: { ...run, snapshot: undefined }, transactions: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = await queryRaw<{
|
||||||
|
id: number; transaction_date: string; description: string;
|
||||||
|
amount: number; amount_aud: number | null; bank_name: string;
|
||||||
|
category: string | null; category_override: string | null;
|
||||||
|
merchant_normalized: string | null; merchant_override: string | null;
|
||||||
|
merchant_name: string | null;
|
||||||
|
tag_ids: number[]; splits: { participant_id: number; share_percent: number }[];
|
||||||
|
}>(
|
||||||
|
`SELECT t.id, t.transaction_date::text, t.description, t.amount, t.amount_aud,
|
||||||
|
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||||
|
t.category, o.category_override,
|
||||||
|
t.merchant_normalized, o.merchant_normalized as merchant_override, t.merchant_name,
|
||||||
|
COALESCE((SELECT json_agg(tt.tag_id) FROM transaction_tags tt WHERE tt.transaction_id = t.id), '[]'::json) as tag_ids,
|
||||||
|
COALESCE((SELECT json_agg(json_build_object('participant_id', ts.participant_id, 'share_percent', ts.share_percent))
|
||||||
|
FROM transaction_splits ts WHERE ts.transaction_id = t.id), '[]'::json) as splits
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
WHERE t.id = ANY($1::int[])
|
||||||
|
ORDER BY t.transaction_date DESC`,
|
||||||
|
[ids]
|
||||||
|
);
|
||||||
|
|
||||||
|
const parse = <T,>(v: unknown, fallback: T): T =>
|
||||||
|
(typeof v === "string" ? JSON.parse(v) : v) ?? fallback;
|
||||||
|
|
||||||
|
const transactions = current.map((t) => {
|
||||||
|
const prev = byId.get(t.id);
|
||||||
|
const nowTags: number[] = parse(t.tag_ids, []);
|
||||||
|
const nowSplits = parse<{ participant_id: number; share_percent: number }[]>(t.splits, []);
|
||||||
|
const prevTags = prev?.prev_tag_ids ?? [];
|
||||||
|
const prevSplits = prev?.prev_splits ?? [];
|
||||||
|
|
||||||
|
const nowCategory = t.category_override ?? t.category;
|
||||||
|
const prevCategory = prev?.prev_category_override ?? t.category;
|
||||||
|
const nowMerchant = t.merchant_override ?? t.merchant_normalized ?? t.merchant_name;
|
||||||
|
const prevMerchant = prev?.prev_merchant_normalized ?? t.merchant_normalized ?? t.merchant_name;
|
||||||
|
|
||||||
|
const changes: { field: string; from: string | null; to: string | null }[] = [];
|
||||||
|
if (nowCategory !== prevCategory) {
|
||||||
|
changes.push({ field: "category", from: prevCategory, to: nowCategory });
|
||||||
|
}
|
||||||
|
if (nowMerchant !== prevMerchant) {
|
||||||
|
changes.push({ field: "merchant", from: prevMerchant, to: nowMerchant });
|
||||||
|
}
|
||||||
|
const addedTags = nowTags.filter((x) => !prevTags.includes(x));
|
||||||
|
if (addedTags.length) {
|
||||||
|
changes.push({ field: "tags", from: null, to: addedTags.join(",") });
|
||||||
|
}
|
||||||
|
const fmtSplits = (arr: { participant_id: number; share_percent: number }[]) =>
|
||||||
|
arr.length ? arr.map((s) => `${s.participant_id}:${Number(s.share_percent)}%`).sort().join(" ") : null;
|
||||||
|
if (fmtSplits(nowSplits) !== fmtSplits(prevSplits)) {
|
||||||
|
changes.push({ field: "split", from: fmtSplits(prevSplits), to: fmtSplits(nowSplits) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
transaction_date: t.transaction_date,
|
||||||
|
description: t.description,
|
||||||
|
amount: t.amount,
|
||||||
|
amount_aud: t.amount_aud,
|
||||||
|
bank_name: t.bank_name,
|
||||||
|
merchant: nowMerchant,
|
||||||
|
changes,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
run: {
|
||||||
|
id: run.id, applied_at: run.applied_at, reverted_at: run.reverted_at,
|
||||||
|
split_from: run.split_from, matched: run.matched,
|
||||||
|
transactions_affected: run.transactions_affected,
|
||||||
|
rule_id: run.rule_id, rule_name: run.rule_name, source: run.source,
|
||||||
|
},
|
||||||
|
// A reverted run still lists its transactions, but they will show no changes
|
||||||
|
// because the values are back where they started.
|
||||||
|
transactions,
|
||||||
|
still_changed: transactions.filter((t) => t.changes.length > 0).length,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getSharedTransactions } from "@/lib/queries";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const tagParam = req.nextUrl.searchParams.get("tag_ids");
|
||||||
|
const rawIds = tagParam ? tagParam.split(",").filter(Boolean) : [];
|
||||||
|
const noTags = rawIds.includes("untagged");
|
||||||
|
const tagIds = rawIds.filter((id) => id !== "untagged").map(Number).filter((n) => !isNaN(n));
|
||||||
|
const participantParam = req.nextUrl.searchParams.get("participant_id");
|
||||||
|
const participantId = participantParam ? Number(participantParam) : undefined;
|
||||||
|
const transactions = await getSharedTransactions(user.id, tagIds.length ? tagIds : undefined, noTags, participantId);
|
||||||
|
return NextResponse.json(transactions);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const sp = req.nextUrl.searchParams;
|
||||||
|
const participantId = sp.get("participant_id");
|
||||||
|
|
||||||
|
// Return payment history between current user and a participant
|
||||||
|
const rows = await queryRaw<{
|
||||||
|
id: number;
|
||||||
|
from_participant_id: number;
|
||||||
|
from_name: string;
|
||||||
|
to_participant_id: number;
|
||||||
|
to_name: string;
|
||||||
|
amount: number;
|
||||||
|
payment_date: string;
|
||||||
|
notes: string | null;
|
||||||
|
linked_transaction_id: number | null;
|
||||||
|
created_at: string;
|
||||||
|
}>(
|
||||||
|
`SELECT sp.id, sp.from_participant_id, pf.name as from_name,
|
||||||
|
sp.to_participant_id, pt.name as to_name,
|
||||||
|
sp.amount, sp.payment_date, sp.notes,
|
||||||
|
sp.linked_transaction_id, sp.created_at
|
||||||
|
FROM split_payments sp
|
||||||
|
JOIN participants pf ON pf.id = sp.from_participant_id
|
||||||
|
JOIN participants pt ON pt.id = sp.to_participant_id
|
||||||
|
WHERE (sp.from_participant_id = $1 OR sp.to_participant_id = $1)
|
||||||
|
AND (sp.from_participant_id = $2 OR sp.to_participant_id = $2)
|
||||||
|
ORDER BY sp.payment_date DESC, sp.created_at DESC`,
|
||||||
|
[user.id, participantId ? Number(participantId) : user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const body = await req.json() as {
|
||||||
|
from_participant_id: number;
|
||||||
|
to_participant_id: number;
|
||||||
|
amount: number;
|
||||||
|
payment_date: string;
|
||||||
|
notes?: string;
|
||||||
|
linked_transaction_id?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { from_participant_id, to_participant_id, amount, payment_date, notes, linked_transaction_id } = body;
|
||||||
|
|
||||||
|
if (!from_participant_id || !to_participant_id || !amount || !payment_date) {
|
||||||
|
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (amount <= 0) {
|
||||||
|
return NextResponse.json({ error: "Amount must be positive" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payment = await prisma.split_payments.create({
|
||||||
|
data: {
|
||||||
|
from_participant_id,
|
||||||
|
to_participant_id,
|
||||||
|
amount,
|
||||||
|
payment_date: new Date(payment_date),
|
||||||
|
notes: notes || null,
|
||||||
|
linked_transaction_id: linked_transaction_id || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const sp = req.nextUrl.searchParams;
|
||||||
|
const id = Number(sp.get("id"));
|
||||||
|
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
|
||||||
|
|
||||||
|
await prisma.split_payments.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { 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,44 @@ 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,
|
||||||
|
rule_id, rule_name, source)
|
||||||
|
VALUES ($1, NULL, $2, $3, $4, $5, $6, 'selection') RETURNING id`,
|
||||||
|
[user.id, ids.length, ids.length, JSON.stringify(snapshot),
|
||||||
|
rules[0].id, rules[0].name]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ updated: ids.length, run_id: run[0].id, rule: rules[0].name });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "assign_trip") {
|
||||||
|
const { trip_id } = body as { ids: number[]; trip_id: number | null };
|
||||||
|
await assignTransactionsToTrip(trip_id, ids);
|
||||||
|
return NextResponse.json({ updated: ids.length });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
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 });
|
||||||
|
}
|
||||||
+619
-327
@@ -1,377 +1,669 @@
|
|||||||
"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">
|
||||||
</button>
|
<th className="text-left px-6 py-1 font-normal w-24">Date</th>
|
||||||
<span className="text-sm font-medium min-w-36 text-center">{formatMonth(selectedMonth)}</span>
|
<th className="text-left px-2 py-1 font-normal">Description</th>
|
||||||
<button
|
<th className="text-right px-2 py-1 font-normal w-24">Amount</th>
|
||||||
onClick={() => setSelectedMonth(nextMonth(selectedMonth))}
|
<th className="text-right px-4 py-1 font-normal w-36">Category</th>
|
||||||
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary cards */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
|
||||||
<p className="text-xs text-zinc-500 mb-1">Total Budgeted</p>
|
|
||||||
<p className="text-2xl font-semibold">${totalBudgeted.toFixed(2)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
|
||||||
<p className="text-xs text-zinc-500 mb-1">Total Spent</p>
|
|
||||||
<p
|
|
||||||
className={`text-2xl font-semibold ${
|
|
||||||
totalBudgeted > 0 && totalSpent > totalBudgeted ? "text-red-400" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
${totalSpent.toFixed(2)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
|
||||||
<p className="text-xs text-zinc-500 mb-1">Over Budget</p>
|
|
||||||
<p
|
|
||||||
className={`text-2xl font-semibold ${
|
|
||||||
overBudgetCount > 0 ? "text-red-400" : "text-emerald-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{overBudgetCount} {overBudgetCount === 1 ? "category" : "categories"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Budget table for current month */}
|
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<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-right px-4 py-3 text-xs text-zinc-500 font-medium">Budget</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-3 text-xs text-zinc-500 font-medium">Remaining</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>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{budgetsLoading || analyticsLoading ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-zinc-500">
|
|
||||||
Loading...
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
) : tableRows.length === 0 ? (
|
</thead>
|
||||||
<tr>
|
<tbody>
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-zinc-500">
|
{txns.map((tx) => (
|
||||||
No spending data for this month. Set a budget for any category below.
|
<tr key={tx.id} className="border-t border-zinc-800/30 hover:bg-zinc-800/20">
|
||||||
</td>
|
<td className="px-6 py-1.5 text-zinc-500 font-mono tabular-nums">{tx.transaction_date.slice(5).replace("-", "/")}</td>
|
||||||
</tr>
|
<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>
|
||||||
tableRows.map(({ cat, spent, txCount, budget, limit, remaining, pct }) => (
|
<td className="px-4 py-1.5 text-right">
|
||||||
<tr key={cat} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
<select
|
||||||
<td className="px-4 py-3 font-medium">{formatCategory(cat)}</td>
|
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"
|
||||||
<td className="px-4 py-3 text-right">
|
defaultValue={tx.effective_category}
|
||||||
{editingCategory === cat ? (
|
onChange={(e) =>
|
||||||
<div className="flex items-center justify-end gap-1">
|
updateTx.mutate({ id: tx.id, category: e.target.value }, {
|
||||||
<input
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["analytics"] }),
|
||||||
autoFocus
|
})
|
||||||
type="number"
|
}
|
||||||
min="0"
|
>
|
||||||
step="0.01"
|
{CATEGORIES.map((cat) => (
|
||||||
value={editValue}
|
<option key={cat} value={cat}>{formatCategory(cat)}</option>
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
))}
|
||||||
onKeyDown={(e) => {
|
</select>
|
||||||
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"
|
|
||||||
>
|
|
||||||
✓
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingCategory(null);
|
|
||||||
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>
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-zinc-600 text-xs">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right text-zinc-400">{txCount}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))}
|
||||||
)}
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
</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) &&
|
||||||
|
!tx.tags?.some((t: any) => (typeof t === "string" ? t : t.name) === "family"))
|
||||||
|
.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>
|
||||||
|
|
||||||
{/* 6-month trend table */}
|
{/* ── Cashflow strip ── */}
|
||||||
{analytics && analytics.months.length > 0 && (
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl grid grid-cols-2 sm:grid-cols-4 divide-x divide-y sm:divide-y-0 divide-zinc-800 overflow-hidden">
|
||||||
|
<div className="px-4 py-3.5">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Income</p>
|
||||||
|
<p className={`text-xl font-mono tabular-nums ${hasIncome ? "text-emerald-400" : "text-zinc-600"}`}>{hasIncome ? fmt(totals.income) : "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3.5">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Expenses</p>
|
||||||
|
<p className="text-xl font-mono tabular-nums text-zinc-100">{fmt(totals.spent)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3.5">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Invested</p>
|
||||||
|
<p className={`text-xl font-mono tabular-nums ${hasInvestments ? "text-indigo-300" : "text-zinc-600"}`}>{hasInvestments ? fmt(totals.investments) : "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3.5">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Net cash</p>
|
||||||
|
{hasIncome ? (
|
||||||
|
<p className={`text-xl font-mono tabular-nums ${totals.net >= 0 ? "text-emerald-400" : "text-red-400"}`}>{totals.net >= 0 ? "+" : ""}{fmt(totals.net)}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xl font-mono tabular-nums text-zinc-600">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Top movers vs last month ── */}
|
||||||
|
{movers.length > 0 && (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
|
||||||
|
<h3 className="text-sm font-medium mb-1">What changed</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mb-4">Biggest category moves vs {formatShortMonth(prevMonth(selectedMonth))}</p>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-x-8 gap-y-2.5">
|
||||||
|
{movers.map((m) => (
|
||||||
|
<div key={m.category} className="flex items-center gap-3">
|
||||||
|
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[m.category] || CHART.axis }} />
|
||||||
|
<span className="text-sm text-zinc-300 w-32 truncate shrink-0">{formatCategory(m.category)}</span>
|
||||||
|
<div className="flex-1 h-1.5 rounded-full bg-zinc-800 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${m.delta > 0 ? "bg-indigo-400" : "bg-emerald-500"}`}
|
||||||
|
style={{ width: `${Math.max(6, Math.round((Math.abs(m.delta) / maxMoverDelta) * 100))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-mono tabular-nums w-16 text-right ${m.delta > 0 ? "text-indigo-300" : "text-emerald-400"}`}>
|
||||||
|
{fmtSigned(m.delta)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Category small multiples ── */}
|
||||||
|
{sparkData.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-zinc-400 mb-3">6-Month Trend</h3>
|
<div className="flex items-baseline justify-between mb-3">
|
||||||
<div className="overflow-x-auto rounded-xl border border-zinc-700">
|
<h3 className="text-sm font-medium">Category trends</h3>
|
||||||
|
<span className="text-xs text-zinc-500">12 months · top {sparkData.length} by total spend</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
{sparkData.map((s) => (
|
||||||
|
<div key={s.category} className="bg-zinc-900 border border-zinc-800 rounded-xl px-3.5 pt-3 pb-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-0.5">
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-zinc-400 truncate">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[s.category] || CHART.axis }} />
|
||||||
|
{formatCategory(s.category)}
|
||||||
|
</span>
|
||||||
|
{Math.abs(s.delta) >= 1 && (
|
||||||
|
<span className={`text-[10px] font-mono tabular-nums shrink-0 ${s.delta > 0 ? "text-indigo-300" : "text-emerald-400"}`}>{fmtSigned(s.delta)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-mono tabular-nums text-zinc-100 mb-1">{fmt(s.thisMonth)}</p>
|
||||||
|
<div className="h-10 -mx-1">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={s.series} margin={{ top: 2, right: 0, bottom: 0, left: 0 }}>
|
||||||
|
<Area
|
||||||
|
dataKey="v"
|
||||||
|
stroke={CATEGORY_COLORS[s.category] || CHART.axis}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
fill={CATEGORY_COLORS[s.category] || CHART.axis}
|
||||||
|
fillOpacity={0.12}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Pareto ── */}
|
||||||
|
{paretoData.length > 0 && (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium">Spend concentration</h3>
|
||||||
|
{pareto80idx >= 0 && (
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
Top {pareto80idx + 1} categor{pareto80idx === 0 ? "y" : "ies"} = 80% of spend
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<ComposedChart data={paretoData} margin={{ top: 4, right: 48, bottom: 0, left: 8 }}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="category"
|
||||||
|
tick={{ fill: CHART.axis, fontSize: 11 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={formatCategory}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="left"
|
||||||
|
tick={{ fill: CHART.axis, fontSize: 11 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v) => `$${v}`}
|
||||||
|
width={52}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="right"
|
||||||
|
orientation="right"
|
||||||
|
tick={{ fill: CHART.axis, fontSize: 11 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v) => `${v}%`}
|
||||||
|
domain={[0, 100]}
|
||||||
|
width={36}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<ParetoTooltip />} cursor={{ fill: "rgba(232,224,204,0.04)" }} />
|
||||||
|
<ReferenceLine yAxisId="right" y={80} stroke={CHART.faint} strokeDasharray="4 2" label={{ value: "80%", fill: CHART.axis, fontSize: 10, position: "right" }} />
|
||||||
|
<Bar yAxisId="left" dataKey="spent" radius={[3, 3, 0, 0]} maxBarSize={40}>
|
||||||
|
{paretoData.map((entry, i) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.category}
|
||||||
|
fill={i <= pareto80idx ? (CATEGORY_COLORS[entry.category] || CHART.accent) : CHART.dim}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
<Line
|
||||||
|
yAxisId="right"
|
||||||
|
dataKey="cumulative"
|
||||||
|
stroke={CHART.accentSoft}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: CHART.accentSoft, r: 3 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="flex items-center gap-4 mt-2 justify-end">
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-0.5 inline-block" style={{ background: CHART.accentSoft }} />Cumulative %</span>
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm inline-block" style={{ background: CHART.dim }} />Beyond 80%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Cumulative pace ── */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium">Spend pace</h3>
|
||||||
|
<span className="text-xs text-zinc-500">cumulative through the month, vs typical</span>
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<LineChart data={cumulativeData} margin={{ top: 4, right: 8, bottom: 0, left: 8 }}>
|
||||||
|
<XAxis dataKey="day" tick={{ fill: CHART.axis, fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `${v}`} interval={4} />
|
||||||
|
<YAxis tick={{ fill: CHART.axis, fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(1)}k`} width={44} />
|
||||||
|
<Tooltip content={<CumulativeTooltip />} cursor={{ stroke: "rgba(232,224,204,0.08)", strokeWidth: 1 }} />
|
||||||
|
<Line dataKey="typical" stroke={CHART.faint} strokeWidth={1.5} strokeDasharray="4 3" dot={false} name="typical" />
|
||||||
|
<Line dataKey="actual" stroke={CHART.accent} strokeWidth={2} dot={false} activeDot={{ r: 4 }} connectNulls={false} name="actual" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="flex items-center gap-4 mt-2 justify-end">
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-4 h-0.5 inline-block" style={{ background: CHART.accent }} />This month</span>
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-4 h-0.5 inline-block border-t border-dashed" style={{ borderColor: CHART.faint }} />Typical pace</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Category breakdown table — expandable rows ── */}
|
||||||
|
{categoryRows.length > 0 && (
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-zinc-800">
|
||||||
|
<h3 className="text-sm font-medium">Where it went — {formatMonth(selectedMonth)}</h3>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-800">
|
||||||
|
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Category</th>
|
||||||
|
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">Spent</th>
|
||||||
|
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium"># Txns</th>
|
||||||
|
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">% of total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{categoryRows.map(({ category, spent, txCount }) => {
|
||||||
|
const isExpanded = expandedCategory === category;
|
||||||
|
return (
|
||||||
|
<Fragment key={category}>
|
||||||
|
<tr
|
||||||
|
className={`border-b border-zinc-800/50 cursor-pointer select-none transition-colors ${isExpanded ? "bg-zinc-800/40" : "hover:bg-zinc-800/30"}`}
|
||||||
|
onClick={() => setExpandedCategory(isExpanded ? null : category)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2.5 font-medium">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[category] || CHART.axis }} />
|
||||||
|
{formatCategory(category)}
|
||||||
|
<span className="text-zinc-600 text-xs ml-1">{isExpanded ? "▲" : "▼"}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right font-mono tabular-nums">{fmtExact(spent)}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-zinc-400 font-mono tabular-nums">{txCount}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-zinc-400 font-mono tabular-nums">
|
||||||
|
{totals.spent > 0 ? ((spent / totals.spent) * 100).toFixed(1) : "0.0"}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{isExpanded && <CategoryPanel category={category} selectedMonth={selectedMonth} />}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 6-month ledger table (heat-tinted) ── */}
|
||||||
|
{tableMonths.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-zinc-400 mb-3">Six-month ledger</h3>
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-zinc-800">
|
||||||
<table className="w-full text-xs border-collapse">
|
<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) => {
|
||||||
<tr key={row.category} className="border-b border-zinc-800/40 hover:bg-zinc-900/30">
|
const rowMax = Math.max(...tableMonths.map((m) => row.spent[m] || 0), 1);
|
||||||
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950">
|
return (
|
||||||
{formatCategory(row.category)}
|
<tr key={row.category} className="border-b border-zinc-800/40 hover:bg-zinc-900/30">
|
||||||
</td>
|
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950">
|
||||||
{analytics.months.map((m) => {
|
<span className="flex items-center gap-1.5">
|
||||||
const spent = row.spent[m];
|
<span className="w-1.5 h-1.5 rounded-sm shrink-0" style={{ background: CATEGORY_COLORS[row.category] || CHART.axis }} />
|
||||||
const budget = row.budget[m];
|
{formatCategory(row.category)}
|
||||||
const overBudget =
|
</span>
|
||||||
spent !== undefined && budget !== undefined && spent > budget;
|
</td>
|
||||||
|
{tableMonths.map((m) => {
|
||||||
|
const spent = row.spent[m];
|
||||||
|
const heat = spent !== undefined ? (spent / rowMax) * 0.28 : 0;
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={m}
|
||||||
|
className={`px-3 py-2 text-right font-mono tabular-nums ${spent === undefined ? "text-zinc-700" : "text-zinc-300"}`}
|
||||||
|
style={heat > 0.02 ? { background: `rgba(188, 111, 48, ${heat.toFixed(3)})` } : undefined}
|
||||||
|
>
|
||||||
|
{spent !== undefined ? fmt(spent) : "—"}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{hasIncome && (
|
||||||
|
<tr className="border-b border-zinc-800/40">
|
||||||
|
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950 text-emerald-600">Income</td>
|
||||||
|
{tableMonths.map((m) => {
|
||||||
|
const inc = analytics.income[m];
|
||||||
return (
|
return (
|
||||||
<td
|
<td key={m} className="px-3 py-2 text-right font-mono tabular-nums text-emerald-500">
|
||||||
key={m}
|
{inc ? fmt(inc) : "—"}
|
||||||
className={`px-3 py-2 text-right tabular-nums ${
|
|
||||||
spent === undefined
|
|
||||||
? "text-zinc-700"
|
|
||||||
: overBudget
|
|
||||||
? "text-red-300 bg-red-950/40"
|
|
||||||
: "text-zinc-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{spent !== undefined ? `$${Number(spent).toFixed(0)}` : "—"}
|
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+596
-3
@@ -1,8 +1,601 @@
|
|||||||
|
"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";
|
||||||
|
import { RulePreviewModal } from "@/components/rule-preview-modal";
|
||||||
|
import { RuleRunDetail } from "@/components/rule-run-detail";
|
||||||
|
|
||||||
|
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 [preview, setPreview] = useState<{ id: number; name: string } | null>(null);
|
||||||
|
const [expandedRun, setExpandedRun] = useState<number | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [conditions, setConditions] = useState<Condition[]>([]);
|
||||||
|
const [actions, setActions] = useState<Actions>(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>
|
<div className="space-y-6">
|
||||||
<h2 className="text-xl font-semibold mb-4">Rules</h2>
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-zinc-500">Coming soon - auto-classify transactions with rules.</p>
|
<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>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Rule Name</label>
|
||||||
|
<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={`rounded-lg border text-sm overflow-hidden ${run.reverted_at ? "bg-zinc-900/40 border-zinc-800 opacity-60" : "bg-zinc-900 border-zinc-700"}`}>
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedRun(expandedRun === run.id ? null : run.id)}
|
||||||
|
className="text-zinc-500 hover:text-zinc-200 w-4 text-left"
|
||||||
|
title="Show what this run changed"
|
||||||
|
>
|
||||||
|
{expandedRun === run.id ? "▾" : "▸"}
|
||||||
|
</button>
|
||||||
|
<span className="text-zinc-300">{new Date(run.applied_at).toLocaleString()}</span>
|
||||||
|
{/* Which rule ran matters most: a merchant rename and a
|
||||||
|
50/50 split of everything look identical as counts. */}
|
||||||
|
<span className="text-zinc-200">
|
||||||
|
{run.rule_name ?? (run.source === "all" ? "All rules" : "Unknown rule")}
|
||||||
|
</span>
|
||||||
|
{run.source === "selection" && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-indigo-900/50 text-indigo-300">
|
||||||
|
selection
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-zinc-500">{run.transactions_affected} transactions</span>
|
||||||
|
{run.split_from && (
|
||||||
|
<span className="text-zinc-600 text-xs">splits from {String(run.split_from).slice(0, 10)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{run.reverted_at ? (
|
||||||
|
<span className="text-xs text-zinc-500">reverted {new Date(run.reverted_at).toLocaleString()}</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm("Revert this run? This will restore all affected transactions to their state before the rules were applied.")) {
|
||||||
|
revertRun.mutate(run.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={revertRun.isPending}
|
||||||
|
className="text-xs text-amber-400 hover:text-amber-300 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Revert
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{expandedRun === run.id && <RuleRunDetail runId={run.id} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-zinc-500 text-sm">Loading rules...</p>
|
||||||
|
) : rules.length === 0 ? (
|
||||||
|
<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">
|
||||||
|
{/* Preview is safe for every rule, including quick actions:
|
||||||
|
it writes nothing, and the apply step takes the ids you
|
||||||
|
tick rather than re-running the conditions. */}
|
||||||
|
<button
|
||||||
|
onClick={() => setPreview({ id: rule.id, name: rule.name })}
|
||||||
|
className="text-xs text-indigo-400 hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
{/* No blind Apply for quick actions: their conditions are
|
||||||
|
empty, so a bulk apply would hit every transaction. */}
|
||||||
|
{!rule.manual_only && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleApply(rule.id)}
|
||||||
|
disabled={applyRules.isPending}
|
||||||
|
className="text-xs text-emerald-400 hover:text-emerald-300 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => updateRule.mutate({ id: rule.id, enabled: !rule.enabled })}
|
||||||
|
className={`relative inline-flex h-5 w-9 rounded-full transition-colors ${
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<RulePreviewModal
|
||||||
|
ruleId={preview.id}
|
||||||
|
ruleName={preview.name}
|
||||||
|
onClose={() => setPreview(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+485
-4
@@ -1,8 +1,489 @@
|
|||||||
export default function SharedPage() {
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
useSharedTransactions,
|
||||||
|
useParticipantBalances,
|
||||||
|
useParticipants,
|
||||||
|
useCreateParticipant,
|
||||||
|
useRecordPayment,
|
||||||
|
usePaymentHistory,
|
||||||
|
useDeletePayment,
|
||||||
|
useCurrentUser,
|
||||||
|
useTags,
|
||||||
|
type SplitPayment,
|
||||||
|
} from "@/lib/hooks";
|
||||||
|
import type { SharedTransactionRow } from "@/lib/queries";
|
||||||
|
import { EditTransactionModal } from "@/components/edit-transaction-modal";
|
||||||
|
|
||||||
|
function formatDate(d: string) {
|
||||||
|
return new Date(d).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
|
||||||
|
|
||||||
|
function formatAmount(n: number, type?: string) {
|
||||||
|
const formatted = `$${Number(n).toFixed(2)}`;
|
||||||
|
return type && !SPEND_TYPES.has(type) ? `+${formatted}` : formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tag multi-select ──────────────────────────────────────────────────────────
|
||||||
|
function TagFilter({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) {
|
||||||
|
const { data: tags = [] } = useTags();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handler(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
let next: string[];
|
||||||
|
if (value.includes(id)) {
|
||||||
|
next = value.filter((x) => x !== id);
|
||||||
|
} else if (id === "untagged") {
|
||||||
|
next = ["untagged"];
|
||||||
|
} else {
|
||||||
|
next = [...value.filter((x) => x !== "untagged"), id];
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = value.length === 0 ? "All Tags"
|
||||||
|
: value.includes("untagged") ? "No tags"
|
||||||
|
: value.length === 1 ? (tags.find((t) => String(t.id) === value[0])?.name ?? "1 tag")
|
||||||
|
: `${value.length} tags`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div ref={ref} className="relative">
|
||||||
<h2 className="text-xl font-semibold mb-4">Shared Expenses</h2>
|
<button
|
||||||
<p className="text-zinc-500">Coming soon - track shared expenses and splits.</p>
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className={`border rounded px-3 py-1.5 text-sm flex items-center gap-2 min-w-[120px] bg-zinc-900 ${value.length > 0 ? "border-indigo-500 text-white" : "border-zinc-700 text-zinc-400"}`}
|
||||||
|
>
|
||||||
|
<span className="flex-1 text-left">{label}</span>
|
||||||
|
<span className="text-zinc-500 text-xs">▾</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute top-full mt-1 z-20 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl min-w-[160px] max-h-56 overflow-y-auto">
|
||||||
|
<label className="flex items-center gap-2 px-3 py-1.5 hover:bg-zinc-800 cursor-pointer text-sm border-b border-zinc-800">
|
||||||
|
<input type="checkbox" checked={value.includes("untagged")} onChange={() => toggle("untagged")}
|
||||||
|
className="accent-indigo-500 flex-shrink-0" />
|
||||||
|
<span className="text-zinc-400 italic">No tags</span>
|
||||||
|
</label>
|
||||||
|
{tags.map((t) => (
|
||||||
|
<label key={t.id} className="flex items-center gap-2 px-3 py-1.5 hover:bg-zinc-800 cursor-pointer text-sm">
|
||||||
|
<input type="checkbox" checked={value.includes(String(t.id))} onChange={() => toggle(String(t.id))}
|
||||||
|
className="accent-indigo-500 flex-shrink-0" />
|
||||||
|
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: t.color }} />
|
||||||
|
{t.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add Participant ───────────────────────────────────────────────────────────
|
||||||
|
function AddParticipantForm({ onDone }: { onDone: () => void }) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const create = useCreateParticipant();
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
if (!name.trim()) { setError("Name is required"); return; }
|
||||||
|
try {
|
||||||
|
await create.mutateAsync({ name: name.trim(), email: email.trim() || undefined });
|
||||||
|
onDone();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to create");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4 space-y-3">
|
||||||
|
<p className="text-sm font-medium">Add Participant</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input type="text" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)}
|
||||||
|
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-zinc-500" />
|
||||||
|
<input type="email" placeholder="Email (optional)" value={email} onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-zinc-500" />
|
||||||
|
<button type="submit" disabled={create.isPending}
|
||||||
|
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium">
|
||||||
|
{create.isPending ? "Adding..." : "Add"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onDone}
|
||||||
|
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded-lg text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Record Payment modal ──────────────────────────────────────────────────────
|
||||||
|
function RecordPaymentModal({
|
||||||
|
participant,
|
||||||
|
currentUserId,
|
||||||
|
currentBalance,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
participant: { id: number; name: string };
|
||||||
|
currentUserId: number;
|
||||||
|
currentBalance: number; // positive = they owe me, negative = I owe them
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const record = useRecordPayment();
|
||||||
|
const theyOweMe = currentBalance > 0;
|
||||||
|
|
||||||
|
// Default direction matches the debt direction
|
||||||
|
const [amount, setAmount] = useState(Math.abs(currentBalance).toFixed(2));
|
||||||
|
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
// direction: "received" = they paid me, "sent" = I paid them
|
||||||
|
const [direction, setDirection] = useState<"received" | "sent">(theyOweMe ? "received" : "sent");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setError("");
|
||||||
|
const amt = parseFloat(amount);
|
||||||
|
if (!amt || amt <= 0) { setError("Enter a valid amount"); return; }
|
||||||
|
try {
|
||||||
|
await record.mutateAsync({
|
||||||
|
from_participant_id: direction === "received" ? participant.id : currentUserId,
|
||||||
|
to_participant_id: direction === "received" ? currentUserId : participant.id,
|
||||||
|
amount: amt,
|
||||||
|
payment_date: date,
|
||||||
|
notes: notes || undefined,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to record payment");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl w-full max-w-sm mx-4 shadow-2xl p-6 space-y-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 className="font-semibold text-sm text-zinc-300">Record Payment</h3>
|
||||||
|
|
||||||
|
{/* Direction toggle */}
|
||||||
|
<div className="flex rounded-lg overflow-hidden border border-zinc-700 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDirection("received")}
|
||||||
|
className={`flex-1 py-1.5 transition-colors ${direction === "received" ? "bg-emerald-700 text-white" : "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"}`}
|
||||||
|
>
|
||||||
|
{participant.name} paid me
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDirection("sent")}
|
||||||
|
className={`flex-1 py-1.5 transition-colors ${direction === "sent" ? "bg-blue-700 text-white" : "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"}`}
|
||||||
|
>
|
||||||
|
I paid {participant.name}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Amount</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500 text-sm">$</span>
|
||||||
|
<input type="number" step="0.01" min="0.01" value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm pl-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Date</label>
|
||||||
|
<input type="date" value={date} onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-zinc-500 mb-1">Notes (optional)</label>
|
||||||
|
<input value={notes} onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="e.g. Bank transfer, cash"
|
||||||
|
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={onClose}
|
||||||
|
className="flex-1 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleSave} disabled={record.isPending}
|
||||||
|
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium">
|
||||||
|
{record.isPending ? "Saving…" : "Record"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payment history inline ────────────────────────────────────────────────────
|
||||||
|
function PaymentHistory({ participantId, currentUserId }: { participantId: number; currentUserId: number }) {
|
||||||
|
const { data: payments = [], isLoading } = usePaymentHistory(participantId);
|
||||||
|
const deletePayment = useDeletePayment();
|
||||||
|
|
||||||
|
if (isLoading) return <p className="text-xs text-zinc-600 mt-2">Loading payments…</p>;
|
||||||
|
if (payments.length === 0) return <p className="text-xs text-zinc-600 italic mt-2">No payments recorded</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 space-y-1.5">
|
||||||
|
<p className="text-xs text-zinc-500 font-medium">Payment history</p>
|
||||||
|
{payments.map((p: SplitPayment) => {
|
||||||
|
const theyPaidMe = p.to_participant_id === currentUserId;
|
||||||
|
return (
|
||||||
|
<div key={p.id} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className={`font-mono font-medium ${theyPaidMe ? "text-emerald-400" : "text-blue-400"}`}>
|
||||||
|
{theyPaidMe ? "+" : "-"}${Number(p.amount).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-500">{formatDate(p.payment_date)}</span>
|
||||||
|
{p.notes && <span className="text-zinc-600 truncate flex-1">{p.notes}</span>}
|
||||||
|
<button
|
||||||
|
onClick={() => deletePayment.mutate(p.id)}
|
||||||
|
className="text-zinc-600 hover:text-red-400 leading-none ml-auto flex-shrink-0"
|
||||||
|
title="Delete payment"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
|
type SortCol = "transaction_date" | "created_at" | "amount";
|
||||||
|
|
||||||
|
export default function SharedPage() {
|
||||||
|
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||||
|
const [participantId, setParticipantId] = useState<number | undefined>(undefined);
|
||||||
|
const [sortCol, setSortCol] = useState<SortCol>("transaction_date");
|
||||||
|
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||||
|
const realTagIds = tagIds.filter((id) => id !== "untagged");
|
||||||
|
const { data: participants = [] } = useParticipants();
|
||||||
|
const { data: rawTransactions = [], isLoading: txLoading } = useSharedTransactions(tagIds, participantId);
|
||||||
|
|
||||||
|
const transactions = [...rawTransactions].sort((a, b) => {
|
||||||
|
const av = sortCol === "amount" ? Number(a.amount) : new Date(a[sortCol]).getTime();
|
||||||
|
const bv = sortCol === "amount" ? Number(b.amount) : new Date(b[sortCol]).getTime();
|
||||||
|
return sortDir === "desc" ? bv - av : av - bv;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleSort(col: SortCol) {
|
||||||
|
if (sortCol === col) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
|
||||||
|
else { setSortCol(col); setSortDir("desc"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortIcon({ col }: { col: SortCol }) {
|
||||||
|
if (sortCol !== col) return <span className="text-zinc-600 ml-0.5">↕</span>;
|
||||||
|
return <span className="ml-0.5">{sortDir === "desc" ? "↓" : "↑"}</span>;
|
||||||
|
}
|
||||||
|
const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds);
|
||||||
|
const { data: me } = useCurrentUser();
|
||||||
|
const [addingParticipant, setAddingParticipant] = useState(false);
|
||||||
|
const [paymentModal, setPaymentModal] = useState<{ id: number; name: string; balance: number } | null>(null);
|
||||||
|
const [showHistory, setShowHistory] = useState<number | null>(null);
|
||||||
|
const [editModal, setEditModal] = useState<SharedTransactionRow | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h2 className="text-2xl font-display">Shared Expenses</h2>
|
||||||
|
<div className="flex items-center gap-2 ml-auto flex-wrap">
|
||||||
|
<select
|
||||||
|
value={participantId ?? ""}
|
||||||
|
onChange={(e) => setParticipantId(e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
className={`border rounded px-3 py-1.5 text-sm bg-zinc-900 ${participantId ? "border-indigo-500 text-white" : "border-zinc-700 text-zinc-400"}`}
|
||||||
|
>
|
||||||
|
<option value="">All People</option>
|
||||||
|
{participants.map((p: { id: number; name: string }) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<TagFilter value={tagIds} onChange={setTagIds} />
|
||||||
|
{!addingParticipant && (
|
||||||
|
<button onClick={() => setAddingParticipant(true)}
|
||||||
|
className="text-sm px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg whitespace-nowrap">
|
||||||
|
+ Add Participant
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addingParticipant && <AddParticipantForm onDone={() => setAddingParticipant(false)} />}
|
||||||
|
|
||||||
|
{/* Balance cards */}
|
||||||
|
{realTagIds.length === 0 && tagIds.includes("untagged") ? null : realTagIds.length > 0 && (
|
||||||
|
<p className="text-xs text-zinc-500 mb-2">Showing split totals for selected tag — payments excluded (payments settle overall debt, not per-tag)</p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{balLoading ? (
|
||||||
|
<p className="text-zinc-500 text-sm col-span-3">Loading balances...</p>
|
||||||
|
) : balances.length === 0 ? (
|
||||||
|
<p className="text-zinc-500 text-sm col-span-3">No participants yet.</p>
|
||||||
|
) : (
|
||||||
|
balances.map((b) => {
|
||||||
|
const theyOweMe = b.total_owed > 0;
|
||||||
|
const net = Math.abs(b.total_owed);
|
||||||
|
const settled = net < 0.005;
|
||||||
|
return (
|
||||||
|
<div key={b.id} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{b.name}</p>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
{settled ? "all square" : theyOweMe ? `owes you` : "you owe"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className={`text-lg font-semibold ${settled ? "text-zinc-500" : theyOweMe ? "text-amber-400" : "text-blue-400"}`}>
|
||||||
|
${net.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPaymentModal({ id: b.id, name: b.name, balance: b.total_owed })}
|
||||||
|
className="flex-1 py-1.5 text-xs font-medium bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg"
|
||||||
|
>
|
||||||
|
Record Payment
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHistory(showHistory === b.id ? null : b.id)}
|
||||||
|
className={`px-3 py-1.5 text-xs rounded-lg ${showHistory === b.id ? "bg-zinc-700 text-white" : "bg-zinc-800 text-zinc-500 hover:text-zinc-300"}`}
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showHistory === b.id && me && (
|
||||||
|
<PaymentHistory participantId={b.id} currentUserId={me.id} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transaction list */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-x-auto">
|
||||||
|
<div className="px-4 py-3 border-b border-zinc-800">
|
||||||
|
<h3 className="text-sm font-medium">Split Transactions</h3>
|
||||||
|
</div>
|
||||||
|
{txLoading ? (
|
||||||
|
<p className="text-zinc-500 text-sm px-4 py-6">Loading...</p>
|
||||||
|
) : transactions.length === 0 ? (
|
||||||
|
<p className="text-zinc-500 text-sm px-4 py-6">
|
||||||
|
No split transactions yet. Use the Split button on any transaction.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm min-w-[520px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-800">
|
||||||
|
<th
|
||||||
|
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
|
||||||
|
onClick={() => toggleSort("transaction_date")}
|
||||||
|
>
|
||||||
|
Date <SortIcon col="transaction_date" />
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
|
||||||
|
onClick={() => toggleSort("created_at")}
|
||||||
|
>
|
||||||
|
Imported <SortIcon col="created_at" />
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">Description</th>
|
||||||
|
<th
|
||||||
|
className="text-right px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white"
|
||||||
|
onClick={() => toggleSort("amount")}
|
||||||
|
>
|
||||||
|
Amount <SortIcon col="amount" />
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Splits</th>
|
||||||
|
<th className="px-4 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(transactions as SharedTransactionRow[]).map((tx) => {
|
||||||
|
const splits = Array.isArray(tx.splits) ? tx.splits : [];
|
||||||
|
return (
|
||||||
|
<tr key={tx.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||||||
|
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">{formatDate(tx.transaction_date)}</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">{formatDate(tx.created_at)}</td>
|
||||||
|
<td className="px-4 py-3 max-w-xs sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">
|
||||||
|
<p className="font-medium break-words">{tx.effective_merchant || tx.description}</p>
|
||||||
|
{tx.effective_merchant && (
|
||||||
|
<p className="text-xs text-zinc-500 break-words">{tx.description}</p>
|
||||||
|
)}
|
||||||
|
{tx.notes && (
|
||||||
|
<p className="text-xs text-zinc-500 italic mt-0.5 break-words">{tx.notes}</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className={`px-4 py-3 text-right font-medium tabular-nums ${SPEND_TYPES.has(tx.transaction_type) ? "" : "text-green-400"}`}>
|
||||||
|
{formatAmount(tx.amount, tx.transaction_type)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{splits.map((s) => (
|
||||||
|
<span key={s.participant_id}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-300">
|
||||||
|
{s.participant_id === me?.id ? "Me" : s.name} {s.share_percent}%
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditModal(tx)}
|
||||||
|
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment modal */}
|
||||||
|
{paymentModal && me && (
|
||||||
|
<RecordPaymentModal
|
||||||
|
participant={{ id: paymentModal.id, name: paymentModal.name }}
|
||||||
|
currentUserId={me.id}
|
||||||
|
currentBalance={paymentModal.balance}
|
||||||
|
onClose={() => setPaymentModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editModal && (
|
||||||
|
<EditTransactionModal
|
||||||
|
transaction={editModal}
|
||||||
|
onClose={() => setEditModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+244
-48
@@ -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>
|
||||||
</div>
|
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Ccy</th>
|
||||||
{s.card_name && (
|
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Amount</th>
|
||||||
<p className="text-sm text-zinc-400 mb-2">{s.card_name}</p>
|
<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>
|
||||||
<div className="text-sm text-zinc-400 space-y-1">
|
<th className="px-4 py-2.5 hidden sm:table-cell"></th>
|
||||||
<p>Account: {s.account_number}</p>
|
</tr>
|
||||||
<p>
|
</thead>
|
||||||
Period: {formatDate(s.billing_start_date)} - {formatDate(s.billing_end_date)}
|
<tbody>
|
||||||
</p>
|
{filtered.map((s, idx) => {
|
||||||
<p>Due: {formatDate(s.payment_due_date)}</p>
|
const stmtType = asStatementType(s.statement_type);
|
||||||
</div>
|
const owed = isLiability(s.statement_type);
|
||||||
<div className="mt-3 pt-3 border-t border-zinc-800 flex items-center justify-between">
|
// Cards headline the amount due; everything else (including loans,
|
||||||
<div>
|
// where the balance is what's still owed) headlines the balance.
|
||||||
<p className="text-lg font-semibold text-red-400">
|
const displayAmount =
|
||||||
{formatCurrency(s.total_amount_due, s.currency)}
|
stmtType === "credit_card" ? s.total_amount_due : s.closing_balance;
|
||||||
</p>
|
const amount = Number(displayAmount);
|
||||||
<p className="text-xs text-zinc-500">
|
const amountColor = owed
|
||||||
{s.transaction_count} transactions
|
? "text-red-400"
|
||||||
</p>
|
: amount >= 0
|
||||||
</div>
|
? "text-green-400"
|
||||||
<Link
|
: "text-red-400";
|
||||||
href={`/transactions?statement_id=${s.id}`}
|
|
||||||
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded text-sm transition-colors"
|
return (
|
||||||
>
|
<tr key={s.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/20 transition-colors">
|
||||||
View
|
<td className="px-3 py-3 text-xs text-zinc-600 tabular-nums">{idx + 1}</td>
|
||||||
</Link>
|
<td className="px-4 py-3 sticky left-0 z-10 bg-zinc-950 border-r border-zinc-800/80">
|
||||||
</div>
|
<div className="font-medium truncate max-w-[160px]" title={s.bank_name}>
|
||||||
</div>
|
{s.bank_name}
|
||||||
))}
|
</div>
|
||||||
|
{s.card_name && (
|
||||||
|
<div className="text-xs text-zinc-500 truncate max-w-[160px]">{s.card_name}</div>
|
||||||
|
)}
|
||||||
|
{stmtType === "loan" && (s.interest_rate || s.scheduled_repayment) && (
|
||||||
|
<div className="text-xs text-zinc-500 truncate max-w-[160px]">
|
||||||
|
{[
|
||||||
|
s.interest_rate ? `${Number(s.interest_rate).toFixed(2)}% p.a.` : null,
|
||||||
|
s.scheduled_repayment
|
||||||
|
? `${formatAmount(s.scheduled_repayment)}${
|
||||||
|
s.repayment_frequency ? ` ${s.repayment_frequency}` : ""
|
||||||
|
}`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/transactions?statement_id=${s.id}`}
|
||||||
|
className="sm:hidden text-xs text-indigo-400 hover:text-indigo-300 mt-1 inline-block"
|
||||||
|
>
|
||||||
|
View →
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-400 font-mono text-xs">
|
||||||
|
{s.account_number}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
|
||||||
|
{formatPeriod(s.billing_start_date, s.billing_end_date)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
|
||||||
|
{formatDate(s.payment_due_date ?? s.billing_end_date)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-500 text-xs">
|
||||||
|
{s.currency}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
|
{displayAmount !== null && displayAmount !== undefined ? (
|
||||||
|
<span className={amountColor}>{formatAmount(displayAmount)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-zinc-600">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-zinc-500">
|
||||||
|
{s.transaction_count}
|
||||||
|
{/* Balance assertion: flags statements whose transactions
|
||||||
|
don't add up to the closing balance. */}
|
||||||
|
{s.balance_diff !== null && s.balance_diff !== undefined &&
|
||||||
|
Math.abs(Number(s.balance_diff)) >= 0.02 && (
|
||||||
|
<div
|
||||||
|
className="text-[10px] text-amber-400 mt-0.5 whitespace-nowrap"
|
||||||
|
title={`Transactions don't reconcile: expected closing ${formatAmount(
|
||||||
|
s.expected_closing
|
||||||
|
)}, statement says ${formatAmount(s.closing_balance)}`}
|
||||||
|
>
|
||||||
|
⚠ {formatAmount(Math.abs(Number(s.balance_diff)))} off
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{participants?.length ? (
|
||||||
|
<select
|
||||||
|
value={s.owner_id ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateStatement.mutate({ id: s.id, owner_id: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
className="bg-zinc-800 border border-zinc-700 rounded text-xs px-2 py-1 text-zinc-300 cursor-pointer hover:border-zinc-600 focus:outline-none focus:border-indigo-500"
|
||||||
|
>
|
||||||
|
{participants.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="text-zinc-600 text-xs">{s.owner_name}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 hidden sm:table-cell">
|
||||||
|
<Link
|
||||||
|
href={`/transactions?statement_id=${s.id}`}
|
||||||
|
className="px-3 py-1 bg-zinc-800 hover:bg-zinc-700 rounded text-xs transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
View →
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+140
-22
@@ -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>
|
||||||
<button
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
onClick={() => deleteTag.mutate(tag.id)}
|
<button
|
||||||
className="text-xs text-zinc-600 hover:text-red-400 transition-colors px-2 py-0.5 rounded hover:bg-zinc-800"
|
onClick={() => setConvertTag(tag)}
|
||||||
>
|
className="text-xs text-zinc-400 hover:text-indigo-400 transition-colors px-2 py-1 rounded hover:bg-zinc-800"
|
||||||
Delete
|
title="Convert to Trip"
|
||||||
</button>
|
>
|
||||||
|
→ Trip
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteTag.mutate(tag.id)}
|
||||||
|
className="text-xs text-zinc-600 hover:text-red-400 transition-colors px-2 py-1 rounded hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{convertTag && (
|
||||||
|
<ConvertModal tag={convertTag} onClose={() => setConvertTag(null)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+763
-67
@@ -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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
debit: "bg-red-900/30 text-red-400",
|
||||||
|
credit: "bg-green-900/30 text-green-400",
|
||||||
|
payment: "bg-blue-900/30 text-blue-400",
|
||||||
|
refund: "bg-emerald-900/30 text-emerald-400",
|
||||||
|
fee: "bg-yellow-900/30 text-yellow-400",
|
||||||
|
interest: "bg-orange-900/30 text-orange-400",
|
||||||
|
transfer: "bg-zinc-800 text-zinc-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_OPTIONS = [
|
||||||
|
"debit", "credit", "payment", "refund", "fee", "interest", "transfer",
|
||||||
|
].map((t) => ({ value: t, label: t }));
|
||||||
|
|
||||||
|
/** Tooltip text for a quick-action button: what the rule will actually do. */
|
||||||
|
function describeActions(
|
||||||
|
actions: RuleRow["actions"],
|
||||||
|
tags: { id: number; name: string }[] = [],
|
||||||
|
participants: { id: number; name: string }[] = []
|
||||||
|
): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (actions.set_category) parts.push(`category → ${formatCategory(actions.set_category)}`);
|
||||||
|
if (actions.set_merchant) parts.push(`merchant → ${actions.set_merchant}`);
|
||||||
|
if (actions.add_tag_ids?.length) {
|
||||||
|
const names = actions.add_tag_ids.map((id) => tags.find((t) => t.id === id)?.name ?? `tag#${id}`);
|
||||||
|
parts.push(`tag: ${names.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (actions.apply_split?.length) {
|
||||||
|
const shares = actions.apply_split.map(
|
||||||
|
(s) => `${participants.find((p) => p.id === s.participant_id)?.name ?? `#${s.participant_id}`} ${s.share_percent}%`
|
||||||
|
);
|
||||||
|
parts.push(`split: ${shares.join(" / ")}`);
|
||||||
|
}
|
||||||
|
return parts.join(" · ") || "no actions";
|
||||||
}
|
}
|
||||||
|
|
||||||
function TypeBadge({ type }: { type: string }) {
|
function TypeBadge({ type }: { type: string }) {
|
||||||
const colors: Record<string, string> = {
|
|
||||||
debit: "bg-red-900/30 text-red-400",
|
|
||||||
credit: "bg-green-900/30 text-green-400",
|
|
||||||
payment: "bg-blue-900/30 text-blue-400",
|
|
||||||
refund: "bg-emerald-900/30 text-emerald-400",
|
|
||||||
fee: "bg-yellow-900/30 text-yellow-400",
|
|
||||||
interest: "bg-orange-900/30 text-orange-400",
|
|
||||||
};
|
|
||||||
return (
|
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">
|
||||||
<input
|
{/* Smart query bar */}
|
||||||
type="text"
|
<div className="flex flex-col gap-1">
|
||||||
placeholder="Search..."
|
<input
|
||||||
value={filters.search}
|
type="text"
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value, offset: 0 }))}
|
value={queryInput}
|
||||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm w-48"
|
onChange={(e) => handleQueryChange(e.target.value)}
|
||||||
/>
|
placeholder="Search… or >500 <=1500 200-800"
|
||||||
|
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm w-full sm:w-64 font-mono placeholder:font-sans placeholder:text-zinc-600"
|
||||||
|
/>
|
||||||
|
{queryTokens.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{queryTokens.map((tok) => (
|
||||||
|
<span
|
||||||
|
key={tok.key}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-indigo-900/50 border border-indigo-700/50 text-indigo-300"
|
||||||
|
>
|
||||||
|
{tok.label}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => clearQueryToken(tok.key)}
|
||||||
|
className="text-indigo-400 hover:text-white leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date range */}
|
||||||
<input
|
<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,13 +994,65 @@ 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
|
||||||
|
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount_aud ?? t.amount, description: t.description, merchant: t.effective_merchant || undefined, transactionIds: undefined })}
|
||||||
|
className={`text-xs px-2 py-0.5 rounded transition-colors ${
|
||||||
|
t.splits?.some((s) => s.participant_id !== me?.id)
|
||||||
|
? "text-amber-400 hover:text-amber-200 hover:bg-zinc-800"
|
||||||
|
: "text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800"
|
||||||
|
}`}
|
||||||
|
title="Split this transaction"
|
||||||
|
>
|
||||||
|
Split
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, transactionIds: undefined })}
|
onClick={() => setEditModal(t)}
|
||||||
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
|
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
|
||||||
title="Split this transaction"
|
title="Edit this transaction"
|
||||||
>
|
>
|
||||||
Split
|
Edit
|
||||||
|
</button>
|
||||||
|
{!SPEND_TYPES.has(t.transaction_type) && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPaymentModal(t)}
|
||||||
|
className="text-xs text-emerald-600 hover:text-emerald-400 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
|
||||||
|
title="Record as debt payment"
|
||||||
|
>
|
||||||
|
Payment
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setAddModal({
|
||||||
|
title: "Duplicate Transaction",
|
||||||
|
prefill: {
|
||||||
|
date: new Date().toISOString().slice(0, 10),
|
||||||
|
description: t.description,
|
||||||
|
// Duplicates become manual (AUD) transactions, so seed
|
||||||
|
// them with the converted figure, not the native one.
|
||||||
|
amount: t.amount_aud ?? t.amount,
|
||||||
|
transaction_type: t.transaction_type,
|
||||||
|
merchant_normalized: t.effective_merchant || undefined,
|
||||||
|
category: t.effective_category || undefined,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
|
||||||
|
title="Duplicate this transaction"
|
||||||
|
>
|
||||||
|
Dupe
|
||||||
</button>
|
</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">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user