diff --git a/README.md b/README.md index cbb5552..e36c0b4 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,26 @@ # xpenser -Open-source personal income and expense tracking for people who want a -self-hosted finance app they can inspect, extend, and connect to their own -workflows. +xpenser is an open-source, self-hostable personal finance tracker for people +who want to replace spreadsheet-based income and expense tracking with +dashboards, categories, vendors, reports, and API/MCP access. -![xpenser landing page preview](./docs/assets/xpenser-landing-preview.png) +![xpenser dashboard month view with income, expenses, net total, and category detail](./docs/assets/xpenser-dashboard-month.png) + +It started as a practical replacement for a real Google Spreadsheet accounting +workflow. The project is early and still evolving, but it is useful enough to +run, inspect, extend, self-host, or use as a working Cleverbrush Framework +reference app. xpenser is also a real-world reference app for -[Cleverbrush Framework](https://docs.cleverbrush.com). It shows how a +[Cleverbrush Framework](https://docs.cleverbrush.com), showing how a schema-first TypeScript stack can drive API contracts, validation, OpenAPI, typed clients, React forms, auth-aware endpoints, observability, Telegram workflows, and MCP access from one cohesive application. ## Why xpenser +- Move spreadsheet-style tracking into a structured app with dashboards, + categories, vendors, reports, and searchable transaction history. - Track income, expenses, refunds, and returns with categories, notes, dates, vendors, and currencies. - Review daily, weekly, monthly, quarterly, and yearly summaries with category @@ -27,6 +34,27 @@ workflows, and MCP access from one cohesive application. - Connect external tools through API keys, a typed Node client, an MCP server, and a Telegram bot. +## Screenshots + + + + + + + + + + + + +
+ xpenser dashboard month view + + xpenser transactions table + + xpenser preferences with email reports, API keys, and MCP settings +
Dashboard month viewTransaction historyEmail reports, API keys, and MCP
+ ## Built With Cleverbrush xpenser is intentionally small enough to inspect while still exercising @@ -208,6 +236,25 @@ For public deployments, put your reverse proxy in front of the web app and set `APP_URL` to the public origin. The API service stays private on the Docker network and the Next app exposes it under `/external-api`. +For a smaller public deployment, use `docker-compose.prod.yml` as the starting +point and provide production secrets for `NEXTAUTH_SECRET`, `AUTH_SECRET`, +`JWT_SECRET`, `WEB_API_SERVICE_SECRET`, and `TELEGRAM_BOT_SERVICE_SECRET`. + +## Optional Integrations + +The default `.env.example` keeps external integrations off unless you configure +their provider credentials: + +- OpenAI email insights: set `OPENAI_API_KEY`, `OPENAI_REPORT_MODEL`, + `RESEND_API_KEY`, `EMAIL_FROM`, `EMAIL_REPORTS_ENABLED=1`, and + `EMAIL_REPORTS_SCHEDULER_ENABLED=1`. +- Telegram bot workflows: set `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_USERNAME`, + and `TELEGRAM_BOT_SERVICE_SECRET`. +- Vendor enrichment: set `BRANDFETCH_API_KEY` or `BRANDFETCH_CLIENT_ID`, then + enable `VENDOR_ENRICHMENT_ENABLED=1`. +- Google sign-in: configure direct Google OAuth as described above, or leave + it disabled and use email/password accounts. + ## External API Create an API key from Settings -> Preferences -> API keys. The API key can be @@ -254,7 +301,8 @@ xpenser exposes an MCP Streamable HTTP endpoint for AI agents at `/external-api/mcp`. Use the same API key from Settings -> Preferences -> API keys as a bearer token. MCP tools can read and manage the API-key owner's vendors, categories, and transactions, so treat MCP access as full account data -access: +access. Use a dedicated API key for MCP clients and revoke it when access is no +longer needed: ```json { @@ -309,9 +357,14 @@ product improvements. ## Project Status -xpenser is early, practical, and evolving. The goal is to remain useful as a -personal finance app while staying clear enough for developers to learn how a -Cleverbrush full-stack project fits together. +xpenser is early, practical, and evolving. It has no meaningful user traction +yet; feedback on product fit, README clarity, self-hosting, and MCP workflows is +welcome. The goal is to remain useful as a personal finance app while staying +clear enough for developers to learn how a Cleverbrush full-stack project fits +together. + +xpenser does not currently ship bank sync, budget planning, net-worth tracking, +native mobile apps, or mature import pipelines. ## License diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index bc5b7e7..90f58b6 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -6,7 +6,7 @@ import { ThemeProvider } from '@/components/theme-provider'; const publicUrl = process.env.APP_URL ?? 'https://xpenser.cleverbrush.com'; const description = - 'Open-source personal finance tracking for self-hosted workflows, built with Cleverbrush Framework.'; + 'Open-source, self-hostable personal finance tracking for replacing spreadsheets with dashboards, reports, API keys, and MCP access.'; export const metadata: Metadata = { metadataBase: new URL(publicUrl), @@ -20,7 +20,7 @@ export const metadata: Metadata = { type: 'website', url: '/', siteName: 'xpenser', - title: 'xpenser', + title: 'xpenser - open-source personal finance tracker', description, images: [ { @@ -33,7 +33,7 @@ export const metadata: Metadata = { }, twitter: { card: 'summary_large_image', - title: 'xpenser', + title: 'xpenser - open-source personal finance tracker', description, images: ['/og-image.png'] } diff --git a/apps/web/app/manifest.ts b/apps/web/app/manifest.ts index 376edfc..2c4b8b3 100644 --- a/apps/web/app/manifest.ts +++ b/apps/web/app/manifest.ts @@ -5,7 +5,7 @@ export default function manifest(): MetadataRoute.Manifest { name: 'xpenser', short_name: 'xpenser', description: - 'Open-source personal finance tracking for self-hosted workflows.', + 'Open-source, self-hostable personal finance tracking for spreadsheet replacement workflows.', start_url: '/dashboard', scope: '/', display: 'standalone', diff --git a/apps/web/components/landing-page.test.tsx b/apps/web/components/landing-page.test.tsx index d415411..04617bd 100644 --- a/apps/web/components/landing-page.test.tsx +++ b/apps/web/components/landing-page.test.tsx @@ -15,16 +15,28 @@ describe('LandingPage', () => { screen.getByRole('heading', { level: 1, name: 'xpenser' }) ).toBeTruthy(); expect( - screen.getByText(/Track income, expenses, refunds/i) + screen.getByText(/Replace spreadsheet-based income and expense/i) + ).toBeTruthy(); + expect(screen.getByText(/early and evolving/i)).toBeTruthy(); + expect( + screen.getByAltText(/xpenser dashboard month view/i) ).toBeTruthy(); expect( screen.getByText(/Learn Cleverbrush from a working app/i) ).toBeTruthy(); expect(screen.getAllByText(/Telegram bot/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/MCP server/i).length).toBeGreaterThan(0); - expect(screen.getAllByText(/self-hosted/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/self-host/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/open-source/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/MIT licensed/i).length).toBeGreaterThan(0); + expect( + screen.getAllByText(/Spreadsheet replacement/i).length + ).toBeGreaterThan(0); + expect( + screen.getByText( + /read or manage vendors, categories, and transactions/i + ) + ).toBeTruthy(); expect( screen.getAllByText(/multiple currencies/i).length ).toBeGreaterThan(0); diff --git a/apps/web/components/landing-page.tsx b/apps/web/components/landing-page.tsx index 77b4805..850691f 100644 --- a/apps/web/components/landing-page.tsx +++ b/apps/web/components/landing-page.tsx @@ -106,28 +106,15 @@ const frameworkFeatures: readonly Feature[] = [ } ]; -const capabilityRows = [ - ['Dashboard', 'Cash flow, net total, category split, trend marks'], - [ - 'Transactions', - 'Filtering, editing, nested categories, multi-currency input' - ], - ['Conversion', 'Automatic default-currency conversion via Frankfurter'], - ['Reports', 'Period comparison with charted historical context'], - [ - 'Email summaries', - 'Configurable weekly and monthly spending and income insights' - ], - ['External API', 'Typed client, API keys, and MCP server'] -] as const; - const heroProofs = [ - 'Self-hosted finance app', + 'Spreadsheet replacement', + 'Self-hostable finance app', 'Multi-currency tracking', - 'MCP agent access', + 'MCP/API access', 'Telegram bot integration', 'Cleverbrush reference code', - 'MIT licensed' + 'MIT licensed', + 'Early project' ] as const; const resourceLinks = [ @@ -178,119 +165,19 @@ function FeatureCard({ description, icon: IconComponent, title }: Feature) { function ProductPreview() { return ( -
-
-
-
- -
-
- Monthly overview -
-
- Income, expenses, and net trend -
-
-
-
- Live data -
-
-
-
-
- Income -
-
$4,280
-
-
-
-
-
-
- Expenses -
-
$2,420
-
-
-
-
-
-
- Net -
-
$1,860
-
-
-
-
-
-
-
-
-
- Category split -
-
- 30 days -
-
-
- {[72, 54, 86, 48].map(width => ( -
-
-
-
- ))} -
-
-
-
- Spending trend -
-
- {[30, 62, 42, 76, 50, 88, 58].map(height => ( -
- ))} -
-
-
-
- {capabilityRows.map(([label, detail]) => ( -
- {label} - - {detail} - -
- ))} -
+
+
+ xpenser dashboard month view showing income, expenses, net total, and category detail
-
+ ); } @@ -396,19 +283,21 @@ export function LandingPage() {
- Open-source personal finance + Early open-source personal finance

xpenser

- Track income, expenses, refunds, currencies, - vendors, and reports in a self-hosted app that keeps - the code open for inspection. Under the product - surface, xpenser shows how Cleverbrush Framework - connects typed contracts, schema-driven forms, - observable services, Telegram, API, and MCP - workflows. + Replace spreadsheet-based income and expense + tracking with dashboards, categories, vendors, + reports, and MCP/API access in a self-hostable app + with source you can inspect. +

+

+ xpenser is early and evolving; feedback on product + fit, self-hosting, README clarity, and MCP workflows + is welcome.

@@ -506,7 +395,7 @@ export function LandingPage() { { icon: BotIcon, title: 'MCP server', - text: 'Expose dashboard, category, and transaction data through tool calls.' + text: 'Let approved agents read or manage vendors, categories, and transactions through tool calls.' }, { icon: SendIcon, @@ -582,7 +471,7 @@ export function LandingPage() { xpenser

- Open-source personal finance built with + Self-hostable personal finance built with Cleverbrush Framework.

diff --git a/apps/web/public/og-image.png b/apps/web/public/og-image.png index 7d79b67..1c61732 100644 Binary files a/apps/web/public/og-image.png and b/apps/web/public/og-image.png differ diff --git a/apps/web/public/screenshots/dashboard-month.png b/apps/web/public/screenshots/dashboard-month.png new file mode 100644 index 0000000..6963686 Binary files /dev/null and b/apps/web/public/screenshots/dashboard-month.png differ diff --git a/docs/assets/xpenser-dashboard-month.png b/docs/assets/xpenser-dashboard-month.png new file mode 100644 index 0000000..6963686 Binary files /dev/null and b/docs/assets/xpenser-dashboard-month.png differ diff --git a/docs/assets/xpenser-landing-preview.png b/docs/assets/xpenser-landing-preview.png index 7edbe40..6963686 100644 Binary files a/docs/assets/xpenser-landing-preview.png and b/docs/assets/xpenser-landing-preview.png differ diff --git a/docs/assets/xpenser-preferences-mcp-email.png b/docs/assets/xpenser-preferences-mcp-email.png new file mode 100644 index 0000000..031e1dd Binary files /dev/null and b/docs/assets/xpenser-preferences-mcp-email.png differ diff --git a/docs/assets/xpenser-transactions.png b/docs/assets/xpenser-transactions.png new file mode 100644 index 0000000..b6216d9 Binary files /dev/null and b/docs/assets/xpenser-transactions.png differ diff --git a/tests/e2e/workflows.spec.ts b/tests/e2e/workflows.spec.ts index 40b5ef7..2210d08 100644 --- a/tests/e2e/workflows.spec.ts +++ b/tests/e2e/workflows.spec.ts @@ -525,6 +525,7 @@ test.describe('authenticated app workflows', () => { }) => { const expenseCategory = uniqueName('E2E trend expense'); const expenseNote = uniqueName('E2E trend note'); + const occurredAt = dateTimeLocalValue(); await createCategory(page, expenseCategory, 'expense'); await createTransaction( @@ -532,10 +533,11 @@ test.describe('authenticated app workflows', () => { expenseCategory, 'expense', '12.34', - expenseNote + expenseNote, + occurredAt ); - await page.goto('/stats'); + await page.goto(`/stats?period=day&date=${occurredAt.slice(0, 10)}`); await page .getByRole('link', { name: new RegExp(expenseCategory) }) .click(); @@ -577,15 +579,17 @@ test.describe('authenticated app workflows', () => { await expect( page.getByRole('button', { name: 'Apply' }) ).toHaveCount(0); - const currentMonthFrom = `${new Date().toISOString().slice(0, 7)}-01`; + const transactionDate = occurredAt.slice(0, 10); + const currentMonthFrom = `${transactionDate.slice(0, 7)}-01`; await fromInput.fill(currentMonthFrom); + await toInput.fill(transactionDate); await expect(page).toHaveURL(url => { return ( url.pathname.startsWith('/stats/categories/') && url.searchParams.get('groupBy') === 'week' && url.searchParams.get('range') === 'custom' && url.searchParams.get('from') === currentMonthFrom && - Boolean(url.searchParams.get('to')) + url.searchParams.get('to') === transactionDate ); });