Compare commits
11 Commits
ef73a9cea0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5df164e9a5 | |||
| b8cd1b0f89 | |||
| ce67e38d77 | |||
| b8296b6e29 | |||
| 0c1f88ed9c | |||
| 4a49add277 | |||
| 07b8c1ef16 | |||
| 1b561af9e9 | |||
| 1296555f17 | |||
| 7491e70a15 | |||
| 0a1f6b48a2 |
@@ -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
|
||||
@@ -32,6 +32,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.sops
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
creation_rules:
|
||||
- path_regex: \.env$
|
||||
age: age1tw6wsyxgxa465cc0wx32u7xuw5675pyz35cuzey4huz69hc54v4qp0pvgn
|
||||
@@ -76,14 +76,25 @@ docker exec postgres-personal psql -U personal -d personal < prisma/migrations/<
|
||||
### Key Tables
|
||||
|
||||
- `statements` — one row per billing period per bank account
|
||||
- `transactions` — line items; `statement_id` is nullable (NULL = manual entry)
|
||||
- `transactions` — line items; `statement_id` is nullable (NULL = manual entry); `reconciled_with_id` links a manual tx to its matched statement tx
|
||||
- `transaction_overrides` — user corrections to AI-extracted data (category, merchant, notes)
|
||||
- `transaction_splits` — shared expense tracking (participant, share_percent, settled)
|
||||
- `split_payments` — recorded cash settlements between participants
|
||||
- `transaction_tags` — many-to-many join to `tags`
|
||||
- `rules` — auto-categorisation rules (JSONB conditions + actions)
|
||||
- `rule_apply_runs` — audit log of bulk rule-apply runs with full snapshot for revert
|
||||
- `expense_metadata` — enrichment from email receipts; `transaction_id` nullable until reconciled
|
||||
- `participants` — people; `id=1` is "Me" (the primary user)
|
||||
- `account_owner_mappings` — persists bank+account → owner assignments
|
||||
|
||||
### Import Date (`created_at`)
|
||||
|
||||
`transactions.created_at` is the import timestamp (DB default `now()`). In the transactions and shared views, the "Imported" column shows:
|
||||
- For statement transactions: when the statement was processed by N8N
|
||||
- For reconciled transactions: the `created_at` of the original manual/CSV transaction (via `LEFT JOIN transactions src ON src.reconciled_with_id = t.id`) — so the original import date is preserved post-reconciliation
|
||||
|
||||
Use `created_at` (not `transaction_date`) to answer "what was added since the last settlement?". Sort by `created_at` is supported server-side in `getTransactions` and client-side in the shared view.
|
||||
|
||||
### Rules System
|
||||
|
||||
Conditions are AND-evaluated. Fields: `merchant_normalized`, `description`, `category`, `bank_name`, `amount`, `transaction_type`. Operators: `contains`, `equals`, `starts_with`, `gt`, `lt`, `not_equals`. Actions: `set_category`, `set_merchant`, `add_tag_ids`, `apply_split`.
|
||||
@@ -111,6 +122,14 @@ Two files only:
|
||||
- Owner filter pattern: `WHERE COALESCE(t.owner_id, s.owner_id) = $1`
|
||||
- Bank name pattern: `COALESCE(s.bank_name, 'Manual') as bank_name`
|
||||
|
||||
### 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.
|
||||
|
||||
## Known Gaps / TODOs
|
||||
|
||||
See `README.md` → **Known Gaps / TODOs** for full details.
|
||||
|
||||
@@ -72,6 +72,8 @@ One row per line item within a statement. Cascade-deleted when the parent statem
|
||||
| `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)`.
|
||||
|
||||
@@ -171,6 +173,55 @@ Saved auto-categorisation rules. Applied in bulk via the Rules page.
|
||||
|
||||
---
|
||||
|
||||
### `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).
|
||||
|
||||
@@ -205,16 +256,19 @@ All routes require authentication via `X-Forwarded-User` header (set by Traefik)
|
||||
|--------|-------|-------------|
|
||||
| GET | `/api/statements` | All statements for current user |
|
||||
| GET / PATCH | `/api/statements/[id]` | Get statement; PATCH to reassign owner (also writes `account_owner_mappings`) |
|
||||
| GET | `/api/transactions` | Paginated transactions with filters: `from`, `to`, `category`, `merchant`, `statement_id`, `search`, `sort`, `dir` |
|
||||
| GET | `/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 that have active splits |
|
||||
| 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 |
|
||||
@@ -292,6 +346,7 @@ docker exec postgres-personal psql -U personal -d personal \
|
||||
|
||||
> `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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+1249
-5
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -6,7 +6,12 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"test:watch": "vitest --config vitest.config.ts",
|
||||
"test:setup": "bash scripts/setup-test-db.sh",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"test:all": "npm test && npm run test:integration"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.4.2",
|
||||
@@ -25,9 +30,12 @@
|
||||
"@types/pg": "^8.18.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
@@ -105,3 +105,91 @@ model budgets {
|
||||
|
||||
@@unique([owner_id, category, month])
|
||||
}
|
||||
|
||||
model statements {
|
||||
id Int @id @default(autoincrement())
|
||||
filename String
|
||||
bank_name String?
|
||||
card_name String?
|
||||
account_type String?
|
||||
account_number String
|
||||
billing_start_date DateTime? @db.Date
|
||||
billing_end_date DateTime? @db.Date
|
||||
total_amount_due Decimal? @db.Decimal(12, 2)
|
||||
minimum_amount_due Decimal? @db.Decimal(12, 2)
|
||||
payment_due_date DateTime? @db.Date
|
||||
event_created Boolean? @default(false)
|
||||
tier_used String?
|
||||
created_at DateTime? @default(now())
|
||||
statement_type String @default("credit_card")
|
||||
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
|
||||
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)
|
||||
owner_id Int?
|
||||
reconciled_with_id Int?
|
||||
statement statements? @relation(fields: [statement_id], references: [id], onDelete: Cascade)
|
||||
reconciled_with transactions? @relation("reconciled", fields: [reconciled_with_id], references: [id], onDelete: SetNull)
|
||||
reconciled_by transactions[] @relation("reconciled")
|
||||
expense_metadata expense_metadata?
|
||||
}
|
||||
|
||||
model expense_metadata {
|
||||
id Int @id @default(autoincrement())
|
||||
transaction_id Int? @unique
|
||||
source String @default("email")
|
||||
paperless_doc_id Int? @unique
|
||||
source_email_subject String?
|
||||
source_email_from String?
|
||||
payment_method String?
|
||||
payment_method_detail String?
|
||||
order_reference String?
|
||||
line_items Json @default("[]")
|
||||
tax_amount Decimal? @db.Decimal(12, 2)
|
||||
subtotal Decimal? @db.Decimal(12, 2)
|
||||
merchant_normalized String?
|
||||
amount Decimal? @db.Decimal(12, 2)
|
||||
transaction_date DateTime? @db.Date
|
||||
extraction_model String? @default("gemini-2.5-flash")
|
||||
created_at DateTime? @default(now())
|
||||
transaction transactions? @relation(fields: [transaction_id], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model rule_apply_runs {
|
||||
id Int @id @default(autoincrement())
|
||||
owner_id Int
|
||||
applied_at DateTime @default(now())
|
||||
split_from DateTime? @db.Date
|
||||
matched Int @default(0)
|
||||
transactions_affected Int @default(0)
|
||||
reverted_at DateTime?
|
||||
snapshot Json @default("[]")
|
||||
}
|
||||
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
# Creates the personal_test database and writes .env.test
|
||||
# Run once before integration tests: npm run test:setup
|
||||
|
||||
set -e
|
||||
|
||||
PG_CONTAINER=postgres-personal
|
||||
PG_USER=personal
|
||||
PG_PASS=personalpassword123
|
||||
TEST_DB=personal_test
|
||||
|
||||
# Discover the container's bridge IP (accessible from the host on Linux)
|
||||
PG_HOST=$(docker inspect "$PG_CONTAINER" \
|
||||
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 2>/dev/null | head -1)
|
||||
|
||||
if [ -z "$PG_HOST" ]; then
|
||||
echo "ERROR: Could not find container '$PG_CONTAINER'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Postgres container at $PG_HOST:5432"
|
||||
|
||||
# Create test database (ignore error if it already exists)
|
||||
docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres \
|
||||
-c "CREATE DATABASE $TEST_DB;" 2>/dev/null || true
|
||||
|
||||
# Wipe and rebuild schema from production (schema only, no data)
|
||||
echo "Copying schema from $PG_USER to $TEST_DB..."
|
||||
docker exec "$PG_CONTAINER" pg_dump -U "$PG_USER" --schema-only "$PG_USER" \
|
||||
| docker exec -i "$PG_CONTAINER" psql -U "$PG_USER" -d "$TEST_DB" -q
|
||||
|
||||
echo "Schema ready."
|
||||
|
||||
# Write .env.test
|
||||
cat > "$(dirname "$0")/../.env.test" << EOF
|
||||
DATABASE_URL=postgresql://$PG_USER:$PG_PASS@$PG_HOST:5432/$TEST_DB
|
||||
EOF
|
||||
|
||||
echo ".env.test written — integration tests can now run with: npm run test:integration"
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Pool } from "pg";
|
||||
import { vi } from "vitest";
|
||||
|
||||
export function createPool() {
|
||||
return new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
}
|
||||
|
||||
// Replace the app's Prisma-based queryRaw with a direct pg call so that
|
||||
// tests don't depend on Prisma's singleton picking up the right DATABASE_URL.
|
||||
// Must be called BEFORE dynamically importing any module that uses @/lib/db.
|
||||
// Uses vi.doMock (not vi.mock) so it is NOT hoisted and CAN close over `p`.
|
||||
export function mockDbWithPool(p: Pool) {
|
||||
vi.resetModules(); // clear module cache so fresh imports pick up the mock
|
||||
vi.doMock("@/lib/db", () => ({
|
||||
queryRaw: async (sql: string, params: unknown[] = []) => {
|
||||
const result = await p.query(sql, params);
|
||||
return result.rows;
|
||||
},
|
||||
prisma: p,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Wipe all data tables and restart sequences between tests. */
|
||||
export async function resetDB(pool: Pool) {
|
||||
await pool.query(`
|
||||
TRUNCATE
|
||||
split_payments,
|
||||
transaction_splits,
|
||||
transaction_tags,
|
||||
transaction_overrides,
|
||||
rule_apply_runs,
|
||||
rules,
|
||||
budgets,
|
||||
account_owner_mappings,
|
||||
transactions,
|
||||
statements,
|
||||
tags,
|
||||
participants
|
||||
RESTART IDENTITY CASCADE
|
||||
`);
|
||||
}
|
||||
|
||||
/** Seed two participants and return their IDs. */
|
||||
export async function seedParticipants(pool: Pool, names: [string, string] = ["Alice", "Bob"]) {
|
||||
const r1 = await pool.query(
|
||||
`INSERT INTO participants (name) VALUES ($1) RETURNING id`,
|
||||
[names[0]]
|
||||
);
|
||||
const r2 = await pool.query(
|
||||
`INSERT INTO participants (name) VALUES ($1) RETURNING id`,
|
||||
[names[1]]
|
||||
);
|
||||
return { ownerId: r1.rows[0].id as number, otherId: r2.rows[0].id as number };
|
||||
}
|
||||
|
||||
/** Insert a manual transaction (no statement) and return its id. */
|
||||
export async function insertTransaction(
|
||||
pool: Pool,
|
||||
ownerId: number,
|
||||
overrides: {
|
||||
description?: string;
|
||||
amount?: number;
|
||||
category?: string;
|
||||
transaction_type?: string;
|
||||
transaction_date?: string;
|
||||
merchant_normalized?: string;
|
||||
} = {}
|
||||
): Promise<number> {
|
||||
const r = await pool.query(
|
||||
`INSERT INTO transactions
|
||||
(owner_id, statement_id, transaction_date, description, amount, transaction_type, category, row_index)
|
||||
VALUES ($1, NULL, $2, $3, $4, $5, $6, 0) RETURNING id`,
|
||||
[
|
||||
ownerId,
|
||||
overrides.transaction_date ?? "2024-06-15",
|
||||
overrides.description ?? "Test transaction",
|
||||
overrides.amount ?? 100,
|
||||
overrides.transaction_type ?? "debit",
|
||||
overrides.category ?? "other",
|
||||
]
|
||||
);
|
||||
return r.rows[0].id as number;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||
import { createPool, resetDB } from "./helpers";
|
||||
|
||||
const pool = createPool();
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
// This tests the name-substitution logic applied in /api/participants GET.
|
||||
// The rule: the participant matching the current user's ID gets name "Me";
|
||||
// everyone else keeps their real name.
|
||||
function substituteMe(
|
||||
participants: { id: number; name: string }[],
|
||||
currentUserId: number
|
||||
) {
|
||||
return participants.map((p) =>
|
||||
p.id === currentUserId ? { ...p, name: "Me" } : p
|
||||
);
|
||||
}
|
||||
|
||||
describe("participant Me substitution", () => {
|
||||
it("replaces the current user's name with Me", async () => {
|
||||
const r = await pool.query(
|
||||
`INSERT INTO participants (name) VALUES ('Siddharth') RETURNING id`
|
||||
);
|
||||
const userId = r.rows[0].id;
|
||||
const participants = [
|
||||
{ id: userId, name: "Siddharth" },
|
||||
{ id: userId + 1, name: "Sonu" },
|
||||
];
|
||||
|
||||
const result = substituteMe(participants, userId);
|
||||
expect(result.find((p) => p.id === userId)?.name).toBe("Me");
|
||||
expect(result.find((p) => p.id === userId + 1)?.name).toBe("Sonu");
|
||||
});
|
||||
|
||||
it("leaves all names unchanged when currentUserId does not match", () => {
|
||||
const participants = [
|
||||
{ id: 1, name: "Siddharth" },
|
||||
{ id: 2, name: "Sonu" },
|
||||
];
|
||||
const result = substituteMe(participants, 999);
|
||||
expect(result).toEqual(participants);
|
||||
});
|
||||
|
||||
it("Sonu sees Me for herself and Siddharth for the primary user", async () => {
|
||||
const r1 = await pool.query(
|
||||
`INSERT INTO participants (name) VALUES ('Siddharth') RETURNING id`
|
||||
);
|
||||
const r2 = await pool.query(
|
||||
`INSERT INTO participants (name, email) VALUES ('Sonu', 'sonu@example.com') RETURNING id`
|
||||
);
|
||||
const siddharthId = r1.rows[0].id;
|
||||
const sonuId = r2.rows[0].id;
|
||||
|
||||
const rawParticipants = [
|
||||
{ id: siddharthId, name: "Siddharth" },
|
||||
{ id: sonuId, name: "Sonu" },
|
||||
];
|
||||
|
||||
// Siddharth's view
|
||||
const siddharthView = substituteMe(rawParticipants, siddharthId);
|
||||
expect(siddharthView.find((p) => p.id === siddharthId)?.name).toBe("Me");
|
||||
expect(siddharthView.find((p) => p.id === sonuId)?.name).toBe("Sonu");
|
||||
|
||||
// Sonu's view
|
||||
const sonuView = substituteMe(rawParticipants, sonuId);
|
||||
expect(sonuView.find((p) => p.id === siddharthId)?.name).toBe("Siddharth");
|
||||
expect(sonuView.find((p) => p.id === sonuId)?.name).toBe("Me");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
|
||||
import { createPool, mockDbWithPool, resetDB, seedParticipants, insertTransaction } from "./helpers";
|
||||
|
||||
// Create a pool and mock @/lib/db BEFORE any dynamic imports that use it.
|
||||
// vi.doMock is NOT hoisted so it can close over the pool instance.
|
||||
const pool = createPool();
|
||||
mockDbWithPool(pool);
|
||||
|
||||
// Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances
|
||||
// use the test pool rather than Prisma's singleton.
|
||||
const { getTransactions, getParticipantBalances } = await import("@/lib/queries");
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── getTransactions ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("getTransactions — owner scoping", () => {
|
||||
it("returns only the owner's transactions", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "Alice groceries" });
|
||||
await insertTransaction(pool, otherId, { description: "Bob petrol" });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].description).toBe("Alice groceries");
|
||||
});
|
||||
|
||||
it("includes transactions where owner is a split participant", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, otherId, { description: "Shared dinner" });
|
||||
|
||||
// Add Alice as a split participant
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[txId, ownerId]
|
||||
);
|
||||
|
||||
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||
expect(data.some((t) => t.description === "Shared dinner")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns correct total count", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "tx1" });
|
||||
await insertTransaction(pool, ownerId, { description: "tx2" });
|
||||
await insertTransaction(pool, ownerId, { description: "tx3" });
|
||||
|
||||
const { total } = await getTransactions(ownerId, { limit: 2, offset: 0 });
|
||||
expect(total).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — date filters", () => {
|
||||
it("filters by from date", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "old tx", transaction_date: "2024-01-10" });
|
||||
await insertTransaction(pool, ownerId, { description: "new tx", transaction_date: "2024-03-01" });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { from: "2024-02-01", limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].description).toBe("new tx");
|
||||
});
|
||||
|
||||
it("filters by to date", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "old tx", transaction_date: "2024-01-10" });
|
||||
await insertTransaction(pool, ownerId, { description: "new tx", transaction_date: "2024-03-01" });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { to: "2024-01-31", limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].description).toBe("old tx");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — category filter", () => {
|
||||
it("filters by category", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "Grocery run", category: "groceries" });
|
||||
await insertTransaction(pool, ownerId, { description: "Dinner out", category: "dining" });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { categories: ["groceries"], limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].description).toBe("Grocery run");
|
||||
});
|
||||
|
||||
it("category override takes precedence over raw category", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId, { category: "dining" });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_overrides (transaction_id, category_override) VALUES ($1, 'groceries')`,
|
||||
[txId]
|
||||
);
|
||||
|
||||
const { data: dining } = await getTransactions(ownerId, { categories: ["dining"], limit: 50, offset: 0 });
|
||||
const { data: groceries } = await getTransactions(ownerId, { categories: ["groceries"], limit: 50, offset: 0 });
|
||||
expect(dining).toHaveLength(0); // override hides original
|
||||
expect(groceries).toHaveLength(1); // override exposes new category
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — search filter", () => {
|
||||
it("searches description case-insensitively", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "COLES WYNDHAM" });
|
||||
await insertTransaction(pool, ownerId, { description: "ALDI POINT COOK" });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { search: "coles", limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].description).toBe("COLES WYNDHAM");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — amount filters", () => {
|
||||
it("filters by amount_min", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { amount: 20 });
|
||||
await insertTransaction(pool, ownerId, { amount: 200 });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { amount_min: 100, limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(Number(data[0].amount)).toBe(200);
|
||||
});
|
||||
|
||||
it("filters by amount_max", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { amount: 20 });
|
||||
await insertTransaction(pool, ownerId, { amount: 200 });
|
||||
|
||||
const { data } = await getTransactions(ownerId, { amount_max: 50, limit: 50, offset: 0 });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(Number(data[0].amount)).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — pagination", () => {
|
||||
it("respects limit and offset", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await insertTransaction(pool, ownerId, { description: `tx-${i}`, transaction_date: `2024-0${i + 1}-01` });
|
||||
}
|
||||
|
||||
const page1 = await getTransactions(ownerId, { limit: 2, offset: 0 });
|
||||
const page2 = await getTransactions(ownerId, { limit: 2, offset: 2 });
|
||||
expect(page1.data).toHaveLength(2);
|
||||
expect(page2.data).toHaveLength(2);
|
||||
expect(page1.data[0].description).not.toBe(page2.data[0].description);
|
||||
expect(page1.total).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — splits and tags attached", () => {
|
||||
it("attaches empty arrays when no splits or tags", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId);
|
||||
|
||||
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||
expect(data[0].splits).toEqual([]);
|
||||
expect(data[0].tags).toEqual([]);
|
||||
});
|
||||
|
||||
it("attaches split participants", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool, ["Alice", "Bob"]);
|
||||
const txId = await insertTransaction(pool, ownerId);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
|
||||
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||
expect(data[0].splits).toHaveLength(1);
|
||||
expect(data[0].splits[0].name).toBe("Bob");
|
||||
expect(Number(data[0].splits[0].share_percent)).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getParticipantBalances ────────────────────────────────────────────────────
|
||||
|
||||
describe("getParticipantBalances", () => {
|
||||
it("shows zero balance when no splits", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
void otherId;
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
expect(balances.every((b) => Number(b.total_owed) === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("calculates positive balance when participant owes owner", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
// Alice pays $100, Bob owes 50%
|
||||
const txId = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bobBalance = balances.find((b) => b.id === otherId);
|
||||
expect(bobBalance).toBeDefined();
|
||||
expect(Number(bobBalance!.total_owed)).toBeCloseTo(50);
|
||||
});
|
||||
|
||||
it("reduces balance after recording a payment", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
// Bob pays Alice $30
|
||||
await pool.query(
|
||||
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date)
|
||||
VALUES ($1, $2, 30, '2024-06-20')`,
|
||||
[otherId, ownerId]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bobBalance = balances.find((b) => b.id === otherId);
|
||||
expect(Number(bobBalance!.total_owed)).toBeCloseTo(20);
|
||||
});
|
||||
|
||||
it("shows negative balance when owner owes participant", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
// Bob pays $100 for a shared expense, Alice owes 50%
|
||||
const txId = await insertTransaction(pool, otherId, { amount: 100 });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[txId, ownerId]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bobBalance = balances.find((b) => b.id === otherId);
|
||||
expect(Number(bobBalance!.total_owed)).toBeCloseTo(-50);
|
||||
});
|
||||
|
||||
it("unsettled_count reflects open splits", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const tx1 = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
const tx2 = await insertTransaction(pool, ownerId, { amount: 80 });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50), ($3, $4, 50)`,
|
||||
[tx1, otherId, tx2, otherId]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bobBalance = balances.find((b) => b.id === otherId);
|
||||
expect(bobBalance!.unsettled_count).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
|
||||
describe("formatCategory", () => {
|
||||
it("capitalises single word", () => {
|
||||
expect(formatCategory("groceries")).toBe("Groceries");
|
||||
});
|
||||
it("capitalises and spaces underscore-separated words", () => {
|
||||
expect(formatCategory("home_goods")).toBe("Home Goods");
|
||||
});
|
||||
it("handles three-word categories", () => {
|
||||
expect(formatCategory("home_maintenance")).toBe("Home Maintenance");
|
||||
});
|
||||
it("handles cash_advance", () => {
|
||||
expect(formatCategory("cash_advance")).toBe("Cash Advance");
|
||||
});
|
||||
it("handles personal_care", () => {
|
||||
expect(formatCategory("personal_care")).toBe("Personal Care");
|
||||
});
|
||||
it("handles single-word categories without underscores", () => {
|
||||
expect(formatCategory("travel")).toBe("Travel");
|
||||
expect(formatCategory("fees")).toBe("Fees");
|
||||
expect(formatCategory("other")).toBe("Other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CATEGORIES", () => {
|
||||
it("contains expected core categories", () => {
|
||||
const cats = CATEGORIES as readonly string[];
|
||||
expect(cats).toContain("groceries");
|
||||
expect(cats).toContain("dining");
|
||||
expect(cats).toContain("transport");
|
||||
expect(cats).toContain("health");
|
||||
expect(cats).toContain("other");
|
||||
});
|
||||
it("has no duplicates", () => {
|
||||
const cats = CATEGORIES as readonly string[];
|
||||
expect(new Set(cats).size).toBe(cats.length);
|
||||
});
|
||||
it("all entries are lowercase with only letters and underscores", () => {
|
||||
for (const cat of CATEGORIES) {
|
||||
expect(cat).toMatch(/^[a-z_]+$/);
|
||||
}
|
||||
});
|
||||
it("formatCategory produces unique display names", () => {
|
||||
const formatted = CATEGORIES.map(formatCategory);
|
||||
expect(new Set(formatted).size).toBe(formatted.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { evaluateCondition, type Condition, type TxFields } from "@/lib/rules";
|
||||
|
||||
function tx(overrides: Partial<TxFields> = {}): TxFields {
|
||||
return {
|
||||
effective_category: "groceries",
|
||||
effective_merchant: "Coles",
|
||||
description: "COLES WYNDHAM VALE",
|
||||
bank_name: "ANZ",
|
||||
amount: 42.5,
|
||||
transaction_type: "debit",
|
||||
tags: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function cond(field: Condition["field"], operator: Condition["operator"], value: string): Condition {
|
||||
return { field, operator, value };
|
||||
}
|
||||
|
||||
// ── String fields ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("merchant_normalized", () => {
|
||||
it("contains — matches substring", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "contains", "coles"), tx())).toBe(true);
|
||||
});
|
||||
it("contains — case-insensitive", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "contains", "COLES"), tx())).toBe(true);
|
||||
});
|
||||
it("contains — no match", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "contains", "woolworths"), tx())).toBe(false);
|
||||
});
|
||||
it("equals — exact match (case-insensitive)", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "equals", "coles"), tx())).toBe(true);
|
||||
});
|
||||
it("equals — no match", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "equals", "cole"), tx())).toBe(false);
|
||||
});
|
||||
it("starts_with — matches prefix", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "starts_with", "col"), tx())).toBe(true);
|
||||
});
|
||||
it("starts_with — no match", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "starts_with", "oles"), tx())).toBe(false);
|
||||
});
|
||||
it("not_equals — different value", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "not_equals", "woolworths"), tx())).toBe(true);
|
||||
});
|
||||
it("not_equals — same value", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "not_equals", "coles"), tx())).toBe(false);
|
||||
});
|
||||
it("empty merchant falls back to empty string", () => {
|
||||
expect(evaluateCondition(cond("merchant_normalized", "contains", "coles"), tx({ effective_merchant: "" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("description", () => {
|
||||
it("contains — matches", () => {
|
||||
expect(evaluateCondition(cond("description", "contains", "wyndham"), tx())).toBe(true);
|
||||
});
|
||||
it("equals — exact (case-insensitive)", () => {
|
||||
expect(evaluateCondition(cond("description", "equals", "coles wyndham vale"), tx())).toBe(true);
|
||||
});
|
||||
it("starts_with", () => {
|
||||
expect(evaluateCondition(cond("description", "starts_with", "coles"), tx())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("category", () => {
|
||||
it("equals category", () => {
|
||||
expect(evaluateCondition(cond("category", "equals", "groceries"), tx())).toBe(true);
|
||||
});
|
||||
it("not_equals different category", () => {
|
||||
expect(evaluateCondition(cond("category", "not_equals", "dining"), tx())).toBe(true);
|
||||
});
|
||||
it("contains partial", () => {
|
||||
expect(evaluateCondition(cond("category", "contains", "grocer"), tx())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bank_name", () => {
|
||||
it("equals bank", () => {
|
||||
expect(evaluateCondition(cond("bank_name", "equals", "anz"), tx())).toBe(true);
|
||||
});
|
||||
it("not_equals different bank", () => {
|
||||
expect(evaluateCondition(cond("bank_name", "not_equals", "nab"), tx())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transaction_type", () => {
|
||||
it("equals debit", () => {
|
||||
expect(evaluateCondition(cond("transaction_type", "equals", "debit"), tx())).toBe(true);
|
||||
});
|
||||
it("not_equals credit", () => {
|
||||
expect(evaluateCondition(cond("transaction_type", "not_equals", "credit"), tx())).toBe(true);
|
||||
});
|
||||
it("equals credit — no match on debit tx", () => {
|
||||
expect(evaluateCondition(cond("transaction_type", "equals", "credit"), tx())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Amount field ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("amount", () => {
|
||||
it("equals exact amount", () => {
|
||||
expect(evaluateCondition(cond("amount", "equals", "42.5"), tx())).toBe(true);
|
||||
});
|
||||
it("equals wrong amount", () => {
|
||||
expect(evaluateCondition(cond("amount", "equals", "42"), tx())).toBe(false);
|
||||
});
|
||||
it("not_equals different amount", () => {
|
||||
expect(evaluateCondition(cond("amount", "not_equals", "100"), tx())).toBe(true);
|
||||
});
|
||||
it("gt — amount is greater", () => {
|
||||
expect(evaluateCondition(cond("amount", "gt", "40"), tx())).toBe(true);
|
||||
});
|
||||
it("gt — amount is equal (not strictly greater)", () => {
|
||||
expect(evaluateCondition(cond("amount", "gt", "42.5"), tx())).toBe(false);
|
||||
});
|
||||
it("gt — amount is less", () => {
|
||||
expect(evaluateCondition(cond("amount", "gt", "50"), tx())).toBe(false);
|
||||
});
|
||||
it("lt — amount is less", () => {
|
||||
expect(evaluateCondition(cond("amount", "lt", "50"), tx())).toBe(true);
|
||||
});
|
||||
it("lt — amount is equal (not strictly less)", () => {
|
||||
expect(evaluateCondition(cond("amount", "lt", "42.5"), tx())).toBe(false);
|
||||
});
|
||||
it("lt — amount is greater", () => {
|
||||
expect(evaluateCondition(cond("amount", "lt", "40"), tx())).toBe(false);
|
||||
});
|
||||
it("unsupported operator (contains) returns false", () => {
|
||||
expect(evaluateCondition(cond("amount", "contains", "42"), tx())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tag field ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("tag", () => {
|
||||
it("equals — tag present", () => {
|
||||
expect(evaluateCondition(cond("tag", "equals", "5"), tx({ tags: [{ id: 5 }] }))).toBe(true);
|
||||
});
|
||||
it("equals — tag absent", () => {
|
||||
expect(evaluateCondition(cond("tag", "equals", "5"), tx({ tags: [] }))).toBe(false);
|
||||
});
|
||||
it("equals — different tag", () => {
|
||||
expect(evaluateCondition(cond("tag", "equals", "5"), tx({ tags: [{ id: 7 }] }))).toBe(false);
|
||||
});
|
||||
it("not_equals — tag absent", () => {
|
||||
expect(evaluateCondition(cond("tag", "not_equals", "5"), tx({ tags: [] }))).toBe(true);
|
||||
});
|
||||
it("not_equals — tag present", () => {
|
||||
expect(evaluateCondition(cond("tag", "not_equals", "5"), tx({ tags: [{ id: 5 }] }))).toBe(false);
|
||||
});
|
||||
it("matches one of multiple tags", () => {
|
||||
expect(evaluateCondition(cond("tag", "equals", "3"), tx({ tags: [{ id: 1 }, { id: 3 }] }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Unknown field ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("unknown field", () => {
|
||||
it("returns false for unrecognised field", () => {
|
||||
// @ts-expect-error intentional invalid field for regression guard
|
||||
expect(evaluateCondition({ field: "nonexistent", operator: "equals", value: "x" }, tx())).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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 });
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } 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);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -2,24 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { getTransactions } from "@/lib/queries";
|
||||
|
||||
interface Condition {
|
||||
field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount" | "transaction_type";
|
||||
operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals";
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface SplitEntry {
|
||||
participant_id: number;
|
||||
share_percent: number;
|
||||
}
|
||||
|
||||
interface Actions {
|
||||
set_category?: string;
|
||||
add_tag_ids?: number[];
|
||||
set_merchant?: string;
|
||||
apply_split?: SplitEntry[];
|
||||
}
|
||||
import { evaluateCondition, type Condition, type Actions } from "@/lib/rules";
|
||||
|
||||
interface SnapshotEntry {
|
||||
transaction_id: number;
|
||||
@@ -30,48 +13,6 @@ interface SnapshotEntry {
|
||||
prev_splits: { participant_id: number; share_percent: number; settled: boolean }[];
|
||||
}
|
||||
|
||||
interface TxFields {
|
||||
effective_category: string;
|
||||
effective_merchant: string;
|
||||
description: string;
|
||||
bank_name: string;
|
||||
amount: number;
|
||||
transaction_type: string;
|
||||
}
|
||||
|
||||
function evaluateCondition(cond: Condition, tx: TxFields): boolean {
|
||||
if (cond.field === "amount") {
|
||||
const numVal = Number(tx.amount);
|
||||
const numCond = Number(cond.value);
|
||||
switch (cond.operator) {
|
||||
case "equals": return numVal === numCond;
|
||||
case "not_equals": return numVal !== numCond;
|
||||
case "gt": return numVal > numCond;
|
||||
case "lt": return numVal < numCond;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
let fieldVal: string;
|
||||
switch (cond.field) {
|
||||
case "merchant_normalized": fieldVal = tx.effective_merchant || ""; break;
|
||||
case "description": fieldVal = tx.description || ""; break;
|
||||
case "category": fieldVal = tx.effective_category || ""; break;
|
||||
case "bank_name": fieldVal = tx.bank_name || ""; break;
|
||||
case "transaction_type": fieldVal = tx.transaction_type || ""; break;
|
||||
default: return false;
|
||||
}
|
||||
|
||||
const strVal = fieldVal.toLowerCase();
|
||||
const strCond = cond.value.toLowerCase();
|
||||
switch (cond.operator) {
|
||||
case "contains": return strVal.includes(strCond);
|
||||
case "equals": return strVal === strCond;
|
||||
case "starts_with": return strVal.startsWith(strCond);
|
||||
case "not_equals": return strVal !== strCond;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
@@ -97,16 +38,19 @@ 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;
|
||||
|
||||
const rules = await queryRaw<{ id: number; conditions: unknown; actions: unknown }>(
|
||||
`SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND enabled = true ORDER BY priority DESC`,
|
||||
[user.id]
|
||||
ruleId
|
||||
? `SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND id = $2`
|
||||
: `SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND enabled = true ORDER BY priority DESC`,
|
||||
ruleId ? [user.id, ruleId] : [user.id]
|
||||
);
|
||||
|
||||
if (!rules.length) return NextResponse.json({ matched: 0, transactions_affected: 0 });
|
||||
|
||||
const body = await req.json().catch(() => ({})) as { splitFrom?: string | null };
|
||||
const splitFrom = body.splitFrom || null;
|
||||
|
||||
const { data: transactions } = await getTransactions(user.id, { limit: 100000, offset: 0 });
|
||||
|
||||
// --- Pre-pass: find all transactions that will match any rule ---
|
||||
|
||||
@@ -7,7 +7,11 @@ export async function GET(req: NextRequest) {
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const tagParam = req.nextUrl.searchParams.get("tag_ids");
|
||||
const tagIds = tagParam ? tagParam.split(",").map(Number).filter(Boolean) : undefined;
|
||||
const transactions = await getSharedTransactions(user.id, tagIds);
|
||||
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,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 });
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export async function GET(req: NextRequest) {
|
||||
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,
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ export default function RootLayout({
|
||||
<Providers>
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 p-6 overflow-auto">{children}</main>
|
||||
<main className="flex-1 pt-[calc(3.5rem+1rem)] px-3 pb-3 md:p-6 md:pt-6 overflow-auto">{children}</main>
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,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-xl font-semibold 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-xl font-semibold">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>
|
||||
);
|
||||
}
|
||||
+33
-8
@@ -11,6 +11,7 @@ const FIELDS = [
|
||||
{ value: "bank_name", label: "Bank" },
|
||||
{ value: "amount", label: "Amount" },
|
||||
{ value: "transaction_type", label: "Transaction Type" },
|
||||
{ value: "tag", label: "Tag" },
|
||||
] as const;
|
||||
|
||||
const TEXT_OPS = [
|
||||
@@ -35,8 +36,12 @@ 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): string {
|
||||
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}"`;
|
||||
@@ -73,7 +78,7 @@ export default function RulesPage() {
|
||||
const tagNames = new Map(tags.map((t) => [t.id, t.name]));
|
||||
const participantNames = new Map(participants.map((p) => [p.id, p.name]));
|
||||
|
||||
const [applyFrom, setApplyFrom] = useState("2026-01-08");
|
||||
const [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);
|
||||
@@ -145,8 +150,8 @@ export default function RulesPage() {
|
||||
closeForm();
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
const result = await applyRules.mutateAsync(applyFrom || undefined);
|
||||
async function handleApply(ruleId?: number) {
|
||||
const result = await applyRules.mutateAsync({ splitFrom: applyFrom || undefined, ruleId });
|
||||
setApplyResult(result);
|
||||
}
|
||||
|
||||
@@ -167,7 +172,7 @@ export default function RulesPage() {
|
||||
title="Split rules only apply to transactions on or after this date. Category/merchant/tag rules apply to all transactions."
|
||||
/>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
onClick={() => handleApply()}
|
||||
disabled={applyRules.isPending}
|
||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
@@ -217,7 +222,8 @@ export default function RulesPage() {
|
||||
{conditions.map((cond, i) => {
|
||||
const isAmount = cond.field === "amount";
|
||||
const isEnum = cond.field === "transaction_type";
|
||||
const ops = isAmount ? AMOUNT_OPS : isEnum ? ENUM_OPS : TEXT_OPS;
|
||||
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
|
||||
@@ -227,6 +233,7 @@ export default function RulesPage() {
|
||||
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);
|
||||
}}
|
||||
@@ -249,7 +256,18 @@ export default function RulesPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isEnum ? (
|
||||
{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 })}
|
||||
@@ -460,11 +478,18 @@ export default function RulesPage() {
|
||||
<span className="text-xs text-zinc-500">priority: {rule.priority}</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400">
|
||||
{conds.length > 0 ? conds.map(humanCondition).join(" AND ") : "(matches all)"}
|
||||
{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">
|
||||
<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 ${
|
||||
|
||||
+99
-14
@@ -4,6 +4,7 @@ import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
useSharedTransactions,
|
||||
useParticipantBalances,
|
||||
useParticipants,
|
||||
useCreateParticipant,
|
||||
useRecordPayment,
|
||||
usePaymentHistory,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
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" });
|
||||
@@ -39,12 +41,20 @@ function TagFilter({ value, onChange }: { value: string[]; onChange: (v: string[
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
if (!tags.length) return null;
|
||||
|
||||
const toggle = (id: string) =>
|
||||
onChange(value.includes(id) ? value.filter((x) => x !== id) : [...value, id]);
|
||||
const 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`;
|
||||
|
||||
@@ -60,6 +70,11 @@ function TagFilter({ value, onChange }: { value: string[]; onChange: (v: string[
|
||||
</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))}
|
||||
@@ -256,20 +271,54 @@ function PaymentHistory({ participantId, currentUserId }: { participantId: numbe
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
type SortCol = "transaction_date" | "created_at" | "amount";
|
||||
|
||||
export default function SharedPage() {
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
const { data: transactions = [], isLoading: txLoading } = useSharedTransactions(tagIds);
|
||||
const { data: balances = [], isLoading: balLoading } = useParticipantBalances(tagIds);
|
||||
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-xl font-semibold">Shared Expenses</h2>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<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)}
|
||||
@@ -283,6 +332,9 @@ export default function SharedPage() {
|
||||
{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>
|
||||
@@ -334,7 +386,7 @@ export default function SharedPage() {
|
||||
</div>
|
||||
|
||||
{/* Transaction list */}
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
|
||||
<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>
|
||||
@@ -345,13 +397,30 @@ export default function SharedPage() {
|
||||
No split transactions yet. Use the Split button on any transaction.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<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">Date</th>
|
||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Description</th>
|
||||
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">Amount</th>
|
||||
<th
|
||||
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium 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>
|
||||
@@ -360,7 +429,8 @@ export default function SharedPage() {
|
||||
return (
|
||||
<tr key={tx.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||||
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">{formatDate(tx.transaction_date)}</td>
|
||||
<td className="px-4 py-3 max-w-xs">
|
||||
<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>
|
||||
@@ -377,11 +447,19 @@ export default function SharedPage() {
|
||||
{splits.map((s) => (
|
||||
<span key={s.participant_id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-300">
|
||||
{s.name} {s.share_percent}%
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
@@ -399,6 +477,13 @@ export default function SharedPage() {
|
||||
onClose={() => setPaymentModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editModal && (
|
||||
<EditTransactionModal
|
||||
transaction={editModal}
|
||||
onClose={() => setEditModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,12 +134,12 @@ export default function StatementsPage() {
|
||||
) : !filtered.length ? (
|
||||
<p className="text-zinc-500 text-sm">{hasFilters ? "No statements match filters" : "No statements found"}</p>
|
||||
) : (
|
||||
<div className="border border-zinc-700 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<div className="border border-zinc-700 rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[800px]">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||||
<th className="text-left px-3 py-2.5 text-xs text-zinc-600 font-medium w-8">#</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Bank</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">Bank</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Account</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Period</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Due / End</th>
|
||||
@@ -147,7 +147,7 @@ export default function StatementsPage() {
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Amount</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Txns</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Owner</th>
|
||||
<th className="px-4 py-2.5"></th>
|
||||
<th className="px-4 py-2.5 hidden sm:table-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -164,13 +164,19 @@ export default function StatementsPage() {
|
||||
return (
|
||||
<tr key={s.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/20 transition-colors">
|
||||
<td className="px-3 py-3 text-xs text-zinc-600 tabular-nums">{idx + 1}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium truncate max-w-[180px]" title={s.bank_name}>
|
||||
<td className="px-4 py-3 sticky left-0 z-10 bg-zinc-950 border-r border-zinc-800/80">
|
||||
<div className="font-medium truncate max-w-[160px]" title={s.bank_name}>
|
||||
{s.bank_name}
|
||||
</div>
|
||||
{s.card_name && (
|
||||
<div className="text-xs text-zinc-500 truncate max-w-[180px]">{s.card_name}</div>
|
||||
<div className="text-xs text-zinc-500 truncate max-w-[160px]">{s.card_name}</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}
|
||||
@@ -211,7 +217,7 @@ export default function StatementsPage() {
|
||||
<span className="text-zinc-600 text-xs">{s.owner_name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<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"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SplitModal } from "@/components/split-modal";
|
||||
import { TagPicker } from "@/components/tag-picker";
|
||||
import { AddTransactionModal } from "@/components/add-transaction-modal";
|
||||
import { EditTransactionModal } from "@/components/edit-transaction-modal";
|
||||
import { CsvImportModal } from "@/components/csv-import-modal";
|
||||
import type { TransactionRow } from "@/lib/queries";
|
||||
|
||||
function formatDate(d: string) {
|
||||
@@ -218,7 +219,7 @@ function MarkAsPaymentModal({
|
||||
const { data: me } = useCurrentUser();
|
||||
const record = useRecordPayment();
|
||||
|
||||
const others = participants.filter((p) => p.name !== "Me");
|
||||
const others = participants.filter((p) => p.id !== me?.id);
|
||||
|
||||
const [participantId, setParticipantId] = useState<number | "">(others[0]?.id ?? "");
|
||||
|
||||
@@ -475,6 +476,7 @@ function TransactionsContent() {
|
||||
offset: 0,
|
||||
amount_min: undefined as number | undefined,
|
||||
amount_max: undefined as number | undefined,
|
||||
has_split: "" as string,
|
||||
});
|
||||
const [queryInput, setQueryInput] = useState("");
|
||||
const [queryTokens, setQueryTokens] = useState<QueryToken[]>([]);
|
||||
@@ -507,6 +509,7 @@ function TransactionsContent() {
|
||||
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 };
|
||||
@@ -517,6 +520,7 @@ function TransactionsContent() {
|
||||
const { data, isLoading } = useTransactions(filters);
|
||||
const { data: banks } = useBanks();
|
||||
const { data: tags } = useTags();
|
||||
const { data: me } = useCurrentUser();
|
||||
const { data: statementInfo } = useStatement(parseInt(filters.statement_id) || 0);
|
||||
const updateTxn = useUpdateTransaction();
|
||||
const bulkAction = useBulkAction();
|
||||
@@ -561,12 +565,20 @@ function TransactionsContent() {
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold">Transactions</h2>
|
||||
<button
|
||||
onClick={() => setAddModal({})}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
+ Add Transaction
|
||||
</button>
|
||||
<div 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 */}
|
||||
@@ -599,7 +611,7 @@ function TransactionsContent() {
|
||||
value={queryInput}
|
||||
onChange={(e) => handleQueryChange(e.target.value)}
|
||||
placeholder="Search… or >500 <=1500 200-800"
|
||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm w-64 font-mono placeholder:font-sans placeholder:text-zinc-600"
|
||||
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">
|
||||
@@ -650,9 +662,14 @@ function TransactionsContent() {
|
||||
placeholder="All Banks"
|
||||
/>
|
||||
<MultiSelect
|
||||
options={(tags ?? []).map((t) => ({ value: String(t.id), label: t.name }))}
|
||||
options={[{ value: "untagged", label: "No tags" }, ...(tags ?? []).map((t) => ({ value: String(t.id), label: t.name }))]}
|
||||
value={filters.tag_ids}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, tag_ids: v, offset: 0 }))}
|
||||
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
|
||||
@@ -661,6 +678,15 @@ function TransactionsContent() {
|
||||
onChange={(v) => setFilters((f) => ({ ...f, transaction_types: v, offset: 0 }))}
|
||||
placeholder="All Types"
|
||||
/>
|
||||
<select
|
||||
value={filters.has_split}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, has_split: e.target.value, offset: 0 }))}
|
||||
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm text-zinc-300"
|
||||
>
|
||||
<option value="">All Splits</option>
|
||||
<option value="yes">Split only</option>
|
||||
<option value="no">Unsplit only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
@@ -733,10 +759,10 @@ function TransactionsContent() {
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto border border-zinc-800 rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full text-sm min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900/50">
|
||||
<th className="p-2 w-8">
|
||||
<th className="p-2 w-8 sticky left-0 z-10 bg-zinc-900">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data?.data.length ? selected.size === data.data.length : false}
|
||||
@@ -745,11 +771,17 @@ function TransactionsContent() {
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
className="p-2 text-left cursor-pointer hover:text-white"
|
||||
className="p-2 text-left cursor-pointer hover:text-white sticky left-8 z-10 bg-zinc-900 border-r border-zinc-800/80 whitespace-nowrap"
|
||||
onClick={() => toggleSort("transaction_date")}
|
||||
>
|
||||
Date {filters.sort_by === "transaction_date" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")}
|
||||
</th>
|
||||
<th
|
||||
className="p-2 text-left text-zinc-500 cursor-pointer hover:text-white whitespace-nowrap"
|
||||
onClick={() => toggleSort("created_at")}
|
||||
>
|
||||
Imported {filters.sort_by === "created_at" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")}
|
||||
</th>
|
||||
<th className="p-2 text-left">Description</th>
|
||||
<th className="p-2 text-left">Merchant</th>
|
||||
<th
|
||||
@@ -767,9 +799,9 @@ function TransactionsContent() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr><td colSpan={10} className="p-8 text-center text-zinc-500">Loading...</td></tr>
|
||||
<tr><td colSpan={11} className="p-8 text-center text-zinc-500">Loading...</td></tr>
|
||||
) : !data?.data.length ? (
|
||||
<tr><td colSpan={10} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
|
||||
<tr><td colSpan={11} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
|
||||
) : (
|
||||
data.data.map((t) => (
|
||||
<tr
|
||||
@@ -778,7 +810,7 @@ function TransactionsContent() {
|
||||
selected.has(t.id) ? "bg-zinc-800/40" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="p-2">
|
||||
<td className={`p-2 sticky left-0 z-10 ${selected.has(t.id) ? "bg-zinc-800" : "bg-zinc-950"}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(t.id)}
|
||||
@@ -786,7 +818,8 @@ function TransactionsContent() {
|
||||
className="accent-blue-600"
|
||||
/>
|
||||
</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 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 && (
|
||||
@@ -851,7 +884,7 @@ function TransactionsContent() {
|
||||
</td>
|
||||
<td className="p-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{t.splits?.filter((s) => s.name !== "Me").map((s) => (
|
||||
{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 ${
|
||||
@@ -865,7 +898,7 @@ function TransactionsContent() {
|
||||
<button
|
||||
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, merchant: t.effective_merchant || undefined, transactionIds: undefined })}
|
||||
className={`text-xs px-2 py-0.5 rounded transition-colors ${
|
||||
t.splits?.some((s) => s.name !== "Me")
|
||||
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"
|
||||
}`}
|
||||
@@ -927,6 +960,8 @@ function TransactionsContent() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showImportModal && <CsvImportModal onClose={() => setShowImportModal(false)} />}
|
||||
|
||||
{addModal && (
|
||||
<AddTransactionModal
|
||||
prefill={addModal.prefill}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCreateTransaction, useParticipants } from "@/lib/hooks";
|
||||
import { useCreateTransaction, useParticipants, useTags } from "@/lib/hooks";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
|
||||
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||
@@ -27,6 +27,7 @@ export function AddTransactionModal({
|
||||
}) {
|
||||
const createTransaction = useCreateTransaction();
|
||||
const { data: participants = [] } = useParticipants();
|
||||
const { data: allTags = [] } = useTags();
|
||||
|
||||
const [date, setDate] = useState(prefill?.date ?? new Date().toISOString().slice(0, 10));
|
||||
const [description, setDescription] = useState(prefill?.description ?? "");
|
||||
@@ -34,6 +35,7 @@ export function AddTransactionModal({
|
||||
const [type, setType] = useState(prefill?.transaction_type ?? "debit");
|
||||
const [merchant, setMerchant] = useState(prefill?.merchant_normalized ?? "");
|
||||
const [category, setCategory] = useState(prefill?.category ?? "");
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
|
||||
const [splits, setSplits] = useState<{ participant_id: number; share_percent: number }[]>(
|
||||
prefill?.splits ?? []
|
||||
);
|
||||
@@ -51,9 +53,15 @@ export function AddTransactionModal({
|
||||
setSplits(splits.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
function toggleTag(id: number) {
|
||||
setSelectedTagIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
await createTransaction.mutateAsync({
|
||||
const result = await createTransaction.mutateAsync({
|
||||
date,
|
||||
description,
|
||||
amount: parseFloat(amount),
|
||||
@@ -62,6 +70,17 @@ export function AddTransactionModal({
|
||||
category: category || undefined,
|
||||
splits: splits.length ? splits : undefined,
|
||||
});
|
||||
if (selectedTagIds.length && result?.id) {
|
||||
await Promise.all(
|
||||
selectedTagIds.map((tagId) =>
|
||||
fetch(`/api/transactions/${result.id}/tags`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tag_id: tagId }),
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -150,6 +169,37 @@ export function AddTransactionModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{allTags.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Tags (optional)</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{allTags.map((tag) => {
|
||||
const selected = selectedTagIds.includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs border transition-colors ${
|
||||
selected
|
||||
? "border-transparent text-white"
|
||||
: "border-zinc-600 text-zinc-400 hover:border-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
style={selected ? { backgroundColor: tag.color + "cc", borderColor: tag.color } : {}}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Splits */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
import { useImportCSV } from "@/lib/hooks";
|
||||
import {
|
||||
parseCSVRows, detectHasHeaders, getColumnLabels, getDataRows, applyMapping,
|
||||
saveBankPreset, loadBankPresets,
|
||||
type DateFormat, type ColumnMapping, type ParsedTransaction, type BankPreset,
|
||||
} from "@/lib/csv-parser";
|
||||
|
||||
const DATE_FORMATS: DateFormat[] = ["DD/MM/YYYY", "YYYY-MM-DD", "MM/DD/YYYY", "M/D/YYYY"];
|
||||
const TX_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||
|
||||
type Step = "upload" | "map" | "review" | "done";
|
||||
|
||||
function ColSelect({
|
||||
label, value, onChange, options, required,
|
||||
}: {
|
||||
label: string; value: string; onChange: (v: string) => void;
|
||||
options: string[]; required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">{label}</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
{!required && <option value="">— none —</option>}
|
||||
{options.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CsvImportModal({ onClose }: { onClose: () => void }) {
|
||||
const importCSV = useImportCSV();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [step, setStep] = useState<Step>("upload");
|
||||
const [rawRows, setRawRows] = useState<string[][]>([]);
|
||||
const [hasHeaders, setHasHeaders] = useState(false);
|
||||
const [columnLabels, setColumnLabels] = useState<string[]>([]);
|
||||
const [dataRows, setDataRows] = useState<string[][]>([]);
|
||||
const [bankName, setBankName] = useState("");
|
||||
const [dateFormat, setDateFormat] = useState<DateFormat>("DD/MM/YYYY");
|
||||
const [mapping, setMapping] = useState<ColumnMapping>({
|
||||
dateCol: "", descriptionCol: "", amountMode: "single", amountCol: "",
|
||||
});
|
||||
const [savePreset, setSavePreset] = useState(false);
|
||||
const [editedRows, setEditedRows] = useState<ParsedTransaction[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [presets, setPresets] = useState<BankPreset[]>([]);
|
||||
const [insertedCount, setInsertedCount] = useState(0);
|
||||
|
||||
useEffect(() => { setPresets(loadBankPresets()); }, []);
|
||||
|
||||
function handleFile(file: File) {
|
||||
setError("");
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
const rows = parseCSVRows(text);
|
||||
if (rows.length === 0) { setError("No data found in file"); return; }
|
||||
setRawRows(rows);
|
||||
const headers = detectHasHeaders(rows, dateFormat);
|
||||
setHasHeaders(headers);
|
||||
const labels = getColumnLabels(rows, headers);
|
||||
setColumnLabels(labels);
|
||||
setDataRows(getDataRows(rows, headers));
|
||||
// auto-set first columns as defaults
|
||||
setMapping((m) => ({
|
||||
...m,
|
||||
dateCol: labels[0] ?? "",
|
||||
descriptionCol: labels[1] ?? "",
|
||||
amountCol: labels[2] ?? "",
|
||||
}));
|
||||
setStep("map");
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function applyPreset(preset: BankPreset) {
|
||||
setBankName(preset.bankName);
|
||||
setDateFormat(preset.dateFormat);
|
||||
setMapping(preset.mapping);
|
||||
}
|
||||
|
||||
function refreshColumns() {
|
||||
if (!rawRows.length) return;
|
||||
const headers = detectHasHeaders(rawRows, dateFormat);
|
||||
setHasHeaders(headers);
|
||||
const labels = getColumnLabels(rawRows, headers);
|
||||
setColumnLabels(labels);
|
||||
setDataRows(getDataRows(rawRows, headers));
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
setError("");
|
||||
if (!bankName.trim()) { setError("Bank name is required"); return; }
|
||||
if (!mapping.dateCol) { setError("Date column is required"); return; }
|
||||
if (!mapping.descriptionCol) { setError("Description column is required"); return; }
|
||||
if (mapping.amountMode === "single" && !mapping.amountCol) { setError("Amount column is required"); return; }
|
||||
if (mapping.amountMode === "debit_credit" && !mapping.debitCol && !mapping.creditCol) {
|
||||
setError("At least one of debit/credit columns is required"); return;
|
||||
}
|
||||
const parsed = applyMapping(dataRows, columnLabels, mapping, dateFormat);
|
||||
if (parsed.length === 0) { setError("No valid transactions could be parsed — check your column mapping and date format"); return; }
|
||||
if (savePreset) {
|
||||
saveBankPreset({ bankName: bankName.trim(), mapping, dateFormat });
|
||||
}
|
||||
setEditedRows(parsed);
|
||||
setStep("review");
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
setError("");
|
||||
const valid = editedRows.filter((r) => r.date && r.amount > 0 && r.description);
|
||||
if (!valid.length) { setError("No valid rows to import"); return; }
|
||||
try {
|
||||
const result = await importCSV.mutateAsync({ bank_name: bankName, transactions: valid });
|
||||
setInsertedCount(result.inserted);
|
||||
setStep("done");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Import failed");
|
||||
}
|
||||
}
|
||||
|
||||
function updateRow(i: number, patch: Partial<ParsedTransaction>) {
|
||||
setEditedRows((rows) => rows.map((r, idx) => idx === i ? { ...r, ...patch } : r));
|
||||
}
|
||||
|
||||
function deleteRow(i: number) {
|
||||
setEditedRows((rows) => rows.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
const modalWidth = step === "review" ? "max-w-5xl" : step === "map" ? "max-w-xl" : "max-w-md";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||
<div
|
||||
className={`bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-full ${modalWidth} flex flex-col max-h-[90vh]`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 flex-shrink-0">
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm text-zinc-200">Import CSV</h3>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{(["upload", "map", "review", "done"] as Step[]).map((s, i) => (
|
||||
<span
|
||||
key={s}
|
||||
className={`text-xs ${step === s ? "text-indigo-400 font-medium" : "text-zinc-600"}`}
|
||||
>
|
||||
{i + 1}. {s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300 text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1 px-6 py-5">
|
||||
|
||||
{/* Step 1: Upload */}
|
||||
{step === "upload" && (
|
||||
<div className="space-y-4">
|
||||
{presets.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Load saved preset</label>
|
||||
<select
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
defaultValue=""
|
||||
onChange={(e) => {
|
||||
const p = presets.find((x) => x.bankName === e.target.value);
|
||||
if (p) applyPreset(p);
|
||||
}}
|
||||
>
|
||||
<option value="">— select preset —</option>
|
||||
{presets.map((p) => <option key={p.bankName} value={p.bankName}>{p.bankName}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="w-full border-2 border-dashed border-zinc-700 hover:border-indigo-500 rounded-xl py-12 text-center transition-colors"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files?.[0]; if (f) handleFile(f); }}
|
||||
>
|
||||
<p className="text-zinc-400 text-sm">Drop a CSV file here, or click to browse</p>
|
||||
<p className="text-zinc-600 text-xs mt-1">Westpac, ANZ, CBA, NAB and others</p>
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Map */}
|
||||
{step === "map" && (
|
||||
<div className="space-y-4">
|
||||
{/* Raw preview */}
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-2">First 3 rows from file:</p>
|
||||
<div className="overflow-x-auto rounded border border-zinc-800">
|
||||
<table className="text-xs text-zinc-400 w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
{columnLabels.map((h) => (
|
||||
<th key={h} className="px-2 py-1.5 text-left font-medium text-zinc-300 whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dataRows.slice(0, 3).map((row, i) => (
|
||||
<tr key={i} className="border-b border-zinc-800/50">
|
||||
{columnLabels.map((_, ci) => (
|
||||
<td key={ci} className="px-2 py-1 truncate max-w-[160px]">{row[ci] ?? ""}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Bank Name</label>
|
||||
<input
|
||||
value={bankName}
|
||||
onChange={(e) => setBankName(e.target.value)}
|
||||
placeholder="e.g. Westpac"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Date Format</label>
|
||||
<select
|
||||
value={dateFormat}
|
||||
onChange={(e) => { setDateFormat(e.target.value as DateFormat); refreshColumns(); }}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
{DATE_FORMATS.map((f) => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ColSelect label="Date Column *" value={mapping.dateCol} onChange={(v) => setMapping((m) => ({ ...m, dateCol: v }))} options={columnLabels} required />
|
||||
<ColSelect label="Description Column *" value={mapping.descriptionCol} onChange={(v) => setMapping((m) => ({ ...m, descriptionCol: v }))} options={columnLabels} required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-2">Amount</label>
|
||||
<div className="flex gap-4 mb-2">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="radio" name="amtmode" value="single" checked={mapping.amountMode === "single"} onChange={() => setMapping((m) => ({ ...m, amountMode: "single" }))} className="accent-indigo-500" />
|
||||
Single signed column
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="radio" name="amtmode" value="debit_credit" checked={mapping.amountMode === "debit_credit"} onChange={() => setMapping((m) => ({ ...m, amountMode: "debit_credit" }))} className="accent-indigo-500" />
|
||||
Separate debit / credit columns
|
||||
</label>
|
||||
</div>
|
||||
{mapping.amountMode === "single" ? (
|
||||
<ColSelect label="Amount Column *" value={mapping.amountCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, amountCol: v }))} options={columnLabels} required />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ColSelect label="Debit Column" value={mapping.debitCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, debitCol: v }))} options={columnLabels} />
|
||||
<ColSelect label="Credit Column" value={mapping.creditCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, creditCol: v }))} options={columnLabels} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ColSelect label="Merchant Column (optional)" value={mapping.merchantCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, merchantCol: v || undefined }))} options={columnLabels} />
|
||||
<ColSelect label="Category Column (optional)" value={mapping.categoryCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, categoryCol: v || undefined }))} options={columnLabels} />
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer text-zinc-400">
|
||||
<input type="checkbox" checked={savePreset} onChange={(e) => setSavePreset(e.target.checked)} className="accent-indigo-500" />
|
||||
Save as preset for {bankName || "this bank"}
|
||||
</label>
|
||||
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Review */}
|
||||
{step === "review" && (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-3">
|
||||
{editedRows.length} transactions parsed. Edit or remove rows before importing.
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded border border-zinc-800">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="border-b border-zinc-800">
|
||||
<tr>
|
||||
{["Date", "Description", "Amount", "Type", "Merchant", "Category", ""].map((h) => (
|
||||
<th key={h} className="px-2 py-2 text-left text-zinc-400 font-medium whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{editedRows.map((row, i) => (
|
||||
<tr key={i} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||||
<td className="px-2 py-1">
|
||||
<input type="date" value={row.date} onChange={(e) => updateRow(i, { date: e.target.value })}
|
||||
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-28 focus:outline-none focus:border-indigo-500" />
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<input value={row.description} onChange={(e) => updateRow(i, { description: e.target.value })}
|
||||
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-48 focus:outline-none focus:border-indigo-500" />
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<input type="number" step="0.01" value={row.amount} onChange={(e) => updateRow(i, { amount: parseFloat(e.target.value) || 0 })}
|
||||
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-20 focus:outline-none focus:border-indigo-500" />
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<select value={row.transaction_type} onChange={(e) => updateRow(i, { transaction_type: e.target.value })}
|
||||
className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5 text-xs">
|
||||
{TX_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<input value={row.merchant_name ?? ""} onChange={(e) => updateRow(i, { merchant_name: e.target.value || undefined })}
|
||||
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-28 focus:outline-none focus:border-indigo-500" />
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<select value={row.category ?? ""} onChange={(e) => updateRow(i, { category: e.target.value || undefined })}
|
||||
className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5 text-xs">
|
||||
<option value="">—</option>
|
||||
{CATEGORIES.map((c) => <option key={c} value={c}>{formatCategory(c)}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<button onClick={() => deleteRow(i)} className="text-zinc-600 hover:text-red-400 text-base leading-none">×</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{error && <p className="text-red-400 text-xs mt-2">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Done */}
|
||||
{step === "done" && (
|
||||
<div className="text-center py-6 space-y-3">
|
||||
<div className="text-4xl">✓</div>
|
||||
<p className="text-zinc-200 font-medium">Imported {insertedCount} transactions</p>
|
||||
<p className="text-zinc-500 text-sm">Tagged with <span className="text-indigo-400">csv-import</span></p>
|
||||
<div className="flex gap-2 justify-center pt-2">
|
||||
<a href="/transactions" className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||
View Transactions
|
||||
</a>
|
||||
<a href="/reconcile" className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm">
|
||||
Reconcile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{step !== "done" && (
|
||||
<div className="flex gap-2 px-6 py-4 border-t border-zinc-800 flex-shrink-0">
|
||||
{step === "map" && (
|
||||
<button onClick={() => setStep("upload")} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
{step === "review" && (
|
||||
<button onClick={() => setStep("map")} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button onClick={onClose} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
{step === "map" && (
|
||||
<button onClick={handleNext} className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium">
|
||||
Next →
|
||||
</button>
|
||||
)}
|
||||
{step === "review" && (
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={importCSV.isPending || editedRows.length === 0}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{importCSV.isPending ? "Importing..." : `Import ${editedRows.length} transactions`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/transactions", label: "Transactions", icon: "receipt" },
|
||||
@@ -12,6 +13,7 @@ const NAV_ITEMS = [
|
||||
{ href: "/merchants", label: "Merchants", icon: "store" },
|
||||
{ href: "/tags", label: "Tags", icon: "tag" },
|
||||
{ href: "/rules", label: "Rules", icon: "settings" },
|
||||
{ href: "/reconcile", label: "Reconcile", icon: "git-merge" },
|
||||
];
|
||||
|
||||
const ICONS: Record<string, React.ReactNode> = {
|
||||
@@ -51,6 +53,11 @@ const ICONS: Record<string, React.ReactNode> = {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m1.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
),
|
||||
"git-merge": (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 3v12M18 9a3 3 0 100-6 3 3 0 000 6zm0 0v12M6 15a3 3 0 100 6 3 3 0 000-6zm0 0c0-4 3-6 6-6h6" />
|
||||
</svg>
|
||||
),
|
||||
store: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
@@ -60,11 +67,34 @@ const ICONS: Record<string, React.ReactNode> = {
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<aside className="w-56 bg-zinc-900 border-r border-zinc-800 flex flex-col min-h-screen">
|
||||
<div className="p-4 border-b border-zinc-800">
|
||||
// Close drawer on route change
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Prevent body scroll when drawer is open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const navContent = (
|
||||
<>
|
||||
<div className="p-4 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold text-white">Finance</h1>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="md:hidden p-1 rounded text-zinc-400 hover:text-white hover:bg-zinc-800"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 p-2">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
@@ -85,6 +115,46 @@ export function Sidebar() {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile header bar */}
|
||||
<div className="md:hidden fixed top-0 left-0 right-0 z-40 bg-zinc-900 border-b border-zinc-800 flex items-center px-4 h-14">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="p-1.5 -ml-1.5 rounded text-zinc-400 hover:text-white hover:bg-zinc-800"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="ml-3 text-base font-semibold text-white">Finance</span>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer overlay */}
|
||||
{open && (
|
||||
<div
|
||||
className="md:hidden fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<aside
|
||||
className={`md:hidden fixed top-0 left-0 z-50 w-64 h-full bg-zinc-900 border-r border-zinc-800 flex flex-col transform transition-transform duration-200 ease-in-out ${
|
||||
open ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
{navContent}
|
||||
</aside>
|
||||
|
||||
{/* Desktop sidebar — unchanged */}
|
||||
<aside className="hidden md:flex w-56 bg-zinc-900 border-r border-zinc-800 flex-col min-h-screen">
|
||||
{navContent}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction, useCreateRule } from "@/lib/hooks";
|
||||
import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction, useCreateRule, useCurrentUser } from "@/lib/hooks";
|
||||
|
||||
interface Split {
|
||||
participant_id: number;
|
||||
@@ -22,6 +22,7 @@ export function SplitModal({ transactionId, transactionIds, amount, description,
|
||||
const singleId = transactionId ?? 0;
|
||||
|
||||
const { data: participants } = useParticipants();
|
||||
const { data: currentUser } = useCurrentUser();
|
||||
const { data: existingSplits } = useTransactionSplits(isBulk ? 0 : singleId);
|
||||
const setSplits = useSetSplits();
|
||||
const bulkAction = useBulkAction();
|
||||
@@ -34,10 +35,9 @@ export function SplitModal({ transactionId, transactionIds, amount, description,
|
||||
|
||||
// Initialise: bulk always defaults to 100% Me; single loads existing splits
|
||||
useEffect(() => {
|
||||
if (!participants || participants.length === 0) return;
|
||||
const me = participants.find((p) => p.name === "Me");
|
||||
if (!participants || participants.length === 0 || !currentUser) return;
|
||||
if (isBulk) {
|
||||
if (me) setSplitsState([{ participant_id: me.id, share_percent: 100 }]);
|
||||
setSplitsState([{ participant_id: currentUser.id, share_percent: 100 }]);
|
||||
} else if (existingSplits && existingSplits.length > 0) {
|
||||
setSplitsState(
|
||||
existingSplits.map((s: { participant_id: number; share_percent: number }) => ({
|
||||
@@ -45,10 +45,10 @@ export function SplitModal({ transactionId, transactionIds, amount, description,
|
||||
share_percent: Number(s.share_percent),
|
||||
}))
|
||||
);
|
||||
} else if (me) {
|
||||
setSplitsState([{ participant_id: me.id, share_percent: 100 }]);
|
||||
} else {
|
||||
setSplitsState([{ participant_id: currentUser.id, share_percent: 100 }]);
|
||||
}
|
||||
}, [existingSplits, participants, isBulk]);
|
||||
}, [existingSplits, participants, isBulk, currentUser]);
|
||||
|
||||
const total = splits.reduce((sum, s) => sum + s.share_percent, 0);
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
export type DateFormat = "DD/MM/YYYY" | "YYYY-MM-DD" | "MM/DD/YYYY" | "M/D/YYYY";
|
||||
|
||||
export interface ColumnMapping {
|
||||
dateCol: string;
|
||||
descriptionCol: string;
|
||||
amountMode: "single" | "debit_credit";
|
||||
amountCol?: string;
|
||||
debitCol?: string;
|
||||
creditCol?: string;
|
||||
merchantCol?: string;
|
||||
categoryCol?: string;
|
||||
}
|
||||
|
||||
export interface BankPreset {
|
||||
bankName: string;
|
||||
mapping: ColumnMapping;
|
||||
dateFormat: DateFormat;
|
||||
}
|
||||
|
||||
export interface ParsedTransaction {
|
||||
date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
transaction_type: string;
|
||||
merchant_name?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export function parseCSVRows(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
const next = text[i + 1];
|
||||
if (inQuotes) {
|
||||
if (c === '"' && next === '"') { field += '"'; i++; }
|
||||
else if (c === '"') { inQuotes = false; }
|
||||
else { field += c; }
|
||||
} else {
|
||||
if (c === '"') { inQuotes = true; }
|
||||
else if (c === ',') { row.push(field.trim()); field = ""; }
|
||||
else if (c === '\r' && next === '\n') {
|
||||
row.push(field.trim());
|
||||
if (row.some((f) => f !== "")) rows.push(row);
|
||||
row = []; field = ""; i++;
|
||||
} else if (c === '\n' || c === '\r') {
|
||||
row.push(field.trim());
|
||||
if (row.some((f) => f !== "")) rows.push(row);
|
||||
row = []; field = "";
|
||||
} else { field += c; }
|
||||
}
|
||||
}
|
||||
if (field || row.length > 0) {
|
||||
row.push(field.trim());
|
||||
if (row.some((f) => f !== "")) rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function parseDate(raw: string, format: DateFormat): string {
|
||||
const s = raw.trim();
|
||||
try {
|
||||
if (format === "DD/MM/YYYY") {
|
||||
const [d, m, y] = s.split("/");
|
||||
if (!d || !m || !y || y.length !== 4) return "";
|
||||
return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
|
||||
}
|
||||
if (format === "YYYY-MM-DD") {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(s) ? s : "";
|
||||
}
|
||||
if (format === "MM/DD/YYYY" || format === "M/D/YYYY") {
|
||||
const [m, d, y] = s.split("/");
|
||||
if (!d || !m || !y || y.length !== 4) return "";
|
||||
return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
|
||||
}
|
||||
} catch { return ""; }
|
||||
return "";
|
||||
}
|
||||
|
||||
export function detectHasHeaders(rows: string[][], dateFormat: DateFormat): boolean {
|
||||
if (rows.length === 0) return false;
|
||||
return parseDate(rows[0][0] ?? "", dateFormat) === "";
|
||||
}
|
||||
|
||||
export function getColumnLabels(rows: string[][], hasHeaders: boolean): string[] {
|
||||
if (hasHeaders && rows.length > 0) {
|
||||
return rows[0].map((h, i) => h || `Column ${i + 1}`);
|
||||
}
|
||||
const maxCols = rows.reduce((m, r) => Math.max(m, r.length), 0);
|
||||
return Array.from({ length: maxCols }, (_, i) => `Column ${i + 1}`);
|
||||
}
|
||||
|
||||
export function getDataRows(rows: string[][], hasHeaders: boolean): string[][] {
|
||||
return hasHeaders ? rows.slice(1) : rows;
|
||||
}
|
||||
|
||||
export function applyMapping(
|
||||
dataRows: string[][],
|
||||
columnLabels: string[],
|
||||
mapping: ColumnMapping,
|
||||
dateFormat: DateFormat
|
||||
): ParsedTransaction[] {
|
||||
const idx = (name: string) => columnLabels.indexOf(name);
|
||||
const dateIdx = idx(mapping.dateCol);
|
||||
const descIdx = idx(mapping.descriptionCol);
|
||||
const merchantIdx = mapping.merchantCol ? idx(mapping.merchantCol) : -1;
|
||||
const categoryIdx = mapping.categoryCol ? idx(mapping.categoryCol) : -1;
|
||||
|
||||
const results: ParsedTransaction[] = [];
|
||||
for (const row of dataRows) {
|
||||
const date = parseDate(row[dateIdx] ?? "", dateFormat);
|
||||
const description = (row[descIdx] ?? "").trim();
|
||||
if (!date || !description) continue;
|
||||
|
||||
let amount = 0;
|
||||
let transaction_type = "debit";
|
||||
|
||||
if (mapping.amountMode === "single" && mapping.amountCol) {
|
||||
const raw = (row[idx(mapping.amountCol)] ?? "").replace(/[^\d.\-+]/g, "");
|
||||
const val = parseFloat(raw);
|
||||
if (isNaN(val) || val === 0) continue;
|
||||
amount = Math.abs(val);
|
||||
transaction_type = val < 0 ? "debit" : "credit";
|
||||
} else if (mapping.amountMode === "debit_credit") {
|
||||
const debitIdx = mapping.debitCol ? idx(mapping.debitCol) : -1;
|
||||
const creditIdx = mapping.creditCol ? idx(mapping.creditCol) : -1;
|
||||
const dVal = parseFloat((row[debitIdx] ?? "").replace(/[^\d.]/g, ""));
|
||||
const cVal = parseFloat((row[creditIdx] ?? "").replace(/[^\d.]/g, ""));
|
||||
if (!isNaN(dVal) && dVal > 0) { amount = dVal; transaction_type = "debit"; }
|
||||
else if (!isNaN(cVal) && cVal > 0) { amount = cVal; transaction_type = "credit"; }
|
||||
else continue;
|
||||
}
|
||||
|
||||
if (amount <= 0) continue;
|
||||
const tx: ParsedTransaction = { date, description, amount, transaction_type };
|
||||
if (merchantIdx >= 0 && row[merchantIdx]) tx.merchant_name = row[merchantIdx].trim();
|
||||
if (categoryIdx >= 0 && row[categoryIdx]) tx.category = row[categoryIdx].trim();
|
||||
results.push(tx);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function saveBankPreset(preset: BankPreset): void {
|
||||
try {
|
||||
const existing = loadBankPresets().filter((p) => p.bankName !== preset.bankName);
|
||||
localStorage.setItem("csv-presets", JSON.stringify([...existing, preset]));
|
||||
} catch { /* localStorage unavailable */ }
|
||||
}
|
||||
|
||||
export function loadBankPresets(): BankPreset[] {
|
||||
try { return JSON.parse(localStorage.getItem("csv-presets") || "[]"); }
|
||||
catch { return []; }
|
||||
}
|
||||
+70
-6
@@ -26,6 +26,7 @@ interface TransactionFilters {
|
||||
offset?: number;
|
||||
amount_min?: number;
|
||||
amount_max?: number;
|
||||
has_split?: string;
|
||||
}
|
||||
|
||||
function buildParams(filters: TransactionFilters): string {
|
||||
@@ -209,12 +210,15 @@ export function useParticipantBalances(tagIds?: string[]) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSharedTransactions(tagIds?: string[]) {
|
||||
export function useSharedTransactions(tagIds?: string[], participantId?: number) {
|
||||
return useQuery({
|
||||
queryKey: ["shared-transactions", tagIds],
|
||||
queryKey: ["shared-transactions", tagIds, participantId],
|
||||
queryFn: async () => {
|
||||
const params = tagIds?.length ? `?tag_ids=${tagIds.join(",")}` : "";
|
||||
const res = await fetch(`/api/shared-transactions${params}`);
|
||||
const sp = new URLSearchParams();
|
||||
if (tagIds?.length) sp.set("tag_ids", tagIds.join(","));
|
||||
if (participantId) sp.set("participant_id", String(participantId));
|
||||
const query = sp.toString() ? `?${sp.toString()}` : "";
|
||||
const res = await fetch(`/api/shared-transactions${query}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
@@ -511,11 +515,11 @@ export function useDeleteRule() {
|
||||
export function useApplyRules() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (splitFrom?: string) => {
|
||||
mutationFn: async (args?: { splitFrom?: string; ruleId?: number }) => {
|
||||
const res = await fetch("/api/rules/apply", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ splitFrom: splitFrom || null }),
|
||||
body: JSON.stringify({ splitFrom: args?.splitFrom || null, ruleId: args?.ruleId || null }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to apply rules");
|
||||
return res.json() as Promise<{ id: number; matched: number; transactions_affected: number }>;
|
||||
@@ -727,6 +731,66 @@ export interface MerchantTxnRow {
|
||||
statement_id: number;
|
||||
}
|
||||
|
||||
// --- CSV Import & Reconcile ---
|
||||
|
||||
export function useImportCSV() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body: {
|
||||
bank_name: string;
|
||||
transactions: {
|
||||
date: string; description: string; amount: number; transaction_type: string;
|
||||
merchant_name?: string; foreign_currency_amount?: number; foreign_currency_code?: string; category?: string;
|
||||
}[];
|
||||
}) => {
|
||||
const res = await fetch("/api/import/csv", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || "Import failed");
|
||||
return res.json() as Promise<{ inserted: number }>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||
qc.invalidateQueries({ queryKey: ["reconcile-pending"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
import type { ManualTxWithMatches } from "./queries";
|
||||
|
||||
export function usePendingReconciliations() {
|
||||
return useQuery<ManualTxWithMatches[]>({
|
||||
queryKey: ["reconcile-pending"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/reconcile/pending");
|
||||
if (!res.ok) throw new Error("Failed to fetch");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReconcile() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (matches: { manual_id: number; statement_tx_id: number }[]) => {
|
||||
const res = await fetch("/api/transactions/reconcile", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ matches }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || "Reconcile failed");
|
||||
return res.json() as Promise<{ reconciled: number }>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["reconcile-pending"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMerchantTransactions(merchant: string | null) {
|
||||
return useQuery<{ transactions: MerchantTxnRow[] }>({
|
||||
queryKey: ["analytics", "merchant-txns", merchant],
|
||||
|
||||
+232
-19
@@ -82,10 +82,14 @@ interface TransactionFilters {
|
||||
offset?: number;
|
||||
amount_min?: number;
|
||||
amount_max?: number;
|
||||
has_split?: string;
|
||||
}
|
||||
|
||||
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
|
||||
const conditions: string[] = [`(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`];
|
||||
const conditions: string[] = [
|
||||
`(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`,
|
||||
`NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)`,
|
||||
];
|
||||
const params: unknown[] = [ownerId];
|
||||
let paramIdx = 2;
|
||||
|
||||
@@ -115,8 +119,14 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
||||
}
|
||||
}
|
||||
if (filters.tag_ids?.length) {
|
||||
conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = ANY($${paramIdx++}::int[]))`);
|
||||
params.push(filters.tag_ids.map(Number));
|
||||
const noTags = filters.tag_ids.includes("untagged");
|
||||
const realTagIds = filters.tag_ids.filter((id) => id !== "untagged").map(Number);
|
||||
if (noTags) {
|
||||
conditions.push(`NOT EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id)`);
|
||||
} else if (realTagIds.length > 0) {
|
||||
conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = ANY($${paramIdx++}::int[]))`);
|
||||
params.push(realTagIds);
|
||||
}
|
||||
}
|
||||
if (filters.transaction_types?.length) {
|
||||
conditions.push(`t.transaction_type = ANY($${paramIdx++}::text[])`);
|
||||
@@ -139,10 +149,15 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
||||
conditions.push(`t.amount <= $${paramIdx++}`);
|
||||
params.push(filters.amount_max);
|
||||
}
|
||||
if (filters.has_split === "yes") {
|
||||
conditions.push(`EXISTS (SELECT 1 FROM transaction_splits ts_f WHERE ts_f.transaction_id = t.id)`);
|
||||
} else if (filters.has_split === "no") {
|
||||
conditions.push(`NOT EXISTS (SELECT 1 FROM transaction_splits ts_f WHERE ts_f.transaction_id = t.id)`);
|
||||
}
|
||||
|
||||
const where = `WHERE ${conditions.join(" AND ")}`;
|
||||
|
||||
const sortCol = filters.sort_by === "amount" ? "t.amount" : "t.transaction_date";
|
||||
const sortCol = filters.sort_by === "amount" ? "t.amount" : filters.sort_by === "created_at" ? "t.created_at" : "t.transaction_date";
|
||||
const sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC";
|
||||
const limit = filters.limit || 50;
|
||||
const offset = filters.offset || 0;
|
||||
@@ -165,12 +180,14 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
||||
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||
p.name as owner_name,
|
||||
COALESCE(src.created_at, t.created_at) as created_at,
|
||||
txn_tags.tags,
|
||||
txn_splits.splits
|
||||
FROM transactions t
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
|
||||
LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags
|
||||
FROM transaction_tags tt
|
||||
@@ -281,10 +298,25 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
|
||||
tagFilter = `AND EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id = ANY($2::int[]))`;
|
||||
}
|
||||
|
||||
// Payments settle the total relationship between two people, not a specific tag.
|
||||
// Only subtract payments when viewing the unfiltered total; with a tag filter
|
||||
// active, show the raw split amount for that tag context only.
|
||||
const paymentsJoin = tagIds?.length ? "" : `
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
CASE WHEN sp.from_participant_id != $1 THEN sp.from_participant_id ELSE sp.to_participant_id END AS pid,
|
||||
SUM(CASE WHEN sp.to_participant_id = $1 THEN sp.amount ELSE -sp.amount END) AS net_paid
|
||||
FROM split_payments sp
|
||||
WHERE sp.from_participant_id = $1 OR sp.to_participant_id = $1
|
||||
GROUP BY pid
|
||||
) payments ON payments.pid = p.id`;
|
||||
const paymentsSelect = tagIds?.length ? "" : "- COALESCE(payments.net_paid, 0)::numeric(12,2)";
|
||||
const paymentsGroup = tagIds?.length ? "" : ", payments.net_paid";
|
||||
|
||||
return queryRaw<ParticipantBalance>(`
|
||||
SELECT p.id, p.name,
|
||||
COALESCE(SUM(splits.signed_amount), 0)::numeric(12,2)
|
||||
- COALESCE(payments.net_paid, 0)::numeric(12,2) AS total_owed,
|
||||
${paymentsSelect} AS total_owed,
|
||||
COALESCE(SUM(splits.split_count), 0)::int AS unsettled_count
|
||||
FROM participants p
|
||||
|
||||
@@ -297,6 +329,7 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
|
||||
JOIN transactions t ON t.id = ts.transaction_id
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE COALESCE(t.owner_id, s.owner_id) = $1 AND ts.participant_id != $1
|
||||
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
|
||||
${tagFilter}
|
||||
|
||||
UNION ALL
|
||||
@@ -309,21 +342,13 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
|
||||
JOIN transactions t ON t.id = ts.transaction_id
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE ts.participant_id = $1 AND COALESCE(t.owner_id, s.owner_id) != $1
|
||||
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
|
||||
${tagFilter}
|
||||
) splits ON splits.pid = p.id
|
||||
|
||||
-- Net payments always unfiltered (payments are against total debt, not per-tag)
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
CASE WHEN sp.from_participant_id != $1 THEN sp.from_participant_id ELSE sp.to_participant_id END AS pid,
|
||||
SUM(CASE WHEN sp.to_participant_id = $1 THEN sp.amount ELSE -sp.amount END) AS net_paid
|
||||
FROM split_payments sp
|
||||
WHERE sp.from_participant_id = $1 OR sp.to_participant_id = $1
|
||||
GROUP BY pid
|
||||
) payments ON payments.pid = p.id
|
||||
${paymentsJoin}
|
||||
|
||||
WHERE p.id != $1
|
||||
GROUP BY p.id, p.name, payments.net_paid
|
||||
GROUP BY p.id, p.name ${paymentsGroup}
|
||||
ORDER BY p.name
|
||||
`, params);
|
||||
}
|
||||
@@ -332,6 +357,182 @@ export interface SharedTransactionRow extends TransactionRow {
|
||||
splits: { participant_id: number; name: string; share_percent: number; settled: boolean }[];
|
||||
}
|
||||
|
||||
export async function ensureTag(name: string, color: string): Promise<number> {
|
||||
const rows = await queryRaw<{ id: number }>(
|
||||
`INSERT INTO tags (name, color) VALUES ($1, $2)
|
||||
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id`,
|
||||
[name, color]
|
||||
);
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
export async function batchInsertCSVTransactions(
|
||||
ownerId: number,
|
||||
rows: {
|
||||
date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
transaction_type: string;
|
||||
merchant_name?: string;
|
||||
foreign_currency_amount?: number;
|
||||
foreign_currency_code?: string;
|
||||
category?: string;
|
||||
}[],
|
||||
tagId: number
|
||||
): Promise<number> {
|
||||
if (rows.length === 0) return 0;
|
||||
|
||||
const baseRows = await queryRaw<{ base: number }>(
|
||||
`SELECT COALESCE(MAX(row_index), -1) as base FROM transactions WHERE owner_id = $1 AND statement_id IS NULL`,
|
||||
[ownerId]
|
||||
);
|
||||
const base = Number(baseRows[0].base);
|
||||
|
||||
const valueClauses: string[] = [];
|
||||
const params: unknown[] = [ownerId];
|
||||
let p = 2;
|
||||
rows.forEach((r, i) => {
|
||||
valueClauses.push(`(NULL, $1, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, ${base + 1 + i})`);
|
||||
params.push(r.date, r.description, r.amount, r.transaction_type, r.merchant_name ?? null, r.foreign_currency_amount ?? null, r.foreign_currency_code ?? null);
|
||||
});
|
||||
|
||||
const txIds = await queryRaw<{ id: number }>(
|
||||
`INSERT INTO transactions (statement_id, owner_id, transaction_date, description, amount, transaction_type, merchant_name, foreign_currency_amount, foreign_currency_code, row_index)
|
||||
VALUES ${valueClauses.join(", ")}
|
||||
RETURNING id`,
|
||||
params
|
||||
);
|
||||
|
||||
if (txIds.length > 0) {
|
||||
const tagValueClauses = txIds.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`);
|
||||
const tagParams: unknown[] = txIds.flatMap((r) => [r.id, tagId]);
|
||||
await queryRaw(
|
||||
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ${tagValueClauses.join(", ")} ON CONFLICT DO NOTHING`,
|
||||
tagParams
|
||||
);
|
||||
}
|
||||
|
||||
return txIds.length;
|
||||
}
|
||||
|
||||
export interface PotentialMatch {
|
||||
id: number;
|
||||
transaction_date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
transaction_type: string;
|
||||
effective_merchant: string;
|
||||
effective_category: string;
|
||||
bank_name: string;
|
||||
billing_end_date: string | null;
|
||||
}
|
||||
|
||||
export interface ManualTxWithMatches extends TransactionRow {
|
||||
matches: PotentialMatch[];
|
||||
}
|
||||
|
||||
export async function getPendingReconciliations(ownerId: number): Promise<ManualTxWithMatches[]> {
|
||||
// Fetch all unreconciled manual transactions
|
||||
const raw = await queryRaw<TransactionRow & { tags: string | TagRow[]; splits: string | TransactionRow["splits"] }>(
|
||||
`SELECT t.*,
|
||||
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
|
||||
COALESCE(o.category_override, t.category) as effective_category,
|
||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||
'Manual' as bank_name,
|
||||
t.owner_id,
|
||||
p.name as owner_name,
|
||||
txn_tags.tags,
|
||||
txn_splits.splits
|
||||
FROM transactions t
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN participants p ON p.id = t.owner_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(json_agg(json_build_object('id', tg.id, 'name', tg.name, 'color', tg.color) ORDER BY tg.name), '[]'::json) as tags
|
||||
FROM transaction_tags tt JOIN tags tg ON tg.id = tt.tag_id
|
||||
WHERE tt.transaction_id = t.id
|
||||
) txn_tags ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(json_agg(json_build_object('participant_id', ts.participant_id, 'name', sp.name, 'share_percent', ts.share_percent, 'settled', ts.settled) ORDER BY sp.name), '[]'::json) as splits
|
||||
FROM transaction_splits ts JOIN participants sp ON sp.id = ts.participant_id
|
||||
WHERE ts.transaction_id = t.id
|
||||
) txn_splits ON true
|
||||
WHERE t.statement_id IS NULL AND t.owner_id = $1 AND t.reconciled_with_id IS NULL
|
||||
ORDER BY t.transaction_date DESC, t.row_index ASC`,
|
||||
[ownerId]
|
||||
);
|
||||
|
||||
const manualTxs = raw.map((r) => ({
|
||||
...r,
|
||||
tags: typeof r.tags === "string" ? JSON.parse(r.tags) : (r.tags ?? []),
|
||||
splits: typeof r.splits === "string" ? JSON.parse(r.splits) : (r.splits ?? []),
|
||||
})) as TransactionRow[];
|
||||
|
||||
if (manualTxs.length === 0) return [];
|
||||
|
||||
// Fetch all potential matches in one query using window function
|
||||
const matchRows = await queryRaw<PotentialMatch & { manual_id: number; rn: number }>(
|
||||
`SELECT manual_id, id, transaction_date, description, amount, transaction_type,
|
||||
effective_merchant, effective_category, bank_name, billing_end_date
|
||||
FROM (
|
||||
SELECT
|
||||
m.id AS manual_id,
|
||||
t.id,
|
||||
t.transaction_date,
|
||||
t.description,
|
||||
t.amount,
|
||||
t.transaction_type,
|
||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, '') AS effective_merchant,
|
||||
COALESCE(o.category_override, t.category, '') AS effective_category,
|
||||
s.bank_name,
|
||||
s.billing_end_date,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY m.id
|
||||
ORDER BY ABS(t.amount - m.amount), ABS(t.transaction_date - m.transaction_date)
|
||||
) AS rn
|
||||
FROM transactions m
|
||||
JOIN transactions t ON t.statement_id IS NOT NULL
|
||||
AND t.transaction_date BETWEEN m.transaction_date - 3 AND m.transaction_date + 3
|
||||
AND t.amount BETWEEN m.amount * 0.99 AND m.amount * 1.01
|
||||
JOIN statements s ON s.id = t.statement_id
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
WHERE m.statement_id IS NULL
|
||||
AND m.owner_id = $1
|
||||
AND m.reconciled_with_id IS NULL
|
||||
AND COALESCE(t.owner_id, s.owner_id) = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM transactions mt WHERE mt.reconciled_with_id = t.id
|
||||
)
|
||||
) sq
|
||||
WHERE rn <= 5
|
||||
ORDER BY manual_id, rn`,
|
||||
[ownerId]
|
||||
);
|
||||
|
||||
// Group matches by manual_id
|
||||
const matchesByManualId = new Map<number, PotentialMatch[]>();
|
||||
for (const row of matchRows) {
|
||||
const list = matchesByManualId.get(row.manual_id) ?? [];
|
||||
list.push({
|
||||
id: row.id,
|
||||
transaction_date: row.transaction_date,
|
||||
description: row.description,
|
||||
amount: row.amount,
|
||||
transaction_type: row.transaction_type,
|
||||
effective_merchant: row.effective_merchant,
|
||||
effective_category: row.effective_category,
|
||||
bank_name: row.bank_name,
|
||||
billing_end_date: row.billing_end_date,
|
||||
});
|
||||
matchesByManualId.set(row.manual_id, list);
|
||||
}
|
||||
|
||||
return manualTxs.map((tx) => ({
|
||||
...tx,
|
||||
matches: matchesByManualId.get(tx.id) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getTags() {
|
||||
return queryRaw<TagRow & { transaction_count: number }>(`
|
||||
SELECT tg.id, tg.name, tg.color,
|
||||
@@ -343,14 +544,22 @@ export async function getTags() {
|
||||
`);
|
||||
}
|
||||
|
||||
export async function getSharedTransactions(ownerId: number, tagIds?: number[]) {
|
||||
export async function getSharedTransactions(ownerId: number, tagIds?: number[], noTags?: boolean, participantId?: number) {
|
||||
const params: unknown[] = [ownerId];
|
||||
let tagClause = "";
|
||||
if (tagIds?.length) {
|
||||
if (noTags) {
|
||||
tagClause = `AND NOT EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id)`;
|
||||
} else if (tagIds?.length) {
|
||||
params.push(tagIds);
|
||||
tagClause = `AND EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id = ANY($2::int[]))`;
|
||||
}
|
||||
|
||||
let participantClause = "";
|
||||
if (participantId) {
|
||||
params.push(participantId);
|
||||
participantClause = `AND EXISTS (SELECT 1 FROM transaction_splits ts_p WHERE ts_p.transaction_id = t.id AND ts_p.participant_id = $${params.length})`;
|
||||
}
|
||||
|
||||
const rows = await queryRaw<TransactionRow & { split_data: string }>(`
|
||||
SELECT t.*,
|
||||
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
||||
@@ -359,6 +568,7 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[])
|
||||
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||
p_owner.name as owner_name,
|
||||
COALESCE(src.created_at, t.created_at) as created_at,
|
||||
json_agg(json_build_object(
|
||||
'split_id', ts.id,
|
||||
'participant_id', ts.participant_id,
|
||||
@@ -372,6 +582,7 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[])
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
LEFT JOIN participants p_owner ON p_owner.id = COALESCE(t.owner_id, s.owner_id)
|
||||
LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL
|
||||
WHERE (
|
||||
(
|
||||
COALESCE(t.owner_id, s.owner_id) = $1
|
||||
@@ -381,8 +592,10 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[])
|
||||
AND EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1)
|
||||
)
|
||||
)
|
||||
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
|
||||
${tagClause}
|
||||
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name
|
||||
${participantClause}
|
||||
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name, src.created_at
|
||||
ORDER BY t.transaction_date DESC
|
||||
`, params);
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export interface Condition {
|
||||
field:
|
||||
| "merchant_normalized"
|
||||
| "description"
|
||||
| "category"
|
||||
| "bank_name"
|
||||
| "amount"
|
||||
| "transaction_type"
|
||||
| "tag";
|
||||
operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals";
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SplitEntry {
|
||||
participant_id: number;
|
||||
share_percent: number;
|
||||
}
|
||||
|
||||
export interface Actions {
|
||||
set_category?: string;
|
||||
add_tag_ids?: number[];
|
||||
set_merchant?: string;
|
||||
apply_split?: SplitEntry[];
|
||||
}
|
||||
|
||||
export interface TxFields {
|
||||
effective_category: string;
|
||||
effective_merchant: string;
|
||||
description: string;
|
||||
bank_name: string;
|
||||
amount: number;
|
||||
transaction_type: string;
|
||||
tags: { id: number }[];
|
||||
}
|
||||
|
||||
export function evaluateCondition(cond: Condition, tx: TxFields): boolean {
|
||||
if (cond.field === "amount") {
|
||||
const numVal = Number(tx.amount);
|
||||
const numCond = Number(cond.value);
|
||||
switch (cond.operator) {
|
||||
case "equals": return numVal === numCond;
|
||||
case "not_equals": return numVal !== numCond;
|
||||
case "gt": return numVal > numCond;
|
||||
case "lt": return numVal < numCond;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (cond.field === "tag") {
|
||||
const tagId = Number(cond.value);
|
||||
const hasTag = tx.tags.some((t) => t.id === tagId);
|
||||
return cond.operator === "not_equals" ? !hasTag : hasTag;
|
||||
}
|
||||
|
||||
let fieldVal: string;
|
||||
switch (cond.field) {
|
||||
case "merchant_normalized": fieldVal = tx.effective_merchant || ""; break;
|
||||
case "description": fieldVal = tx.description || ""; break;
|
||||
case "category": fieldVal = tx.effective_category || ""; break;
|
||||
case "bank_name": fieldVal = tx.bank_name || ""; break;
|
||||
case "transaction_type": fieldVal = tx.transaction_type || ""; break;
|
||||
default: return false;
|
||||
}
|
||||
|
||||
const strVal = fieldVal.toLowerCase();
|
||||
const strCond = cond.value.toLowerCase();
|
||||
switch (cond.operator) {
|
||||
case "contains": return strVal.includes(strCond);
|
||||
case "equals": return strVal === strCond;
|
||||
case "starts_with": return strVal.startsWith(strCond);
|
||||
case "not_equals": return strVal !== strCond;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/__tests__/unit/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
// Load .env.test into process.env HERE, at config-load time.
|
||||
// Forked workers inherit the parent process env, so Prisma's singleton
|
||||
// will see the test DATABASE_URL when db.ts is first imported.
|
||||
const envTestPath = resolve(__dirname, ".env.test");
|
||||
if (existsSync(envTestPath)) {
|
||||
for (const line of readFileSync(envTestPath, "utf-8").split("\n")) {
|
||||
const m = line.match(/^([^#=]+)=(.*)/);
|
||||
if (m) process.env[m[1].trim()] = m[2].trim();
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/__tests__/integration/**/*.test.ts"],
|
||||
pool: "forks",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user