├── examples └── .gitkeep ├── .prettierignore ├── src ├── core │ ├── aggregation │ │ ├── sum-by.ts │ │ ├── sort-by.ts │ │ ├── group-by.ts │ │ ├── transaction-grouper.ts │ │ ├── group-by.test.ts │ │ └── transaction-grouper.test.ts │ ├── input │ │ ├── validators.ts │ │ └── argument-parser.ts │ ├── mapping │ │ ├── transaction-mapper.ts │ │ ├── category-classifier.ts │ │ ├── category-mapper.ts │ │ └── category-mapper.test.ts │ ├── data │ │ ├── fetch-accounts.ts │ │ ├── fetch-categories.ts │ │ ├── fetch-transactions.ts │ │ ├── fetch-accounts.test.ts │ │ ├── fetch-categories.test.ts │ │ └── fetch-transactions.test.ts │ ├── index.ts │ └── types │ │ └── domain.ts ├── tools │ ├── spending-by-category │ │ ├── types.ts │ │ ├── category-mapper.ts │ │ ├── group-aggregator.ts │ │ ├── transaction-grouper.ts │ │ ├── input-parser.ts │ │ ├── report-generator.ts │ │ ├── data-fetcher.ts │ │ └── index.ts │ ├── balance-history │ │ ├── types.ts │ │ ├── input-parser.ts │ │ ├── data-fetcher.ts │ │ ├── report-generator.ts │ │ ├── index.ts │ │ └── balance-calculator.ts │ ├── get-transactions │ │ ├── data-fetcher.ts │ │ ├── types.ts │ │ ├── transaction-mapper.ts │ │ ├── report-generator.ts │ │ ├── input-parser.ts │ │ └── index.ts │ ├── monthly-summary │ │ ├── types.ts │ │ ├── input-parser.ts │ │ ├── category-classifier.ts │ │ ├── report-data-builder.ts │ │ ├── data-fetcher.ts │ │ ├── summary-calculator.ts │ │ ├── transaction-aggregator.ts │ │ ├── index.ts │ │ └── report-generator.ts │ ├── get-accounts │ │ └── index.ts │ └── index.ts ├── utils.ts ├── utils │ └── response.ts ├── types.ts ├── actual-api.ts ├── index.ts ├── prompts.ts └── resources.ts ├── .env.example ├── .gitignore ├── .gemini └── settings.json ├── .prettierrc ├── tsconfig.eslint.json ├── .vscode └── settings.json ├── .changeset ├── config.json └── README.md ├── tsconfig.build.json ├── INITIAL_TEMPLATE.md ├── Dockerfile ├── tsconfig.json ├── vitest.config.ts ├── LICENSE ├── .claude └── commands │ ├── execute-prp.md │ └── generate-prp.md ├── .github └── workflows │ ├── release-please.yml │ ├── release.yml │ ├── docker-publish.yml │ └── pr-validation.yml ├── eslint.config.ts ├── CHANGELOG.md ├── package.json ├── README.md ├── PRPs ├── templates │ └── prp_base.md └── vitest-unit-testing-core.md └── CLAUDE.md /examples/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md 2 | -------------------------------------------------------------------------------- /src/core/aggregation/sum-by.ts: -------------------------------------------------------------------------------- 1 | // Generic sum-by utilities 2 | export {}; 3 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ACTUAL_SERVER_URL= 2 | ACTUAL_PASSWORD= 3 | ACTUAL_BUDGET_SYNC_ID= 4 | -------------------------------------------------------------------------------- /src/core/aggregation/sort-by.ts: -------------------------------------------------------------------------------- 1 | // Generic sort-by utilities 2 | export {}; 3 | -------------------------------------------------------------------------------- /src/core/input/validators.ts: -------------------------------------------------------------------------------- 1 | // Shared validators for input data 2 | export {}; 3 | -------------------------------------------------------------------------------- /src/core/mapping/transaction-mapper.ts: -------------------------------------------------------------------------------- 1 | // Shared transaction mapping logic 2 | export {}; 3 | -------------------------------------------------------------------------------- /src/core/mapping/category-classifier.ts: -------------------------------------------------------------------------------- 1 | // Shared category classification logic 2 | export {}; 3 | -------------------------------------------------------------------------------- /src/core/input/argument-parser.ts: -------------------------------------------------------------------------------- 1 | // Shared argument parsing utilities will go here 2 | export {}; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | coverage/ 4 | *.log 5 | .env 6 | data/** 7 | .DS_Store 8 | .vscode/launch.json 9 | .gemini/ 10 | 11 | **/*.local.* -------------------------------------------------------------------------------- /.gemini/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "context7": { 4 | "command": "npx", 5 | "args": ["-y", "@upstash/context7-mcp"] 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "printWidth": 120, 6 | "tabWidth": 2, 7 | "useTabs": false 8 | } 9 | -------------------------------------------------------------------------------- /src/tools/spending-by-category/types.ts: -------------------------------------------------------------------------------- 1 | // Types/interfaces for spending-by-category tool 2 | 3 | export type { CategorySpending, GroupSpending } from '../../core/types/domain.js'; 4 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "rootDir": "." 6 | }, 7 | "include": ["src/**/*", "tests/**/*", "vitest.config.ts", "*.ts", "*.js"] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.useFlatConfig": true, 3 | "editor.formatOnSave": true, 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": "explicit" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/core/data/fetch-accounts.ts: -------------------------------------------------------------------------------- 1 | import { getAccounts } from '../../actual-api.js'; 2 | import type { Account } from '../../core/types/domain.js'; 3 | 4 | export async function fetchAllAccounts(): Promise { 5 | return getAccounts(); 6 | } 7 | -------------------------------------------------------------------------------- /src/tools/balance-history/types.ts: -------------------------------------------------------------------------------- 1 | // Types/interfaces for balance-history tool 2 | 3 | export interface BalanceHistoryArgs { 4 | accountId: string; 5 | months?: number; 6 | } 7 | 8 | export interface MonthBalance { 9 | year: number; 10 | month: number; 11 | balance: number; 12 | transactions: number; 13 | } 14 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "declarationMap": true, 6 | "sourceMap": true, 7 | "outDir": "./build", 8 | "rootDir": "./src" 9 | }, 10 | "include": ["src/**/*"], 11 | "exclude": ["node_modules", "build", "src/**/*.test.ts", "vitest.config.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /src/tools/spending-by-category/category-mapper.ts: -------------------------------------------------------------------------------- 1 | // Maps category IDs to names/groups and identifies income/savings/investment categories 2 | import type { Category as _Category, CategoryGroup as _CategoryGroup } from '../../core/types/domain.js'; 3 | import { CategoryMapper } from '../../core/mapping/category-mapper.js'; 4 | 5 | // This file now only re-exports CategoryMapper from core. 6 | export { CategoryMapper }; 7 | -------------------------------------------------------------------------------- /src/core/data/fetch-categories.ts: -------------------------------------------------------------------------------- 1 | import { getCategories, getCategoryGroups } from '../../actual-api.js'; 2 | import type { Category, CategoryGroup } from '../../core/types/domain.js'; 3 | 4 | export async function fetchAllCategories(): Promise { 5 | return getCategories(); 6 | } 7 | 8 | export async function fetchAllCategoryGroups(): Promise { 9 | return getCategoryGroups(); 10 | } 11 | -------------------------------------------------------------------------------- /src/tools/spending-by-category/group-aggregator.ts: -------------------------------------------------------------------------------- 1 | // Aggregates category spendings into groups and sorts them 2 | import type { 3 | CategorySpending as _CategorySpending, 4 | GroupSpending as _GroupSpending, 5 | } from '../../core/types/domain.js'; 6 | import { GroupAggregator } from '../../core/aggregation/group-by.js'; 7 | 8 | // This file now only re-exports GroupAggregator from core. 9 | export { GroupAggregator }; 10 | -------------------------------------------------------------------------------- /src/tools/get-transactions/data-fetcher.ts: -------------------------------------------------------------------------------- 1 | // Fetches transactions and related data for get-transactions tool 2 | import { fetchTransactionsForAccount } from '../../core/data/fetch-transactions.js'; 3 | import type { Transaction } from '../../core/types/domain.js'; 4 | 5 | export class GetTransactionsDataFetcher { 6 | async fetch(accountId: string, start: string, end: string): Promise { 7 | return await fetchTransactionsForAccount(accountId, start, end); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/tools/spending-by-category/transaction-grouper.ts: -------------------------------------------------------------------------------- 1 | // Groups transactions by category and aggregates spending 2 | import type { 3 | Transaction as _Transaction, 4 | CategorySpending as _CategorySpending, 5 | CategoryGroupInfo as _CategoryGroupInfo, 6 | } from '../../core/types/domain.js'; 7 | import { TransactionGrouper } from '../../core/aggregation/transaction-grouper.js'; 8 | 9 | // This file now only re-exports TransactionGrouper from core. 10 | export { TransactionGrouper }; 11 | -------------------------------------------------------------------------------- /src/tools/get-transactions/types.ts: -------------------------------------------------------------------------------- 1 | // Types/interfaces for get-transactions tool 2 | 3 | export interface GetTransactionsArgs { 4 | accountId: string; 5 | startDate?: string; 6 | endDate?: string; 7 | minAmount?: number; 8 | maxAmount?: number; 9 | category?: string; 10 | payee?: string; 11 | limit?: number; 12 | } 13 | 14 | export interface MappedTransaction { 15 | date: string; 16 | payee: string; 17 | category: string; 18 | amount: string; 19 | notes: string; 20 | } 21 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /INITIAL_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## FEATURE: 2 | 3 | [Insert your feature here] 4 | 5 | ## EXAMPLES: 6 | 7 | [Provide and explain examples that you have in the `examples/` folder] 8 | 9 | ## DOCUMENTATION: 10 | 11 | [List out any documentation (web pages, sources for an MCP server like Crawl4AI RAG, etc.) that will need to be referenced during development] 12 | 13 | ## OTHER CONSIDERATIONS: 14 | 15 | [Any other considerations or specific requirements - great place to include gotchas that you see AI coding assistants miss with your projects a lot] 16 | -------------------------------------------------------------------------------- /src/tools/monthly-summary/types.ts: -------------------------------------------------------------------------------- 1 | // Types and interfaces specific to monthly summary 2 | import { MonthData } from '../../types.js'; 3 | export interface MonthlySummaryReportData { 4 | start: string; 5 | end: string; 6 | accountName?: string; 7 | accountId?: string; 8 | sortedMonths: MonthData[]; 9 | avgIncome: number; 10 | avgExpenses: number; 11 | avgInvestments: number; 12 | avgTraditionalSavings: number; 13 | avgTotalSavings: number; 14 | avgTraditionalSavingsRate: number; 15 | avgTotalSavingsRate: number; 16 | } 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ---- Builder ---- 2 | FROM node:22.12-alpine AS builder 3 | 4 | WORKDIR /app 5 | 6 | COPY package.json package-lock.json ./ 7 | RUN --mount=type=cache,target=/root/.npm npm ci 8 | 9 | COPY . ./ 10 | RUN npm run build 11 | 12 | # ---- Release ---- 13 | FROM node:22-alpine AS release 14 | 15 | WORKDIR /app 16 | 17 | COPY --from=builder /app/package.json ./ 18 | COPY --from=builder /app/package-lock.json ./ 19 | COPY --from=builder /app/build ./build 20 | 21 | ENV NODE_ENV=production 22 | RUN npm ci --omit=dev 23 | 24 | EXPOSE 3000 25 | ENTRYPOINT ["node", "build/index.js", "--sse"] -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | // Core exports 2 | export * from './input/argument-parser.js'; 3 | export * from './input/validators.js'; 4 | export * from './data/fetch-accounts.js'; 5 | export * from './data/fetch-categories.js'; 6 | export * from './data/fetch-transactions.js'; 7 | export * from './aggregation/group-by.js'; 8 | export * from './aggregation/sum-by.js'; 9 | export * from './aggregation/sort-by.js'; 10 | export * from './mapping/category-mapper.js'; 11 | export * from './mapping/transaction-mapper.js'; 12 | export * from './mapping/category-classifier.js'; 13 | export * from './types/domain.js'; 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "outDir": "./build", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "declaration": true, 13 | "declarationDir": "./build", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "types": ["vitest/globals", "node"] 17 | }, 18 | "include": ["src/**/*"], 19 | "exclude": ["node_modules", "build"] 20 | } 21 | -------------------------------------------------------------------------------- /src/tools/monthly-summary/input-parser.ts: -------------------------------------------------------------------------------- 1 | export class MonthlySummaryInputParser { 2 | parse(args: unknown): { months: number; accountId?: string } { 3 | if (!args || typeof args !== 'object') { 4 | throw new Error('Arguments must be an object'); 5 | } 6 | const argsObj = args as Record; 7 | const months = typeof argsObj.months === 'number' && argsObj.months > 0 ? argsObj.months : 3; 8 | const accountId = 9 | typeof argsObj.accountId === 'string' && argsObj.accountId.length > 0 ? argsObj.accountId : undefined; 10 | return { months, accountId }; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import tsconfigPaths from 'vite-tsconfig-paths'; 3 | 4 | export default defineConfig({ 5 | plugins: [tsconfigPaths()], 6 | test: { 7 | environment: 'node', 8 | include: ['src/core/**/*.test.ts'], 9 | globals: true, 10 | coverage: { 11 | provider: 'v8', 12 | reporter: ['text', 'json', 'json-summary', 'html'], 13 | include: ['src/core/**/*.ts'], 14 | exclude: ['src/core/**/*.test.ts', 'src/core/types/domain.ts'], 15 | }, 16 | alias: { 17 | '^(\\.{1,2}/.*)\\.js$': '$1', // Handle .js imports in TypeScript 18 | }, 19 | testTransformMode: { 20 | web: ['\\.tsx?$'], 21 | }, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /src/tools/balance-history/input-parser.ts: -------------------------------------------------------------------------------- 1 | // Parses and validates input arguments for balance-history tool 2 | 3 | export interface BalanceHistoryInput { 4 | accountId: string; 5 | months: number; 6 | } 7 | 8 | export class BalanceHistoryInputParser { 9 | parse(args: unknown): BalanceHistoryInput { 10 | if (!args || typeof args !== 'object') { 11 | throw new Error('Arguments must be an object'); 12 | } 13 | const argsObj = args as Record; 14 | const { accountId, months } = argsObj; 15 | if (!accountId || typeof accountId !== 'string') { 16 | throw new Error('accountId is required and must be a string'); 17 | } 18 | return { 19 | accountId, 20 | months: typeof months === 'number' && months > 0 ? months : 12, 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/tools/get-transactions/transaction-mapper.ts: -------------------------------------------------------------------------------- 1 | // Maps and formats transaction data for get-transactions tool 2 | import { formatAmount, formatDate } from '../../utils.js'; 3 | import type { Transaction } from '../../types.js'; 4 | 5 | export class GetTransactionsMapper { 6 | map(transactions: Transaction[]): Array<{ 7 | date: string; 8 | payee: string; 9 | category: string; 10 | amount: string; 11 | notes: string; 12 | }> { 13 | // TODO: Payee and category are not visible in the transaction object 14 | return transactions.map((t) => ({ 15 | date: formatDate(t.date), 16 | payee: t.payee_name || '(No payee)', 17 | category: t.category_name || '(Uncategorized)', 18 | amount: formatAmount(t.amount), 19 | notes: t.notes || '', 20 | })); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/core/data/fetch-transactions.ts: -------------------------------------------------------------------------------- 1 | import { getTransactions } from '../../actual-api.js'; 2 | import type { Account, Transaction } from '../../core/types/domain.js'; 3 | 4 | export async function fetchTransactionsForAccount( 5 | accountId: string, 6 | start: string, 7 | end: string 8 | ): Promise { 9 | return getTransactions(accountId, start, end); 10 | } 11 | 12 | export async function fetchAllOnBudgetTransactions( 13 | accounts: Account[], 14 | start: string, 15 | end: string 16 | ): Promise { 17 | let transactions: Transaction[] = []; 18 | const onBudgetAccounts = accounts.filter((a) => !a.offbudget && !a.closed); 19 | for (const account of onBudgetAccounts) { 20 | const tx = await getTransactions(account.id, start, end); 21 | transactions = [...transactions, ...tx]; 22 | } 23 | return transactions; 24 | } 25 | -------------------------------------------------------------------------------- /src/tools/monthly-summary/category-classifier.ts: -------------------------------------------------------------------------------- 1 | import type { Category } from '../../types.js'; 2 | 3 | export class MonthlySummaryCategoryClassifier { 4 | classify(categories: Category[]): { 5 | incomeCategories: Set; 6 | investmentSavingsCategories: Set; 7 | } { 8 | const incomeCategories = new Set(); 9 | const investmentSavingsCategories = new Set(); 10 | 11 | categories.forEach((cat) => { 12 | if (cat.is_income) incomeCategories.add(cat.id); 13 | if ( 14 | cat.name.toLowerCase().includes('investment') || 15 | cat.name.toLowerCase().includes('vacation') || 16 | cat.name.toLowerCase().includes('savings') 17 | ) { 18 | investmentSavingsCategories.add(cat.id); 19 | } 20 | }); 21 | 22 | return { incomeCategories, investmentSavingsCategories }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/tools/get-transactions/report-generator.ts: -------------------------------------------------------------------------------- 1 | // Generates the response/report for get-transactions tool 2 | 3 | export class GetTransactionsReportGenerator { 4 | generate( 5 | mappedTransactions: Array<{ 6 | date: string; 7 | payee: string; 8 | category: string; 9 | amount: string; 10 | notes: string; 11 | }>, 12 | filterDescription: string, 13 | filteredCount: number, 14 | totalCount: number 15 | ): string { 16 | const header = '| Date | Payee | Category | Amount | Notes |\n| ---- | ----- | -------- | ------ | ----- |\n'; 17 | const rows = mappedTransactions 18 | .map((t) => `| ${t.date} | ${t.payee} | ${t.category} | ${t.amount} | ${t.notes} |`) 19 | .join('\n'); 20 | return `# Filtered Transactions\n\n${filterDescription}\nMatching Transactions: ${filteredCount}/${totalCount}\n\n${header}${rows}`; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/tools/monthly-summary/report-data-builder.ts: -------------------------------------------------------------------------------- 1 | import type { MonthData, Account } from '../../types.js'; 2 | import type { MonthlySummaryReportData } from './types.js'; 3 | 4 | export class MonthlySummaryReportDataBuilder { 5 | build( 6 | start: string, 7 | end: string, 8 | accountId: string | undefined, 9 | accounts: Account[], 10 | sortedMonths: MonthData[], 11 | averages: { 12 | avgIncome: number; 13 | avgExpenses: number; 14 | avgInvestments: number; 15 | avgTraditionalSavings: number; 16 | avgTotalSavings: number; 17 | avgTraditionalSavingsRate: number; 18 | avgTotalSavingsRate: number; 19 | } 20 | ): MonthlySummaryReportData { 21 | const accountName = accountId ? accounts.find((a) => a.id === accountId)?.name : undefined; 22 | 23 | return { 24 | start, 25 | end, 26 | accountId, 27 | accountName, 28 | sortedMonths, 29 | ...averages, 30 | }; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/tools/balance-history/data-fetcher.ts: -------------------------------------------------------------------------------- 1 | // Fetches accounts, transactions, and balances for balance-history tool 2 | import { fetchAllAccounts } from '../../core/data/fetch-accounts.js'; 3 | import { fetchTransactionsForAccount } from '../../core/data/fetch-transactions.js'; 4 | import type { Account, Transaction } from '../../core/types/domain.js'; 5 | import api from '@actual-app/api'; 6 | 7 | export class BalanceHistoryDataFetcher { 8 | async fetchAll( 9 | accountId: string, 10 | start: string, 11 | end: string 12 | ): Promise<{ 13 | accounts: Account[]; 14 | account: Account | undefined; 15 | transactions: Transaction[]; 16 | currentBalance: number; 17 | }> { 18 | const accounts = await fetchAllAccounts(); 19 | const account = accounts.find((a) => a.id === accountId); 20 | const transactions = await fetchTransactionsForAccount(accountId, start, end); 21 | const currentBalance = await api.getAccountBalance(accountId); 22 | return { accounts, account, transactions, currentBalance }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Stefan Stefanov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/tools/spending-by-category/input-parser.ts: -------------------------------------------------------------------------------- 1 | // Parses and validates input arguments for spending-by-category tool 2 | 3 | export interface SpendingByCategoryInput { 4 | startDate: string; 5 | endDate: string; 6 | accountId?: string; 7 | includeIncome: boolean; 8 | } 9 | 10 | export class SpendingByCategoryInputParser { 11 | parse(args: unknown): SpendingByCategoryInput { 12 | if (!args || typeof args !== 'object') { 13 | throw new Error('Arguments must be an object'); 14 | } 15 | const argsObj = args as Record; 16 | const { startDate, endDate, accountId, includeIncome } = argsObj; 17 | if (!startDate || typeof startDate !== 'string') { 18 | throw new Error('startDate is required and must be a string (YYYY-MM-DD)'); 19 | } 20 | if (!endDate || typeof endDate !== 'string') { 21 | throw new Error('endDate is required and must be a string (YYYY-MM-DD)'); 22 | } 23 | return { 24 | startDate, 25 | endDate, 26 | accountId: accountId && typeof accountId === 'string' ? accountId : undefined, 27 | includeIncome: typeof includeIncome === 'boolean' ? includeIncome : false, 28 | }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/tools/spending-by-category/report-generator.ts: -------------------------------------------------------------------------------- 1 | // Generates the markdown report for spending-by-category tool 2 | import type { GroupSpending } from './types.js'; 3 | import { formatAmount } from '../../utils.js'; 4 | 5 | export class SpendingByCategoryReportGenerator { 6 | generate( 7 | sortedGroups: GroupSpending[], 8 | period: { start: string; end: string }, 9 | accountLabel: string, 10 | includeIncome: boolean 11 | ): string { 12 | let markdown = `# Spending by Category\n\n`; 13 | markdown += `Period: ${period.start} to ${period.end}\n\n`; 14 | markdown += `${accountLabel}\n\n`; 15 | markdown += `Income categories: ${includeIncome ? 'Included' : 'Excluded'}\n\n`; 16 | sortedGroups.forEach((group) => { 17 | markdown += `## ${group.name}\n`; 18 | markdown += `Total: ${formatAmount(group.total)}\n\n`; 19 | markdown += `| Category | Amount | Transactions |\n`; 20 | markdown += `| -------- | ------ | ------------ |\n`; 21 | group.categories.forEach((category) => { 22 | markdown += `| ${category.name} | ${formatAmount(category.total)} | ${category.transactions} |\n`; 23 | }); 24 | markdown += `\n`; 25 | }); 26 | return markdown; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/core/aggregation/group-by.ts: -------------------------------------------------------------------------------- 1 | // Aggregates category spendings into groups and sorts them 2 | import type { CategorySpending, GroupSpending } from '../types/domain.js'; 3 | 4 | export class GroupAggregator { 5 | aggregateAndSort(spendingByCategory: Record): GroupSpending[] { 6 | const spendingByGroup: Record = {}; 7 | Object.values(spendingByCategory).forEach((category) => { 8 | if (!spendingByGroup[category.group]) { 9 | spendingByGroup[category.group] = { 10 | name: category.group, 11 | total: 0, 12 | categories: [], 13 | }; 14 | } 15 | spendingByGroup[category.group].total += category.total; 16 | spendingByGroup[category.group].categories.push(category); 17 | }); 18 | // Sort groups by absolute total (descending) 19 | const sortedGroups: GroupSpending[] = Object.values(spendingByGroup).sort( 20 | (a, b) => Math.abs(b.total) - Math.abs(a.total) 21 | ); 22 | // Sort categories within each group by absolute total (descending) 23 | sortedGroups.forEach((group) => { 24 | group.categories.sort((a, b) => Math.abs(b.total) - Math.abs(a.total)); 25 | }); 26 | return sortedGroups; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/core/types/domain.ts: -------------------------------------------------------------------------------- 1 | // Shared domain types and interfaces (Account, Transaction, Category, etc.) 2 | 3 | export interface Account { 4 | id: string; 5 | name: string; 6 | type?: string; 7 | offbudget?: boolean; 8 | closed?: boolean; 9 | balance?: number; 10 | } 11 | 12 | export interface Transaction { 13 | id?: string; 14 | account: string; 15 | date: string; 16 | amount: number; 17 | payee?: string; 18 | payee_name?: string; 19 | category?: string; 20 | category_name?: string; 21 | notes?: string; 22 | } 23 | 24 | export interface Category { 25 | id: string; 26 | name: string; 27 | group_id: string; 28 | is_income?: boolean; 29 | } 30 | 31 | export interface CategoryGroup { 32 | id: string; 33 | name: string; 34 | is_income?: boolean; 35 | } 36 | 37 | export interface CategoryGroupInfo { 38 | id: string; 39 | name: string; 40 | isIncome: boolean; 41 | isSavingsOrInvestment: boolean; 42 | } 43 | 44 | export interface CategorySpending { 45 | id: string; 46 | name: string; 47 | group: string; 48 | isIncome: boolean; 49 | total: number; 50 | transactions: number; 51 | } 52 | 53 | export interface GroupSpending { 54 | name: string; 55 | total: number; 56 | categories: CategorySpending[]; 57 | } 58 | -------------------------------------------------------------------------------- /src/tools/monthly-summary/data-fetcher.ts: -------------------------------------------------------------------------------- 1 | import { fetchAllAccounts } from '../../core/data/fetch-accounts.js'; 2 | import { fetchAllCategories } from '../../core/data/fetch-categories.js'; 3 | import { fetchTransactionsForAccount, fetchAllOnBudgetTransactions } from '../../core/data/fetch-transactions.js'; 4 | import type { Account, Category, Transaction } from '../../core/types/domain.js'; 5 | 6 | export class MonthlySummaryDataFetcher { 7 | /** 8 | * Fetch accounts, categories, and all transactions for the given period. 9 | * If accountId is provided, only fetch transactions for that account. 10 | */ 11 | async fetchAll( 12 | accountId: string | undefined, 13 | start: string, 14 | end: string 15 | ): Promise<{ 16 | accounts: Account[]; 17 | categories: Category[]; 18 | transactions: Transaction[]; 19 | }> { 20 | const accounts = await fetchAllAccounts(); 21 | const categories = await fetchAllCategories(); 22 | 23 | let transactions: Transaction[] = []; 24 | if (accountId) { 25 | transactions = await fetchTransactionsForAccount(accountId, start, end); 26 | } else { 27 | transactions = await fetchAllOnBudgetTransactions(accounts, start, end); 28 | } 29 | 30 | return { accounts, categories, transactions }; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/tools/get-accounts/index.ts: -------------------------------------------------------------------------------- 1 | // ---------------------------- 2 | // GET ACCOUNTS TOOL 3 | // ---------------------------- 4 | 5 | import { successWithJson, errorFromCatch } from '../../utils/response.js'; 6 | import { fetchAllAccounts } from '../../core/data/fetch-accounts.js'; 7 | import type { Account } from '../../core/types/domain.js'; 8 | import { z } from 'zod'; 9 | import { zodToJsonSchema } from 'zod-to-json-schema'; 10 | import { type ToolInput } from '../../types.js'; 11 | 12 | // Define an empty schema with zod 13 | const GetAccountsArgsSchema = z.object({}); 14 | 15 | export const schema = { 16 | name: 'get-accounts', 17 | description: 'Retrieve a list of all accounts with their current balance and ID.', 18 | inputSchema: zodToJsonSchema(GetAccountsArgsSchema) as ToolInput, 19 | }; 20 | 21 | export async function handler(): Promise | ReturnType> { 22 | try { 23 | const accounts: Account[] = await fetchAllAccounts(); 24 | 25 | const structured = accounts.map((account) => ({ 26 | id: account.id, 27 | name: account.name, 28 | type: account.type || 'Account', 29 | closed: account.closed, 30 | offBudget: account.offbudget, 31 | })); 32 | 33 | return successWithJson(structured); 34 | } catch (err) { 35 | return errorFromCatch(err); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/tools/get-transactions/input-parser.ts: -------------------------------------------------------------------------------- 1 | // Parses and validates input arguments for get-transactions tool 2 | 3 | export interface GetTransactionsInput { 4 | accountId: string; 5 | startDate?: string; 6 | endDate?: string; 7 | minAmount?: number; 8 | maxAmount?: number; 9 | category?: string; 10 | payee?: string; 11 | limit?: number; 12 | } 13 | 14 | export class GetTransactionsInputParser { 15 | parse(args: unknown): GetTransactionsInput { 16 | if (!args || typeof args !== 'object') { 17 | throw new Error('Arguments must be an object'); 18 | } 19 | const argsObj = args as Record; 20 | const { accountId, startDate, endDate, minAmount, maxAmount, category, payee, limit } = argsObj; 21 | if (!accountId || typeof accountId !== 'string') { 22 | throw new Error('accountId is required and must be a string'); 23 | } 24 | return { 25 | accountId, 26 | startDate: typeof startDate === 'string' ? startDate : undefined, 27 | endDate: typeof endDate === 'string' ? endDate : undefined, 28 | minAmount: typeof minAmount === 'number' ? minAmount : undefined, 29 | maxAmount: typeof maxAmount === 'number' ? maxAmount : undefined, 30 | category: typeof category === 'string' ? category : undefined, 31 | payee: typeof payee === 'string' ? payee : undefined, 32 | limit: typeof limit === 'number' ? limit : undefined, 33 | }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.claude/commands/execute-prp.md: -------------------------------------------------------------------------------- 1 | # Execute BASE PRP 2 | 3 | Implement a feature using using the PRP file. 4 | 5 | ## PRP File: $ARGUMENTS 6 | 7 | ## Execution Process 8 | 9 | 1. **Load PRP** 10 | - Read the specified PRP file 11 | - Understand all context and requirements 12 | - Follow all instructions in the PRP and extend the research if needed 13 | - Ensure you have all needed context to implement the PRP fully 14 | - Do more web searches and codebase exploration as needed 15 | 16 | 2. **ULTRATHINK** 17 | - Think hard before you execute the plan. Create a comprehensive plan addressing all requirements. 18 | - Break down complex tasks into smaller, manageable steps using your todos tools. 19 | - Use the TodoWrite tool to create and track your implementation plan. 20 | - Identify implementation patterns from existing code to follow. 21 | 22 | 3. **Execute the plan** 23 | - Execute the PRP 24 | - Implement all the code 25 | 26 | 4. **Validate** 27 | - Run each validation command 28 | - Fix any failures 29 | - Re-run until all pass 30 | 31 | 5. **Complete** 32 | - Ensure all checklist items done 33 | - Run final validation suite 34 | - Report completion status 35 | - Read the PRP again to ensure you have implemented everything 36 | 37 | 6. **Reference the PRP** 38 | - You can always reference the PRP again if needed 39 | 40 | Note: If validation fails, use error patterns in PRP to fix and retry. 41 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: release-please 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | release-please: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: googleapis/release-please-action@v4 11 | id: release 12 | with: 13 | token: ${{ secrets.GITHUB_TOKEN }} 14 | release-type: node 15 | - uses: actions/checkout@v2 16 | if: ${{ steps.release.outputs.release_created }} 17 | - uses: actions/setup-node@v3 18 | if: ${{ steps.release.outputs.release_created }} 19 | with: 20 | node-version: 22 21 | registry-url: https://registry.npmjs.org/ 22 | - run: npm ci 23 | if: ${{ steps.release.outputs.release_created }} 24 | - run: npm publish 25 | env: 26 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 27 | if: ${{ steps.release.outputs.release_created }} 28 | - name: Log in to Docker Hub 29 | uses: docker/login-action@v2 30 | if: ${{ steps.release.outputs.release_created }} 31 | with: 32 | username: ${{ secrets.DOCKER_USERNAME }} 33 | password: ${{ secrets.DOCKER_PASSWORD }} 34 | - name: Build and push 35 | uses: docker/build-push-action@v4 36 | if: ${{ steps.release.outputs.release_created }} 37 | with: 38 | push: true 39 | tags: ${{ github.repository }}:${{ steps.release.outputs.tag_name }},${{ github.repository }}:latest 40 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | pull-requests: write 14 | id-token: write 15 | outputs: 16 | hasChangesets: ${{ steps.changesets.outputs.hasChangesets }} 17 | versionTag: ${{ steps.version.outputs.VERSION }} 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: 20 24 | registry-url: https://registry.npmjs.org 25 | - name: Install dependencies 26 | run: npm ci 27 | - name: Create Release Pull Request 28 | id: changesets 29 | uses: changesets/action@v1 30 | with: 31 | publish: npm publish --provenance --access public 32 | env: 33 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | - name: Extract version from publishedPackages 36 | id: version 37 | run: | 38 | echo "VERSION=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[0].version')" >> $GITHUB_OUTPUT 39 | 40 | docker-publish: 41 | needs: release 42 | if: needs.release.outputs.hasChangesets == 'false' 43 | uses: ./.github/workflows/docker-publish.yml 44 | with: 45 | versionTag: ${{ needs.release.outputs.versionTag }} 46 | secrets: inherit -------------------------------------------------------------------------------- /src/tools/spending-by-category/data-fetcher.ts: -------------------------------------------------------------------------------- 1 | // Fetches accounts, categories, groups, and transactions for spending-by-category tool 2 | import { fetchAllAccounts } from '../../core/data/fetch-accounts.js'; 3 | import { fetchAllCategories, fetchAllCategoryGroups } from '../../core/data/fetch-categories.js'; 4 | import { fetchTransactionsForAccount, fetchAllOnBudgetTransactions } from '../../core/data/fetch-transactions.js'; 5 | import type { Account, Category, CategoryGroup, Transaction } from '../../core/types/domain.js'; 6 | 7 | export class SpendingByCategoryDataFetcher { 8 | /** 9 | * Fetch all required data for the spending-by-category tool. 10 | * If accountId is provided, only fetch transactions for that account. 11 | */ 12 | async fetchAll( 13 | accountId: string | undefined, 14 | start: string, 15 | end: string 16 | ): Promise<{ 17 | accounts: Account[]; 18 | categories: Category[]; 19 | categoryGroups: CategoryGroup[]; 20 | transactions: Transaction[]; 21 | }> { 22 | const accounts = await fetchAllAccounts(); 23 | const categories = await fetchAllCategories(); 24 | const categoryGroups = await fetchAllCategoryGroups(); 25 | 26 | let transactions: Transaction[] = []; 27 | if (accountId) { 28 | transactions = await fetchTransactionsForAccount(accountId, start, end); 29 | } else { 30 | transactions = await fetchAllOnBudgetTransactions(accounts, start, end); 31 | } 32 | return { accounts, categories, categoryGroups, transactions }; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/core/aggregation/transaction-grouper.ts: -------------------------------------------------------------------------------- 1 | // Groups transactions by category and aggregates spending 2 | import type { Transaction, CategorySpending, CategoryGroupInfo } from '../types/domain.js'; 3 | 4 | export class TransactionGrouper { 5 | groupByCategory( 6 | transactions: Transaction[], 7 | getCategoryName: (categoryId: string) => string, 8 | getGroupInfo: (categoryId: string) => CategoryGroupInfo | undefined, 9 | includeIncome: boolean 10 | ): Record { 11 | const spendingByCategory: Record = {}; 12 | transactions.forEach((transaction) => { 13 | if (!transaction.category) return; // Skip uncategorized 14 | const categoryId = transaction.category; 15 | const categoryName = getCategoryName(categoryId); 16 | const group = getGroupInfo(categoryId) || { 17 | name: 'Unknown Group', 18 | isIncome: false, 19 | }; 20 | // Skip income categories if not requested 21 | if (group.isIncome && !includeIncome) return; 22 | if (!spendingByCategory[categoryId]) { 23 | spendingByCategory[categoryId] = { 24 | id: categoryId, 25 | name: categoryName, 26 | group: group.name, 27 | isIncome: group.isIncome, 28 | total: 0, 29 | transactions: 0, 30 | }; 31 | } 32 | spendingByCategory[categoryId].total += transaction.amount; 33 | spendingByCategory[categoryId].transactions += 1; 34 | }); 35 | return spendingByCategory; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/tools/monthly-summary/summary-calculator.ts: -------------------------------------------------------------------------------- 1 | import type { MonthData } from '../../types.js'; 2 | 3 | export class MonthlySummaryCalculator { 4 | calculateAverages(sortedMonths: MonthData[]): { 5 | avgIncome: number; 6 | avgExpenses: number; 7 | avgInvestments: number; 8 | avgTraditionalSavings: number; 9 | avgTotalSavings: number; 10 | avgTraditionalSavingsRate: number; 11 | avgTotalSavingsRate: number; 12 | } { 13 | const totalIncome = sortedMonths.reduce((sum, m) => sum + m.income, 0); 14 | const totalExpenses = sortedMonths.reduce((sum, m) => sum + m.expenses, 0); 15 | const totalInvestments = sortedMonths.reduce((sum, m) => sum + m.investments, 0); 16 | const monthCount = sortedMonths.length; 17 | 18 | const avgIncome = monthCount > 0 ? totalIncome / monthCount : 0; 19 | const avgExpenses = monthCount > 0 ? totalExpenses / monthCount : 0; 20 | const avgInvestments = monthCount > 0 ? totalInvestments / monthCount : 0; 21 | 22 | const avgTraditionalSavings = avgIncome - avgExpenses; 23 | const avgTotalSavings = avgTraditionalSavings + avgInvestments; 24 | const avgTraditionalSavingsRate = avgIncome > 0 ? (avgTraditionalSavings / avgIncome) * 100 : 0; 25 | const avgTotalSavingsRate = avgIncome > 0 ? ((avgTraditionalSavings + avgInvestments) / avgIncome) * 100 : 0; 26 | 27 | return { 28 | avgIncome, 29 | avgExpenses, 30 | avgInvestments, 31 | avgTraditionalSavings, 32 | avgTotalSavings, 33 | avgTraditionalSavingsRate, 34 | avgTotalSavingsRate, 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/core/data/fetch-accounts.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { fetchAllAccounts } from './fetch-accounts.js'; 3 | 4 | // CRITICAL: Mock before imports 5 | vi.mock('../../actual-api.js', () => ({ 6 | getAccounts: vi.fn(), 7 | })); 8 | 9 | import { getAccounts } from '../../actual-api.js'; 10 | 11 | describe('fetchAllAccounts', () => { 12 | beforeEach(() => { 13 | vi.clearAllMocks(); 14 | }); 15 | 16 | it('should return accounts from API', async () => { 17 | const mockAccounts = [ 18 | { 19 | id: '1', 20 | name: 'Test Account', 21 | type: 'checking', 22 | offbudget: false, 23 | closed: false, 24 | }, 25 | { 26 | id: '2', 27 | name: 'Savings Account', 28 | type: 'savings', 29 | offbudget: false, 30 | closed: false, 31 | }, 32 | ]; 33 | vi.mocked(getAccounts).mockResolvedValue(mockAccounts); 34 | 35 | const result = await fetchAllAccounts(); 36 | 37 | expect(result).toEqual(mockAccounts); 38 | expect(getAccounts).toHaveBeenCalledOnce(); 39 | }); 40 | 41 | it('should handle API errors', async () => { 42 | vi.mocked(getAccounts).mockRejectedValue(new Error('API Error')); 43 | 44 | await expect(fetchAllAccounts()).rejects.toThrow('API Error'); 45 | expect(getAccounts).toHaveBeenCalledOnce(); 46 | }); 47 | 48 | it('should handle empty response', async () => { 49 | vi.mocked(getAccounts).mockResolvedValue([]); 50 | 51 | const result = await fetchAllAccounts(); 52 | 53 | expect(result).toEqual([]); 54 | expect(getAccounts).toHaveBeenCalledOnce(); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/tools/monthly-summary/transaction-aggregator.ts: -------------------------------------------------------------------------------- 1 | import { Transaction, MonthData } from '../../types.js'; 2 | 3 | export class MonthlySummaryTransactionAggregator { 4 | aggregate( 5 | transactions: Transaction[], 6 | incomeCategories: Set, 7 | investmentSavingsCategories: Set 8 | ): MonthData[] { 9 | const monthlyData: Record = {}; 10 | 11 | transactions.forEach((transaction) => { 12 | const date = new Date(transaction.date); 13 | const yearMonth = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; 14 | 15 | if (!monthlyData[yearMonth]) { 16 | monthlyData[yearMonth] = { 17 | year: date.getFullYear(), 18 | month: date.getMonth() + 1, 19 | income: 0, 20 | expenses: 0, 21 | investments: 0, 22 | transactions: 0, 23 | }; 24 | } 25 | 26 | const isIncome = transaction.category ? incomeCategories.has(transaction.category) : false; 27 | const isInvestmentOrSavings = transaction.category 28 | ? investmentSavingsCategories.has(transaction.category) 29 | : false; 30 | 31 | if (isIncome || transaction.amount > 0) { 32 | monthlyData[yearMonth].income += Math.abs(transaction.amount); 33 | } else if (isInvestmentOrSavings) { 34 | monthlyData[yearMonth].investments += Math.abs(transaction.amount); 35 | } else { 36 | monthlyData[yearMonth].expenses += Math.abs(transaction.amount); 37 | } 38 | 39 | monthlyData[yearMonth].transactions += 1; 40 | }); 41 | 42 | return Object.values(monthlyData).sort((a, b) => (a.year !== b.year ? a.year - b.year : a.month - b.month)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/tools/balance-history/report-generator.ts: -------------------------------------------------------------------------------- 1 | // Generates the markdown report for balance-history tool 2 | import { formatAmount } from '../../utils.js'; 3 | import type { Account } from '../../types.js'; 4 | import type { MonthBalance } from './balance-calculator.js'; 5 | 6 | export class BalanceHistoryReportGenerator { 7 | generate(account: Account, period: { start: string; end: string }, sortedMonths: MonthBalance[]): string { 8 | let markdown = `# Account Balance History\n\n`; 9 | markdown += `Account: ${account.name}\n`; 10 | markdown += `Period: ${period.start} to ${period.end}\n\n`; 11 | 12 | // Add balance history table 13 | markdown += `| Month | End of Month Balance | Monthly Change | Transactions |\n`; 14 | markdown += `| ----- | -------------------- | -------------- | ------------ |\n`; 15 | 16 | let previousBalance: number | null = null; 17 | 18 | sortedMonths.forEach((month) => { 19 | const monthName: string = new Date(month.year, month.month - 1, 1).toLocaleString('default', { month: 'long' }); 20 | const balance: string = formatAmount(month.balance); 21 | 22 | let change = ''; 23 | let changeAmount = 0; 24 | 25 | if (previousBalance !== null) { 26 | changeAmount = month.balance - previousBalance; 27 | const changeFormatted: string = formatAmount(changeAmount); 28 | const direction: string = changeAmount > 0 ? '↑' : changeAmount < 0 ? '↓' : ''; 29 | change = `${direction} ${changeFormatted}`; 30 | } 31 | 32 | previousBalance = month.balance; 33 | 34 | markdown += `| ${monthName} ${month.year} | ${balance} | ${change} | ${month.transactions} |\n`; 35 | }); 36 | 37 | return markdown; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/core/mapping/category-mapper.ts: -------------------------------------------------------------------------------- 1 | // Maps category IDs to names/groups and identifies income/savings/investment categories 2 | import type { Category, CategoryGroup, CategoryGroupInfo } from '../types/domain.js'; 3 | 4 | export class CategoryMapper { 5 | categoryNames: Record = {}; 6 | groupNames: Record = {}; 7 | categoryToGroup: Record = {}; 8 | investmentCategories: Set = new Set(); 9 | 10 | constructor(categories: Category[], categoryGroups: CategoryGroup[]) { 11 | categories.forEach((cat) => { 12 | this.categoryNames[cat.id] = cat.name; 13 | }); 14 | categoryGroups.forEach((group) => { 15 | this.groupNames[group.id] = group.name; 16 | }); 17 | categories.forEach((cat) => { 18 | const groupName = this.groupNames[cat.group_id] || 'Unknown Group'; 19 | const isIncome = !!cat.is_income; 20 | const isSavingsOrInvestment = 21 | groupName.toLowerCase().includes('investment') || groupName.toLowerCase().includes('savings'); 22 | this.categoryToGroup[cat.id] = { 23 | id: cat.group_id, 24 | name: groupName, 25 | isIncome, 26 | isSavingsOrInvestment, 27 | }; 28 | if (isSavingsOrInvestment) { 29 | this.investmentCategories.add(cat.id); 30 | } 31 | }); 32 | } 33 | 34 | getCategoryName(categoryId: string): string { 35 | return this.categoryNames[categoryId] || 'Unknown Category'; 36 | } 37 | 38 | getGroupInfo(categoryId: string): CategoryGroupInfo | undefined { 39 | return this.categoryToGroup[categoryId]; 40 | } 41 | 42 | isInvestmentCategory(categoryId: string): boolean { 43 | return this.investmentCategories.has(categoryId); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get date range parameters with defaults 3 | */ 4 | export function getDateRange(startDate?: string, endDate?: string): { startDate: string; endDate: string } { 5 | const today = new Date(); 6 | const defaultStartDate = new Date(); 7 | defaultStartDate.setMonth(today.getMonth() - 3); // 3 months ago by default 8 | 9 | return { 10 | startDate: startDate || formatDate(defaultStartDate), 11 | endDate: endDate || formatDate(today), 12 | }; 13 | } 14 | 15 | /** 16 | * Format a date as YYYY-MM-DD 17 | */ 18 | export function formatDate(date: Date | string | undefined | null): string { 19 | if (!date) return ''; 20 | if (typeof date === 'string') return date; 21 | 22 | const d = new Date(date); 23 | return d.toISOString().split('T')[0]; 24 | } 25 | 26 | /** 27 | * Format currency amounts for display 28 | */ 29 | export function formatAmount(amount: number | undefined | null): string { 30 | if (amount === undefined || amount === null) return 'N/A'; 31 | 32 | // Convert from cents to dollars 33 | const dollars = amount / 100; 34 | return new Intl.NumberFormat('en-US', { 35 | style: 'currency', 36 | currency: 'USD', 37 | }).format(dollars); 38 | } 39 | 40 | // Helper to calculate start/end date strings for the N most recent months 41 | export function getDateRangeForMonths(months: number): { 42 | start: string; 43 | end: string; 44 | } { 45 | const now = new Date(); 46 | const end = new Date(now.getFullYear(), now.getMonth() + 1, 0); // last day of current month 47 | const start = new Date(end.getFullYear(), end.getMonth() - months + 1, 1); // first day of N months ago 48 | return { 49 | start: start.toISOString().slice(0, 10), 50 | end: end.toISOString().slice(0, 10), 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker Publish 2 | on: 3 | workflow_call: 4 | inputs: 5 | versionTag: 6 | required: false 7 | type: string 8 | pull_request: 9 | branches: 10 | - master 11 | - main 12 | 13 | jobs: 14 | build-and-push: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: read 18 | packages: write 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v3 23 | - name: Log in to GitHub Container Registry 24 | uses: docker/login-action@v3 25 | with: 26 | registry: ghcr.io 27 | username: ${{ github.actor }} 28 | password: ${{ secrets.GITHUB_TOKEN }} 29 | - name: Set semver and latest tags if release 30 | id: tagmeta 31 | run: | 32 | if [ "${{ github.event_name }}" = "workflow_call" ]; then 33 | echo "SEMVER=type=semver,pattern=${{ inputs.versionTag }}" >> $GITHUB_OUTPUT 34 | echo "LATEST=type=raw,value=latest" >> $GITHUB_OUTPUT 35 | else 36 | echo "SEMVER=" >> $GITHUB_OUTPUT 37 | echo "LATEST=" >> $GITHUB_OUTPUT 38 | fi 39 | - name: Extract Docker metadata 40 | id: meta 41 | uses: docker/metadata-action@v5 42 | with: 43 | images: ghcr.io/${{ github.actor }}/${{ github.repository }} 44 | tags: | 45 | type=ref,event=branch 46 | type=ref,event=pr 47 | type=sha 48 | ${{ steps.tagmeta.outputs.SEMVER }} 49 | ${{ steps.tagmeta.outputs.LATEST }} 50 | - name: Build and push Docker image 51 | uses: docker/build-push-action@v6 52 | with: 53 | context: . 54 | file: ./Dockerfile 55 | push: true 56 | tags: ${{ steps.meta.outputs.tags }} 57 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /eslint.config.ts: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import tseslint from 'typescript-eslint'; 3 | import prettier from 'eslint-plugin-prettier'; 4 | import prettierConfig from 'eslint-config-prettier'; 5 | import globals from 'globals'; 6 | 7 | export default tseslint.config( 8 | // Global ignores 9 | { 10 | ignores: ['node_modules', 'dist', 'build', 'coverage', '**/*.d.ts', 'eslint.config.*'], 11 | }, 12 | 13 | // Base configurations 14 | eslint.configs.recommended, 15 | 16 | // Global language options 17 | { 18 | languageOptions: { 19 | globals: { 20 | ...globals.node, 21 | ...globals.vitest, 22 | }, 23 | }, 24 | }, 25 | 26 | // TypeScript-specific configuration 27 | { 28 | files: ['**/*.ts'], 29 | extends: [tseslint.configs.recommended], 30 | languageOptions: { 31 | parser: tseslint.parser, 32 | parserOptions: { 33 | projectService: { 34 | allowDefaultProject: ['*.ts', '*.js', 'vitest.config.ts'], 35 | }, 36 | tsconfigRootDir: import.meta.dirname, 37 | }, 38 | }, 39 | plugins: { 40 | '@typescript-eslint': tseslint.plugin, 41 | prettier, 42 | }, 43 | rules: { 44 | // TypeScript-specific rules 45 | 'no-unused-vars': 'off', 46 | '@typescript-eslint/no-unused-vars': [ 47 | 'error', 48 | { 49 | argsIgnorePattern: '^_', 50 | varsIgnorePattern: '^_', 51 | caughtErrorsIgnorePattern: '^_', 52 | }, 53 | ], 54 | '@typescript-eslint/explicit-function-return-type': [ 55 | 'error', 56 | { 57 | allowExpressions: true, 58 | allowTypedFunctionExpressions: true, 59 | }, 60 | ], 61 | '@typescript-eslint/no-explicit-any': 'warn', 62 | '@typescript-eslint/no-inferrable-types': 'error', 63 | 64 | // Prettier integration 65 | 'prettier/prettier': 'error', 66 | }, 67 | }, 68 | 69 | // Prettier configuration (disables conflicting rules) 70 | prettierConfig 71 | ); 72 | -------------------------------------------------------------------------------- /src/tools/balance-history/index.ts: -------------------------------------------------------------------------------- 1 | // Orchestrator for balance-history tool 2 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 3 | import { BalanceHistoryInputParser } from './input-parser.js'; 4 | import { BalanceHistoryDataFetcher } from './data-fetcher.js'; 5 | import { BalanceHistoryCalculator } from './balance-calculator.js'; 6 | import { BalanceHistoryReportGenerator } from './report-generator.js'; 7 | import { success, errorFromCatch } from '../../utils/response.js'; 8 | import { formatDate } from '../../utils.js'; 9 | import { BalanceHistoryArgsSchema, type BalanceHistoryArgs, ToolInput } from '../../types.js'; 10 | import { zodToJsonSchema } from 'zod-to-json-schema'; 11 | 12 | export const schema = { 13 | name: 'balance-history', 14 | description: 'Get account balance history over time', 15 | inputSchema: zodToJsonSchema(BalanceHistoryArgsSchema) as ToolInput, 16 | }; 17 | 18 | export async function handler(args: BalanceHistoryArgs): Promise { 19 | try { 20 | const input = new BalanceHistoryInputParser().parse(args); 21 | const { accountId, months } = input; 22 | 23 | // Calculate date range 24 | const endDate = new Date(); 25 | const startDate = new Date(); 26 | startDate.setMonth(endDate.getMonth() - months); 27 | const start = formatDate(startDate); 28 | const end = formatDate(endDate); 29 | 30 | // Fetch data 31 | const { 32 | accounts: _accounts, 33 | account, 34 | transactions, 35 | currentBalance, 36 | } = await new BalanceHistoryDataFetcher().fetchAll(accountId, start, end); 37 | if (!account) { 38 | return errorFromCatch(`Account with ID ${accountId} not found`); 39 | } 40 | 41 | // Calculate balance history 42 | const sortedMonths = new BalanceHistoryCalculator().calculate(transactions, currentBalance, months, endDate); 43 | 44 | // Generate report 45 | const markdown = new BalanceHistoryReportGenerator().generate(account, { start, end }, sortedMonths); 46 | return success(markdown); 47 | } catch (err) { 48 | return errorFromCatch(err); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/response.ts: -------------------------------------------------------------------------------- 1 | // ---------------------------- 2 | // RESPONSE UTILITIES 3 | // ---------------------------- 4 | 5 | import { CallToolResult, TextContent, ImageContent, AudioContent } from '@modelcontextprotocol/sdk/types.js'; 6 | 7 | /** 8 | * Standard MCP content item types (union of all supported content types) 9 | */ 10 | export type ContentItem = TextContent | ImageContent | AudioContent; 11 | 12 | /** 13 | * Text content item (most common type) 14 | */ 15 | export type TextContentItem = TextContent; 16 | 17 | /** 18 | * Standard MCP response structure (compatible with CallToolResult) 19 | */ 20 | export type Response = CallToolResult; 21 | 22 | /** 23 | * Create a successful plain text response 24 | * @param text - The text message 25 | * @returns A success response object with text content 26 | */ 27 | export function success(text: string): CallToolResult { 28 | return { 29 | content: [{ type: 'text', text }], 30 | }; 31 | } 32 | 33 | /** 34 | * Create a success response with structured content 35 | * @param content - Array of content items 36 | * @returns A success response object with provided content 37 | */ 38 | export function successWithContent(content: ContentItem): CallToolResult { 39 | return { 40 | content: [content], 41 | }; 42 | } 43 | 44 | /** 45 | * Create a success response with JSON data 46 | * @param data - Any data object that can be JSON-stringified 47 | * @returns A success response with JSON data wrapped as a resource 48 | */ 49 | export function successWithJson(data: T): CallToolResult { 50 | return { 51 | content: [ 52 | { 53 | type: 'text', 54 | text: JSON.stringify(data), 55 | }, 56 | ], 57 | }; 58 | } 59 | 60 | /** 61 | * Create an error response 62 | * @param message - The error message 63 | * @returns An error response object 64 | */ 65 | export function error(message: string): CallToolResult { 66 | return { 67 | isError: true, 68 | content: [{ type: 'text', text: `Error: ${message}` }], 69 | }; 70 | } 71 | 72 | /** 73 | * Create an error response from an Error object or any thrown value 74 | * @param err - The error object or value 75 | * @returns An error response object 76 | */ 77 | export function errorFromCatch(err: unknown): CallToolResult { 78 | const message = err instanceof Error ? err.message : String(err); 79 | return error(message); 80 | } 81 | -------------------------------------------------------------------------------- /src/tools/balance-history/balance-calculator.ts: -------------------------------------------------------------------------------- 1 | // Calculates balance history per month for balance-history tool 2 | import type { Transaction } from '../../types.js'; 3 | 4 | export interface MonthBalance { 5 | year: number; 6 | month: number; 7 | balance: number; 8 | transactions: number; 9 | } 10 | 11 | export class BalanceHistoryCalculator { 12 | calculate(transactions: Transaction[], currentBalance: number, months: number, endDate: Date): MonthBalance[] { 13 | const balanceHistory: Record = {}; 14 | let runningBalance: number = currentBalance; 15 | 16 | // Sort transactions by date (newest first) 17 | const sortedTransactions: Transaction[] = [...transactions].sort((a, b) => { 18 | return new Date(b.date).getTime() - new Date(a.date).getTime(); 19 | }); 20 | 21 | // Initialize with current balance for current month 22 | const currentYearMonth = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, '0')}`; 23 | 24 | balanceHistory[currentYearMonth] = { 25 | year: endDate.getFullYear(), 26 | month: endDate.getMonth() + 1, 27 | balance: runningBalance, 28 | transactions: 0, 29 | }; 30 | 31 | // Process transactions to calculate past balances 32 | sortedTransactions.forEach((transaction) => { 33 | const date = new Date(transaction.date); 34 | const yearMonth = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; 35 | 36 | // Subtract transaction amount from running balance (going backwards in time) 37 | runningBalance -= transaction.amount; 38 | 39 | if (!balanceHistory[yearMonth]) { 40 | balanceHistory[yearMonth] = { 41 | year: date.getFullYear(), 42 | month: date.getMonth() + 1, 43 | balance: runningBalance, 44 | transactions: 0, 45 | }; 46 | } 47 | 48 | balanceHistory[yearMonth].transactions += 1; 49 | }); 50 | 51 | // Convert to array and sort by date 52 | let sortedMonths: MonthBalance[] = Object.values(balanceHistory).sort((a, b) => { 53 | if (a.year !== b.year) return a.year - b.year; 54 | return a.month - b.month; 55 | }); 56 | 57 | // Only include the most recent N months 58 | if (sortedMonths.length > months) { 59 | sortedMonths = sortedMonths.slice(sortedMonths.length - months); 60 | } 61 | 62 | return sortedMonths; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.1.0](https://github.com/s-stefanov/actual-mcp/compare/v1.0.2...v1.1.0) (2025-07-26) 4 | 5 | 6 | ### Features 7 | 8 | * Add Vitest unit testing framework for src/core module ([#14](https://github.com/s-stefanov/actual-mcp/issues/14)) ([80d3d80](https://github.com/s-stefanov/actual-mcp/commit/80d3d8028fec938ed06f03b60b234be19b3881d1)) 9 | * create PR checks ([#16](https://github.com/s-stefanov/actual-mcp/issues/16)) ([b60ea97](https://github.com/s-stefanov/actual-mcp/commit/b60ea973ddffc9b93a32679beb61d616decb0455)) 10 | * ESLint Introduction. Typings and fixes ([#15](https://github.com/s-stefanov/actual-mcp/issues/15)) ([8f33ad8](https://github.com/s-stefanov/actual-mcp/commit/8f33ad88c91ab3636fa95a53337cc8cc952a5773)) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * update actual version to latest ([f4b18e1](https://github.com/s-stefanov/actual-mcp/commit/f4b18e13329bbf78ef498e1e200ea51dae3f9d88)) 16 | * update response type of accounts tool. test return is correct ([3dbe79a](https://github.com/s-stefanov/actual-mcp/commit/3dbe79a665a26acea6133812f36bf8a41ac60eae)) 17 | 18 | ## [1.0.2](https://github.com/s-stefanov/actual-mcp/compare/v1.0.1...v1.0.2) (2025-07-02) 19 | 20 | 21 | ### Bug Fixes 22 | 23 | * deployment steps ([66a0311](https://github.com/s-stefanov/actual-mcp/commit/66a0311dccfa8f1cdb47052c74e21f070c0e7863)) 24 | 25 | ## [1.0.1](https://github.com/s-stefanov/actual-mcp/compare/v1.0.0...v1.0.1) (2025-07-01) 26 | 27 | 28 | ### Bug Fixes 29 | 30 | * deployment ([1c57a9d](https://github.com/s-stefanov/actual-mcp/commit/1c57a9d980bbf5724121763372a30a202e961273)) 31 | 32 | ## 1.0.0 (2025-07-01) 33 | 34 | 35 | ### Features 36 | 37 | * get accounts tool ([#6](https://github.com/s-stefanov/actual-mcp/issues/6)) ([9008dbe](https://github.com/s-stefanov/actual-mcp/commit/9008dbe8a94e83b822f28a1c0190f281882b7fcc)) 38 | * github pipelines ([#9](https://github.com/s-stefanov/actual-mcp/issues/9)) ([e9ae9ff](https://github.com/s-stefanov/actual-mcp/commit/e9ae9ff2a53c19ba9065804c64fb257bfbc3a8f7)) 39 | * Refactoring of tools & types ([#5](https://github.com/s-stefanov/actual-mcp/issues/5)) ([af9d185](https://github.com/s-stefanov/actual-mcp/commit/af9d1850ca76315185f36331f758597f510a4528)) 40 | 41 | 42 | ### Bug Fixes 43 | 44 | * docker run with pre-compiled binaries ([#4](https://github.com/s-stefanov/actual-mcp/issues/4)) ([2171a0f](https://github.com/s-stefanov/actual-mcp/commit/2171a0f5ccb2cd1ecc29affb86fb9ae6e3710200)) 45 | -------------------------------------------------------------------------------- /src/tools/monthly-summary/index.ts: -------------------------------------------------------------------------------- 1 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 2 | import { zodToJsonSchema } from 'zod-to-json-schema'; 3 | import { MonthlySummaryInputParser } from './input-parser.js'; 4 | import { MonthlySummaryDataFetcher } from './data-fetcher.js'; 5 | import { MonthlySummaryCategoryClassifier } from './category-classifier.js'; 6 | import { MonthlySummaryTransactionAggregator } from './transaction-aggregator.js'; 7 | import { MonthlySummaryCalculator } from './summary-calculator.js'; 8 | import { MonthlySummaryReportDataBuilder } from './report-data-builder.js'; 9 | import { MonthlySummaryReportGenerator } from './report-generator.js'; 10 | import { successWithContent, errorFromCatch } from '../../utils/response.js'; 11 | import { getDateRangeForMonths } from '../../utils.js'; 12 | import { MonthlySummaryArgsSchema, type MonthlySummaryArgs, ToolInput } from '../../types.js'; 13 | 14 | export const schema = { 15 | name: 'monthly-summary', 16 | description: 'Get monthly income, expenses, and savings', 17 | inputSchema: zodToJsonSchema(MonthlySummaryArgsSchema) as ToolInput, 18 | }; 19 | 20 | export async function handler(args: MonthlySummaryArgs): Promise { 21 | try { 22 | const input = new MonthlySummaryInputParser().parse(args); 23 | const { start, end } = getDateRangeForMonths(input.months); 24 | 25 | const { accounts, categories, transactions } = await new MonthlySummaryDataFetcher().fetchAll( 26 | input.accountId, 27 | start, 28 | end 29 | ); 30 | const { incomeCategories, investmentSavingsCategories } = new MonthlySummaryCategoryClassifier().classify( 31 | categories 32 | ); 33 | const sortedMonths = new MonthlySummaryTransactionAggregator().aggregate( 34 | transactions, 35 | incomeCategories, 36 | investmentSavingsCategories 37 | ); 38 | const averages = new MonthlySummaryCalculator().calculateAverages(sortedMonths); 39 | const reportData = new MonthlySummaryReportDataBuilder().build( 40 | start, 41 | end, 42 | input.accountId, 43 | accounts, 44 | sortedMonths, 45 | averages 46 | ); 47 | const markdown = new MonthlySummaryReportGenerator().generate(reportData); 48 | 49 | return successWithContent({ type: 'text', text: markdown }); 50 | } catch (err) { 51 | // Use the standardized error response 52 | // errorFromCatch is imported from ../../utils/response.js 53 | return errorFromCatch(err); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/tools/spending-by-category/index.ts: -------------------------------------------------------------------------------- 1 | // Orchestrator for spending-by-category tool 2 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 3 | import { SpendingByCategoryInputParser } from './input-parser.js'; 4 | import { SpendingByCategoryDataFetcher } from './data-fetcher.js'; 5 | import { CategoryMapper } from '../../core/mapping/category-mapper.js'; 6 | import { TransactionGrouper } from '../../core/aggregation/transaction-grouper.js'; 7 | import { GroupAggregator } from '../../core/aggregation/group-by.js'; 8 | import { SpendingByCategoryReportGenerator } from './report-generator.js'; 9 | import { success, errorFromCatch } from '../../utils/response.js'; 10 | import type { SpendingByCategoryInput } from './input-parser.js'; 11 | import { zodToJsonSchema } from 'zod-to-json-schema'; 12 | import { SpendingByCategoryArgsSchema, type SpendingByCategoryArgs, ToolInput, type Account } from '../../types.js'; 13 | 14 | export const schema = { 15 | name: 'spending-by-category', 16 | description: 'Get spending breakdown by category for a specified date range', 17 | inputSchema: zodToJsonSchema(SpendingByCategoryArgsSchema) as ToolInput, 18 | }; 19 | 20 | export async function handler(args: SpendingByCategoryArgs): Promise { 21 | try { 22 | const input: SpendingByCategoryInput = new SpendingByCategoryInputParser().parse(args); 23 | const { startDate, endDate, accountId, includeIncome } = input; 24 | const { accounts, categories, categoryGroups, transactions } = await new SpendingByCategoryDataFetcher().fetchAll( 25 | accountId, 26 | startDate, 27 | endDate 28 | ); 29 | const categoryMapper = new CategoryMapper(categories, categoryGroups); 30 | const spendingByCategory = new TransactionGrouper().groupByCategory( 31 | transactions, 32 | (categoryId) => categoryMapper.getCategoryName(categoryId), 33 | (categoryId) => categoryMapper.getGroupInfo(categoryId), 34 | includeIncome 35 | ); 36 | const sortedGroups = new GroupAggregator().aggregateAndSort(spendingByCategory); 37 | 38 | let accountLabel = 'Accounts: All on-budget accounts'; 39 | if (accountId) { 40 | const account: Account | undefined = accounts.find((a) => a.id === accountId); 41 | accountLabel = `Account: ${account ? account.name : accountId}`; 42 | } 43 | 44 | const markdown = new SpendingByCategoryReportGenerator().generate( 45 | sortedGroups, 46 | { start: startDate, end: endDate }, 47 | accountLabel, 48 | includeIncome 49 | ); 50 | return success(markdown); 51 | } catch (err) { 52 | return errorFromCatch(err); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "actual-mcp", 3 | "version": "1.1.0", 4 | "description": "Actual Budget MCP server exposing API functionality", 5 | "private": false, 6 | "main": "build/index.js", 7 | "types": "build/index.d.ts", 8 | "type": "module", 9 | "bin": { 10 | "actual-mcp": "build/index.js" 11 | }, 12 | "files": [ 13 | "build", 14 | "README.md", 15 | "LICENSE" 16 | ], 17 | "scripts": { 18 | "build": "tsc -p tsconfig.build.json && node -e \"require('fs').chmodSync('build/index.js', '755')\"", 19 | "watch": "tsc --watch", 20 | "inspector": "npx @modelcontextprotocol/inspector -e ACTUAL_SERVER_URL=$ACTUAL_SERVER_URL -e ACTUAL_PASSWORD=$ACTUAL_PASSWORD -e ACTUAL_BUDGET_SYNC_ID=$ACTUAL_BUDGET_SYNC_ID node build/index.js", 21 | "start": "tsx src/index.ts", 22 | "test": "npm run test:unit", 23 | "test:unit": "vitest run", 24 | "test:unit:watch": "vitest", 25 | "test:ui": "vitest --ui", 26 | "test:coverage": "vitest run --coverage", 27 | "lint": "eslint .", 28 | "lint:fix": "eslint . --fix", 29 | "format": "prettier --write .", 30 | "format:check": "prettier --check .", 31 | "type-check": "tsc --noEmit", 32 | "quality": "npm run lint && npm run format:check && npm run type-check", 33 | "prepublishOnly": "npm run build" 34 | }, 35 | "dependencies": { 36 | "@actual-app/api": "^25.7.0", 37 | "@modelcontextprotocol/sdk": "^1.12.0", 38 | "express": "^5.1.0", 39 | "zod": "^3.25.76", 40 | "zod-to-json-schema": "^3.24.6" 41 | }, 42 | "devDependencies": { 43 | "@changesets/cli": "^2.29.4", 44 | "@types/express": "^5.0.2", 45 | "@types/node": "^20.11.24", 46 | "@vitest/coverage-v8": "^3.2.4", 47 | "@vitest/ui": "^3.2.4", 48 | "dotenv": "^16.4.7", 49 | "eslint": "^9.31.0", 50 | "eslint-config-prettier": "^10.1.5", 51 | "eslint-plugin-prettier": "^5.5.1", 52 | "globals": "^16.3.0", 53 | "jiti": "^2.4.2", 54 | "prettier": "^3.6.2", 55 | "tsx": "^4.19.4", 56 | "typescript": "^5.3.3", 57 | "typescript-eslint": "^8.37.0", 58 | "vite-tsconfig-paths": "^5.1.4", 59 | "vitest": "^3.2.4" 60 | }, 61 | "publishConfig": { 62 | "access": "public" 63 | }, 64 | "homepage": "https://github.com/s-stefanov/actual-mcp", 65 | "repository": { 66 | "type": "git", 67 | "url": "git+https://github.com/s-stefanov/actual-mcp.git" 68 | }, 69 | "bugs": { 70 | "url": "https://github.com/s-stefanov/actual-mcp/issues" 71 | }, 72 | "keywords": [ 73 | "mcp", 74 | "actual-budget", 75 | "budgeting", 76 | "ai", 77 | "model-context-protocol", 78 | "actual" 79 | ], 80 | "author": "Stefan Stefanov", 81 | "license": "MIT" 82 | } 83 | -------------------------------------------------------------------------------- /src/core/data/fetch-categories.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { fetchAllCategories, fetchAllCategoryGroups } from './fetch-categories.js'; 3 | 4 | // CRITICAL: Mock before imports 5 | vi.mock('../../actual-api.js', () => ({ 6 | getCategories: vi.fn(), 7 | getCategoryGroups: vi.fn(), 8 | })); 9 | 10 | import { getCategories, getCategoryGroups } from '../../actual-api.js'; 11 | 12 | describe('fetchAllCategories', () => { 13 | beforeEach(() => { 14 | vi.clearAllMocks(); 15 | }); 16 | 17 | it('should return categories from API', async () => { 18 | const mockCategories = [ 19 | { id: '1', name: 'Food', group_id: 'g1' }, 20 | { id: '2', name: 'Transport', group_id: 'g2' }, 21 | ]; 22 | vi.mocked(getCategories).mockResolvedValue(mockCategories); 23 | 24 | const result = await fetchAllCategories(); 25 | 26 | expect(result).toEqual(mockCategories); 27 | expect(getCategories).toHaveBeenCalledOnce(); 28 | }); 29 | 30 | it('should handle API errors', async () => { 31 | vi.mocked(getCategories).mockRejectedValue(new Error('Categories API Error')); 32 | 33 | await expect(fetchAllCategories()).rejects.toThrow('Categories API Error'); 34 | expect(getCategories).toHaveBeenCalledOnce(); 35 | }); 36 | 37 | it('should handle empty response', async () => { 38 | vi.mocked(getCategories).mockResolvedValue([]); 39 | 40 | const result = await fetchAllCategories(); 41 | 42 | expect(result).toEqual([]); 43 | expect(getCategories).toHaveBeenCalledOnce(); 44 | }); 45 | }); 46 | 47 | describe('fetchAllCategoryGroups', () => { 48 | beforeEach(() => { 49 | vi.clearAllMocks(); 50 | }); 51 | 52 | it('should return category groups from API', async () => { 53 | const mockCategoryGroups = [ 54 | { id: 'g1', name: 'Living', is_income: false, hidden: false, categories: [] }, 55 | { id: 'g2', name: 'Income', is_income: true, hidden: false, categories: [] }, 56 | ]; 57 | vi.mocked(getCategoryGroups).mockResolvedValue(mockCategoryGroups); 58 | 59 | const result = await fetchAllCategoryGroups(); 60 | 61 | expect(result).toEqual(mockCategoryGroups); 62 | expect(getCategoryGroups).toHaveBeenCalledOnce(); 63 | }); 64 | 65 | it('should handle API errors', async () => { 66 | vi.mocked(getCategoryGroups).mockRejectedValue(new Error('Category Groups API Error')); 67 | 68 | await expect(fetchAllCategoryGroups()).rejects.toThrow('Category Groups API Error'); 69 | expect(getCategoryGroups).toHaveBeenCalledOnce(); 70 | }); 71 | 72 | it('should handle empty response', async () => { 73 | vi.mocked(getCategoryGroups).mockResolvedValue([]); 74 | 75 | const result = await fetchAllCategoryGroups(); 76 | 77 | expect(result).toEqual([]); 78 | expect(getCategoryGroups).toHaveBeenCalledOnce(); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /.claude/commands/generate-prp.md: -------------------------------------------------------------------------------- 1 | # Create PRP 2 | 3 | ## Feature file: $ARGUMENTS 4 | 5 | Generate a complete PRP for general feature implementation with thorough research. Ensure context is passed to the AI agent to enable self-validation and iterative refinement. Read the feature file first to understand what needs to be created, how the examples provided help, and any other considerations. 6 | 7 | The AI agent only gets the context you are appending to the PRP and training data. Assuma the AI agent has access to the codebase and the same knowledge cutoff as you, so its important that your research findings are included or referenced in the PRP. The Agent has Websearch capabilities, so pass urls to documentation and examples. 8 | 9 | ## Research Process 10 | 11 | 1. **Codebase Analysis** 12 | - Search for similar features/patterns in the codebase 13 | - Identify files to reference in PRP 14 | - Note existing conventions to follow 15 | - Check test patterns for validation approach 16 | 17 | 2. **External Research** 18 | - Search for similar features/patterns online 19 | - Library documentation (include specific URLs) 20 | - Implementation examples (GitHub/StackOverflow/blogs) 21 | - Best practices and common pitfalls 22 | 23 | 3. **User Clarification** (if needed) 24 | - Specific patterns to mirror and where to find them? 25 | - Integration requirements and where to find them? 26 | 27 | ## PRP Generation 28 | 29 | Using PRPs/templates/prp_base.md as template: 30 | 31 | ### Critical Context to Include and pass to the AI agent as part of the PRP 32 | 33 | - **Documentation**: URLs with specific sections 34 | - **Code Examples**: Real snippets from codebase 35 | - **Gotchas**: Library quirks, version issues 36 | - **Patterns**: Existing approaches to follow 37 | 38 | ### Implementation Blueprint 39 | 40 | - Start with pseudocode showing approach 41 | - Reference real files for patterns 42 | - Include error handling strategy 43 | - list tasks to be completed to fullfill the PRP in the order they should be completed 44 | 45 | ### Validation Gates (Must be Executable) eg for python 46 | 47 | ```bash 48 | # Syntax/Style 49 | ruff check --fix && mypy . 50 | 51 | # Unit Tests 52 | uv run pytest tests/ -v 53 | 54 | ``` 55 | 56 | **_ CRITICAL AFTER YOU ARE DONE RESEARCHING AND EXPLORING THE CODEBASE BEFORE YOU START WRITING THE PRP _** 57 | 58 | **_ ULTRATHINK ABOUT THE PRP AND PLAN YOUR APPROACH THEN START WRITING THE PRP _** 59 | 60 | ## Output 61 | 62 | Save as: `PRPs/{feature-name}.md` 63 | 64 | ## Quality Checklist 65 | 66 | - [ ] All necessary context included 67 | - [ ] Validation gates are executable by AI 68 | - [ ] References existing patterns 69 | - [ ] Clear implementation path 70 | - [ ] Error handling documented 71 | 72 | Score the PRP on a scale of 1-10 (confidence level to succeed in one-pass implementation using claude codes) 73 | 74 | Remember: The goal is one-pass implementation success through comprehensive context. 75 | -------------------------------------------------------------------------------- /src/tools/index.ts: -------------------------------------------------------------------------------- 1 | // ---------------------------- 2 | // TOOLS 3 | // ---------------------------- 4 | 5 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 6 | import { CallToolRequestSchema, ListToolsRequestSchema, ListToolsResult } from '@modelcontextprotocol/sdk/types.js'; 7 | import { initActualApi, shutdownActualApi } from '../actual-api.js'; 8 | import { GetTransactionsArgs, SpendingByCategoryArgs, MonthlySummaryArgs, BalanceHistoryArgs } from '../types.js'; 9 | import { schema as getTransactionsSchema, handler as getTransactionsHandler } from './get-transactions/index.js'; 10 | import { 11 | schema as spendingByCategorySchema, 12 | handler as spendingByCategoryHandler, 13 | } from './spending-by-category/index.js'; 14 | import { schema as monthlySummarySchema, handler as monthlySummaryHandler } from './monthly-summary/index.js'; 15 | import { schema as balanceHistorySchema, handler as balanceHistoryHandler } from './balance-history/index.js'; 16 | import { error, errorFromCatch } from '../utils/response.js'; 17 | import { schema as getAccountsSchema, handler as getAccountsHandler } from './get-accounts/index.js'; 18 | 19 | export const setupTools = (server: Server): void => { 20 | /** 21 | * Handler for listing available tools 22 | */ 23 | server.setRequestHandler(ListToolsRequestSchema, toolsSchema); 24 | 25 | /** 26 | * Handler for calling tools 27 | */ 28 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 29 | try { 30 | await initActualApi(); 31 | const { name, arguments: args } = request.params; 32 | 33 | // Execute the requested tool 34 | switch (name) { 35 | case 'get-transactions': { 36 | // TODO: Validate against schema 37 | return getTransactionsHandler(args as unknown as GetTransactionsArgs); 38 | } 39 | 40 | case 'spending-by-category': { 41 | return spendingByCategoryHandler(args as unknown as SpendingByCategoryArgs); 42 | } 43 | 44 | case 'monthly-summary': { 45 | return monthlySummaryHandler(args as unknown as MonthlySummaryArgs); 46 | } 47 | 48 | case 'balance-history': { 49 | return balanceHistoryHandler(args as unknown as BalanceHistoryArgs); 50 | } 51 | 52 | case 'get-accounts': { 53 | return getAccountsHandler(); 54 | } 55 | 56 | default: 57 | return error(`Unknown tool ${name}`); 58 | } 59 | } catch (error) { 60 | console.error(`Error executing tool ${request.params.name}:`, error); 61 | return errorFromCatch(error); 62 | } finally { 63 | await shutdownActualApi(); 64 | } 65 | }); 66 | }; 67 | 68 | function toolsSchema(): ListToolsResult { 69 | return { 70 | tools: [ 71 | getTransactionsSchema, 72 | spendingByCategorySchema, 73 | monthlySummarySchema, 74 | balanceHistorySchema, 75 | getAccountsSchema, 76 | ], 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /.github/workflows/pr-validation.yml: -------------------------------------------------------------------------------- 1 | name: PR Validation 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | types: [opened, synchronize, reopened] 7 | 8 | permissions: 9 | contents: read # Read repository contents 10 | pull-requests: write # Comment on PRs with coverage 11 | checks: write # Update check status 12 | 13 | jobs: 14 | build-and-test: 15 | name: Build and Test 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 10 18 | 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | 23 | - name: Setup Node.js 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: 22 27 | cache: 'npm' 28 | 29 | - name: Install dependencies 30 | run: npm ci 31 | 32 | - name: Build TypeScript 33 | run: npm run build 34 | 35 | - name: Run tests with coverage 36 | run: npm run test:coverage 37 | 38 | - name: Upload coverage reports 39 | uses: actions/upload-artifact@v4 40 | if: always() 41 | with: 42 | name: coverage-reports 43 | path: | 44 | coverage/ 45 | coverage.json 46 | retention-days: 7 47 | 48 | type-check: 49 | name: Type Check 50 | runs-on: ubuntu-latest 51 | timeout-minutes: 5 52 | 53 | steps: 54 | - name: Checkout code 55 | uses: actions/checkout@v4 56 | 57 | - name: Setup Node.js 58 | uses: actions/setup-node@v4 59 | with: 60 | node-version: 22 61 | cache: 'npm' 62 | 63 | - name: Install dependencies 64 | run: npm ci 65 | 66 | - name: Type check 67 | run: npx tsc --noEmit 68 | 69 | coverage-report: 70 | name: Coverage Report 71 | runs-on: ubuntu-latest 72 | needs: build-and-test 73 | if: always() 74 | 75 | steps: 76 | - name: Checkout code 77 | uses: actions/checkout@v4 78 | 79 | - name: Download coverage reports 80 | uses: actions/download-artifact@v4 81 | with: 82 | name: coverage-reports 83 | 84 | - name: Vitest Coverage Report 85 | uses: davelosert/vitest-coverage-report-action@v2 86 | if: always() 87 | with: 88 | json-summary-path: ./coverage/coverage-summary.json 89 | json-final-path: ./coverage/coverage-final.json 90 | 91 | lint: 92 | name: Lint 93 | runs-on: ubuntu-latest 94 | timeout-minutes: 5 95 | 96 | steps: 97 | - name: Checkout code 98 | uses: actions/checkout@v4 99 | 100 | - name: Setup Node.js 101 | uses: actions/setup-node@v4 102 | with: 103 | node-version: 22 104 | cache: 'npm' 105 | 106 | - name: Install dependencies 107 | run: npm ci 108 | 109 | - name: Run quality checks 110 | run: npm run quality 111 | -------------------------------------------------------------------------------- /src/tools/get-transactions/index.ts: -------------------------------------------------------------------------------- 1 | // Orchestrator for get-transactions tool 2 | import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 3 | import { GetTransactionsInputParser } from './input-parser.js'; 4 | import { GetTransactionsDataFetcher } from './data-fetcher.js'; 5 | import { GetTransactionsMapper } from './transaction-mapper.js'; 6 | import { GetTransactionsReportGenerator } from './report-generator.js'; 7 | import { success, errorFromCatch } from '../../utils/response.js'; 8 | import { getDateRange } from '../../utils.js'; 9 | import { GetTransactionsArgsSchema, type GetTransactionsArgs, type ToolInput } from '../../types.js'; 10 | import { zodToJsonSchema } from 'zod-to-json-schema'; 11 | 12 | export const schema = { 13 | name: 'get-transactions', 14 | description: 'Get transactions for an account with optional filtering', 15 | inputSchema: zodToJsonSchema(GetTransactionsArgsSchema) as ToolInput, 16 | }; 17 | 18 | export async function handler(args: GetTransactionsArgs): Promise { 19 | try { 20 | const input = new GetTransactionsInputParser().parse(args); 21 | const { accountId, startDate, endDate, minAmount, maxAmount, category, payee, limit } = input; 22 | const { startDate: start, endDate: end } = getDateRange(startDate, endDate); 23 | 24 | // Fetch transactions 25 | const transactions = await new GetTransactionsDataFetcher().fetch(accountId, start, end); 26 | let filtered = [...transactions]; 27 | 28 | if (minAmount !== undefined) { 29 | filtered = filtered.filter((t) => t.amount >= minAmount * 100); 30 | } 31 | if (maxAmount !== undefined) { 32 | filtered = filtered.filter((t) => t.amount <= maxAmount * 100); 33 | } 34 | if (category) { 35 | const lowerCategory = category.toLowerCase(); 36 | filtered = filtered.filter((t) => (t.category_name || '').toLowerCase().includes(lowerCategory)); 37 | } 38 | if (payee) { 39 | const lowerPayee = payee.toLowerCase(); 40 | filtered = filtered.filter((t) => (t.payee_name || '').toLowerCase().includes(lowerPayee)); 41 | } 42 | if (limit && filtered.length > limit) { 43 | filtered = filtered.slice(0, limit); 44 | } 45 | 46 | // Map transactions for output 47 | const mapped = new GetTransactionsMapper().map(filtered); 48 | 49 | // Build filter description 50 | const filterDescription = [ 51 | startDate || endDate ? `Date range: ${startDate} to ${endDate}` : null, 52 | minAmount !== undefined ? `Min amount: $${minAmount.toFixed(2)}` : null, 53 | maxAmount !== undefined ? `Max amount: $${maxAmount.toFixed(2)}` : null, 54 | category ? `Category: ${category}` : null, 55 | payee ? `Payee: ${payee}` : null, 56 | ] 57 | .filter(Boolean) 58 | .join(', '); 59 | 60 | const markdown = new GetTransactionsReportGenerator().generate( 61 | mapped, 62 | filterDescription, 63 | filtered.length, 64 | transactions.length 65 | ); 66 | return success(markdown); 67 | } catch (err) { 68 | return errorFromCatch(err); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for Actual Budget API 2 | export type { Account, Transaction, Category, CategoryGroup } from './core/types/domain.js'; 3 | import { z } from 'zod'; 4 | import { ToolSchema } from '@modelcontextprotocol/sdk/types.js'; 5 | 6 | const _ToolInputSchema = ToolSchema.shape.inputSchema; 7 | export type ToolInput = z.infer; 8 | 9 | export interface BudgetFile { 10 | id?: string; 11 | cloudFileId?: string; 12 | name: string; 13 | } 14 | 15 | // Type definitions for tool arguments 16 | export const GetTransactionsArgsSchema = z.object({ 17 | accountId: z.string(), 18 | startDate: z.string().optional(), 19 | endDate: z.string().optional(), 20 | minAmount: z.number().optional(), 21 | maxAmount: z.number().optional(), 22 | category: z.string().optional(), 23 | payee: z.string().optional(), 24 | limit: z.number().optional(), 25 | }); 26 | 27 | export type GetTransactionsArgs = z.infer; 28 | 29 | export const SpendingByCategoryArgsSchema = z.object({ 30 | startDate: z.string().optional(), 31 | endDate: z.string().optional(), 32 | accountId: z.string().optional(), 33 | includeIncome: z.boolean().optional(), 34 | }); 35 | 36 | export type SpendingByCategoryArgs = z.infer; 37 | 38 | export const MonthlySummaryArgsSchema = z.object({ 39 | months: z.number().optional().default(3), 40 | accountId: z.string().optional(), 41 | }); 42 | 43 | export type MonthlySummaryArgs = z.infer; 44 | 45 | export const BalanceHistoryArgsSchema = z.object({ 46 | accountId: z.string(), 47 | months: z.number().optional().default(3), 48 | }); 49 | 50 | export type BalanceHistoryArgs = z.infer; 51 | 52 | export const FinancialInsightsArgsSchema = z.object({ 53 | startDate: z.string().optional(), 54 | endDate: z.string().optional(), 55 | }); 56 | 57 | export type FinancialInsightsArgs = z.infer; 58 | 59 | export const BudgetReviewArgsSchema = z.object({ 60 | months: z.number().optional().default(3), 61 | }); 62 | 63 | export type BudgetReviewArgs = z.infer; 64 | 65 | // Additional types used in implementation 66 | export interface CategoryGroupInfo { 67 | id: string; 68 | name: string; 69 | isIncome: boolean; 70 | isSavingsOrInvestment: boolean; 71 | } 72 | 73 | export interface CategorySpending { 74 | name: string; 75 | group: string; 76 | isIncome: boolean; 77 | total: number; 78 | transactions: number; 79 | } 80 | 81 | export interface GroupSpending { 82 | name: string; 83 | total: number; 84 | categories: CategorySpending[]; 85 | } 86 | 87 | export interface MonthData { 88 | year: number; 89 | month: number; 90 | income: number; 91 | expenses: number; 92 | investments: number; 93 | transactions: number; 94 | } 95 | 96 | export interface MonthBalance { 97 | year: number; 98 | month: number; 99 | balance: number; 100 | transactions: number; 101 | } 102 | -------------------------------------------------------------------------------- /src/tools/monthly-summary/report-generator.ts: -------------------------------------------------------------------------------- 1 | import { MonthlySummaryReportData } from './types.js'; 2 | import { formatAmount } from '../../utils.js'; 3 | import type { MonthData } from '../../types.js'; 4 | 5 | export class MonthlySummaryReportGenerator { 6 | generate(data: MonthlySummaryReportData): string { 7 | const { 8 | start, 9 | end, 10 | accountId, 11 | accountName, 12 | sortedMonths, 13 | avgIncome, 14 | avgExpenses, 15 | avgInvestments, 16 | avgTraditionalSavings, 17 | avgTotalSavings, 18 | avgTraditionalSavingsRate, 19 | avgTotalSavingsRate, 20 | } = data; 21 | 22 | let markdown = `# Monthly Financial Summary\n\n`; 23 | markdown += `Period: ${start} to ${end}\n\n`; 24 | 25 | if (accountId) { 26 | markdown += `Account: ${accountName || accountId}\n\n`; 27 | } else { 28 | markdown += `Accounts: All on-budget accounts\n\n`; 29 | } 30 | 31 | // Add summary table 32 | markdown += `## Monthly Breakdown\n\n`; 33 | markdown += `| Month | Income | Regular Expenses | Investments | Traditional Savings | Total Savings | Total Savings Rate |\n`; 34 | markdown += `| ----- | ------ | ---------------- | ----------- | ------------------- | ------------- | ------------------ |\n`; 35 | 36 | sortedMonths.forEach((month: MonthData) => { 37 | const monthName: string = new Date(month.year, month.month - 1, 1).toLocaleString('default', { month: 'long' }); 38 | const income: string = formatAmount(month.income); 39 | const expenses: string = formatAmount(month.expenses); 40 | const investments: string = formatAmount(month.investments); 41 | 42 | const traditionalSavings: number = month.income - month.expenses; 43 | const totalSavings: number = traditionalSavings + month.investments; 44 | 45 | const savingsFormatted: string = formatAmount(traditionalSavings); 46 | const totalSavingsFormatted: string = formatAmount(totalSavings); 47 | 48 | const savingsRate: string = month.income > 0 ? ((totalSavings / month.income) * 100).toFixed(1) + '%' : 'N/A'; 49 | 50 | markdown += `| ${monthName} ${month.year} | ${income} | ${expenses} | ${investments} | ${savingsFormatted} | ${totalSavingsFormatted} | ${savingsRate} |\n`; 51 | }); 52 | 53 | // Add averages 54 | markdown += `\n## Averages\n\n`; 55 | markdown += `Average Monthly Income: ${formatAmount(avgIncome)}\n`; 56 | markdown += `Average Monthly Regular Expenses: ${formatAmount(avgExpenses)}\n`; 57 | markdown += `Average Monthly Investments: ${formatAmount(avgInvestments)}\n`; 58 | markdown += `Average Monthly Traditional Savings: ${formatAmount(avgTraditionalSavings)}\n`; 59 | markdown += `Average Monthly Total Savings: ${formatAmount(avgTotalSavings)}\n`; 60 | markdown += `Average Traditional Savings Rate: ${avgTraditionalSavingsRate.toFixed(1)}%\n`; 61 | markdown += `Average Total Savings Rate: ${avgTotalSavingsRate.toFixed(1)}%\n`; 62 | 63 | markdown += `\n## Definitions\n\n`; 64 | markdown += `* **Traditional Savings**: Income minus regular expenses (excluding investments)\n`; 65 | markdown += `* **Total Savings**: Traditional savings plus investments\n`; 66 | 67 | return markdown; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/actual-api.ts: -------------------------------------------------------------------------------- 1 | import api from '@actual-app/api'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import os from 'os'; 5 | import { BudgetFile } from './types.js'; 6 | import { 7 | APIAccountEntity, 8 | APICategoryEntity, 9 | APICategoryGroupEntity, 10 | } from '@actual-app/api/@types/loot-core/src/server/api-models.js'; 11 | import { TransactionEntity } from '@actual-app/api/@types/loot-core/src/types/models/index.js'; 12 | 13 | const DEFAULT_DATA_DIR: string = path.resolve(os.homedir() || '.', '.actual'); 14 | 15 | // API initialization state 16 | let initialized = false; 17 | let initializing = false; 18 | let initializationError: Error | null = null; 19 | 20 | /** 21 | * Initialize the Actual Budget API 22 | */ 23 | export async function initActualApi(): Promise { 24 | if (initialized) return; 25 | if (initializing) { 26 | // Wait for initialization to complete if already in progress 27 | while (initializing) { 28 | await new Promise((resolve) => setTimeout(resolve, 100)); 29 | } 30 | if (initializationError) throw initializationError; 31 | return; 32 | } 33 | 34 | try { 35 | console.error('Initializing Actual Budget API...'); 36 | const dataDir = process.env.ACTUAL_DATA_DIR || DEFAULT_DATA_DIR; 37 | if (!fs.existsSync(dataDir)) { 38 | fs.mkdirSync(dataDir, { recursive: true }); 39 | } 40 | await api.init({ 41 | dataDir, 42 | serverURL: process.env.ACTUAL_SERVER_URL, 43 | password: process.env.ACTUAL_PASSWORD, 44 | }); 45 | 46 | const budgets: BudgetFile[] = await api.getBudgets(); 47 | if (!budgets || budgets.length === 0) { 48 | throw new Error('No budgets found. Please create a budget in Actual first.'); 49 | } 50 | 51 | // Use specified budget or the first one 52 | const budgetId: string = process.env.ACTUAL_BUDGET_SYNC_ID || budgets[0].cloudFileId || budgets[0].id || ''; 53 | console.error(`Loading budget: ${budgetId}`); 54 | await api.downloadBudget(budgetId); 55 | 56 | initialized = true; 57 | console.error('Actual Budget API initialized successfully'); 58 | } catch (error) { 59 | console.error('Failed to initialize Actual Budget API:', error); 60 | initializationError = error instanceof Error ? error : new Error(String(error)); 61 | throw initializationError; 62 | } finally { 63 | initializing = false; 64 | } 65 | } 66 | 67 | /** 68 | * Shutdown the Actual Budget API 69 | */ 70 | export async function shutdownActualApi(): Promise { 71 | if (!initialized) return; 72 | await api.shutdown(); 73 | initialized = false; 74 | } 75 | 76 | /** 77 | * Get all accounts (ensures API is initialized) 78 | */ 79 | export async function getAccounts(): Promise { 80 | await initActualApi(); 81 | return api.getAccounts(); 82 | } 83 | 84 | /** 85 | * Get all categories (ensures API is initialized) 86 | */ 87 | export async function getCategories(): Promise { 88 | await initActualApi(); 89 | return api.getCategories(); 90 | } 91 | 92 | /** 93 | * Get all category groups (ensures API is initialized) 94 | */ 95 | export async function getCategoryGroups(): Promise { 96 | await initActualApi(); 97 | return api.getCategoryGroups(); 98 | } 99 | 100 | /** 101 | * Get transactions for a specific account and date range (ensures API is initialized) 102 | */ 103 | export async function getTransactions(accountId: string, start: string, end: string): Promise { 104 | await initActualApi(); 105 | return api.getTransactions(accountId, start, end); 106 | } 107 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * MCP Server for Actual Budget 4 | * 5 | * This server exposes your Actual Budget data to LLMs through the Model Context Protocol, 6 | * allowing for natural language interaction with your financial data. 7 | * 8 | * Features: 9 | * - List and view accounts 10 | * - View transactions with filtering 11 | * - Generate financial statistics and analysis 12 | */ 13 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 14 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; 15 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 16 | import express, { Request, Response } from 'express'; 17 | import { parseArgs } from 'node:util'; 18 | import path from 'path'; 19 | import { setupPrompts } from './prompts.js'; 20 | import { setupResources } from './resources.js'; 21 | import { setupTools } from './tools/index.js'; 22 | 23 | // Configuration 24 | const DEFAULT_DATA_DIR: string = path.resolve(process.env.HOME || process.env.USERPROFILE || '.', '.actual'); 25 | 26 | // Initialize the MCP server 27 | const server = new Server( 28 | { 29 | name: 'Actual Budget', 30 | version: '1.0.0', 31 | }, 32 | { 33 | capabilities: { 34 | resources: {}, 35 | tools: {}, 36 | prompts: {}, 37 | logging: {}, 38 | }, 39 | } 40 | ); 41 | 42 | // Argument parsing 43 | const { 44 | values: { sse: useSse, port }, 45 | } = parseArgs({ 46 | options: { 47 | sse: { type: 'boolean', default: false }, 48 | port: { type: 'string' }, 49 | }, 50 | allowPositionals: true, 51 | }); 52 | 53 | const resolvedPort = port ? parseInt(port, 10) : 3000; 54 | 55 | // ---------------------------- 56 | // SERVER STARTUP 57 | // ---------------------------- 58 | 59 | // Start the server 60 | async function main(): Promise { 61 | // Validate environment variables 62 | if (!process.env.ACTUAL_DATA_DIR && !process.env.ACTUAL_SERVER_URL) { 63 | console.error('Warning: Neither ACTUAL_DATA_DIR nor ACTUAL_SERVER_URL is set.'); 64 | console.error(`Will try to use default data directory: ${DEFAULT_DATA_DIR}`); 65 | } 66 | 67 | if (process.env.ACTUAL_SERVER_URL && !process.env.ACTUAL_PASSWORD) { 68 | console.error('Warning: ACTUAL_SERVER_URL is set but ACTUAL_PASSWORD is not.'); 69 | console.error('If your server requires authentication, initialization will fail.'); 70 | } 71 | 72 | if (useSse) { 73 | const app = express(); 74 | app.use(express.json()); 75 | let transport: SSEServerTransport | null = null; 76 | 77 | // Placeholder for future HTTP transport (stateless) 78 | app.post('/mcp', async (req: Request, res: Response) => { 79 | res.status(501).json({ error: 'HTTP transport not implemented yet' }); 80 | }); 81 | 82 | app.get('/sse', (req: Request, res: Response) => { 83 | transport = new SSEServerTransport('/messages', res); 84 | server.connect(transport); 85 | }); 86 | app.post('/messages', async (req: Request, res: Response) => { 87 | if (transport) { 88 | await transport.handlePostMessage(req, res, req.body); 89 | } else { 90 | res.status(500).json({ error: 'Transport not initialized' }); 91 | } 92 | }); 93 | 94 | app.listen(resolvedPort, (error) => { 95 | if (error) { 96 | console.error('Error:', error); 97 | } else { 98 | console.error(`Actual Budget MCP Server (SSE) started on port ${resolvedPort}`); 99 | } 100 | }); 101 | } else { 102 | const transport = new StdioServerTransport(); 103 | await server.connect(transport); 104 | console.error('Actual Budget MCP Server (stdio) started'); 105 | } 106 | } 107 | 108 | setupResources(server); 109 | setupTools(server); 110 | setupPrompts(server); 111 | 112 | process.on('SIGINT', () => { 113 | console.error('SIGINT received, shutting down server'); 114 | server.close(); 115 | process.exit(0); 116 | }); 117 | 118 | main() 119 | .then(() => { 120 | console.log = (message: string) => 121 | server.sendLoggingMessage({ 122 | level: 'info', 123 | message, 124 | }); 125 | console.error = (message: string) => 126 | server.sendLoggingMessage({ 127 | level: 'error', 128 | message, 129 | }); 130 | }) 131 | .catch((error: unknown) => { 132 | console.error('Server error:', error); 133 | process.exit(1); 134 | }); 135 | -------------------------------------------------------------------------------- /src/core/aggregation/group-by.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { GroupAggregator } from './group-by.js'; 3 | import type { CategorySpending } from '../types/domain.js'; 4 | 5 | describe('GroupAggregator', () => { 6 | const aggregator = new GroupAggregator(); 7 | 8 | it('should aggregate and sort spending by group', () => { 9 | const input: Record = { 10 | cat1: { 11 | id: '1', 12 | name: 'Food', 13 | group: 'Living', 14 | isIncome: false, 15 | total: -100, 16 | transactions: 5, 17 | }, 18 | cat2: { 19 | id: '2', 20 | name: 'Rent', 21 | group: 'Living', 22 | isIncome: false, 23 | total: -800, 24 | transactions: 1, 25 | }, 26 | cat3: { 27 | id: '3', 28 | name: 'Salary', 29 | group: 'Income', 30 | isIncome: true, 31 | total: 2000, 32 | transactions: 1, 33 | }, 34 | }; 35 | 36 | const result = aggregator.aggregateAndSort(input); 37 | 38 | expect(result).toHaveLength(2); 39 | expect(result[0].name).toBe('Income'); // Highest absolute value 40 | expect(result[0].total).toBe(2000); 41 | expect(result[0].categories).toHaveLength(1); 42 | expect(result[1].name).toBe('Living'); 43 | expect(result[1].total).toBe(-900); 44 | expect(result[1].categories).toHaveLength(2); 45 | }); 46 | 47 | it('should handle empty input', () => { 48 | const result = aggregator.aggregateAndSort({}); 49 | expect(result).toEqual([]); 50 | }); 51 | 52 | it('should sort categories within groups by absolute total', () => { 53 | const input: Record = { 54 | cat1: { 55 | id: '1', 56 | name: 'Food', 57 | group: 'Living', 58 | isIncome: false, 59 | total: -100, 60 | transactions: 5, 61 | }, 62 | cat2: { 63 | id: '2', 64 | name: 'Rent', 65 | group: 'Living', 66 | isIncome: false, 67 | total: -800, 68 | transactions: 1, 69 | }, 70 | cat3: { 71 | id: '3', 72 | name: 'Utilities', 73 | group: 'Living', 74 | isIncome: false, 75 | total: -200, 76 | transactions: 3, 77 | }, 78 | }; 79 | 80 | const result = aggregator.aggregateAndSort(input); 81 | 82 | expect(result).toHaveLength(1); 83 | expect(result[0].name).toBe('Living'); 84 | expect(result[0].categories).toHaveLength(3); 85 | // Should be sorted by absolute total: Rent (-800), Utilities (-200), Food (-100) 86 | expect(result[0].categories[0].name).toBe('Rent'); 87 | expect(result[0].categories[1].name).toBe('Utilities'); 88 | expect(result[0].categories[2].name).toBe('Food'); 89 | }); 90 | 91 | it('should handle single category', () => { 92 | const input: Record = { 93 | cat1: { 94 | id: '1', 95 | name: 'Food', 96 | group: 'Living', 97 | isIncome: false, 98 | total: -100, 99 | transactions: 5, 100 | }, 101 | }; 102 | 103 | const result = aggregator.aggregateAndSort(input); 104 | 105 | expect(result).toHaveLength(1); 106 | expect(result[0].name).toBe('Living'); 107 | expect(result[0].total).toBe(-100); 108 | expect(result[0].categories).toHaveLength(1); 109 | expect(result[0].categories[0].name).toBe('Food'); 110 | }); 111 | 112 | it('should handle positive and negative amounts correctly', () => { 113 | const input: Record = { 114 | cat1: { 115 | id: '1', 116 | name: 'Salary', 117 | group: 'Income', 118 | isIncome: true, 119 | total: 3000, 120 | transactions: 1, 121 | }, 122 | cat2: { 123 | id: '2', 124 | name: 'Bonus', 125 | group: 'Income', 126 | isIncome: true, 127 | total: 1000, 128 | transactions: 1, 129 | }, 130 | cat3: { 131 | id: '3', 132 | name: 'Food', 133 | group: 'Living', 134 | isIncome: false, 135 | total: -500, 136 | transactions: 10, 137 | }, 138 | }; 139 | 140 | const result = aggregator.aggregateAndSort(input); 141 | 142 | expect(result).toHaveLength(2); 143 | expect(result[0].name).toBe('Income'); // 4000 absolute value 144 | expect(result[0].total).toBe(4000); 145 | expect(result[1].name).toBe('Living'); // 500 absolute value 146 | expect(result[1].total).toBe(-500); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /src/core/data/fetch-transactions.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { fetchTransactionsForAccount, fetchAllOnBudgetTransactions } from './fetch-transactions.js'; 3 | import type { Account } from '../types/domain.js'; 4 | 5 | // CRITICAL: Mock before imports 6 | vi.mock('../../actual-api.js', () => ({ 7 | getTransactions: vi.fn(), 8 | })); 9 | 10 | import { getTransactions } from '../../actual-api.js'; 11 | 12 | describe('fetchTransactionsForAccount', () => { 13 | beforeEach(() => { 14 | vi.clearAllMocks(); 15 | }); 16 | 17 | it('should return transactions for specific account', async () => { 18 | const mockTransactions = [ 19 | { 20 | id: '1', 21 | account: 'acc1', 22 | date: '2023-01-01', 23 | amount: -100, 24 | payee: 'Store', 25 | }, 26 | { 27 | id: '2', 28 | account: 'acc1', 29 | date: '2023-01-02', 30 | amount: -50, 31 | payee: 'Gas Station', 32 | }, 33 | ]; 34 | vi.mocked(getTransactions).mockResolvedValue(mockTransactions); 35 | 36 | const result = await fetchTransactionsForAccount('acc1', '2023-01-01', '2023-01-31'); 37 | 38 | expect(result).toEqual(mockTransactions); 39 | expect(getTransactions).toHaveBeenCalledWith('acc1', '2023-01-01', '2023-01-31'); 40 | }); 41 | 42 | it('should handle API errors', async () => { 43 | vi.mocked(getTransactions).mockRejectedValue(new Error('Transactions API Error')); 44 | 45 | await expect(fetchTransactionsForAccount('acc1', '2023-01-01', '2023-01-31')).rejects.toThrow( 46 | 'Transactions API Error' 47 | ); 48 | expect(getTransactions).toHaveBeenCalledWith('acc1', '2023-01-01', '2023-01-31'); 49 | }); 50 | 51 | it('should handle empty response', async () => { 52 | vi.mocked(getTransactions).mockResolvedValue([]); 53 | 54 | const result = await fetchTransactionsForAccount('acc1', '2023-01-01', '2023-01-31'); 55 | 56 | expect(result).toEqual([]); 57 | expect(getTransactions).toHaveBeenCalledWith('acc1', '2023-01-01', '2023-01-31'); 58 | }); 59 | }); 60 | 61 | describe('fetchAllOnBudgetTransactions', () => { 62 | beforeEach(() => { 63 | vi.clearAllMocks(); 64 | }); 65 | 66 | it('should return transactions for all on-budget accounts', async () => { 67 | const mockAccounts: Account[] = [ 68 | { id: 'acc1', name: 'Checking', offbudget: false, closed: false }, 69 | { id: 'acc2', name: 'Savings', offbudget: false, closed: false }, 70 | { id: 'acc3', name: 'Credit Card', offbudget: true, closed: false }, // Should be excluded 71 | { id: 'acc4', name: 'Old Account', offbudget: false, closed: true }, // Should be excluded 72 | ]; 73 | 74 | const mockTransactions1 = [{ id: '1', account: 'acc1', date: '2023-01-01', amount: -100 }]; 75 | const mockTransactions2 = [{ id: '2', account: 'acc2', date: '2023-01-01', amount: -50 }]; 76 | 77 | vi.mocked(getTransactions).mockResolvedValueOnce(mockTransactions1).mockResolvedValueOnce(mockTransactions2); 78 | 79 | const result = await fetchAllOnBudgetTransactions(mockAccounts, '2023-01-01', '2023-01-31'); 80 | 81 | expect(result).toEqual([...mockTransactions1, ...mockTransactions2]); 82 | expect(getTransactions).toHaveBeenCalledTimes(2); 83 | expect(getTransactions).toHaveBeenCalledWith('acc1', '2023-01-01', '2023-01-31'); 84 | expect(getTransactions).toHaveBeenCalledWith('acc2', '2023-01-01', '2023-01-31'); 85 | }); 86 | 87 | it('should handle empty accounts array', async () => { 88 | const result = await fetchAllOnBudgetTransactions([], '2023-01-01', '2023-01-31'); 89 | 90 | expect(result).toEqual([]); 91 | expect(getTransactions).not.toHaveBeenCalled(); 92 | }); 93 | 94 | it('should handle accounts with no on-budget accounts', async () => { 95 | const mockAccounts: Account[] = [ 96 | { id: 'acc1', name: 'Credit Card', offbudget: true, closed: false }, 97 | { id: 'acc2', name: 'Old Account', offbudget: false, closed: true }, 98 | ]; 99 | 100 | const result = await fetchAllOnBudgetTransactions(mockAccounts, '2023-01-01', '2023-01-31'); 101 | 102 | expect(result).toEqual([]); 103 | expect(getTransactions).not.toHaveBeenCalled(); 104 | }); 105 | 106 | it('should handle API errors for individual accounts', async () => { 107 | const mockAccounts: Account[] = [{ id: 'acc1', name: 'Checking', offbudget: false, closed: false }]; 108 | 109 | vi.mocked(getTransactions).mockRejectedValue(new Error('Transaction API Error')); 110 | 111 | await expect(fetchAllOnBudgetTransactions(mockAccounts, '2023-01-01', '2023-01-31')).rejects.toThrow( 112 | 'Transaction API Error' 113 | ); 114 | expect(getTransactions).toHaveBeenCalledWith('acc1', '2023-01-01', '2023-01-31'); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Actual Budget MCP Server 2 | 3 | MCP server for integrating Actual Budget with Claude and other LLM assistants. 4 | 5 | ## Overview 6 | 7 | The Actual Budget MCP Server allows you to interact with your personal financial data from [Actual Budget](https://actualbudget.com/) using natural language through LLMs. It exposes your accounts, transactions, and financial metrics through the Model Context Protocol (MCP). 8 | 9 | ## Features 10 | 11 | ### Resources 12 | 13 | - **Account Listings** - Browse all your accounts with their balances 14 | - **Account Details** - View detailed information about specific accounts 15 | - **Transaction History** - Access transaction data with complete details 16 | 17 | ### Tools 18 | 19 | - **`get-transactions`** - Retrieve and filter transactions by account, date, amount, category, or payee 20 | - **`spending-by-category`** - Generate spending breakdowns categorized by type 21 | - **`monthly-summary`** - Get monthly income, expenses, and savings metrics 22 | - **`balance-history`** - View account balance changes over time 23 | 24 | ### Prompts 25 | 26 | - **`financial-insights`** - Generate insights and recommendations based on your financial data 27 | - **`budget-review`** - Analyze your budget compliance and suggest adjustments 28 | 29 | ## Usage with Claude Desktop 30 | 31 | To use this server with Claude Desktop, add it to your Claude configuration: 32 | 33 | On MacOS: 34 | 35 | ```bash 36 | code ~/Library/Application\ Support/Claude/claude_desktop_config.json 37 | ``` 38 | 39 | On Windows: 40 | 41 | ```bash 42 | code %APPDATA%\Claude\claude_desktop_config.json 43 | ``` 44 | 45 | Add the following to your configuration: 46 | 47 | ```json 48 | { 49 | "mcpServers": { 50 | "actualBudget": { 51 | "command": "npx", 52 | "args": ["-y", "actual-mcp"], 53 | "env": { 54 | "ACTUAL_DATA_DIR": "/path/to/your/actual/data", 55 | "ACTUAL_PASSWORD": "your-password", 56 | "ACTUAL_SERVER_URL": "https://your-actual-server.com", 57 | "ACTUAL_BUDGET_SYNC_ID": "your-budget-id" 58 | } 59 | } 60 | } 61 | } 62 | ``` 63 | 64 | ```json 65 | { 66 | "mcpServers": { 67 | "actualBudget": { 68 | "command": "docker", 69 | "args": ["run", "-it", "--rm", "-p", "3000:3000", "sstefanov/actual-mcp:latest"], 70 | "env": { 71 | "ACTUAL_DATA_DIR": "/path/to/your/actual/data", 72 | "ACTUAL_PASSWORD": "your-password", 73 | "ACTUAL_SERVER_URL": "https://your-actual-server.com", 74 | "ACTUAL_BUDGET_SYNC_ID": "your-budget-id" 75 | } 76 | } 77 | } 78 | } 79 | ``` 80 | 81 | After saving the configuration, restart Claude Desktop. 82 | 83 | ## Installation 84 | 85 | ### Prerequisites 86 | 87 | - [Node.js](https://nodejs.org/) (v16 or higher) 88 | - [Actual Budget](https://actualbudget.com/) installed and configured 89 | - [Claude Desktop](https://claude.ai/download) or another MCP-compatible client 90 | 91 | ### Setup 92 | 93 | 1. Clone the repository: 94 | 95 | ```bash 96 | git clone https://github.com/s-stefanov/actual-mcp.git 97 | cd actual-mcp 98 | ``` 99 | 100 | 2. Install dependencies: 101 | 102 | ```bash 103 | npm install 104 | ``` 105 | 106 | 3. Build the server: 107 | 108 | ```bash 109 | npm run build 110 | ``` 111 | 112 | 4. Configure environment variables (optional): 113 | 114 | ```bash 115 | # Path to your Actual Budget data directory (default: ~/.actual) 116 | export ACTUAL_DATA_DIR="/path/to/your/actual/data" 117 | 118 | # If using a remote Actual server 119 | export ACTUAL_SERVER_URL="https://your-actual-server.com" 120 | export ACTUAL_PASSWORD="your-password" 121 | 122 | # Specific budget to use (optional) 123 | export ACTUAL_BUDGET_SYNC_ID="your-budget-id" 124 | ``` 125 | 126 | ## Example Queries 127 | 128 | Once connected, you can ask Claude questions like: 129 | 130 | - "What's my current account balance?" 131 | - "Show me my spending by category last month" 132 | - "How much did I spend on groceries in January?" 133 | - "What's my savings rate over the past 3 months?" 134 | - "Analyze my budget and suggest areas to improve" 135 | 136 | ## Development 137 | 138 | For development with auto-rebuild: 139 | 140 | ```bash 141 | npm run watch 142 | ``` 143 | 144 | ### Testing the connection to Actual 145 | 146 | To verify the server can connect to your Actual Budget data: 147 | 148 | ```bash 149 | node build/index.js --test-resources 150 | ``` 151 | 152 | ### Debugging 153 | 154 | Since MCP servers communicate over stdio, debugging can be challenging. You can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector): 155 | 156 | ```bash 157 | npx @modelcontextprotocol/inspector node build/index.js 158 | ``` 159 | 160 | ## Project Structure 161 | 162 | - `index.ts` - Main server implementation 163 | - `types.ts` - Type definitions for API responses and parameters 164 | - `prompts.ts` - Prompt templates for LLM interactions 165 | - `utils.ts` - Helper functions for date formatting and more 166 | 167 | ## License 168 | 169 | MIT 170 | 171 | ## Contributing 172 | 173 | Contributions are welcome! Please feel free to submit a Pull Request. 174 | -------------------------------------------------------------------------------- /src/prompts.ts: -------------------------------------------------------------------------------- 1 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 2 | import { GetPromptRequestSchema, ListPromptsRequestSchema, GetPromptResult } from '@modelcontextprotocol/sdk/types.js'; 3 | import { FinancialInsightsArgs, BudgetReviewArgs } from './types.js'; 4 | import { getDateRange } from './utils.js'; 5 | 6 | export const promptsSchema = [ 7 | { 8 | name: 'financial-insights', 9 | description: 'Generate financial insights and advice', 10 | arguments: [ 11 | { 12 | name: 'startDate', 13 | description: 'Start date in YYYY-MM-DD format', 14 | required: false, 15 | }, 16 | { 17 | name: 'endDate', 18 | description: 'End date in YYYY-MM-DD format', 19 | required: false, 20 | }, 21 | ], 22 | }, 23 | { 24 | name: 'budget-review', 25 | description: 'Review my budget and spending', 26 | arguments: [ 27 | { 28 | name: 'months', 29 | description: 'Number of months to analyze', 30 | required: false, 31 | }, 32 | ], 33 | }, 34 | ]; 35 | 36 | const financialInsightsPrompt = (args: FinancialInsightsArgs): GetPromptResult => { 37 | const { startDate, endDate } = args || {}; 38 | const { startDate: start, endDate: end } = getDateRange(startDate, endDate); 39 | 40 | return { 41 | description: `Financial insights and recommendations from ${start} to ${end}`, 42 | messages: [ 43 | { 44 | role: 'user', 45 | content: { 46 | type: 'text', 47 | text: `Please analyze my financial data and provide insights and recommendations. Focus on spending patterns, savings rate, and potential areas to optimize my budget. Analyze data from ${start} to ${end}. 48 | 49 | IMPORTANT: Any transactions in the "Investment & Savings" category group should be treated as POSITIVE savings, not as spending. These represent money I'm putting aside for the future, so they should be counted as savings achievements rather than expenses. 50 | 51 | You can use these tools to gather the data you need: 52 | 1. Use the spending-by-category tool to analyze my spending breakdown 53 | 2. Use the monthly-summary tool to get my income, expenses, and savings rate 54 | 3. Use the get-transactions tool to examine specific transactions if needed 55 | 56 | When you examine the spending-by-category results: 57 | - Look for any category group called "Investment & Savings" or similar 58 | - Consider these amounts as positive financial actions (saving/investing), not spending 59 | - Include these amounts when calculating my total savings rate 60 | - Do NOT recommend reducing these amounts unless they're clearly unsustainable 61 | 62 | Based on this analysis, please provide: 63 | 1. A summary of my financial situation, including total savings (regular savings + investments) 64 | 2. Key insights about my spending patterns (excluding investments/savings) 65 | 3. Areas where I might be overspending (focusing on consumption categories only) 66 | 4. Recommendations to optimize my budget while maintaining or increasing savings/investments 67 | 5. Any other relevant financial advice 68 | `, 69 | }, 70 | }, 71 | ], 72 | }; 73 | }; 74 | 75 | const budgetReviewPrompt = (args: BudgetReviewArgs): GetPromptResult => { 76 | const { months = 3 } = args || {}; 77 | 78 | return { 79 | description: `Budget review for the past ${months} months`, 80 | messages: [ 81 | { 82 | role: 'user', 83 | content: { 84 | type: 'text', 85 | text: `Please review my budget and spending for the past ${months} months. I'd like to understand how well I'm sticking to my budget and where I might be able to make adjustments. 86 | 87 | IMPORTANT: Any transactions in the "Investment & Savings" category group should be treated as POSITIVE savings, not as spending. These represent money I'm putting aside for the future, so they should be counted as savings achievements rather than expenses. 88 | 89 | To gather this data: 90 | 1. Use the spending-by-category tool to see my spending breakdown 91 | 2. Use the monthly-summary tool to get my overall income and expenses 92 | 3. Use the get-transactions tool if you need to look at specific transactions 93 | 94 | When analyzing the data: 95 | - Categories in the "Investment & Savings" group are positive financial actions, not expenses 96 | - Include these amounts in my total savings rate calculation 97 | - Don't suggest reducing these amounts unless they're clearly unsustainable for my income level 98 | 99 | Please provide: 100 | 1. An analysis of my top spending categories (excluding savings/investments) 101 | 2. Whether my spending is consistent month-to-month 102 | 3. My total savings rate including both regular savings and investments 103 | 4. Areas where I might be able to reduce discretionary spending 104 | 5. Suggestions for realistic budget adjustments to maximize savings/investments 105 | `, 106 | }, 107 | }, 108 | ], 109 | }; 110 | }; 111 | 112 | // ---------------------------- 113 | // PROMPTS 114 | // ---------------------------- 115 | 116 | export const setupPrompts = (server: Server): void => { 117 | /** 118 | * Handler for listing available prompts 119 | */ 120 | server.setRequestHandler(ListPromptsRequestSchema, async () => { 121 | return { 122 | prompts: promptsSchema, 123 | }; 124 | }); 125 | 126 | /** 127 | * Handler for getting prompts 128 | */ 129 | server.setRequestHandler(GetPromptRequestSchema, async (request) => { 130 | try { 131 | const { name, arguments: promptArgs } = request.params; 132 | 133 | switch (name) { 134 | case 'financial-insights': { 135 | return financialInsightsPrompt(promptArgs as FinancialInsightsArgs); 136 | } 137 | 138 | case 'budget-review': { 139 | return budgetReviewPrompt(promptArgs as unknown as BudgetReviewArgs); 140 | } 141 | 142 | default: 143 | throw new Error(`Unknown prompt: ${name}`); 144 | } 145 | } catch (error) { 146 | console.error(`Error getting prompt ${request.params.name}:`, error); 147 | throw error instanceof Error ? error : new Error(String(error)); 148 | } 149 | }); 150 | }; 151 | -------------------------------------------------------------------------------- /src/resources.ts: -------------------------------------------------------------------------------- 1 | // ---------------------------- 2 | // RESOURCES 3 | // ---------------------------- 4 | 5 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 6 | import { ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js'; 7 | import api from '@actual-app/api'; 8 | 9 | // Import types from types.ts 10 | import { Account, Transaction } from './types.js'; 11 | import { formatAmount, formatDate, getDateRange } from './utils.js'; 12 | import { initActualApi, shutdownActualApi } from './actual-api.js'; 13 | import { fetchAllAccounts } from './core/data/fetch-accounts.js'; 14 | 15 | export const setupResources = (server: Server): void => { 16 | /** 17 | * Handler for listing available resources (accounts) 18 | */ 19 | server.setRequestHandler(ListResourcesRequestSchema, async () => { 20 | try { 21 | await initActualApi(); 22 | const accounts: Account[] = await fetchAllAccounts(); 23 | return { 24 | resources: accounts.map((account) => ({ 25 | uri: `actual://accounts/${account.id}`, 26 | name: account.name, 27 | description: `${account.name} (${account.type || 'Account'})${account.closed ? ' - CLOSED' : ''}`, 28 | mimeType: 'text/markdown', 29 | })), 30 | }; 31 | } catch (error) { 32 | console.error('Error listing resources:', error); 33 | throw error; 34 | } finally { 35 | await shutdownActualApi(); 36 | } 37 | }); 38 | 39 | /** 40 | * Handler for reading resources (account details and transactions) 41 | */ 42 | server.setRequestHandler(ReadResourceRequestSchema, async (request) => { 43 | try { 44 | await initActualApi(); 45 | const uri: string = request.params.uri; 46 | const url = new URL(uri); 47 | 48 | // Parse the path to determine what to return 49 | const pathParts: string[] = url.pathname.split('/').filter(Boolean); 50 | 51 | // If the path is just "accounts", return list of all accounts 52 | if (pathParts.length === 0 && url.hostname === 'accounts') { 53 | const accounts: Account[] = await api.getAccounts(); 54 | 55 | const accountsText: string = accounts 56 | .map((account) => { 57 | const closed = account.closed ? ' (CLOSED)' : ''; 58 | const offBudget = account.offbudget ? ' (OFF BUDGET)' : ''; 59 | const balance = account.balance !== undefined ? ` - ${formatAmount(account.balance)}` : ''; 60 | 61 | return `- ${account.name}${closed}${offBudget}${balance} [ID: ${account.id}]`; 62 | }) 63 | .join('\n'); 64 | 65 | return { 66 | contents: [ 67 | { 68 | uri: uri, 69 | text: `# Actual Budget Accounts\n\n${accountsText}\n\nTotal Accounts: ${accounts.length}`, 70 | mimeType: 'text/markdown', 71 | }, 72 | ], 73 | }; 74 | } 75 | 76 | // If the path is "accounts/{id}", return account details 77 | if (pathParts.length === 1 && url.hostname === 'accounts') { 78 | const accountId: string = pathParts[0]; 79 | const accounts: Account[] = await api.getAccounts(); 80 | const account: Account | undefined = accounts.find((a) => a.id === accountId); 81 | 82 | if (!account) { 83 | return { 84 | contents: [ 85 | { 86 | uri: uri, 87 | text: `Error: Account with ID ${accountId} not found`, 88 | mimeType: 'text/plain', 89 | }, 90 | ], 91 | }; 92 | } 93 | 94 | const balance: number = await api.getAccountBalance(accountId); 95 | const formattedBalance: string = formatAmount(balance); 96 | 97 | const details = `# Account: ${account.name} 98 | 99 | ID: ${account.id} 100 | Type: ${account.type || 'Unknown'} 101 | Balance: ${formattedBalance} 102 | On Budget: ${!account.offbudget} 103 | Status: ${account.closed ? 'Closed' : 'Open'} 104 | 105 | To view transactions for this account, use the get-transactions tool.`; 106 | 107 | return { 108 | contents: [ 109 | { 110 | uri: uri, 111 | text: details, 112 | mimeType: 'text/markdown', 113 | }, 114 | ], 115 | }; 116 | } 117 | 118 | // If the path is "accounts/{id}/transactions", return transactions 119 | if (pathParts.length === 2 && pathParts[1] === 'transactions' && url.hostname === 'accounts') { 120 | const accountId: string = pathParts[0]; 121 | const { startDate, endDate } = getDateRange(); 122 | const transactions: Transaction[] = await api.getTransactions(accountId, startDate, endDate); 123 | 124 | if (!transactions || transactions.length === 0) { 125 | return { 126 | contents: [ 127 | { 128 | uri: uri, 129 | text: `No transactions found for account ID ${accountId} between ${startDate} and ${endDate}`, 130 | mimeType: 'text/plain', 131 | }, 132 | ], 133 | }; 134 | } 135 | 136 | // Create a markdown table of transactions 137 | const header = '| Date | Payee | Category | Amount | Notes |\n| ---- | ----- | -------- | ------ | ----- |\n'; 138 | const rows: string = transactions 139 | .map((t) => { 140 | const amount: string = formatAmount(t.amount); 141 | const date: string = formatDate(t.date); 142 | const payee: string = t.payee_name || '(No payee)'; 143 | const category: string = t.category_name || '(Uncategorized)'; 144 | const notes: string = t.notes || ''; 145 | 146 | return `| ${date} | ${payee} | ${category} | ${amount} | ${notes} |`; 147 | }) 148 | .join('\n'); 149 | 150 | const text = `# Transactions for Account\n\nTime period: ${startDate} to ${endDate}\nTotal Transactions: ${transactions.length}\n\n${header}${rows}`; 151 | 152 | return { 153 | contents: [ 154 | { 155 | uri: uri, 156 | text: text, 157 | mimeType: 'text/markdown', 158 | }, 159 | ], 160 | }; 161 | } 162 | 163 | // If we don't recognize the URI pattern, return an error 164 | return { 165 | contents: [ 166 | { 167 | uri: uri, 168 | text: `Error: Unrecognized resource URI: ${uri}`, 169 | mimeType: 'text/plain', 170 | }, 171 | ], 172 | }; 173 | } catch (error) { 174 | console.error('Error reading resource:', error); 175 | throw error; 176 | } 177 | }); 178 | }; 179 | -------------------------------------------------------------------------------- /PRPs/templates/prp_base.md: -------------------------------------------------------------------------------- 1 | name: "Base PRP Template v2 - Context-Rich with Validation Loops" 2 | description: | 3 | 4 | ## Purpose 5 | 6 | Template optimized for AI agents to implement features with sufficient context and self-validation capabilities to achieve working code through iterative refinement. 7 | 8 | ## Core Principles 9 | 10 | 1. **Context is King**: Include ALL necessary documentation, examples, and caveats 11 | 2. **Validation Loops**: Provide executable tests/lints the AI can run and fix 12 | 3. **Information Dense**: Use keywords and patterns from the codebase 13 | 4. **Progressive Success**: Start simple, validate, then enhance 14 | 5. **Global rules**: Be sure to follow all rules in CLAUDE.md 15 | 16 | --- 17 | 18 | ## Goal 19 | 20 | [What needs to be built - be specific about the end state and desires] 21 | 22 | ## Why 23 | 24 | - [Business value and user impact] 25 | - [Integration with existing features] 26 | - [Problems this solves and for whom] 27 | 28 | ## What 29 | 30 | [User-visible behavior and technical requirements] 31 | 32 | ### Success Criteria 33 | 34 | - [ ] [Specific measurable outcomes] 35 | 36 | ## All Needed Context 37 | 38 | ### Documentation & References (list all context needed to implement the feature) 39 | 40 | ```yaml 41 | # MUST READ - Include these in your context window 42 | - url: [Official API docs URL] 43 | why: [Specific sections/methods you'll need] 44 | 45 | - file: [path/to/example.py] 46 | why: [Pattern to follow, gotchas to avoid] 47 | 48 | - doc: [Library documentation URL] 49 | section: [Specific section about common pitfalls] 50 | critical: [Key insight that prevents common errors] 51 | 52 | - docfile: [PRPs/ai_docs/file.md] 53 | why: [docs that the user has pasted in to the project] 54 | ``` 55 | 56 | ### Current Codebase tree (run `tree` in the root of the project) to get an overview of the codebase 57 | 58 | ```bash 59 | 60 | ``` 61 | 62 | ### Desired Codebase tree with files to be added and responsibility of file 63 | 64 | ```bash 65 | 66 | ``` 67 | 68 | ### Known Gotchas of our codebase & Library Quirks 69 | 70 | ```python 71 | # CRITICAL: [Library name] requires [specific setup] 72 | # Example: FastAPI requires async functions for endpoints 73 | # Example: This ORM doesn't support batch inserts over 1000 records 74 | # Example: We use pydantic v2 and 75 | ``` 76 | 77 | ## Implementation Blueprint 78 | 79 | ### Data models and structure 80 | 81 | Create the core data models, we ensure type safety and consistency. 82 | 83 | ```python 84 | Examples: 85 | - orm models 86 | - pydantic models 87 | - pydantic schemas 88 | - pydantic validators 89 | 90 | ``` 91 | 92 | ### list of tasks to be completed to fullfill the PRP in the order they should be completed 93 | 94 | ```yaml 95 | Task 1: 96 | MODIFY src/existing_module.py: 97 | - FIND pattern: "class OldImplementation" 98 | - INJECT after line containing "def __init__" 99 | - PRESERVE existing method signatures 100 | 101 | CREATE src/new_feature.py: 102 | - MIRROR pattern from: src/similar_feature.py 103 | - MODIFY class name and core logic 104 | - KEEP error handling pattern identical 105 | 106 | ...(...) 107 | 108 | Task N: 109 | ... 110 | 111 | ``` 112 | 113 | ### Per task pseudocode as needed added to each task 114 | 115 | ```python 116 | 117 | # Task 1 118 | # Pseudocode with CRITICAL details dont write entire code 119 | async def new_feature(param: str) -> Result: 120 | # PATTERN: Always validate input first (see src/validators.py) 121 | validated = validate_input(param) # raises ValidationError 122 | 123 | # GOTCHA: This library requires connection pooling 124 | async with get_connection() as conn: # see src/db/pool.py 125 | # PATTERN: Use existing retry decorator 126 | @retry(attempts=3, backoff=exponential) 127 | async def _inner(): 128 | # CRITICAL: API returns 429 if >10 req/sec 129 | await rate_limiter.acquire() 130 | return await external_api.call(validated) 131 | 132 | result = await _inner() 133 | 134 | # PATTERN: Standardized response format 135 | return format_response(result) # see src/utils/responses.py 136 | ``` 137 | 138 | ### Integration Points 139 | 140 | ```yaml 141 | DATABASE: 142 | - migration: "Add column 'feature_enabled' to users table" 143 | - index: 'CREATE INDEX idx_feature_lookup ON users(feature_id)' 144 | 145 | CONFIG: 146 | - add to: config/settings.py 147 | - pattern: "FEATURE_TIMEOUT = int(os.getenv('FEATURE_TIMEOUT', '30'))" 148 | 149 | ROUTES: 150 | - add to: src/api/routes.py 151 | - pattern: "router.include_router(feature_router, prefix='/feature')" 152 | ``` 153 | 154 | ## Validation Loop 155 | 156 | ### Level 1: Syntax & Style 157 | 158 | ```bash 159 | # Run these FIRST - fix any errors before proceeding 160 | ruff check src/new_feature.py --fix # Auto-fix what's possible 161 | mypy src/new_feature.py # Type checking 162 | 163 | # Expected: No errors. If errors, READ the error and fix. 164 | ``` 165 | 166 | ### Level 2: Unit Tests each new feature/file/function use existing test patterns 167 | 168 | ```python 169 | # CREATE test_new_feature.py with these test cases: 170 | def test_happy_path(): 171 | """Basic functionality works""" 172 | result = new_feature("valid_input") 173 | assert result.status == "success" 174 | 175 | def test_validation_error(): 176 | """Invalid input raises ValidationError""" 177 | with pytest.raises(ValidationError): 178 | new_feature("") 179 | 180 | def test_external_api_timeout(): 181 | """Handles timeouts gracefully""" 182 | with mock.patch('external_api.call', side_effect=TimeoutError): 183 | result = new_feature("valid") 184 | assert result.status == "error" 185 | assert "timeout" in result.message 186 | ``` 187 | 188 | ```bash 189 | # Run and iterate until passing: 190 | uv run pytest test_new_feature.py -v 191 | # If failing: Read error, understand root cause, fix code, re-run (never mock to pass) 192 | ``` 193 | 194 | ### Level 3: Integration Test 195 | 196 | ```bash 197 | # Start the service 198 | uv run python -m src.main --dev 199 | 200 | # Test the endpoint 201 | curl -X POST http://localhost:8000/feature \ 202 | -H "Content-Type: application/json" \ 203 | -d '{"param": "test_value"}' 204 | 205 | # Expected: {"status": "success", "data": {...}} 206 | # If error: Check logs at logs/app.log for stack trace 207 | ``` 208 | 209 | ## Final validation Checklist 210 | 211 | - [ ] All tests pass: `uv run pytest tests/ -v` 212 | - [ ] No linting errors: `uv run ruff check src/` 213 | - [ ] No type errors: `uv run mypy src/` 214 | - [ ] Manual test successful: [specific curl/command] 215 | - [ ] Error cases handled gracefully 216 | - [ ] Logs are informative but not verbose 217 | - [ ] Documentation updated if needed 218 | 219 | --- 220 | 221 | ## Anti-Patterns to Avoid 222 | 223 | - ❌ Don't create new patterns when existing ones work 224 | - ❌ Don't skip validation because "it should work" 225 | - ❌ Don't ignore failing tests - fix them 226 | - ❌ Don't use sync functions in async context 227 | - ❌ Don't hardcode values that should be config 228 | - ❌ Don't catch all exceptions - be specific 229 | -------------------------------------------------------------------------------- /src/core/aggregation/transaction-grouper.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { TransactionGrouper } from './transaction-grouper.js'; 3 | import type { Transaction, CategoryGroupInfo } from '../types/domain.js'; 4 | 5 | describe('TransactionGrouper', () => { 6 | const grouper = new TransactionGrouper(); 7 | 8 | const mockGetCategoryName = vi.fn((categoryId: string) => { 9 | const names: Record = { 10 | cat1: 'Food', 11 | cat2: 'Rent', 12 | cat3: 'Salary', 13 | cat4: 'Utilities', 14 | }; 15 | return names[categoryId] || 'Unknown Category'; 16 | }); 17 | 18 | const mockGetGroupInfo = vi.fn((categoryId: string): CategoryGroupInfo | undefined => { 19 | const groups: Record = { 20 | cat1: { 21 | id: 'g1', 22 | name: 'Living', 23 | isIncome: false, 24 | isSavingsOrInvestment: false, 25 | }, 26 | cat2: { 27 | id: 'g1', 28 | name: 'Living', 29 | isIncome: false, 30 | isSavingsOrInvestment: false, 31 | }, 32 | cat3: { 33 | id: 'g2', 34 | name: 'Income', 35 | isIncome: true, 36 | isSavingsOrInvestment: false, 37 | }, 38 | cat4: { 39 | id: 'g1', 40 | name: 'Living', 41 | isIncome: false, 42 | isSavingsOrInvestment: false, 43 | }, 44 | }; 45 | return groups[categoryId]; 46 | }); 47 | 48 | const mockTransactions: Transaction[] = [ 49 | { 50 | id: '1', 51 | account: 'acc1', 52 | date: '2023-01-01', 53 | amount: -100, 54 | category: 'cat1', 55 | }, 56 | { 57 | id: '2', 58 | account: 'acc1', 59 | date: '2023-01-02', 60 | amount: -50, 61 | category: 'cat1', 62 | }, 63 | { 64 | id: '3', 65 | account: 'acc1', 66 | date: '2023-01-03', 67 | amount: -800, 68 | category: 'cat2', 69 | }, 70 | { 71 | id: '4', 72 | account: 'acc1', 73 | date: '2023-01-04', 74 | amount: 2000, 75 | category: 'cat3', 76 | }, 77 | { 78 | id: '5', 79 | account: 'acc1', 80 | date: '2023-01-05', 81 | amount: -200, 82 | category: 'cat4', 83 | }, 84 | { id: '6', account: 'acc1', date: '2023-01-06', amount: 100 }, // No category 85 | ]; 86 | 87 | beforeEach(() => { 88 | vi.clearAllMocks(); 89 | }); 90 | 91 | it('should group transactions by category excluding income when includeIncome is false', () => { 92 | const result = grouper.groupByCategory(mockTransactions, mockGetCategoryName, mockGetGroupInfo, false); 93 | 94 | expect(result).toHaveProperty('cat1'); 95 | expect(result).toHaveProperty('cat2'); 96 | expect(result).toHaveProperty('cat4'); 97 | expect(result).not.toHaveProperty('cat3'); // Income excluded 98 | 99 | expect(result['cat1']).toEqual({ 100 | id: 'cat1', 101 | name: 'Food', 102 | group: 'Living', 103 | isIncome: false, 104 | total: -150, // -100 + -50 105 | transactions: 2, 106 | }); 107 | 108 | expect(result['cat2']).toEqual({ 109 | id: 'cat2', 110 | name: 'Rent', 111 | group: 'Living', 112 | isIncome: false, 113 | total: -800, 114 | transactions: 1, 115 | }); 116 | 117 | expect(result['cat4']).toEqual({ 118 | id: 'cat4', 119 | name: 'Utilities', 120 | group: 'Living', 121 | isIncome: false, 122 | total: -200, 123 | transactions: 1, 124 | }); 125 | }); 126 | 127 | it('should group transactions by category including income when includeIncome is true', () => { 128 | const result = grouper.groupByCategory(mockTransactions, mockGetCategoryName, mockGetGroupInfo, true); 129 | 130 | expect(result).toHaveProperty('cat1'); 131 | expect(result).toHaveProperty('cat2'); 132 | expect(result).toHaveProperty('cat3'); // Income included 133 | expect(result).toHaveProperty('cat4'); 134 | 135 | expect(result['cat3']).toEqual({ 136 | id: 'cat3', 137 | name: 'Salary', 138 | group: 'Income', 139 | isIncome: true, 140 | total: 2000, 141 | transactions: 1, 142 | }); 143 | }); 144 | 145 | it('should skip transactions without category', () => { 146 | const transactionsWithoutCategory: Transaction[] = [ 147 | { id: '1', account: 'acc1', date: '2023-01-01', amount: -100 }, // No category 148 | { 149 | id: '2', 150 | account: 'acc1', 151 | date: '2023-01-02', 152 | amount: -50, 153 | category: 'cat1', 154 | }, 155 | ]; 156 | 157 | const result = grouper.groupByCategory(transactionsWithoutCategory, mockGetCategoryName, mockGetGroupInfo, false); 158 | 159 | expect(Object.keys(result)).toHaveLength(1); 160 | expect(result).toHaveProperty('cat1'); 161 | expect(result).not.toHaveProperty('undefined'); 162 | }); 163 | 164 | it('should handle unknown categories with default group', () => { 165 | const transactionsWithUnknownCategory: Transaction[] = [ 166 | { 167 | id: '1', 168 | account: 'acc1', 169 | date: '2023-01-01', 170 | amount: -100, 171 | category: 'unknown', 172 | }, 173 | ]; 174 | 175 | const result = grouper.groupByCategory( 176 | transactionsWithUnknownCategory, 177 | mockGetCategoryName, 178 | mockGetGroupInfo, 179 | false 180 | ); 181 | 182 | expect(result).toHaveProperty('unknown'); 183 | expect(result['unknown']).toEqual({ 184 | id: 'unknown', 185 | name: 'Unknown Category', 186 | group: 'Unknown Group', 187 | isIncome: false, 188 | total: -100, 189 | transactions: 1, 190 | }); 191 | }); 192 | 193 | it('should handle empty transactions array', () => { 194 | const result = grouper.groupByCategory([], mockGetCategoryName, mockGetGroupInfo, false); 195 | 196 | expect(result).toEqual({}); 197 | }); 198 | 199 | it('should accumulate multiple transactions for the same category', () => { 200 | const sameCategory: Transaction[] = [ 201 | { 202 | id: '1', 203 | account: 'acc1', 204 | date: '2023-01-01', 205 | amount: -100, 206 | category: 'cat1', 207 | }, 208 | { 209 | id: '2', 210 | account: 'acc1', 211 | date: '2023-01-02', 212 | amount: -50, 213 | category: 'cat1', 214 | }, 215 | { 216 | id: '3', 217 | account: 'acc1', 218 | date: '2023-01-03', 219 | amount: -25, 220 | category: 'cat1', 221 | }, 222 | ]; 223 | 224 | const result = grouper.groupByCategory(sameCategory, mockGetCategoryName, mockGetGroupInfo, false); 225 | 226 | expect(result['cat1']).toEqual({ 227 | id: 'cat1', 228 | name: 'Food', 229 | group: 'Living', 230 | isIncome: false, 231 | total: -175, // -100 + -50 + -25 232 | transactions: 3, 233 | }); 234 | }); 235 | 236 | it('should handle positive transaction amounts correctly', () => { 237 | const positiveTransactions: Transaction[] = [ 238 | { 239 | id: '1', 240 | account: 'acc1', 241 | date: '2023-01-01', 242 | amount: 100, 243 | category: 'cat1', 244 | }, 245 | { 246 | id: '2', 247 | account: 'acc1', 248 | date: '2023-01-02', 249 | amount: 50, 250 | category: 'cat1', 251 | }, 252 | ]; 253 | 254 | const result = grouper.groupByCategory(positiveTransactions, mockGetCategoryName, mockGetGroupInfo, false); 255 | 256 | expect(result['cat1']).toEqual({ 257 | id: 'cat1', 258 | name: 'Food', 259 | group: 'Living', 260 | isIncome: false, 261 | total: 150, // 100 + 50 262 | transactions: 2, 263 | }); 264 | }); 265 | }); 266 | -------------------------------------------------------------------------------- /src/core/mapping/category-mapper.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from 'vitest'; 2 | import { CategoryMapper } from './category-mapper.js'; 3 | import type { Category, CategoryGroup } from '../types/domain.js'; 4 | 5 | describe('CategoryMapper', () => { 6 | let mapper: CategoryMapper; 7 | let mockCategories: Category[]; 8 | let mockCategoryGroups: CategoryGroup[]; 9 | 10 | beforeEach(() => { 11 | mockCategories = [ 12 | { id: 'cat1', name: 'Food', group_id: 'g1' }, 13 | { id: 'cat2', name: 'Rent', group_id: 'g1' }, 14 | { id: 'cat3', name: 'Salary', group_id: 'g2', is_income: true }, 15 | { id: 'cat4', name: 'Bonus', group_id: 'g2', is_income: true }, 16 | { id: 'cat5', name: 'Stocks', group_id: 'g3' }, 17 | { id: 'cat6', name: '401k', group_id: 'g4' }, 18 | ]; 19 | 20 | mockCategoryGroups = [ 21 | { id: 'g1', name: 'Living', is_income: false }, 22 | { id: 'g2', name: 'Income', is_income: true }, 23 | { id: 'g3', name: 'Investment', is_income: false }, 24 | { id: 'g4', name: 'Savings', is_income: false }, 25 | ]; 26 | 27 | mapper = new CategoryMapper(mockCategories, mockCategoryGroups); 28 | }); 29 | 30 | describe('constructor', () => { 31 | it('should initialize categoryNames mapping', () => { 32 | expect(mapper.categoryNames['cat1']).toBe('Food'); 33 | expect(mapper.categoryNames['cat2']).toBe('Rent'); 34 | expect(mapper.categoryNames['cat3']).toBe('Salary'); 35 | }); 36 | 37 | it('should initialize groupNames mapping', () => { 38 | expect(mapper.groupNames['g1']).toBe('Living'); 39 | expect(mapper.groupNames['g2']).toBe('Income'); 40 | expect(mapper.groupNames['g3']).toBe('Investment'); 41 | }); 42 | 43 | it('should initialize categoryToGroup mapping', () => { 44 | expect(mapper.categoryToGroup['cat1']).toEqual({ 45 | id: 'g1', 46 | name: 'Living', 47 | isIncome: false, 48 | isSavingsOrInvestment: false, 49 | }); 50 | 51 | expect(mapper.categoryToGroup['cat3']).toEqual({ 52 | id: 'g2', 53 | name: 'Income', 54 | isIncome: true, 55 | isSavingsOrInvestment: false, 56 | }); 57 | }); 58 | 59 | it('should identify savings and investment categories', () => { 60 | expect(mapper.investmentCategories.has('cat5')).toBe(true); // Investment group 61 | expect(mapper.investmentCategories.has('cat6')).toBe(true); // Savings group 62 | expect(mapper.investmentCategories.has('cat1')).toBe(false); // Living group 63 | }); 64 | 65 | it('should handle case insensitive investment/savings detection', () => { 66 | const mockCategoryGroupsWithCase = [ 67 | { id: 'g1', name: 'INVESTMENT', is_income: false }, 68 | { id: 'g2', name: 'Personal Savings', is_income: false }, 69 | { id: 'g3', name: 'Regular Expenses', is_income: false }, 70 | ]; 71 | 72 | const mockCategoriesWithCase = [ 73 | { id: 'cat1', name: 'Stocks', group_id: 'g1' }, 74 | { id: 'cat2', name: 'Emergency Fund', group_id: 'g2' }, 75 | { id: 'cat3', name: 'Food', group_id: 'g3' }, 76 | ]; 77 | 78 | const mapperWithCase = new CategoryMapper(mockCategoriesWithCase, mockCategoryGroupsWithCase); 79 | 80 | expect(mapperWithCase.investmentCategories.has('cat1')).toBe(true); 81 | expect(mapperWithCase.investmentCategories.has('cat2')).toBe(true); 82 | expect(mapperWithCase.investmentCategories.has('cat3')).toBe(false); 83 | }); 84 | }); 85 | 86 | describe('getCategoryName', () => { 87 | it('should return category name for valid category ID', () => { 88 | expect(mapper.getCategoryName('cat1')).toBe('Food'); 89 | expect(mapper.getCategoryName('cat2')).toBe('Rent'); 90 | expect(mapper.getCategoryName('cat3')).toBe('Salary'); 91 | }); 92 | 93 | it("should return 'Unknown Category' for invalid category ID", () => { 94 | expect(mapper.getCategoryName('invalid')).toBe('Unknown Category'); 95 | expect(mapper.getCategoryName('')).toBe('Unknown Category'); 96 | }); 97 | }); 98 | 99 | describe('getGroupInfo', () => { 100 | it('should return group info for valid category ID', () => { 101 | const groupInfo = mapper.getGroupInfo('cat1'); 102 | expect(groupInfo).toEqual({ 103 | id: 'g1', 104 | name: 'Living', 105 | isIncome: false, 106 | isSavingsOrInvestment: false, 107 | }); 108 | }); 109 | 110 | it('should return income group info for income category', () => { 111 | const groupInfo = mapper.getGroupInfo('cat3'); 112 | expect(groupInfo).toEqual({ 113 | id: 'g2', 114 | name: 'Income', 115 | isIncome: true, 116 | isSavingsOrInvestment: false, 117 | }); 118 | }); 119 | 120 | it('should return investment group info for investment category', () => { 121 | const groupInfo = mapper.getGroupInfo('cat5'); 122 | expect(groupInfo).toEqual({ 123 | id: 'g3', 124 | name: 'Investment', 125 | isIncome: false, 126 | isSavingsOrInvestment: true, 127 | }); 128 | }); 129 | 130 | it('should return undefined for invalid category ID', () => { 131 | expect(mapper.getGroupInfo('invalid')).toBeUndefined(); 132 | expect(mapper.getGroupInfo('')).toBeUndefined(); 133 | }); 134 | }); 135 | 136 | describe('isInvestmentCategory', () => { 137 | it('should return true for investment categories', () => { 138 | expect(mapper.isInvestmentCategory('cat5')).toBe(true); // Investment group 139 | expect(mapper.isInvestmentCategory('cat6')).toBe(true); // Savings group 140 | }); 141 | 142 | it('should return false for non-investment categories', () => { 143 | expect(mapper.isInvestmentCategory('cat1')).toBe(false); // Living group 144 | expect(mapper.isInvestmentCategory('cat2')).toBe(false); // Living group 145 | expect(mapper.isInvestmentCategory('cat3')).toBe(false); // Income group 146 | }); 147 | 148 | it('should return false for invalid category ID', () => { 149 | expect(mapper.isInvestmentCategory('invalid')).toBe(false); 150 | expect(mapper.isInvestmentCategory('')).toBe(false); 151 | }); 152 | }); 153 | 154 | describe('edge cases', () => { 155 | it('should handle empty categories and groups', () => { 156 | const emptyMapper = new CategoryMapper([], []); 157 | 158 | expect(emptyMapper.getCategoryName('cat1')).toBe('Unknown Category'); 159 | expect(emptyMapper.getGroupInfo('cat1')).toBeUndefined(); 160 | expect(emptyMapper.isInvestmentCategory('cat1')).toBe(false); 161 | }); 162 | 163 | it('should handle category without corresponding group', () => { 164 | const categoriesWithoutGroup = [{ id: 'cat1', name: 'Food', group_id: 'nonexistent' }]; 165 | 166 | const mapperWithMissingGroup = new CategoryMapper(categoriesWithoutGroup, []); 167 | 168 | expect(mapperWithMissingGroup.getCategoryName('cat1')).toBe('Food'); 169 | expect(mapperWithMissingGroup.getGroupInfo('cat1')).toEqual({ 170 | id: 'nonexistent', 171 | name: 'Unknown Group', 172 | isIncome: false, 173 | isSavingsOrInvestment: false, 174 | }); 175 | }); 176 | 177 | it('should handle category marked as income but group not marked as income', () => { 178 | const conflictingCategories = [{ id: 'cat1', name: 'Bonus', group_id: 'g1', is_income: true }]; 179 | 180 | const conflictingGroups = [{ id: 'g1', name: 'Living', is_income: false }]; 181 | 182 | const conflictingMapper = new CategoryMapper(conflictingCategories, conflictingGroups); 183 | 184 | expect(conflictingMapper.getGroupInfo('cat1')).toEqual({ 185 | id: 'g1', 186 | name: 'Living', 187 | isIncome: true, // Uses category's is_income flag 188 | isSavingsOrInvestment: false, 189 | }); 190 | }); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | ### 🔄 Project Awareness & Context 2 | 3 | - **Always read `PLANNING.md`** at the start of a new conversation to understand the project's architecture, goals, style, and constraints. 4 | - **Check `TASK.md`** before starting a new task. If the task isn’t listed, add it with a brief description and today's date. 5 | - **Use consistent naming conventions, file structure, and architecture patterns** as described in `PLANNING.md`. 6 | - **Follow the existing TypeScript project structure** and maintain consistency with established patterns. 7 | 8 | ### 🧱 Code Structure & Modularity 9 | 10 | - **Never create a file longer than 500 lines of code.** If a file approaches this limit, refactor by splitting it into modules or helper files. 11 | - **Organize code into clearly separated modules**, grouped by feature or responsibility. 12 | For Actual MCP server, this looks like: 13 | - **Main server** (`src/index.ts`): MCP server initialization and transport setup. 14 | - **MCP Integration**: Uses `@modelcontextprotocol/sdk` for server implementation. 15 | - **Actual Budget API** (`src/actual-api.ts`): Manages the connection lifecycle to Actual Budget data. 16 | - **Tools** (`src/tools/`): Each tool follows a consistent modular pattern: 17 | - `index.ts`: Schema definition and main handler. 18 | - `input-parser.ts`: Argument validation and parsing. 19 | - `data-fetcher.ts`: Data retrieval from external API. 20 | - `report-generator.ts`: Output formatting. 21 | - `types.ts`: Tool-specific type definitions. 22 | - **Core Utilities** (`src/core/`): Shared functionality for data fetching, input handling, aggregation, and mapping. 23 | - **Use clear, consistent imports** (prefer relative imports within packages). 24 | - **Use clear, consistent imports** (prefer relative imports within packages). 25 | - **Use process.env** for environment variables. 26 | 27 | ### 🧪 Testing & Reliability 28 | 29 | - **Always create Vitest unit tests for new features** (functions, classes, modules, etc). 30 | - **Use TypeScript in tests** and maintain type safety throughout test suites. 31 | - **After updating any logic**, check whether existing unit tests need to be updated. If so, do it. 32 | - **Tests should be co-located** with source files using `.test.ts` naming convention. 33 | - Example: `src/core/data/fetch-accounts.ts` → `src/core/data/fetch-accounts.test.ts` 34 | - **Use proper ESM module mocking** with `vi.mock()` for external dependencies. 35 | - **Test commands available**: 36 | - `npm run test` - Run all tests once 37 | - `npm run test:unit:watch` - Run tests in watch mode 38 | - `npm run test:coverage` - Generate coverage reports 39 | - `npm run test:ui` - Open Vitest UI for interactive testing 40 | - **Code quality commands**: 41 | - `npm run lint` - Run ESLint to check for code quality issues 42 | - `npm run lint:fix` - Auto-fix ESLint issues where possible 43 | - `npm run format` - Format code with Prettier 44 | - `npm run format:check` - Check if code is properly formatted 45 | - `npm run type-check` - Run TypeScript type checking without compilation 46 | - `npm run quality` - Run full quality check (lint + format + type-check) 47 | - **Include comprehensive test coverage**: 48 | - 1 test for expected use (happy path) 49 | - 1 edge case (boundary conditions) 50 | - 1 failure case (error handling) 51 | - Mock external dependencies (actual-api.js, etc.) 52 | - **Follow existing test patterns** in `src/core/` for consistency. 53 | 54 | ### ✅ Task Completion 55 | 56 | - **Mark completed tasks in `TASK.md`** immediately after finishing them. 57 | - Add new sub-tasks or TODOs discovered during development to `TASK.md` under a “Discovered During Work” section. 58 | 59 | ### 📎 Style & Conventions 60 | 61 | - **Use TypeScript** as the primary language with strict type checking. 62 | - **Follow ESLint and Prettier** configuration for consistent code formatting. 63 | - **Use explicit type annotations** for function parameters and return types. 64 | - **Prefer interfaces over types** for object shapes when possible. 65 | - **Use JSDoc comments for complex functions** following TypeScript conventions: 66 | 67 | ```typescript 68 | /** 69 | * Brief summary of the function. 70 | * 71 | * @param param1 - Description of the parameter 72 | * @returns Description of the return value 73 | */ 74 | function example(param1: string): Promise { 75 | // implementation 76 | } 77 | ``` 78 | 79 | - **Use `npm run build`** to compile TypeScript and **`npm run watch`** for development. 80 | - **Follow Node.js/npm conventions** for package management and scripts. 81 | 82 | ### 📚 Documentation & Explainability 83 | 84 | - **Update `README.md`** when new features are added, dependencies change, or setup steps are modified. 85 | - **Comment non-obvious code** and ensure everything is understandable to a mid-level developer. 86 | - When writing complex logic, **add an inline `# Reason:` comment** explaining the why, not just the what. 87 | 88 | ### 🧠 AI Behavior Rules 89 | 90 | - **Never assume missing context. Ask questions if uncertain.** 91 | - **Never hallucinate libraries or functions** – only use known, verified npm packages. 92 | - **Check package.json dependencies** before using any external libraries. 93 | - **Use Context7 for up-to-date library documentation** when working with external packages or APIs. 94 | - **Maintain backward compatibility** when making changes to existing APIs or interfaces. 95 | - **Always confirm file paths and module names** exist before referencing them in code or tests. 96 | - **Never delete or overwrite existing code** unless explicitly instructed to or if part of a task from `TASK.md`. 97 | 98 | ### Using Gemini CLI for Large Codebase Analysis 99 | 100 | When analyzing large codebases or multiple files that might exceed context limits, use the Gemini CLI with its massive 101 | context window. Use `gemini -p` to leverage Google Gemini's large context capacity. 102 | 103 | #### File and Directory Inclusion Syntax 104 | 105 | Use the `@` syntax to include files and directories in your Gemini prompts. The paths should be relative to WHERE you run the 106 | gemini command: 107 | 108 | #### Examples: 109 | 110 | **Single file analysis:** 111 | gemini -p "@src/main.py Explain this file's purpose and structure" 112 | 113 | Multiple files: 114 | gemini -p "@package.json @src/index.js Analyze the dependencies used in the code" 115 | 116 | Entire directory: 117 | gemini -p "@src/ Summarize the architecture of this codebase" 118 | 119 | Multiple directories: 120 | gemini -p "@src/ @tests/ Analyze test coverage for the source code" 121 | 122 | Current directory and subdirectories: 123 | gemini -p "@./ Give me an overview of this entire project" 124 | 125 | **Or use --all_files flag:** 126 | gemini --all_files -p "Analyze the project structure and dependencies" 127 | 128 | **Implementation Verification Examples** 129 | 130 | Check if a feature is implemented: 131 | gemini -p "@src/ @lib/ Has dark mode been implemented in this codebase? Show me the relevant files and functions" 132 | 133 | Verify authentication implementation: 134 | gemini -p "@src/ @middleware/ Is JWT authentication implemented? List all auth-related endpoints and middleware" 135 | 136 | Check for specific patterns: 137 | gemini -p "@src/ Are there any React hooks that handle WebSocket connections? List them with file paths" 138 | 139 | Verify error handling: 140 | gemini -p "@src/ @api/ Is proper error handling implemented for all API endpoints? Show examples of try-catch blocks" 141 | 142 | Check for rate limiting: 143 | gemini -p "@backend/ @middleware/ Is rate limiting implemented for the API? Show the implementation details" 144 | 145 | Verify caching strategy: 146 | gemini -p "@src/ @lib/ @services/ Is Redis caching implemented? List all cache-related functions and their usage" 147 | 148 | Check for specific security measures: 149 | gemini -p "@src/ @api/ Are SQL injection protections implemented? Show how user inputs are sanitized" 150 | 151 | Verify test coverage for features: 152 | gemini -p "@src/payment/ @tests/ Is the payment processing module fully tested? List all test cases" 153 | 154 | #### When to Use Gemini CLI 155 | 156 | Use gemini -p when: 157 | 158 | - Analyzing entire codebases or large directories 159 | - Comparing multiple large files 160 | - Need to understand project-wide patterns or architecture 161 | - Current context window is insufficient for the task 162 | - Working with files totaling more than 100KB 163 | - Verifying if specific features, patterns, or security measures are implemented 164 | - Checking for the presence of certain coding patterns across the entire codebase 165 | 166 | #### Important Notes 167 | 168 | - Paths in @ syntax are relative to your current working directory when invoking gemini 169 | - The CLI will include file contents directly in the context 170 | - No need for --yolo flag for read-only analysis 171 | - Gemini's context window can handle entire codebases that would overflow Claude's context 172 | - When checking implementations, be specific about what you're looking for to get accurate results 173 | -------------------------------------------------------------------------------- /PRPs/vitest-unit-testing-core.md: -------------------------------------------------------------------------------- 1 | # PRP: Vitest Unit Testing Framework Implementation for src/core 2 | 3 | ## Goal 4 | 5 | Introduce Vitest testing framework to the `src/core` module with co-located test files, proper TypeScript configuration, and comprehensive unit test coverage for all core functionality. 6 | 7 | ## Why 8 | 9 | - **Code Quality**: Establish a foundation for unit testing to catch bugs early and ensure code reliability 10 | - **Refactoring Safety**: Enable safe refactoring of core modules with confidence in test coverage 11 | - **Documentation**: Tests serve as living documentation of expected behavior 12 | - **Development Speed**: Faster feedback loop for developers working on core functionality 13 | - **Future Growth**: Establish testing patterns that can be extended to other modules 14 | 15 | ## What 16 | 17 | Implementation of Vitest testing framework focused exclusively on the `src/core` module with: 18 | 19 | - Co-located test files (e.g., `data-fetcher.test.ts` alongside `data-fetcher.ts`) 20 | - TypeScript configuration optimized for testing 21 | - Separate build configuration to exclude tests from production builds 22 | - Comprehensive test coverage for all core functionality 23 | - Proper mocking and testing utilities setup 24 | 25 | ### Success Criteria 26 | 27 | - [ ] All files in `src/core` except empty files have corresponding `.test.ts` files 28 | - [ ] `npm run test` executes all tests successfully 29 | - [ ] `npm run build` excludes test files from production build 30 | - [ ] Test coverage reporting is configured and working 31 | - [ ] TypeScript compilation works for both test and build contexts 32 | - [ ] Tests follow consistent patterns and best practices 33 | 34 | ## All Needed Context 35 | 36 | ### Documentation & References 37 | 38 | ```yaml 39 | # MUST READ - Include these in your context window 40 | - url: https://vitest.dev/guide/ 41 | why: Core Vitest setup and configuration patterns 42 | 43 | - url: https://vitest.dev/config/ 44 | why: Configuration options for vitest.config.ts 45 | 46 | - url: https://vitest.dev/guide/mocking.html 47 | why: ESM module mocking patterns and vi.mock usage 48 | 49 | - url: https://vitest.dev/guide/in-source.html 50 | why: Understanding in-source testing and co-location patterns 51 | 52 | - file: examples/vitest.config.ts 53 | why: Reference configuration with proper TypeScript support 54 | 55 | - file: examples/tsconfig.json 56 | why: Node16 module resolution pattern for Vitest compatibility 57 | 58 | - file: examples/tsconfig.build.json 59 | why: Separate build configuration excluding tests 60 | 61 | - file: src/core/index.ts 62 | why: Current module exports and structure to understand testing scope 63 | 64 | - file: src/core/types/domain.ts 65 | why: Type definitions that will be used in tests 66 | 67 | - file: src/core/aggregation/group-by.ts 68 | why: Example of class-based module requiring comprehensive testing 69 | 70 | - file: src/core/mapping/category-mapper.ts 71 | why: Complex class with constructor and multiple methods to test 72 | ``` 73 | 74 | ### Current Codebase Structure 75 | 76 | ```bash 77 | src/ 78 | ├── core/ 79 | │ ├── aggregation/ 80 | │ │ ├── group-by.ts # GroupAggregator class 81 | │ │ ├── sort-by.ts 82 | │ │ ├── sum-by.ts 83 | │ │ └── transaction-grouper.ts 84 | │ ├── data/ 85 | │ │ ├── fetch-accounts.ts # Simple async function 86 | │ │ ├── fetch-categories.ts 87 | │ │ └── fetch-transactions.ts 88 | │ ├── input/ 89 | │ │ ├── argument-parser.ts # Currently minimal 90 | │ │ └── validators.ts 91 | │ ├── mapping/ 92 | │ │ ├── category-classifier.ts 93 | │ │ ├── category-mapper.ts # Complex class with Map operations 94 | │ │ └── transaction-mapper.ts 95 | │ └── types/ 96 | │ └── domain.ts # Interface definitions 97 | ├── actual-api.ts 98 | ├── index.ts 99 | └── tools/ 100 | ``` 101 | 102 | ### Desired Codebase Structure Post-Implementation 103 | 104 | ```bash 105 | src/ 106 | ├── core/ 107 | │ ├── aggregation/ 108 | │ │ ├── group-by.ts 109 | │ │ ├── group-by.test.ts # NEW: Unit tests for GroupAggregator 110 | │ │ ├── sort-by.ts # Empty file, skip test 111 | │ │ ├── sum-by.ts # Empty file, skip test 112 | │ │ ├── transaction-grouper.ts 113 | │ │ └── transaction-grouper.test.ts # NEW: Unit tests 114 | │ ├── data/ 115 | │ │ ├── fetch-accounts.ts 116 | │ │ ├── fetch-accounts.test.ts # NEW: Mock actual-api calls 117 | │ │ ├── fetch-categories.ts 118 | │ │ ├── fetch-categories.test.ts # NEW: Unit tests 119 | │ │ ├── fetch-transactions.ts 120 | │ │ └── fetch-transactions.test.ts # NEW: Unit tests 121 | │ ├── input/ 122 | │ │ ├── argument-parser.ts # Empty file, skip test 123 | │ │ ├── validators.ts # Empty file, skip test 124 | │ ├── mapping/ 125 | │ │ ├── category-classifier.ts # Empty file, skip test 126 | │ │ ├── category-mapper.ts 127 | │ │ ├── category-mapper.test.ts # NEW: Complex class testing 128 | │ │ ├── transaction-mapper.ts # Empty file, skip test 129 | │ └── types/ 130 | │ └── domain.ts # No tests needed (interfaces only) 131 | ├── vitest.config.ts # NEW: Vitest configuration 132 | ├── tsconfig.json # MODIFIED: For test compatibility 133 | ├── tsconfig.build.json # NEW: Build-only configuration 134 | ``` 135 | 136 | ### Known Gotchas & Library Quirks 137 | 138 | ```typescript 139 | // CRITICAL: Current tsconfig uses ESNext + bundler, but Vitest needs Node16 140 | // Current: "module": "ESNext", "moduleResolution": "bundler" 141 | // Needed: "module": "Node16", "moduleResolution": "Node16" 142 | 143 | // CRITICAL: Files use .js imports even in TypeScript 144 | // Example: import { getAccounts } from "../../actual-api.js" 145 | // Vitest needs alias configuration to handle this 146 | 147 | // CRITICAL: ESM modules require careful mocking 148 | // Use vi.mock() for module mocking, not jest.mock() 149 | // Mock must be called before imports in test files 150 | 151 | // CRITICAL: Build configuration must exclude .test.ts files 152 | // Use separate tsconfig.build.json that excludes test files 153 | // Update package.json build script to use tsconfig.build.json 154 | 155 | // CRITICAL: Package.json is type: "module" (ESM) 156 | // Vitest configuration must use ES modules syntax 157 | // Test files must use ES import/export syntax 158 | ``` 159 | 160 | ## Implementation Blueprint 161 | 162 | ### Data Models and Structure 163 | 164 | The existing domain types in `src/core/types/domain.ts` will be used for test data creation: 165 | 166 | ```typescript 167 | // Test data factories for consistent testing 168 | interface TestDataFactory { 169 | createAccount(overrides?: Partial): Account; 170 | createTransaction(overrides?: Partial): Transaction; 171 | createCategory(overrides?: Partial): Category; 172 | createCategoryGroup(overrides?: Partial): CategoryGroup; 173 | } 174 | ``` 175 | 176 | ### List of Tasks to be Completed 177 | 178 | ```yaml 179 | Task 1: Install Vitest Dependencies 180 | ADD to package.json devDependencies: 181 | - vitest: "^2.0.0" 182 | - @vitest/ui: "^2.0.0" 183 | - vite-tsconfig-paths: "^5.0.0" 184 | - @types/node: "^20.11.24" (already present) 185 | 186 | Task 2: Create Vitest Configuration 187 | CREATE vitest.config.ts: 188 | - MIRROR pattern from: examples/vitest.config.ts 189 | - MODIFY to include src/core/**/*.test.ts pattern 190 | - KEEP alias configuration for .js imports 191 | - ADD coverage configuration for src/core only 192 | 193 | Task 3: Update TypeScript Configuration 194 | MODIFY tsconfig.json: 195 | - CHANGE "module": "ESNext" to "module": "Node16" 196 | - CHANGE "moduleResolution": "bundler" to "moduleResolution": "Node16" 197 | - ADD "types": ["vitest/globals", "node"] 198 | - ADD "include": ["src/**/*", "vitest.config.ts"] 199 | 200 | CREATE tsconfig.build.json: 201 | - EXTEND tsconfig.json 202 | - EXCLUDE test files: ["**/*.test.ts", "vitest.config.ts"] 203 | - KEEP build-specific options (declaration, outDir, etc.) 204 | 205 | Task 4: Update Package.json Scripts 206 | MODIFY package.json scripts: 207 | - CHANGE "build": "tsc" to "tsc -p tsconfig.build.json" 208 | - ADD "test": "vitest" 209 | - ADD "test:ui": "vitest --ui" 210 | - ADD "test:run": "vitest run" 211 | - ADD "test:coverage": "vitest run --coverage" 212 | 213 | Task 5: Create Test Files for Data Module 214 | CREATE src/core/data/fetch-accounts.test.ts: 215 | - MOCK ../../actual-api.js module 216 | - TEST successful account fetching 217 | - TEST error handling scenarios 218 | 219 | CREATE src/core/data/fetch-categories.test.ts: 220 | CREATE src/core/data/fetch-transactions.test.ts: 221 | - MIRROR pattern from fetch-accounts.test.ts 222 | - MODIFY for respective functions 223 | 224 | Task 6: Create Test Files for Aggregation Module 225 | CREATE src/core/aggregation/group-by.test.ts: 226 | - TEST GroupAggregator.aggregateAndSort method 227 | - TEST sorting by absolute values 228 | - TEST category grouping logic 229 | - TEST empty input handling 230 | 231 | CREATE src/core/aggregation/sort-by.test.ts: 232 | CREATE src/core/aggregation/sum-by.test.ts: 233 | CREATE src/core/aggregation/transaction-grouper.test.ts: 234 | - FOLLOW similar testing patterns for each module 235 | 236 | Task 7: Create Test Files for Mapping Module 237 | CREATE src/core/mapping/category-mapper.test.ts: 238 | - TEST CategoryMapper constructor with sample data 239 | - TEST getCategoryName method 240 | - TEST getGroupInfo method 241 | - TEST isInvestmentCategory method 242 | - TEST edge cases (unknown categories, missing groups) 243 | 244 | CREATE src/core/mapping/category-classifier.test.ts: 245 | CREATE src/core/mapping/transaction-mapper.test.ts: 246 | - MIRROR testing patterns from category-mapper.test.ts 247 | 248 | Task 8: Create Test Files for Input Module 249 | CREATE src/core/input/argument-parser.test.ts: 250 | CREATE src/core/input/validators.test.ts: 251 | - PREPARE for future implementation 252 | - CREATE placeholder tests that can be expanded 253 | ``` 254 | 255 | ### Per Task Pseudocode 256 | 257 | ```typescript 258 | // Task 2: vitest.config.ts 259 | export default defineConfig({ 260 | plugins: [tsconfigPaths()], 261 | test: { 262 | environment: 'node', 263 | include: ['src/core/**/*.test.ts'], 264 | globals: true, 265 | coverage: { 266 | provider: 'v8', 267 | include: ['src/core/**/*.ts'], 268 | exclude: ['src/core/**/*.test.ts', 'src/core/types/domain.ts'], 269 | }, 270 | alias: { 271 | '^(\\.{1,2}/.*)\\.js$': '$1', // Handle .js imports in TypeScript 272 | }, 273 | }, 274 | }); 275 | 276 | // Task 5: fetch-accounts.test.ts pattern 277 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 278 | import { fetchAllAccounts } from './fetch-accounts.js'; 279 | 280 | // CRITICAL: Mock before imports 281 | vi.mock('../../actual-api.js', () => ({ 282 | getAccounts: vi.fn(), 283 | })); 284 | 285 | import { getAccounts } from '../../actual-api.js'; 286 | 287 | describe('fetchAllAccounts', () => { 288 | beforeEach(() => { 289 | vi.clearAllMocks(); 290 | }); 291 | 292 | it('should return accounts from API', async () => { 293 | const mockAccounts = [{ id: '1', name: 'Test Account' }]; 294 | vi.mocked(getAccounts).mockResolvedValue(mockAccounts); 295 | 296 | const result = await fetchAllAccounts(); 297 | 298 | expect(result).toEqual(mockAccounts); 299 | expect(getAccounts).toHaveBeenCalledOnce(); 300 | }); 301 | 302 | it('should handle API errors', async () => { 303 | vi.mocked(getAccounts).mockRejectedValue(new Error('API Error')); 304 | 305 | await expect(fetchAllAccounts()).rejects.toThrow('API Error'); 306 | }); 307 | }); 308 | 309 | // Task 6: group-by.test.ts pattern 310 | import { describe, it, expect } from 'vitest'; 311 | import { GroupAggregator } from './group-by.js'; 312 | import type { CategorySpending } from '../types/domain.js'; 313 | 314 | describe('GroupAggregator', () => { 315 | const aggregator = new GroupAggregator(); 316 | 317 | it('should aggregate and sort spending by group', () => { 318 | const input: Record = { 319 | cat1: { 320 | id: '1', 321 | name: 'Food', 322 | group: 'Living', 323 | isIncome: false, 324 | total: -100, 325 | transactions: 5, 326 | }, 327 | cat2: { 328 | id: '2', 329 | name: 'Rent', 330 | group: 'Living', 331 | isIncome: false, 332 | total: -800, 333 | transactions: 1, 334 | }, 335 | cat3: { 336 | id: '3', 337 | name: 'Salary', 338 | group: 'Income', 339 | isIncome: true, 340 | total: 2000, 341 | transactions: 1, 342 | }, 343 | }; 344 | 345 | const result = aggregator.aggregateAndSort(input); 346 | 347 | expect(result).toHaveLength(2); 348 | expect(result[0].name).toBe('Income'); // Highest absolute value 349 | expect(result[0].total).toBe(2000); 350 | expect(result[1].name).toBe('Living'); 351 | expect(result[1].total).toBe(-900); 352 | }); 353 | 354 | it('should handle empty input', () => { 355 | const result = aggregator.aggregateAndSort({}); 356 | expect(result).toEqual([]); 357 | }); 358 | }); 359 | ``` 360 | 361 | ### Integration Points 362 | 363 | ```yaml 364 | DEPENDENCIES: 365 | - add to: package.json devDependencies 366 | - pattern: "vitest", "@vitest/ui", "vite-tsconfig-paths" 367 | 368 | CONFIGURATION: 369 | - modify: tsconfig.json (module resolution) 370 | - create: tsconfig.build.json (build exclusions) 371 | - create: vitest.config.ts (test configuration) 372 | 373 | SCRIPTS: 374 | - modify: package.json scripts 375 | - pattern: "test": "vitest", "build": "tsc -p tsconfig.build.json" 376 | 377 | GITIGNORE: 378 | - add: coverage/ (for test coverage reports) 379 | - pattern: existing node_modules, build patterns remain 380 | ``` 381 | 382 | ## Validation Loop 383 | 384 | ### Level 1: Configuration & Setup 385 | 386 | ```bash 387 | # Install dependencies 388 | npm install 389 | 390 | # Verify TypeScript compilation for tests 391 | npx tsc --noEmit -p tsconfig.json 392 | 393 | # Expected: No compilation errors for test files 394 | ``` 395 | 396 | ### Level 2: Basic Test Execution 397 | 398 | ```bash 399 | # Run tests to verify framework setup 400 | npm run test 401 | 402 | # Expected: All tests pass (even if minimal) 403 | # If failing: Check import paths, mock configurations, TypeScript issues 404 | ``` 405 | 406 | ### Level 3: Build Verification 407 | 408 | ```bash 409 | # Ensure build excludes test files 410 | npm run build 411 | 412 | # Verify test files are excluded 413 | ls -la build/core/ 414 | 415 | # Expected: No .test.js files in build output 416 | # If test files present: Check tsconfig.build.json exclusions 417 | ``` 418 | 419 | ### Level 4: Coverage and Quality 420 | 421 | ```bash 422 | # Run with coverage 423 | npm run test:coverage 424 | 425 | # Expected: Coverage report generated for src/core 426 | # Expected: >80% coverage for implemented modules 427 | ``` 428 | 429 | ### Level 5: Individual Module Testing 430 | 431 | ```bash 432 | # Test specific modules 433 | npx vitest run src/core/aggregation/group-by.test.ts 434 | npx vitest run src/core/mapping/category-mapper.test.ts 435 | 436 | # Expected: Each module tests pass independently 437 | # If failing: Check module-specific mocking and data setup 438 | ``` 439 | 440 | ## Final Validation Checklist 441 | 442 | - [ ] All tests pass: `npm run test` 443 | - [ ] Build works and excludes tests: `npm run build` 444 | - [ ] No TypeScript errors: `npx tsc --noEmit` 445 | - [ ] Coverage reports generate: `npm run test:coverage` 446 | - [ ] Each core module has corresponding test file 447 | - [ ] Test files follow naming convention: `*.test.ts` 448 | - [ ] Mock configurations work for external dependencies 449 | - [ ] UI tests work: `npm run test:ui` 450 | 451 | ## Quality Score Assessment 452 | 453 | **Confidence Level: 8/10** 454 | 455 | **Strengths:** 456 | 457 | - Comprehensive context provided from existing codebase 458 | - Clear TypeScript configuration requirements understood 459 | - Vitest documentation and patterns well-researched 460 | - Specific file structure and naming conventions defined 461 | - Validation gates are executable and comprehensive 462 | 463 | **Potential Challenges:** 464 | 465 | - ESM module mocking complexity in existing codebase 466 | - TypeScript configuration changes may need iteration 467 | - Some core modules may need refactoring for testability 468 | 469 | **Success Factors:** 470 | 471 | - Co-location pattern simplifies test discovery 472 | - Existing type definitions provide good test data structure 473 | - Clear validation loop enables iterative improvement 474 | - Focus on single module reduces complexity 475 | 476 | --- 477 | 478 | ## Anti-Patterns to Avoid 479 | 480 | - ❌ Don't create tests that simply call the function without assertions 481 | - ❌ Don't mock everything - test real logic where possible 482 | - ❌ Don't skip error case testing 483 | - ❌ Don't use any/unknown types in test files 484 | - ❌ Don't commit failing tests or skip them without good reason 485 | - ❌ Don't test implementation details - focus on behavior 486 | - ❌ Don't create overly complex test setup that's hard to maintain 487 | --------------------------------------------------------------------------------