Compare commits
4 Commits
0c1f88ed9c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5df164e9a5 | |||
| b8cd1b0f89 | |||
| ce67e38d77 | |||
| b8296b6e29 |
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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("[]")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export async function GET(req: NextRequest) {
|
||||
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 transactions = await getSharedTransactions(user.id, tagIds.length ? tagIds : undefined, noTags);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -50,18 +50,20 @@ export async function POST(req: NextRequest) {
|
||||
my_share_percent: override.my_share_percent,
|
||||
},
|
||||
});
|
||||
await tx.transaction_overrides.deleteMany({ where: { transaction_id: manual_id } });
|
||||
}
|
||||
|
||||
// Copy tags: manual → statement tx
|
||||
// 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 } });
|
||||
}
|
||||
|
||||
// Copy splits: manual → statement tx
|
||||
// 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({
|
||||
@@ -72,6 +74,7 @@ export async function POST(req: NextRequest) {
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
await tx.transaction_splits.deleteMany({ where: { transaction_id: manual_id } });
|
||||
}
|
||||
|
||||
// Mark manual tx as reconciled (link to statement tx)
|
||||
|
||||
+15
-2
@@ -4,6 +4,7 @@ import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
useSharedTransactions,
|
||||
useParticipantBalances,
|
||||
useParticipants,
|
||||
useCreateParticipant,
|
||||
useRecordPayment,
|
||||
usePaymentHistory,
|
||||
@@ -274,10 +275,12 @@ type SortCol = "transaction_date" | "created_at" | "amount";
|
||||
|
||||
export default function SharedPage() {
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
const [participantId, setParticipantId] = useState<number | undefined>(undefined);
|
||||
const [sortCol, setSortCol] = useState<SortCol>("transaction_date");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
const realTagIds = tagIds.filter((id) => id !== "untagged");
|
||||
const { data: rawTransactions = [], isLoading: txLoading } = useSharedTransactions(tagIds);
|
||||
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();
|
||||
@@ -305,7 +308,17 @@ export default function SharedPage() {
|
||||
<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)}
|
||||
|
||||
+7
-4
@@ -210,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();
|
||||
},
|
||||
});
|
||||
|
||||
+11
-1
@@ -329,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
|
||||
@@ -341,6 +342,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 ts.participant_id = $1 AND COALESCE(t.owner_id, s.owner_id) != $1
|
||||
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
|
||||
${tagFilter}
|
||||
) splits ON splits.pid = p.id
|
||||
${paymentsJoin}
|
||||
@@ -542,7 +544,7 @@ export async function getTags() {
|
||||
`);
|
||||
}
|
||||
|
||||
export async function getSharedTransactions(ownerId: number, tagIds?: number[], noTags?: boolean) {
|
||||
export async function getSharedTransactions(ownerId: number, tagIds?: number[], noTags?: boolean, participantId?: number) {
|
||||
const params: unknown[] = [ownerId];
|
||||
let tagClause = "";
|
||||
if (noTags) {
|
||||
@@ -552,6 +554,12 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
|
||||
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,
|
||||
@@ -584,7 +592,9 @@ 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}
|
||||
${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);
|
||||
|
||||
Reference in New Issue
Block a user