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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,11 @@ This script handles:
2. **API deployment** — Deploys backend to Cloudflare Workers
3. **Client build & deploy** — Builds React app and deploys to Cloudflare Pages

On its first run it also asks whether to deploy the read-only Finance MCP
Worker. That choice is stored privately in `.deploy-config`; use
On its first run it also asks whether to deploy the Finance MCP Worker. The
Worker provides read-only analysis tools plus review-only transaction draft
creation; MCP-created drafts never affect balances until they are confirmed in
the Finance Manager UI. That deployment choice is stored privately in
`.deploy-config`; use
`npm run deploy:mcp` to include MCP immediately, or `npm run deploy -- --no-mcp`
to change the saved default. See [the MCP deployment guide](mcp/README.md).

Expand Down
22 changes: 22 additions & 0 deletions api/migrations/009-mcp-review-drafts.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- Draft transactions proposed through the MCP server remain pending until the
-- user explicitly confirms them in the Finance Manager UI.
ALTER TABLE transactions ADD COLUMN pending_kind TEXT NOT NULL DEFAULT 'upcoming'
CHECK (pending_kind IN ('upcoming', 'mcp_review'));
ALTER TABLE transactions ADD COLUMN review_source TEXT NOT NULL DEFAULT 'manual'
CHECK (review_source IN ('manual', 'chatgpt_mcp'));
ALTER TABLE transactions ADD COLUMN review_batch_id TEXT;
ALTER TABLE transactions ADD COLUMN review_flags TEXT NOT NULL DEFAULT '[]';

-- The MCP server uses this small ledger to make batch creation idempotent. The
-- signed proposal token contains the proposal contents and expiry; only its
-- canonical hash needs to be retained here.
CREATE TABLE IF NOT EXISTS mcp_draft_batches (
id TEXT PRIMARY KEY,
proposal_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_transactions_status_pending_kind_date
ON transactions(status, pending_kind, date);
CREATE INDEX IF NOT EXISTS idx_transactions_review_batch_id
ON transactions(review_batch_id);
6 changes: 5 additions & 1 deletion api/src/dtos/transaction.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TransactionStatus } from '../models/Transaction'
import { TransactionPendingKind, TransactionReviewSource, TransactionStatus } from '../models/Transaction'

