Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <files>` 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 -- <files>` 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/<feature>`, 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.
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand All @@ -142,6 +143,7 @@ Failure:
- Monzo category mappings are flow-aware (`in|out`) and auto-create categories named `Monzo: <Category>` 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.
Expand All @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<transaction_id>` 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: <Category>` categories with `expense`/`income` kind inferred from flow. Pot transfers use a dedicated transfer category.
Expand Down
3 changes: 3 additions & 0 deletions apps/pwa/src/features/home/components/MonthlyLedgerCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ export const MonthlyLedgerCard = ({
<Typography variant="caption" color="text.secondary">
Monthly cashflow ledger (actual transactions only)
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
Pending Monzo card transactions are excluded from totals until settled.
</Typography>

<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mt: 1.5 }}>
<Typography variant="caption" color="text.secondary">
Expand Down
29 changes: 26 additions & 3 deletions apps/pwa/src/pages/ExpensesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Avatar,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -403,9 +405,30 @@ export const ExpensesPage = () => {
<Typography variant="body1" sx={{ fontWeight: 600 }} noWrap>
{merchant}
</Typography>
<Typography variant="body2" color="text.secondary" noWrap>
{subtitle}
</Typography>
<Stack
direction="row"
spacing={0.75}
alignItems="center"
sx={{ minWidth: 0 }}
>
<Typography
variant="body2"
color="text.secondary"
noWrap
sx={{ flex: 1 }}
>
{subtitle}
</Typography>
{isPendingMonzo ? (
<Chip
size="small"
variant="outlined"
color="warning"
label="Pending"
sx={{ height: 20 }}
/>
) : null}
</Stack>
{canShowReimbursement && outstandingMinor > 0 ? (
<Stack direction="row" spacing={0.75} sx={{ mt: 0.25 }}>
<Button
Expand Down
35 changes: 34 additions & 1 deletion packages/domain/src/repositories/expenses.repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, desc, eq, gte, inArray, lte } from 'drizzle-orm';
import { and, desc, eq, gte, inArray, isNull, lt, lte } from 'drizzle-orm';

import { expenses } from '@tithe/db';

Expand Down Expand Up @@ -100,6 +100,15 @@ export interface ListExpensesOutput {
expenses: ExpenseDto[];
}

export interface ListPendingMonzoExpensesInRangeInput {
from: string;
to: string;
}

export interface ListPendingMonzoExpensesInRangeOutput {
expenses: ExpenseDto[];
}

export interface FindExpenseByIdInput {
id: string;
}
Expand Down Expand Up @@ -213,6 +222,9 @@ export interface UpdateExpenseReimbursementOutput {

export interface ExpensesRepository {
list(input: ListExpensesInput): ListExpensesOutput;
listPendingMonzoInRange: (
input: ListPendingMonzoExpensesInRangeInput,
) => ListPendingMonzoExpensesInRangeOutput;
findById(input: FindExpenseByIdInput): FindExpenseByIdOutput;
findByIds(input: FindExpensesByIdsInput): FindExpensesByIdsOutput;
findBySourceProviderTransactionId: (
Expand Down Expand Up @@ -248,6 +260,27 @@ export class SqliteExpensesRepository implements ExpensesRepository {
return { expenses: rows.map(mapExpense) };
}

listPendingMonzoInRange({
from,
to,
}: ListPendingMonzoExpensesInRangeInput): ListPendingMonzoExpensesInRangeOutput {
const rows = this.db
.select()
.from(expenses)
.where(
and(
eq(expenses.source, 'monzo'),
isNull(expenses.postedAt),
gte(expenses.occurredAt, from),
lt(expenses.occurredAt, to),
),
)
.orderBy(desc(expenses.occurredAt))
.all();

return { expenses: rows.map(mapExpense) };
}

findById({ id }: FindExpenseByIdInput): FindExpenseByIdOutput {
const row = this.db.select().from(expenses).where(eq(expenses.id, id)).get();
return { expense: row ? mapExpense(row) : null };
Expand Down
13 changes: 8 additions & 5 deletions packages/domain/src/repositories/reports.repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, eq, gte, inArray, lt, lte, sql } from 'drizzle-orm';
import { and, eq, gte, inArray, isNotNull, lt, lte, ne, or, sql } from 'drizzle-orm';

import {
categories,
Expand Down Expand Up @@ -137,6 +137,7 @@ export class SqliteReportsRepository implements ReportsRepository {
constructor(private readonly db: RepositoryDb) {}

monthlyTrends({ months }: MonthlyTrendsInput): MonthlyTrendsOutput {
const settledOrNonMonzo = or(ne(expenses.source, 'monzo'), isNotNull(expenses.postedAt));
const rows = this.db
.select({
month: sql<string>`substr(${expenses.occurredAt}, 1, 7)`,
Expand All @@ -145,7 +146,7 @@ export class SqliteReportsRepository implements ReportsRepository {
txCount: sql<number>`COUNT(*)`,
})
.from(expenses)
.where(eq(expenses.kind, 'expense'))
.where(and(eq(expenses.kind, 'expense'), settledOrNonMonzo))
.groupBy(sql`substr(${expenses.occurredAt}, 1, 7)`)
.orderBy(sql`substr(${expenses.occurredAt}, 1, 7) DESC`)
.limit(months)
Expand All @@ -162,6 +163,7 @@ export class SqliteReportsRepository implements ReportsRepository {
}

categoryBreakdown({ from, to }: CategoryBreakdownInput): CategoryBreakdownOutput {
const settledOrNonMonzo = or(ne(expenses.source, 'monzo'), isNotNull(expenses.postedAt));
const filters = [];
if (from) {
filters.push(gte(expenses.occurredAt, from));
Expand All @@ -184,8 +186,8 @@ export class SqliteReportsRepository implements ReportsRepository {

const rows =
filters.length > 0
? query.where(and(eq(expenses.kind, 'expense'), ...filters)).all()
: query.where(eq(expenses.kind, 'expense')).all();
? query.where(and(eq(expenses.kind, 'expense'), settledOrNonMonzo, ...filters)).all()
: query.where(and(eq(expenses.kind, 'expense'), settledOrNonMonzo)).all();

return {
rows: rows.map((row) => ({
Expand Down Expand Up @@ -227,6 +229,7 @@ export class SqliteReportsRepository implements ReportsRepository {
}

monthlyLedger({ from, to }: MonthlyLedgerInput): MonthlyLedgerOutput {
const settledOrNonMonzo = or(ne(expenses.source, 'monzo'), isNotNull(expenses.postedAt));
const rows = this.db
.select({
id: expenses.id,
Expand All @@ -241,7 +244,7 @@ export class SqliteReportsRepository implements ReportsRepository {
})
.from(expenses)
.innerJoin(categories, eq(expenses.categoryId, categories.id))
.where(and(gte(expenses.occurredAt, from), lt(expenses.occurredAt, to)))
.where(and(gte(expenses.occurredAt, from), lt(expenses.occurredAt, to), settledOrNonMonzo))
.all();

const expenseIds = rows.map((row) => row.id);
Expand Down
21 changes: 15 additions & 6 deletions packages/domain/src/services/monzo.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,6 @@ const normalizeMonzoCategory = (value: string | undefined): string => {
return normalized && normalized.length > 0 ? normalized : 'uncategorised';
};

const isPendingTransaction = (transaction: MonzoTransaction): boolean =>
transaction.settled === null || transaction.settled === undefined || transaction.settled === '';

const tryGetMonzoPotId = (description: string): string | null => {
const trimmed = description.trim();
if (!trimmed.startsWith(MONZO_POT_ID_PREFIX)) {
Expand Down Expand Up @@ -581,12 +578,11 @@ export const createMonzoService = ({ runtime, audit }: MonzoServiceDeps): MonzoS
to: window.to,
});

const eligible = allTransactions.filter(
(transaction) => !isPendingTransaction(transaction) && transaction.amount !== 0,
);
const eligible = allTransactions.filter((transaction) => transaction.amount !== 0);
const skippedNonEligible = allTransactions.length - eligible.length;
let skippedDuplicates = 0;
const potCandidateIds = new Set<string>();
const fetchedTransactionIds = new Set(eligible.map((transaction) => transaction.id));

for (const transaction of eligible) {
const potId = tryGetMonzoPotId(transaction.description);
Expand Down Expand Up @@ -844,6 +840,19 @@ export const createMonzoService = ({ runtime, audit }: MonzoServiceDeps): MonzoS
}
}

const pendingInWindow = expensesTxRepo.listPendingMonzoInRange({
from: window.from,
to: window.to,
}).expenses;
for (const pendingExpense of pendingInWindow) {
const transactionId = pendingExpense.providerTransactionId;
if (transactionId && fetchedTransactionIds.has(transactionId)) {
continue;
}

expensesTxRepo.deleteById({ id: pendingExpense.id });
}

monzoTxRepo.upsertConnection(
mergeConnection(currentConnection, {
id: currentConnection.id,
Expand Down
26 changes: 26 additions & 0 deletions tests/api/fastify-enforcement.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

Expand All @@ -10,6 +11,7 @@ import {
resolveCorsOrigin,
tithePlugin,
} from '@tithe/api/server';
import { runMigrations } from '@tithe/db';
import { AppError, type DomainServices } from '@tithe/domain';
import Fastify from 'fastify';
import { vi } from 'vitest';
Expand All @@ -34,6 +36,30 @@ const featureRouteFiles = [
] as const;

describe('API Fastify enforcement', () => {
let previousDbPath: string | undefined;
let testDir: string | null = null;

beforeEach(() => {
previousDbPath = process.env.DB_PATH;
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tithe-fastify-test-'));
const dbPath = path.join(testDir, 'api.sqlite');
process.env.DB_PATH = dbPath;
runMigrations(dbPath);
});

afterEach(() => {
if (previousDbPath === undefined) {
process.env.DB_PATH = undefined;
} else {
process.env.DB_PATH = previousDbPath;
}

if (testDir) {
fs.rmSync(testDir, { recursive: true, force: true });
}

testDir = null;
});
it('returns contract envelope for Fastify validation errors', async () => {
const app = buildServer({ config: baseConfig });

Expand Down
Loading