Compare commits
4 Commits
f90ba332bd
...
a7461ff83b
| Author | SHA1 | Date | |
|---|---|---|---|
| a7461ff83b | |||
| c1d031511a | |||
| 7379437cc3 | |||
| 8bd7d77a8a |
@@ -1,36 +1,302 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# Finance App
|
||||
|
||||
## Getting Started
|
||||
Personal finance tracker built on Next.js 16 (App Router), PostgreSQL, and Prisma. Bank statements are ingested automatically from Paperless-NGX via an N8N workflow that uses Gemini to extract structured data from PDF statements.
|
||||
|
||||
First, run the development server:
|
||||
## Stack
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
- **Frontend**: Next.js 16 App Router, TypeScript, Tailwind CSS, Recharts
|
||||
- **Backend**: Next.js API routes, raw PostgreSQL via `pg` + `@prisma/adapter-pg`
|
||||
- **Database**: PostgreSQL (`postgres-personal` container)
|
||||
- **Auth**: `X-Forwarded-User` header (email) set by Traefik forward-auth → mapped to `participants.email`
|
||||
- **Ingestion**: N8N workflow → Gemini 2.5 Flash (PDF parsing) → PostgreSQL
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### `statements`
|
||||
The top-level document, one row per billing period per account.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | int | Primary key |
|
||||
| `bank_name` | text | Normalised bank name (e.g. "American Express") |
|
||||
| `card_name` | text | Product name (e.g. "Rewards Travel Adventures") |
|
||||
| `account_number` | text | Account/card number (spaces stripped) |
|
||||
| `account_type` | text | Raw account type string from statement |
|
||||
| `statement_type` | text | Normalised type: `Credit Card`, `Business Card`, `multi-currency account`, etc. |
|
||||
| `account_holder_name` | text | Name on the account if extracted |
|
||||
| `billing_start_date` | date | Period start |
|
||||
| `billing_end_date` | date | Period end — used as the deduplication anchor |
|
||||
| `opening_balance` | numeric | Balance at start of period |
|
||||
| `closing_balance` | numeric | Balance at end of period |
|
||||
| `total_credits` | numeric | Sum of all credits in period |
|
||||
| `total_debits` | numeric | Sum of all debits in period |
|
||||
| `total_amount_due` | numeric | Amount due (credit cards) |
|
||||
| `minimum_amount_due` | numeric | Minimum payment due (credit cards) |
|
||||
| `payment_due_date` | date | Payment due date (credit cards) |
|
||||
| `credit_limit` | numeric | Credit limit (credit cards) |
|
||||
| `available_credit` | numeric | Available credit at statement date |
|
||||
| `interest_charged` | numeric | Interest charged this period (from statement summary) |
|
||||
| `fees_charged` | numeric | Fees charged this period (from statement summary) |
|
||||
| `currency` | text | Statement currency (e.g. `AUD`, `USD`) |
|
||||
| `exchange_rate_to_aud` | numeric | FX rate at ingestion time (live from open.er-api.com) |
|
||||
| `owner_id` | int FK → `participants` | Which person owns this statement |
|
||||
| `paperless_doc_id` | int | Paperless-NGX document ID — deduplication key |
|
||||
| `tier_used` | text | AI model used for extraction (e.g. `gemini-2.5-flash`) |
|
||||
| `event_created` | bool | Whether a Google Calendar reminder was created for payment due date |
|
||||
|
||||
**Deduplication**: unique index on `(bank_name, account_number, billing_end_date)` prevents re-ingestion of the same period. `paperless_doc_id` has a separate unique index for Paperless-linked documents.
|
||||
|
||||
**Credit card detection**: `statement_type ILIKE '%card%'`
|
||||
|
||||
---
|
||||
|
||||
### `transactions`
|
||||
One row per line item within a statement. Cascade-deleted when the parent statement is deleted.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | int | Primary key |
|
||||
| `statement_id` | int FK → `statements` | Parent statement |
|
||||
| `transaction_date` | date | Date of transaction |
|
||||
| `description` | text | Raw description from the statement |
|
||||
| `amount` | numeric | Original amount in statement currency |
|
||||
| `amount_aud` | numeric | AUD-converted amount (= amount if already AUD) |
|
||||
| `transaction_type` | text | `debit`, `credit`, `payment`, `refund`, `fee`, `interest`, `transfer` |
|
||||
| `merchant_name` | text | Raw merchant name extracted by Gemini |
|
||||
| `merchant_normalized` | text | Cleaned/normalised merchant name (Gemini) |
|
||||
| `location` | text | Location if present on statement |
|
||||
| `foreign_currency_amount` | numeric | Original foreign amount if this was an FX transaction |
|
||||
| `foreign_currency_code` | text | Foreign currency code (e.g. `USD`) |
|
||||
| `category` | text | AI-assigned category (see category taxonomy below) |
|
||||
| `row_index` | int | Position in statement — used for deduplication |
|
||||
|
||||
**Deduplication**: unique index on `(statement_id, transaction_date, description, amount, row_index)`.
|
||||
|
||||
**Analytics**: all spend queries use `amount_aud` for cross-currency consistency. Split-adjusted queries apply `amount_aud * share_percent / 100` where a split exists for the current user.
|
||||
|
||||
---
|
||||
|
||||
### `transaction_overrides`
|
||||
User corrections to AI-extracted data. Stored separately to preserve the original extraction.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `transaction_id` | int FK → `transactions` (unique) | One override per transaction |
|
||||
| `merchant_normalized` | text | User-corrected merchant name |
|
||||
| `category_override` | text | User-corrected category |
|
||||
| `notes` | text | Free-text notes |
|
||||
|
||||
All analytics queries use `COALESCE(o.category_override, t.category)` and `COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name)` to prefer overrides over AI values.
|
||||
|
||||
---
|
||||
|
||||
### `transaction_splits`
|
||||
Shared expense tracking — records that a transaction was split between participants.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `transaction_id` | int FK → `transactions` | The transaction being split |
|
||||
| `participant_id` | int FK → `participants` | Who shares in this transaction |
|
||||
| `share_percent` | numeric(5,2) | Their percentage (1–100) |
|
||||
| `settled` | bool | Whether this share has been settled |
|
||||
| `settled_at` | timestamptz | When it was settled |
|
||||
|
||||
A transaction can be split across multiple participants. The statement owner's own share is implicit (`100 - SUM(other shares)`). Analytics queries LEFT JOIN `transaction_splits` on `participant_id = current_user.id` — if no split row exists, the full amount belongs to the owner.
|
||||
|
||||
---
|
||||
|
||||
### `transaction_tags`
|
||||
Many-to-many join between transactions and tags.
|
||||
|
||||
| Column | Type |
|
||||
|--------|------|
|
||||
| `transaction_id` | int FK → `transactions` |
|
||||
| `tag_id` | int FK → `tags` |
|
||||
|
||||
---
|
||||
|
||||
### `tags`
|
||||
User-defined coloured labels for ad-hoc transaction grouping beyond the fixed category taxonomy.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | int | Primary key |
|
||||
| `name` | text (unique) | Tag name |
|
||||
| `color` | text | Hex colour (default `#6366f1`) |
|
||||
|
||||
---
|
||||
|
||||
### `participants`
|
||||
People who own statements or share expenses.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | int | Primary key |
|
||||
| `name` | text (unique) | Display name |
|
||||
| `email` | text (unique) | Login identity — matched against `X-Forwarded-User` header |
|
||||
|
||||
---
|
||||
|
||||
### `account_owner_mappings`
|
||||
Persists `(bank, account_number) → owner` assignments so future ingestion auto-assigns the correct owner without manual intervention.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `bank_name` | text | |
|
||||
| `account_number` | text | |
|
||||
| `owner_id` | int FK → `participants` | |
|
||||
|
||||
Written when a user reassigns a statement owner in the UI. Consulted by the N8N workflow on every new statement insert.
|
||||
|
||||
---
|
||||
|
||||
### `rules`
|
||||
Saved auto-categorisation rules. Applied in bulk via the Rules page.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `owner_id` | int FK → `participants` | Rule belongs to this user |
|
||||
| `name` | text | Rule label |
|
||||
| `conditions` | jsonb | Array of `{field, operator, value}` — AND logic |
|
||||
| `actions` | jsonb | `{set_category, add_tag_ids, set_merchant}` |
|
||||
| `enabled` | bool | |
|
||||
| `priority` | int | Higher priority rules run first |
|
||||
|
||||
**Condition fields**: `merchant_normalized`, `description`, `category`, `bank_name`, `amount`
|
||||
**Condition operators**: `contains`, `equals`, `starts_with`, `gt`, `lt`, `not_equals`
|
||||
|
||||
---
|
||||
|
||||
### `budgets`
|
||||
Monthly spend targets per category. Stored but currently unused in the UI (replaced by the analytics/insights views).
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `owner_id` | int FK → `participants` | |
|
||||
| `category` | text | Category name |
|
||||
| `month` | date | Always first of month (e.g. `2026-03-01`) |
|
||||
| `amount_limit` | numeric | Spend target for that category/month |
|
||||
|
||||
---
|
||||
|
||||
## Category Taxonomy
|
||||
|
||||
Fixed set defined in `src/lib/categories.ts`. Applied by Gemini at ingestion and overridable by the user or rules engine:
|
||||
|
||||
`groceries` · `dining` · `transport` · `fuel` · `shopping` · `utilities` · `entertainment` · `travel` · `health` · `insurance` · `subscriptions` · `cash_advance` · `government` · `education` · `rent` · `transfers` · `income` · `investment` · `personal_care` · `pets` · `gifts` · `charity` · `other`
|
||||
|
||||
**Committed spend** (Insights page): `rent`, `utilities`, `insurance`, `subscriptions`
|
||||
**Excluded from spend analytics**: `transfers`, `investment`
|
||||
|
||||
---
|
||||
|
||||
## API Routes
|
||||
|
||||
All routes require authentication via `X-Forwarded-User` header (set by Traefik). Responses are always scoped to the authenticated user's `owner_id`.
|
||||
|
||||
| Method | Route | Description |
|
||||
|--------|-------|-------------|
|
||||
| GET | `/api/statements` | All statements for current user |
|
||||
| GET / PATCH | `/api/statements/[id]` | Get statement; PATCH to reassign owner (also writes `account_owner_mappings`) |
|
||||
| GET | `/api/transactions` | Paginated transactions with filters: `from`, `to`, `category`, `merchant`, `statement_id`, `search`, `sort`, `dir` |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| POST | `/api/splits/settle` | Mark a split as settled |
|
||||
| GET / POST | `/api/participants` | List participants; POST to create (with optional `email`) |
|
||||
| GET | `/api/participants/[id]/balance` | Net balance owed by/to a specific participant |
|
||||
| GET | `/api/participants/balances` | All participant balances |
|
||||
| GET / POST | `/api/rules` | List or create rules |
|
||||
| PATCH / DELETE | `/api/rules/[id]` | Update or delete a rule |
|
||||
| POST | `/api/rules/apply` | Run all enabled rules against all transactions; returns `{matched, transactions_affected}` |
|
||||
| GET / POST | `/api/budgets` | List budgets for a month (`?month=YYYY-MM`); upsert budget |
|
||||
| DELETE | `/api/budgets/[id]` | Delete a budget |
|
||||
| GET | `/api/merchants` | Merchant name autocomplete suggestions |
|
||||
| GET | `/api/me` | Current user info derived from `X-Forwarded-User` header |
|
||||
| GET / POST | `/api/tags` | List or create tags |
|
||||
| PATCH / DELETE | `/api/tags/[id]` | Update or delete a tag |
|
||||
|
||||
---
|
||||
|
||||
## Ingestion Pipeline
|
||||
|
||||
```
|
||||
Paperless-NGX
|
||||
└─ documents tagged "Bank Statement" + "Credit Card" (without "cc-processor")
|
||||
│
|
||||
▼
|
||||
N8N workflow — polls every 5 minutes (workflow ID: FysADdFwEtwONQl4)
|
||||
│
|
||||
├─ Duplicate check: SELECT WHERE paperless_doc_id = <id>
|
||||
│ └─ Already processed → skip, mark in Paperless
|
||||
│
|
||||
├─ Download PDF binary from Paperless API
|
||||
│
|
||||
├─ Gemini 2.5 Flash — PDF → structured JSON
|
||||
│ responseSchema: { summary: {...}, transactions: [...] }
|
||||
│ timeout: 180s, retryOnFail: 3×, delay: 30s
|
||||
│
|
||||
├─ Parse & normalise
|
||||
│ account_number: strip spaces
|
||||
│ bank_name: title-case
|
||||
│ FX rate: fetch live from open.er-api.com if non-AUD
|
||||
│
|
||||
├─ Statement exists? (bank + account + billing_end_date)
|
||||
│ └─ Duplicate → skip, mark in Paperless
|
||||
│
|
||||
├─ New bank? → Slack approval gate (human confirms before insert)
|
||||
│
|
||||
├─ Lookup account_owner_mappings → resolve owner_id (default: 1 = "Me")
|
||||
│
|
||||
├─ INSERT statements + transactions
|
||||
│
|
||||
├─ Google Calendar reminder for payment_due_date (credit cards)
|
||||
│
|
||||
└─ Paperless: PATCH document to add "cc-processor" tag
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
N8N workflow JSON: `docker/automation/workflows/cc-statement-processor-paperless.json` in the smarthome repo.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
---
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
## Schema Migrations
|
||||
|
||||
## Learn More
|
||||
Located in `prisma/migrations/`. Applied manually against the running container:
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
```bash
|
||||
docker exec postgres-personal psql -U personal -d personal \
|
||||
< prisma/migrations/<migration>/migration.sql
|
||||
```
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
| Migration | What it adds |
|
||||
|-----------|-------------|
|
||||
| `0001_init` | `statements`, `transactions`, `participants` |
|
||||
| `0002_splits` | `transaction_splits` |
|
||||
| `0003_owner_segregation` | `owner_id` on statements, `account_owner_mappings`, `email` on participants |
|
||||
| `0004_tags` | `tags`, `transaction_tags` |
|
||||
| `0005_rules` | `rules` |
|
||||
| `0006_budgets` | `budgets` |
|
||||
| `0007_cashflow` | `amount_aud`, `exchange_rate_to_aud` on transactions; `exchange_rate_to_aud` on statements |
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
> `paperless_doc_id` on statements and the `uq_statements_paperless_doc_id` index were added directly (not tracked in a migration file).
|
||||
|
||||
## Deploy on Vercel
|
||||
---
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
## Deployment
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
Runs as a Docker container alongside the rest of the home lab stack. Build and deploy:
|
||||
|
||||
```bash
|
||||
# From smarthome repo root
|
||||
docker compose --env-file docker/common.env --env-file docker/finance/.env \
|
||||
-f docker/finance/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
The container uses Next.js standalone output. `@prisma/adapter-pg` and `pg` are listed in `serverExternalPackages` in `next.config.ts` to ensure they are included in the standalone bundle.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
// Statement-level fees and interest (aggregated by Gemini from the PDF)
|
||||
const stmtRows = await queryRaw<{
|
||||
bank_name: string;
|
||||
fees: string;
|
||||
interest: string;
|
||||
}>(
|
||||
`SELECT
|
||||
bank_name,
|
||||
SUM(COALESCE(fees_charged, 0))::numeric(12,2) AS fees,
|
||||
SUM(COALESCE(interest_charged, 0))::numeric(12,2) AS interest
|
||||
FROM statements
|
||||
WHERE owner_id = $1
|
||||
GROUP BY bank_name
|
||||
HAVING SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0)) > 0
|
||||
ORDER BY (SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0))) DESC`,
|
||||
[user.id]
|
||||
);
|
||||
|
||||
// Transaction-level fee and interest line items (split-adjusted)
|
||||
const txnRows = await queryRaw<{
|
||||
id: number;
|
||||
transaction_date: string;
|
||||
description: string;
|
||||
merchant_name: string | null;
|
||||
transaction_type: string;
|
||||
my_amount: string;
|
||||
bank_name: string;
|
||||
}>(
|
||||
`SELECT
|
||||
t.id,
|
||||
t.transaction_date,
|
||||
t.description,
|
||||
t.merchant_name,
|
||||
t.transaction_type,
|
||||
CASE
|
||||
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
||||
ELSE COALESCE(t.amount_aud, t.amount)
|
||||
END::numeric(12,2) AS my_amount,
|
||||
s.bank_name
|
||||
FROM transactions t
|
||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||
JOIN statements s ON s.id = t.statement_id
|
||||
WHERE s.owner_id = $1
|
||||
AND t.transaction_type IN ('fee', 'interest')
|
||||
ORDER BY t.transaction_date DESC`,
|
||||
[user.id]
|
||||
);
|
||||
|
||||
const by_bank = stmtRows.map((r) => ({
|
||||
bank_name: r.bank_name,
|
||||
fees: Number(r.fees),
|
||||
interest: Number(r.interest),
|
||||
total: Number(r.fees) + Number(r.interest),
|
||||
}));
|
||||
|
||||
const transactions = txnRows.map((r) => ({
|
||||
...r,
|
||||
my_amount: Number(r.my_amount),
|
||||
}));
|
||||
|
||||
// Totals from statement-level data (more complete — Gemini reads the statement summary)
|
||||
const total_fees = by_bank.reduce((s, r) => s + r.fees, 0);
|
||||
const total_interest = by_bank.reduce((s, r) => s + r.interest, 0);
|
||||
|
||||
return NextResponse.json({ by_bank, transactions, total_fees, total_interest });
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const rows = await queryRaw<{
|
||||
merchant: string;
|
||||
category: string;
|
||||
occurrences: number;
|
||||
avg_amount: string;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
total_paid: string;
|
||||
median_interval: string;
|
||||
frequency: string | null;
|
||||
}>(
|
||||
`WITH merchant_txns AS (
|
||||
SELECT
|
||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) AS merchant,
|
||||
COALESCE(o.category_override, t.category) AS category,
|
||||
t.transaction_date,
|
||||
CASE
|
||||
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
||||
ELSE COALESCE(t.amount_aud, t.amount)
|
||||
END AS my_amount
|
||||
FROM transactions t
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||
JOIN statements s ON s.id = t.statement_id
|
||||
WHERE s.owner_id = $1
|
||||
AND t.transaction_type IN ('debit', 'fee')
|
||||
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) IS NOT NULL
|
||||
),
|
||||
merchant_with_lag AS (
|
||||
SELECT
|
||||
merchant,
|
||||
category,
|
||||
transaction_date,
|
||||
my_amount,
|
||||
LAG(transaction_date) OVER (PARTITION BY merchant ORDER BY transaction_date) AS prev_date
|
||||
FROM merchant_txns
|
||||
),
|
||||
merchant_stats AS (
|
||||
SELECT
|
||||
merchant,
|
||||
MODE() WITHIN GROUP (ORDER BY category) AS category,
|
||||
COUNT(*) + 1 AS occurrences,
|
||||
AVG(my_amount)::numeric(12,2) AS avg_amount,
|
||||
MIN(transaction_date) AS first_seen,
|
||||
MAX(transaction_date) AS last_seen,
|
||||
SUM(my_amount)::numeric(12,2) AS total_paid,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (
|
||||
ORDER BY (transaction_date - prev_date)::int
|
||||
) AS median_interval,
|
||||
STDDEV((transaction_date - prev_date)::int) AS stddev_interval
|
||||
FROM merchant_with_lag
|
||||
WHERE prev_date IS NOT NULL
|
||||
GROUP BY merchant
|
||||
HAVING COUNT(*) >= 2
|
||||
),
|
||||
classified AS (
|
||||
SELECT *,
|
||||
CASE
|
||||
WHEN median_interval BETWEEN 6 AND 8 THEN 'weekly'
|
||||
WHEN median_interval BETWEEN 13 AND 16 THEN 'fortnightly'
|
||||
WHEN median_interval BETWEEN 27 AND 35 THEN 'monthly'
|
||||
WHEN median_interval BETWEEN 85 AND 95 THEN 'quarterly'
|
||||
WHEN median_interval BETWEEN 350 AND 380 THEN 'annual'
|
||||
ELSE NULL
|
||||
END AS frequency
|
||||
FROM merchant_stats
|
||||
WHERE stddev_interval < median_interval * 0.4
|
||||
AND (
|
||||
median_interval BETWEEN 6 AND 8 OR
|
||||
median_interval BETWEEN 13 AND 16 OR
|
||||
median_interval BETWEEN 27 AND 35 OR
|
||||
median_interval BETWEEN 85 AND 95 OR
|
||||
median_interval BETWEEN 350 AND 380
|
||||
)
|
||||
)
|
||||
SELECT merchant, category, occurrences, avg_amount, first_seen, last_seen, total_paid,
|
||||
median_interval::numeric(8,1), frequency
|
||||
FROM classified
|
||||
WHERE frequency IS NOT NULL
|
||||
ORDER BY
|
||||
CASE frequency
|
||||
WHEN 'weekly' THEN avg_amount * 4.33
|
||||
WHEN 'fortnightly' THEN avg_amount * 2.17
|
||||
WHEN 'monthly' THEN avg_amount
|
||||
WHEN 'quarterly' THEN avg_amount / 3
|
||||
WHEN 'annual' THEN avg_amount / 12
|
||||
END DESC NULLS LAST`,
|
||||
[user.id]
|
||||
);
|
||||
|
||||
const today = new Date();
|
||||
const subscriptions = rows.map((r) => {
|
||||
const lastSeen = new Date(r.last_seen);
|
||||
const daysSinceLast = Math.floor((today.getTime() - lastSeen.getTime()) / 86400000);
|
||||
const medianInterval = Number(r.median_interval);
|
||||
const is_active = daysSinceLast < medianInterval * 1.5;
|
||||
|
||||
const avg = Number(r.avg_amount);
|
||||
const monthly_equiv =
|
||||
r.frequency === "weekly" ? avg * 4.33 :
|
||||
r.frequency === "fortnightly" ? avg * 2.17 :
|
||||
r.frequency === "quarterly" ? avg / 3 :
|
||||
r.frequency === "annual" ? avg / 12 :
|
||||
avg;
|
||||
|
||||
return {
|
||||
merchant: r.merchant,
|
||||
category: r.category,
|
||||
frequency: r.frequency,
|
||||
avg_amount: avg,
|
||||
monthly_equiv: Math.round(monthly_equiv * 100) / 100,
|
||||
first_seen: r.first_seen,
|
||||
last_seen: r.last_seen,
|
||||
occurrences: r.occurrences,
|
||||
total_paid: Number(r.total_paid),
|
||||
is_active,
|
||||
};
|
||||
});
|
||||
|
||||
const total_monthly_equiv = subscriptions
|
||||
.filter((s) => s.is_active)
|
||||
.reduce((sum, s) => sum + s.monthly_equiv, 0);
|
||||
|
||||
return NextResponse.json({ subscriptions, total_monthly_equiv: Math.round(total_monthly_equiv * 100) / 100 });
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, Legend,
|
||||
} from "recharts";
|
||||
import { useMonthlyAnalytics, useSubscriptions, useFees } from "@/lib/hooks";
|
||||
import { formatCategory } from "@/lib/categories";
|
||||
|
||||
const COMMITTED_CATEGORIES = new Set(["rent", "utilities", "insurance", "subscriptions"]);
|
||||
|
||||
function fmt(n: number) {
|
||||
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", maximumFractionDigits: 0 }).format(n);
|
||||
}
|
||||
function fmtExact(n: number) {
|
||||
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", minimumFractionDigits: 2 }).format(n);
|
||||
}
|
||||
function fmtDate(d: string) {
|
||||
return new Date(d).toLocaleDateString("en-AU", { month: "short", year: "numeric" });
|
||||
}
|
||||
function trend(values: number[]): { pct: number; dir: "up" | "down" | "flat" } {
|
||||
if (values.length < 2) return { pct: 0, dir: "flat" };
|
||||
const recent = values.slice(-3).reduce((a, b) => a + b, 0) / 3;
|
||||
const prior = values.slice(0, 3).reduce((a, b) => a + b, 0) / 3;
|
||||
if (prior === 0) return { pct: 0, dir: "flat" };
|
||||
const pct = Math.round(((recent - prior) / prior) * 100);
|
||||
return { pct: Math.abs(pct), dir: pct > 2 ? "up" : pct < -2 ? "down" : "flat" };
|
||||
}
|
||||
|
||||
const FREQ_LABEL: Record<string, string> = {
|
||||
weekly: "Weekly",
|
||||
fortnightly: "Fortnightly",
|
||||
monthly: "Monthly",
|
||||
quarterly: "Quarterly",
|
||||
annual: "Annual",
|
||||
};
|
||||
|
||||
// ─── Section wrapper ────────────────────────────────────────────────
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-base font-semibold text-zinc-200 mb-3">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Custom tooltip ──────────────────────────────────────────────────
|
||||
function CommittedTooltip({ active, payload, label }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const committed = payload.find((p: any) => p.dataKey === "committed")?.value ?? 0;
|
||||
const discretionary = payload.find((p: any) => p.dataKey === "discretionary")?.value ?? 0;
|
||||
return (
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded px-3 py-2 text-xs space-y-1">
|
||||
<div className="font-medium text-zinc-300 mb-1">{label}</div>
|
||||
<div className="flex justify-between gap-4"><span className="text-indigo-400">Committed</span><span>{fmt(committed)}</span></div>
|
||||
<div className="flex justify-between gap-4"><span className="text-zinc-400">Discretionary</span><span>{fmt(discretionary)}</span></div>
|
||||
<div className="flex justify-between gap-4 border-t border-zinc-700 pt-1"><span className="text-zinc-500">Total</span><span>{fmt(committed + discretionary)}</span></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────
|
||||
export default function InsightsPage() {
|
||||
const { data: analytics } = useMonthlyAnalytics(12);
|
||||
const { data: subData } = useSubscriptions();
|
||||
const { data: feesData } = useFees();
|
||||
|
||||
// Build committed/discretionary chart data
|
||||
const chartData = useMemo(() => {
|
||||
if (!analytics) return [];
|
||||
return [...analytics.months].reverse().map((month) => {
|
||||
let committed = 0;
|
||||
let discretionary = 0;
|
||||
for (const row of analytics.rows) {
|
||||
const spend = Number(row.spent[month] ?? 0);
|
||||
if (COMMITTED_CATEGORIES.has(row.category)) committed += spend;
|
||||
else discretionary += spend;
|
||||
}
|
||||
return {
|
||||
month: month.slice(5) + "/" + month.slice(2, 4),
|
||||
committed: Math.round(committed),
|
||||
discretionary: Math.round(discretionary),
|
||||
total: Math.round(committed + discretionary),
|
||||
};
|
||||
});
|
||||
}, [analytics]);
|
||||
|
||||
const committedValues = chartData.map((d) => d.committed);
|
||||
const committedTrend = trend(committedValues);
|
||||
const avgCommitted = committedValues.length
|
||||
? Math.round(committedValues.reduce((a, b) => a + b, 0) / committedValues.length)
|
||||
: 0;
|
||||
const latestCommitted = committedValues[committedValues.length - 1] ?? 0;
|
||||
|
||||
const activeSubscriptions = subData?.subscriptions.filter((s) => s.is_active) ?? [];
|
||||
const inactiveSubscriptions = subData?.subscriptions.filter((s) => !s.is_active) ?? [];
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<h2 className="text-xl font-semibold mb-6">Insights</h2>
|
||||
|
||||
{/* ── 1. Committed vs Discretionary ── */}
|
||||
<Section title="Committed vs Discretionary Spend">
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
|
||||
<div className="text-xs text-zinc-500 mb-1">This month — committed floor</div>
|
||||
<div className="text-xl font-semibold text-indigo-400">{fmt(latestCommitted)}</div>
|
||||
</div>
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
|
||||
<div className="text-xs text-zinc-500 mb-1">12-month avg committed</div>
|
||||
<div className="text-xl font-semibold text-zinc-200">{fmt(avgCommitted)}</div>
|
||||
</div>
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3">
|
||||
<div className="text-xs text-zinc-500 mb-1">Trend (first 3 vs last 3 mo)</div>
|
||||
<div className={`text-xl font-semibold ${committedTrend.dir === "up" ? "text-red-400" : committedTrend.dir === "down" ? "text-green-400" : "text-zinc-400"}`}>
|
||||
{committedTrend.dir === "up" ? "↑" : committedTrend.dir === "down" ? "↓" : "→"} {committedTrend.pct}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<ComposedChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<XAxis dataKey="month" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} width={44} />
|
||||
<Tooltip content={<CommittedTooltip />} />
|
||||
<Bar dataKey="committed" stackId="a" fill="#6366f1" name="Committed" radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="discretionary" stackId="a" fill="#3f3f46" name="Discretionary" radius={[3, 3, 0, 0]} />
|
||||
<Line type="monotone" dataKey="committed" stroke="#818cf8" strokeWidth={2} dot={false} strokeDasharray="4 2" />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex gap-4 mt-2 justify-end">
|
||||
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm bg-indigo-500 inline-block" />Committed (rent, utilities, insurance, subscriptions)</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm bg-zinc-600 inline-block" />Discretionary</span>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── 2. Recurring Charges ── */}
|
||||
<Section title="Recurring Charges">
|
||||
{!subData ? (
|
||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||
) : subData.subscriptions.length === 0 ? (
|
||||
<p className="text-zinc-500 text-sm">No recurring patterns detected yet — more transaction history needed.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-zinc-500">{activeSubscriptions.length} active · {inactiveSubscriptions.length} inactive</span>
|
||||
<span className="text-sm font-medium text-indigo-400">{fmtExact(subData.total_monthly_equiv)}<span className="text-xs text-zinc-500 font-normal ml-1">/ month committed</span></span>
|
||||
</div>
|
||||
<div className="border border-zinc-700 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Merchant</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Category</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Frequency</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">My $/mo equiv</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Avg charge</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Since</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Total paid</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...activeSubscriptions, ...inactiveSubscriptions].map((s) => (
|
||||
<tr key={s.merchant} className={`border-b border-zinc-800/50 ${s.is_active ? "hover:bg-zinc-800/20" : "opacity-40"} transition-colors`}>
|
||||
<td className="px-4 py-3 font-medium">{s.merchant}</td>
|
||||
<td className="px-4 py-3 text-zinc-400 text-xs">{formatCategory(s.category ?? "other")}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${s.is_active ? "bg-indigo-900/40 text-indigo-300" : "bg-zinc-800 text-zinc-500"}`}>
|
||||
{FREQ_LABEL[s.frequency] ?? s.frequency}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-zinc-200">{fmtExact(s.monthly_equiv)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{fmtExact(s.avg_amount)}</td>
|
||||
<td className="px-4 py-3 text-right text-zinc-500 text-xs whitespace-nowrap">{fmtDate(s.first_seen)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{fmtExact(s.total_paid)}</td>
|
||||
<td className="px-4 py-3 text-right text-zinc-500">{s.occurrences}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── 3. Fees & Interest ── */}
|
||||
<Section title="Fees & Interest">
|
||||
{!feesData ? (
|
||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||
) : feesData.by_bank.length === 0 && feesData.transactions.length === 0 ? (
|
||||
<p className="text-zinc-500 text-sm">No fees or interest recorded across your statements.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{feesData.by_bank.length > 0 && (
|
||||
<div className="border border-zinc-700 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Bank</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Fees</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Interest</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{feesData.by_bank.map((r) => (
|
||||
<tr key={r.bank_name} className="border-b border-zinc-800/50">
|
||||
<td className="px-4 py-3 font-medium">{r.bank_name}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{r.fees > 0 ? fmtExact(r.fees) : <span className="text-zinc-700">—</span>}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{r.interest > 0 ? fmtExact(r.interest) : <span className="text-zinc-700">—</span>}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-red-400 font-medium">{fmtExact(r.total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="bg-zinc-900/50">
|
||||
<td className="px-4 py-2.5 text-xs text-zinc-500 font-medium">Total</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-xs text-zinc-400">{fmtExact(feesData.total_fees)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-xs text-zinc-400">{fmtExact(feesData.total_interest)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-red-400 font-medium">{fmtExact(feesData.total_fees + feesData.total_interest)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{feesData.transactions.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-2">Individual fee / interest transactions</p>
|
||||
<div className="border border-zinc-700 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Date</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Bank</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Description</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Type</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{feesData.transactions.map((t) => (
|
||||
<tr key={t.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/20">
|
||||
<td className="px-4 py-2.5 text-zinc-500 text-xs whitespace-nowrap">
|
||||
{new Date(t.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" })}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-zinc-400 text-xs">{t.bank_name}</td>
|
||||
<td className="px-4 py-2.5 text-zinc-300">{t.description}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${t.transaction_type === "interest" ? "bg-orange-900/40 text-orange-300" : "bg-zinc-800 text-zinc-400"}`}>
|
||||
{t.transaction_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-red-400">{fmtExact(t.my_amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+127
-15
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useStatements } from "@/lib/hooks";
|
||||
import { useStatements, useParticipants, useUpdateStatement } from "@/lib/hooks";
|
||||
|
||||
function formatDate(d: string | null) {
|
||||
if (!d) return "—";
|
||||
@@ -30,40 +31,139 @@ function formatAmount(n: number | null): string {
|
||||
}).format(Number(n));
|
||||
}
|
||||
|
||||
const selectCls =
|
||||
"bg-zinc-900 border border-zinc-700 rounded text-xs px-2 py-1.5 text-zinc-300 cursor-pointer hover:border-zinc-600 focus:outline-none focus:border-indigo-500";
|
||||
|
||||
export default function StatementsPage() {
|
||||
const { data: statements, isLoading } = useStatements();
|
||||
const { data: participants } = useParticipants();
|
||||
const updateStatement = useUpdateStatement();
|
||||
|
||||
const [bankFilter, setBankFilter] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<"all" | "card" | "bank">("all");
|
||||
const [ownerFilter, setOwnerFilter] = useState("");
|
||||
const [yearFilter, setYearFilter] = useState("");
|
||||
|
||||
const banks = useMemo(
|
||||
() => [...new Set((statements ?? []).map((s) => s.bank_name))].sort(),
|
||||
[statements]
|
||||
);
|
||||
const years = useMemo(
|
||||
() =>
|
||||
[
|
||||
...new Set(
|
||||
(statements ?? [])
|
||||
.map((s) => s.billing_end_date?.slice(0, 4))
|
||||
.filter(Boolean) as string[]
|
||||
),
|
||||
].sort((a, b) => b.localeCompare(a)),
|
||||
[statements]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!statements) return [];
|
||||
return statements.filter((s) => {
|
||||
const isCard = s.statement_type?.toLowerCase().includes("card") ?? false;
|
||||
if (bankFilter && s.bank_name !== bankFilter) return false;
|
||||
if (typeFilter === "card" && !isCard) return false;
|
||||
if (typeFilter === "bank" && isCard) return false;
|
||||
if (ownerFilter && String(s.owner_id) !== ownerFilter) return false;
|
||||
if (yearFilter && s.billing_end_date?.slice(0, 4) !== yearFilter) return false;
|
||||
return true;
|
||||
});
|
||||
}, [statements, bankFilter, typeFilter, ownerFilter, yearFilter]);
|
||||
|
||||
const hasFilters = bankFilter || typeFilter !== "all" || ownerFilter || yearFilter;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Statements</h2>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold">Statements</h2>
|
||||
{!isLoading && statements && (
|
||||
<span className="text-xs text-zinc-500">
|
||||
{hasFilters ? `${filtered.length} of ${statements.length}` : statements.length} statements
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{!isLoading && statements && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<select value={bankFilter} onChange={(e) => setBankFilter(e.target.value)} className={selectCls}>
|
||||
<option value="">All banks</option>
|
||||
{banks.map((b) => (
|
||||
<option key={b} value={b}>{b}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value as typeof typeFilter)} className={selectCls}>
|
||||
<option value="all">All types</option>
|
||||
<option value="card">Credit card</option>
|
||||
<option value="bank">Bank account</option>
|
||||
</select>
|
||||
|
||||
{participants && participants.length > 1 && (
|
||||
<select value={ownerFilter} onChange={(e) => setOwnerFilter(e.target.value)} className={selectCls}>
|
||||
<option value="">All owners</option>
|
||||
{participants.map((p) => (
|
||||
<option key={p.id} value={String(p.id)}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<select value={yearFilter} onChange={(e) => setYearFilter(e.target.value)} className={selectCls}>
|
||||
<option value="">All years</option>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={() => { setBankFilter(""); setTypeFilter("all"); setOwnerFilter(""); setYearFilter(""); }}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300 px-2 py-1.5 transition-colors"
|
||||
>
|
||||
× Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||
) : !statements?.length ? (
|
||||
<p className="text-zinc-500 text-sm">No statements found</p>
|
||||
) : !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">
|
||||
<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">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 / Updated</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Due / End</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Ccy</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Amount</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Txns</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Owner</th>
|
||||
<th className="px-4 py-2.5"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statements.map((s) => {
|
||||
const isCreditCard = Number(s.credit_limit) > 0 || s.payment_due_date != null;
|
||||
{filtered.map((s, idx) => {
|
||||
const isCreditCard = s.statement_type?.toLowerCase().includes("card") ?? false;
|
||||
const displayAmount = isCreditCard ? s.total_amount_due : s.closing_balance;
|
||||
const amountLabel = isCreditCard ? "due" : "balance";
|
||||
const amount = Number(displayAmount);
|
||||
const amountColor = isCreditCard
|
||||
? "text-red-400"
|
||||
: amount >= 0
|
||||
? "text-green-400"
|
||||
: "text-red-400";
|
||||
|
||||
return (
|
||||
<tr key={s.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/20 transition-colors">
|
||||
<td className="px-3 py-3 text-xs text-zinc-600 tabular-nums">{idx + 1}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium truncate max-w-[180px]" title={s.bank_name}>
|
||||
{s.bank_name}
|
||||
@@ -79,26 +179,38 @@ export default function StatementsPage() {
|
||||
{formatPeriod(s.billing_start_date, s.billing_end_date)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
|
||||
{isCreditCard
|
||||
? formatDate(s.payment_due_date)
|
||||
: formatDate(s.billing_end_date)}
|
||||
{isCreditCard ? formatDate(s.payment_due_date) : formatDate(s.billing_end_date)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-500 text-xs">
|
||||
{s.currency}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">
|
||||
{displayAmount !== null && displayAmount !== undefined ? (
|
||||
<span className={isCreditCard ? "text-red-400" : "text-zinc-300"}>
|
||||
{formatAmount(displayAmount)}
|
||||
</span>
|
||||
<span className={amountColor}>{formatAmount(displayAmount)}</span>
|
||||
) : (
|
||||
<span className="text-zinc-600">—</span>
|
||||
)}
|
||||
<div className="text-xs text-zinc-600">{amountLabel}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-zinc-500">
|
||||
{s.transaction_count}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{participants?.length ? (
|
||||
<select
|
||||
value={s.owner_id ?? ""}
|
||||
onChange={(e) =>
|
||||
updateStatement.mutate({ id: s.id, owner_id: Number(e.target.value) })
|
||||
}
|
||||
className="bg-zinc-800 border border-zinc-700 rounded text-xs px-2 py-1 text-zinc-300 cursor-pointer hover:border-zinc-600 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
{participants.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-zinc-600 text-xs">{s.owner_name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/transactions?statement_id=${s.id}`}
|
||||
|
||||
@@ -8,6 +8,7 @@ const NAV_ITEMS = [
|
||||
{ href: "/statements", label: "Statements", icon: "file-text" },
|
||||
{ href: "/shared", label: "Shared", icon: "users" },
|
||||
{ href: "/budget", label: "Analytics", icon: "bar-chart" },
|
||||
{ href: "/insights", label: "Insights", icon: "lightbulb" },
|
||||
{ href: "/tags", label: "Tags", icon: "tag" },
|
||||
{ href: "/rules", label: "Rules", icon: "settings" },
|
||||
];
|
||||
@@ -44,6 +45,11 @@ const ICONS: Record<string, React.ReactNode> = {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
lightbulb: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m1.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export function Sidebar() {
|
||||
|
||||
@@ -502,3 +502,58 @@ export function useMonthlyAnalytics(months?: number) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface SubscriptionRow {
|
||||
merchant: string;
|
||||
category: string;
|
||||
frequency: string;
|
||||
avg_amount: number;
|
||||
monthly_equiv: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
occurrences: number;
|
||||
total_paid: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export function useSubscriptions() {
|
||||
return useQuery<{ subscriptions: SubscriptionRow[]; total_monthly_equiv: number }>({
|
||||
queryKey: ["analytics", "subscriptions"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/analytics/subscriptions");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface FeeBankRow {
|
||||
bank_name: string;
|
||||
fees: number;
|
||||
interest: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface FeeTxnRow {
|
||||
id: number;
|
||||
transaction_date: string;
|
||||
description: string;
|
||||
merchant_name: string | null;
|
||||
transaction_type: string;
|
||||
my_amount: number;
|
||||
bank_name: string;
|
||||
}
|
||||
|
||||
export function useFees() {
|
||||
return useQuery<{
|
||||
by_bank: FeeBankRow[];
|
||||
transactions: FeeTxnRow[];
|
||||
total_fees: number;
|
||||
total_interest: number;
|
||||
}>({
|
||||
queryKey: ["analytics", "fees"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/analytics/fees");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface StatementRow {
|
||||
fees_charged: number | null;
|
||||
credit_limit: number | null;
|
||||
currency: string;
|
||||
statement_type: string | null;
|
||||
tier_used: string | null;
|
||||
owner_id: number;
|
||||
owner_name: string;
|
||||
|
||||
Reference in New Issue
Block a user