export interface CreateTransactionDto {
account_id: string
Expand Down Expand Up @@ -36,6 +36,10 @@ export interface TransactionResponseDto {
exclude_from_estimate?: boolean
is_recurring?: boolean
status: TransactionStatus
pending_kind: TransactionPendingKind
review_source: TransactionReviewSource
review_batch_id?: string | null
review_flags: string[]
confirmed_at?: number | null
cancelled_at?: number | null
created_at?: number | null
Expand Down
4 changes: 4 additions & 0 deletions api/src/mappers/transaction.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export class TransactionMapper {
exclude_from_estimate: transaction.exclude_from_estimate,
is_recurring: transaction.is_recurring,
status: transaction.status || 'posted',
pending_kind: transaction.pending_kind || 'upcoming',
review_source: transaction.review_source || 'manual',
review_batch_id: transaction.review_batch_id,
review_flags: transaction.review_flags || [],
confirmed_at: transaction.confirmed_at,
cancelled_at: transaction.cancelled_at,
created_at: transaction.created_at,
Expand Down
6 changes: 6 additions & 0 deletions api/src/models/Transaction.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export type TransactionStatus = 'posted' | 'pending' | 'cancelled'
export type TransactionPendingKind = 'upcoming' | 'mcp_review'
export type TransactionReviewSource = 'manual' | 'chatgpt_mcp'

export interface Transaction {
id: string
Expand All @@ -12,6 +14,10 @@ export interface Transaction {
exclude_from_estimate?: boolean
is_recurring?: boolean
status?: TransactionStatus
pending_kind?: TransactionPendingKind
review_source?: TransactionReviewSource
review_batch_id?: string | null
review_flags?: string[]
confirmed_at?: number | null
cancelled_at?: number | null
created_at?: number | null
Expand Down
53 changes: 48 additions & 5 deletions api/src/repositories/transaction.repository.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,43 @@
import { Transaction, TransactionStatus } from '../models/Transaction'

type RawTransactionRow = Omit<Transaction, 'exclude_from_estimate'> & {
import {
Transaction,
TransactionPendingKind,
TransactionReviewSource,
TransactionStatus,
} from '../models/Transaction'

type RawTransactionRow = Omit<Transaction, 'exclude_from_estimate' | 'pending_kind' | 'review_source' | 'review_flags'> & {
exclude_from_estimate: number
status?: TransactionStatus | null
pending_kind?: TransactionPendingKind | null
review_source?: TransactionReviewSource | null
review_flags?: string | null
}

type D1Value = string | number | null

export class TransactionRepository {
constructor(private db: D1Database) {}

private parseReviewFlags(value?: string | null): string[] {
if (!value) return []

try {
const parsed: unknown = JSON.parse(value)
if (!Array.isArray(parsed)) return []
return parsed.filter((flag): flag is string => typeof flag === 'string')
} catch {
return []
}
}
Comment thread
Copilot marked this conversation as resolved.

private mapTransaction(raw: RawTransactionRow): Transaction {
return {
...raw,
exclude_from_estimate: raw.exclude_from_estimate === 1,
status: raw.status || 'posted'
status: raw.status || 'posted',
pending_kind: raw.pending_kind || 'upcoming',
review_source: raw.review_source || 'manual',
review_flags: this.parseReviewFlags(raw.review_flags),
}
}

Expand All @@ -30,7 +53,7 @@ export class TransactionRepository {

async create(transaction: Transaction): Promise<void> {
await this.db.prepare(
'INSERT INTO transactions (id, account_id, category_id, amount, description, date, linked_transaction_id, exclude_from_estimate, status, confirmed_at, cancelled_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO transactions (id, account_id, category_id, amount, description, date, linked_transaction_id, exclude_from_estimate, status, pending_kind, review_source, review_batch_id, review_flags, confirmed_at, cancelled_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).bind(
transaction.id,
transaction.account_id,
Expand All @@ -41,6 +64,10 @@ export class TransactionRepository {
transaction.linked_transaction_id || null,
transaction.exclude_from_estimate ? 1 : 0,
transaction.status || 'posted',
transaction.pending_kind || 'upcoming',
transaction.review_source || 'manual',
transaction.review_batch_id ?? null,
JSON.stringify(transaction.review_flags || []),
transaction.confirmed_at ?? null,
transaction.cancelled_at ?? null,
transaction.created_at ?? Date.now(),
Expand Down Expand Up @@ -80,6 +107,22 @@ export class TransactionRepository {
fields.push('status = ?')
values.push(updates.status)
}
if (updates.pending_kind !== undefined) {
fields.push('pending_kind = ?')
values.push(updates.pending_kind)
}
if (updates.review_source !== undefined) {
fields.push('review_source = ?')
values.push(updates.review_source)
}
if (updates.review_batch_id !== undefined) {
fields.push('review_batch_id = ?')
values.push(updates.review_batch_id)
}
if (updates.review_flags !== undefined) {
fields.push('review_flags = ?')
values.push(JSON.stringify(updates.review_flags))
}
if (updates.confirmed_at !== undefined) {
fields.push('confirmed_at = ?')
values.push(updates.confirmed_at)
Expand Down
4 changes: 4 additions & 0 deletions api/src/services/transaction.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ export class TransactionService {
linked_transaction_id: dto.linked_transaction_id,
exclude_from_estimate: dto.exclude_from_estimate,
status,
pending_kind: 'upcoming',
review_source: 'manual',
review_batch_id: null,
review_flags: [],
confirmed_at: status === 'posted' ? now : null,
cancelled_at: null,
created_at: now,
Expand Down
127 changes: 127 additions & 0 deletions api/src/tests/transaction-review-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from 'vitest'
import { TransactionMapper } from '../mappers/transaction.mapper'
import { TransactionRepository } from '../repositories/transaction.repository'

function createDb(rows: Record<string, unknown>[] = []) {
const calls: Array<{ sql: string; values: unknown[] }> = []

const db = {
prepare: vi.fn((sql: string) => {
const statement = {
values: [] as unknown[],
bind(...values: unknown[]) {
this.values = values
calls.push({ sql, values })
return this
},
async all<T>() {
return { results: rows as T[] }
},
async first<T>() {
return (rows[0] as T | undefined) || null
},
async run() {
return { meta: { changes: 1 } }
},
}
return statement
}),
}

return { db: db as unknown as D1Database, calls }
}

describe('transaction review metadata', () => {
it('maps persisted MCP metadata and parses review flags', async () => {
const { db } = createDb([{
id: 'tx-1',
account_id: 'account-1',
category_id: null,
amount: -250,
description: 'Market',
date: '2026-08-03',
linked_transaction_id: null,
exclude_from_estimate: 0,
status: 'pending',
pending_kind: 'mcp_review',
review_source: 'chatgpt_mcp',
review_batch_id: 'batch-1',
review_flags: '["possible_duplicate"]',
}])

const transactions = await new TransactionRepository(db).findUpcoming()

expect(transactions[0]).toMatchObject({
pending_kind: 'mcp_review',
review_source: 'chatgpt_mcp',
review_batch_id: 'batch-1',
review_flags: ['possible_duplicate'],
})
})

it('uses safe metadata defaults for legacy rows and malformed flags', async () => {
const { db } = createDb([{
id: 'tx-1',
account_id: 'account-1',
amount: 250,
date: '2026-08-03',
exclude_from_estimate: 0,
status: 'pending',
review_flags: '{"not":"an-array"}',
}])

const transaction = await new TransactionRepository(db).findById('tx-1')

expect(transaction).toMatchObject({
pending_kind: 'upcoming',
review_source: 'manual',
review_flags: [],
})
})

it('persists normalized metadata for repository-created transactions', async () => {
const { db, calls } = createDb()
const repository = new TransactionRepository(db)

await repository.create({
id: 'tx-1',
account_id: 'account-1',
amount: -250,
date: '2026-08-03',
status: 'pending',
pending_kind: 'mcp_review',
review_source: 'chatgpt_mcp',
review_batch_id: 'batch-1',
review_flags: ['possible_duplicate'],
})

expect(calls[0].values.slice(8, 13)).toEqual([
'pending',
'mcp_review',
'chatgpt_mcp',
'batch-1',
'["possible_duplicate"]',
])
})

it('exposes normalized review metadata in API response DTOs', () => {
const response = TransactionMapper.toResponseDto({
id: 'tx-1',
account_id: 'account-1',
amount: -250,
date: '2026-08-03',
status: 'pending',
pending_kind: 'mcp_review',
review_source: 'chatgpt_mcp',
review_batch_id: 'batch-1',
review_flags: ['possible_duplicate'],
})

expect(response).toMatchObject({
pending_kind: 'mcp_review',
review_source: 'chatgpt_mcp',
review_batch_id: 'batch-1',
review_flags: ['possible_duplicate'],
})
})
})
66 changes: 66 additions & 0 deletions api/src/tests/upcoming-transactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ describe('upcoming transactions', () => {
})

expect('status' in result && result.status).toBe('pending')
expect('pending_kind' in result && result.pending_kind).toBe('upcoming')
expect('review_source' in result && result.review_source).toBe('manual')
expect('review_flags' in result && result.review_flags).toEqual([])
expect(accounts['account-1'].balance).toBe(1000)
expect(accountRepo.updateBalance).not.toHaveBeenCalled()
})
Expand Down Expand Up @@ -364,4 +367,67 @@ describe('upcoming transactions', () => {
expect(accounts['account-1'].balance).toBe(1000)
expect(accountRepo.updateBalance).not.toHaveBeenCalled()
})

it('preserves MCP review metadata when a draft is edited', async () => {
const accounts = { 'account-1': makeAccount() }
const transactions: Record<string, Transaction> = {
'tx-1': {
id: 'tx-1',
account_id: 'account-1',
amount: -250,
date: '2026-07-07',
status: 'pending',
pending_kind: 'mcp_review',
review_source: 'chatgpt_mcp',
review_batch_id: 'batch-1',
review_flags: ['possible_duplicate'],
},
}
const { service, accountRepo } = createService(transactions, accounts)

const result = await service.updateTransaction('tx-1', {
amount: -275,
category_id: 'groceries',
})

expect(result).toMatchObject({
status: 'pending',
pending_kind: 'mcp_review',
review_source: 'chatgpt_mcp',
review_batch_id: 'batch-1',
review_flags: ['possible_duplicate'],
amount: -275,
category_id: 'groceries',
})
expect(accountRepo.updateBalance).not.toHaveBeenCalled()
})

it('preserves MCP review provenance after confirmation', async () => {
const accounts = { 'account-1': makeAccount() }
const transactions: Record<string, Transaction> = {
'tx-1': {
id: 'tx-1',
account_id: 'account-1',
amount: -250,
date: '2000-01-01',
status: 'pending',
pending_kind: 'mcp_review',
review_source: 'chatgpt_mcp',
review_batch_id: 'batch-1',
review_flags: ['possible_duplicate'],
},
}
const { service } = createService(transactions, accounts)

const result = await service.confirmTransaction('tx-1')

expect(result).toMatchObject({
status: 'posted',
pending_kind: 'mcp_review',
review_source: 'chatgpt_mcp',
review_batch_id: 'batch-1',
review_flags: ['possible_duplicate'],
})
expect(accounts['account-1'].balance).toBe(750)
})
})
Loading
Loading