From 9397e72eb2882ad679acb229b4a20ed9aaf93248 Mon Sep 17 00:00:00 2001 From: Serg Dort Date: Sat, 28 Feb 2026 23:42:50 +0000 Subject: [PATCH 1/3] Import pending Monzo transactions and keep reports settled-only --- .agents/notes.md | 2 +- AGENTS.md | 6 +- README.md | 5 +- .../home/components/MonthlyLedgerCard.tsx | 3 + apps/pwa/src/pages/ExpensesPage.tsx | 29 +- .../src/repositories/expenses.repository.ts | 35 ++- .../src/repositories/reports.repository.ts | 13 +- packages/domain/src/services/monzo.service.ts | 21 +- tests/domain/monzo.spec.ts | 276 +++++++++++++++++- tests/pwa/mobile.spec.ts | 10 +- 10 files changed, 375 insertions(+), 25 deletions(-) diff --git a/.agents/notes.md b/.agents/notes.md index 6cf575f..ca620cf 100644 --- a/.agents/notes.md +++ b/.agents/notes.md @@ -28,7 +28,7 @@ [0] In patch/merge helpers for nullable fields, avoid `??` when explicit `null` is a valid "clear this value" update (for example Monzo connection `lastErrorText`/OAuth state); use `value === undefined ? fallback : value`. [0] Do not auto-run Monzo sync from OAuth callback: Monzo app permission approval can lag token exchange, so callback-time sync often fails with temporary permission errors; connect should store tokens and require manual `Sync now`. [0] Monzo `/transactions` paging code must not assume descending order by `created`; if results come back ascending, `before=oldest-1` fetches only the oldest page in the window (symptom: first sync tops out around early-window dates like Dec 2 instead of today). -[1] In `@tithe/tests`, `pnpm --filter @tithe/tests test -- ` may still execute the full Vitest suite via the package script; use `pnpm --filter @tithe/tests exec vitest run ...` for targeted files, and pass paths relative to the `tests/` package root (for example `domain/monzo.spec.ts`). +[2] In `@tithe/tests`, `pnpm --filter @tithe/tests test -- ` may still execute the full Vitest suite via the package script; use `pnpm --filter @tithe/tests exec vitest run ...` for targeted files, and pass paths relative to the `tests/` package root (for example `domain/monzo.spec.ts`). [0] Monzo sync behavior in `packages/domain/src/services/monzo.service.ts` uses the local domain client (`packages/domain/src/services/monzo-client.ts`), not `packages/integrations-monzo`, so API-field additions for sync must be implemented in the domain client too. [0] In `apps/pwa/src/features/`, files at the feature root import shared `src/*` modules with `../../...`, while nested `components/`/`dialogs/`/`hooks/` files need `../../../...`; it's easy to overshoot by one level when splitting a large page. [0] `biome check src` currently reports a `useExhaustiveDependencies` warning in `apps/pwa/src/pages/ExpensesPage.tsx` for the avatar `useEffect` dependency on `merchantLogoUrl`; this appears pre-existing and can block full PWA lint even when the Home refactor files are clean. diff --git a/AGENTS.md b/AGENTS.md index d326084..71bd0c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,8 +129,9 @@ Failure: - `tithe --json monzo connect` stores short-lived OAuth `state` and returns `authUrl`. - `GET /v1/integrations/monzo/connect/callback` requires query `code+state` or `error`. - Monzo OAuth callback stores/refreshes tokens only and does not auto-run sync; first import happens on manual `monzo sync` / PWA Monthly Ledger `Sync month`. -- `tithe --json monzo sync` imports settled Monzo debits and credits (`amount != 0`, pending/zero skipped); optional `--month`/`--from --to` scopes the sync window and `--override` overwrites existing imported Monzo rows in that window. +- `tithe --json monzo sync` imports Monzo debits and credits where `amount != 0`, including pending rows (`posted_at = null` until settlement); optional `--month`/`--from --to` scopes the sync window and `--override` overwrites existing imported Monzo rows in that window. - Monzo import dedupe key is `expenses.source='monzo' + expenses.provider_transaction_id=transaction.id`. +- Monzo sync performs strict pending reconciliation within the active sync window: pending imported rows missing from the fetched Monzo transaction IDs are deleted, even if local note/reimbursement metadata exists on those rows. - Monzo month sync overwrite updates existing imported rows in place (same `id`/provider transaction id) and refreshes Monzo-derived fields including category, amount/date, kind, and merchant metadata while preserving local notes and local reimbursement fields. - Expense API responses include optional Monzo merchant display metadata (`merchantLogoUrl`, `merchantEmoji`) for UI avatar rendering. - Expense API responses include semantic `kind` (`expense|income|transfer_internal|transfer_external`) and reimbursement fields (`reimbursementStatus`, `myShareMinor`, `recoverableMinor`, `recoveredMinor`, `outstandingMinor`). @@ -142,6 +143,7 @@ Failure: - Monzo category mappings are flow-aware (`in|out`) and auto-create categories named `Monzo: ` with category kind inferred from flow (`expense` for debits, `income` for credits). Pot transfers use a dedicated transfer category (`Monzo Pot Transfers`). - Optional `MONZO_SCOPE` can be set when building Monzo auth URL; if unset, no explicit scope is requested. - `GET /v1/reports/monthly-ledger` returns a month-range ledger with legacy `income`/`expense`/`transfer` sections plus additive v2 `cashFlow`, `spending`, and `reimbursements` blocks and split `transferInternal`/`transferExternal` sections. +- Reports (`trends`, `category-breakdown`, `monthly-ledger`) exclude pending Monzo rows (`source='monzo'` and `posted_at IS NULL`) from totals by default. - Reimbursement auto-match in v2 uses explicit category-link rules (`expense category -> income/transfer category`), not hidden grouping keys. - `reimbursement_group_id` may still exist on expense rows as a reserved/deferred field, but v2 auto-match does not use it. - PWA Home embeds a full monthly cashflow ledger (month navigation, income/expense/transfer totals, category breakdown lists) and replaces the previous spend-only snapshot card. @@ -150,7 +152,7 @@ Failure: - PWA Home Monthly Ledger widget also surfaces v2 summary metrics (`Cash In`, `Cash Out`, `Net Flow`, `True Spend`, `Reimbursement Outstanding`) with `Gross/Net` and `Exclude internal transfers` toggles. - PWA Home `Add Transaction` is a single manual entry flow for `income|expense|transfer`; transfer entries require direction and support transfer subtype (`internal|external`) via semantic `kind`, and reimbursable expense categories can capture `Track reimbursement` + `My share`. - PWA Home pending commitments support a quick `Mark paid` action that creates a linked actual transaction (`source='commitment'`) and updates the ledger. -- PWA Expenses page now surfaces semantic/reimbursement chips (`Internal transfer`, `External transfer`, `Reimbursable`, `Partial`, `Settled`, `Written off`) and basic reimbursement actions (`Link repayment`, `Mark written off`, `Reopen`). +- PWA Expenses page now surfaces semantic/reimbursement chips (`Internal transfer`, `External transfer`, `Pending`, `Reimbursable`, `Partial`, `Settled`, `Written off`) and basic reimbursement actions (`Link repayment`, `Mark written off`, `Reopen`). - PWA Categories page uses a floating `+` action to open `Add Category`, and category add/edit dialogs can capture expense-category reimbursement settings/defaults while reimbursement auto-match rule management also runs in a dialog. - PWA short-form list-page dialogs (for example Expenses/Categories add/edit flows) should follow the Expenses pattern: MUI `Dialog` with `fullWidth` and no mobile `fullScreen`. - Ledger v2 development rollout requires a fresh local DB reset (no backfill); reset `DB_PATH` (default `~/.tithe/tithe.db`) before running v2 migrations/commands. diff --git a/README.md b/README.md index 179688a..ed50e42 100644 --- a/README.md +++ b/README.md @@ -364,14 +364,17 @@ Current status in this implementation: - Home dashboard cards load independently: a ledger/Monzo/commitments fetch error is shown in that card without blocking the entire Home screen. - `Connect` opens the Monzo OAuth flow in a separate window/tab (opened immediately on click to avoid popup blocking after async API calls). - Initial import window is last 90 days; subsequent sync uses cursor overlap. -- Import policy is settled debit + credit (`amount != 0`) only (pending/zero skipped). +- Import policy includes non-zero Monzo debits + credits (`amount != 0`), including pending rows (`postedAt=null` until settlement). +- Sync performs strict pending reconciliation inside the active sync window: pending imported rows missing from fetched Monzo transaction IDs are removed. - Imported Monzo rows use `source=monzo` and `providerTransactionId=` for dedupe. - Monzo sync classifies pot transfers as `transfer_internal`, non-pot debits as `expense`, and non-pot credits as `income`. - `tithe --json monzo sync --override` (or PWA Monthly Ledger `Sync month`) overwrites existing `monzo` rows in place using latest Monzo-derived category/amount/date/kind/merchant fields while preserving local notes and local reimbursement metadata. +- Reports (`trends`, `category-breakdown`, `monthly-ledger`) keep totals settled-only by excluding pending Monzo rows. - Expense API responses include optional Monzo merchant display metadata (`merchantLogoUrl`, `merchantEmoji`) used by the PWA expenses list avatar. - Expense API responses include semantic `kind` plus reimbursement fields/derived reimbursement totals for Ledger v2 workflows. - Expense API responses also include `transferDirection` (`in|out|null`); transfer semantic rows require it, income/expense rows return `null`. - PWA expenses list merchant avatars use `logo -> emoji -> initials` fallback for imported Monzo merchants. +- PWA Expenses rows show a `Pending` badge for Monzo rows where `postedAt` is still null. - Monzo sync best-effort resolves pot-transfer descriptions that are raw Monzo pot IDs (`pot_...`) into display labels like `Pot: Savings` for new imports; if pot lookup fails or the pot is missing, the raw description is kept. - Merchant logo/emoji metadata is stored for new Monzo imports only (no historical backfill for older imported rows). - Monzo category mappings are flow-aware (`in|out`) and auto-create `Monzo: ` categories with `expense`/`income` kind inferred from flow. Pot transfers use a dedicated transfer category. diff --git a/apps/pwa/src/features/home/components/MonthlyLedgerCard.tsx b/apps/pwa/src/features/home/components/MonthlyLedgerCard.tsx index 59c8f1f..c05988e 100644 --- a/apps/pwa/src/features/home/components/MonthlyLedgerCard.tsx +++ b/apps/pwa/src/features/home/components/MonthlyLedgerCard.tsx @@ -208,6 +208,9 @@ export const MonthlyLedgerCard = ({ Monthly cashflow ledger (actual transactions only) + + Pending Monzo card transactions are excluded from totals until settled. + diff --git a/apps/pwa/src/pages/ExpensesPage.tsx b/apps/pwa/src/pages/ExpensesPage.tsx index 5a1fbad..9017207 100644 --- a/apps/pwa/src/pages/ExpensesPage.tsx +++ b/apps/pwa/src/pages/ExpensesPage.tsx @@ -4,6 +4,7 @@ import { Avatar, Box, Button, + Chip, CircularProgress, Dialog, DialogActions, @@ -340,6 +341,7 @@ export const ExpensesPage = () => { expense.reimbursementStatus !== 'none'; const outstandingMinor = expense.outstandingMinor ?? 0; const amountView = expenseAmountPresentation(expense); + const isPendingMonzo = expense.source === 'monzo' && expense.postedAt === null; const subtitle = canShowReimbursement ? `${reimbursementLabel ?? 'Reimbursable'} ยท Outstanding ${pounds( @@ -403,9 +405,30 @@ export const ExpensesPage = () => { {merchant} - - {subtitle} - + + + {subtitle} + + {isPendingMonzo ? ( + + ) : null} + {canShowReimbursement && outstandingMinor > 0 ? (