Skip to main content
Glama
CourtimusPrime

Invoice-MCP

README.md
# Rayshio

Rayshio is an invoice aggregator software for businesses that gives your AI agents MCP access to your expenses.

## How it Works

Rayshio aggregates invoices directly from a company's billing email address:

1. User signs in with whatever provider their work email lives on
2. Pulls a sample of invoices from the email to find the recipient email. _Sometimes, billing emails are aliases that forward emails to the user's email address._
3. Poll through all emails to the billed email address(es) to find all invoices
4. Invoice PDF is stored in MongoDB
5. Invoice data is stored in PostgreSQL

> _See `SPEC.md` for the full design._

## Setup

1. `cp .env.example .env` and fill in values (`scripts/` has key generators noted inline)
2. `docker compose up -d`
3. `pnpm install`
4. `pnpm migrate up` — applies `migrations/` to Postgres
5. `pnpm cli seed-org --name "Your Org"`
6. `pnpm cli auth` — browser OAuth consent, stores encrypted refresh token
7. `pnpm cli check-connections` — sanity check all three databases

## Running

| Command                            | What                                                                     |
| ---------------------------------- | ------------------------------------------------------------------------ |
| `pnpm worker`                      | ingestion worker (BullMQ; registers the monthly sync cron)               |
| `pnpm mcp`                         | MCP server + dashboard API on `:3000`; also serves `web/dist` when built |
| `pnpm dev:web`                     | Vite dev server on `:5173`, proxying `/api` → `:3000`                    |
| `pnpm cli discover`                | whole-mailbox billing-sender discovery → auto-backfill                   |
| `pnpm cli backfill --service <id>` | full history for one sender                                              |
| `pnpm cli sync`                    | incremental sync now (otherwise monthly via `SYNC_CRON`)                 |
| `pnpm cli categorize`              | backfill usage categories onto invoice line items                        |

### `just`

A root `justfile` wraps the common loops so nothing depends on remembering
which pnpm script maps to which process. Recipe names cannot contain a colon —
`just` rejects it at parse time — hence `dev-web` rather than `dev:web`.

| Command          | What                                                                |
| ---------------- | ------------------------------------------------------------------- |
| `just`           | list recipes                                                        |
| `just dev-mcp`   | `pnpm mcp` — API + MCP on `:3000`                                    |
| `just dev-web`   | `pnpm dev:web` — Vite on `:5173`                                     |
| `just dev-all`   | API, Vite, and worker in one terminal; uses `:3100` / `:5273` when `:3000` / `:5173` are occupied |
| `just dev`       | alias for `dev-all`                                                  |
| `just build`     | `pnpm build` (backend `dist/` + `web/dist`)                          |
| `just test`      | `pnpm test` — full vitest run                                        |
| `just clean`     | `pnpm lint:fix` — Biome format + lint with autofix                   |
| `just kill 3000` | kill whatever holds a port, when a dev server outlives its terminal  |

`dev-all` runs each server in its own process group so Ctrl-C reaches the
`tsx`/`vite` child, not just the `pnpm` wrapper it was launched through.

## Dashboard

A React SPA at `/`, served by the same process as the MCP server. Pages:
Dashboard (spend, budget, top vendors, recent invoices), Breakdown (usage
categories rolled up across vendors), Invoices (paginated, searchable),
Reports (the same views over fiscal quarters and years), Calendar (received +
projected), and MCP (connection recipes).

Fiscal periods derive from `client.org.fiscal_year_start_month` (default
January, changeable on the Reports page). Fiscal years are named for the year
they **end** in — an April start makes Apr 2025 – Mar 2026 into "FY2026" — and
every label carries its date range, since that convention is not universal.

Sign in with Google (Better Auth). Signing in creates a user but grants no
access: membership in an org is a deliberate act, so the first person through
the door does not inherit a tenant. Grant it with

```
pnpm cli grant-membership --org 1 --email you@example.com --role owner
```

Sign-up is allowlisted by `ALLOWED_SIGNUP_EMAILS`; an address outside it can
still join by holding a pending invitation (`pnpm cli invite`).

MCP keys live in `client.api_key` — `pnpm cli create-api-key --org 1` mints one
and prints it once. No key is ever sent to the browser.

Two behaviours worth knowing:

- **Currency conversion is query-time only.** Invoices are stored in the currency
  the vendor billed; the dashboard's currency selector is a _display_ target, and
  every invoice is converted to it at the ECB rate on that invoice's own date, so
  a past month's total does not move when today's rate does. Converted values are
  never written back (`SPEC.md:190-196`), and converted figures are labeled with
  the rate date. The ECB publishes ~30 currencies — hard-pegged currencies outside
  that set (AED) use an explicit peg table in `src/fx/rates.ts`. The MCP tools are
  unaffected and still sum per-currency with no conversion, which is the spec's
  documented default.
- **Categories live on line items.** `billing.invoice_line_items.category` is
  assigned by an LLM from a fixed five-value set, so a single vendor invoice can
  split across categories. Classification never fails an invoice — anything
  unclassified reads as `Other` until `pnpm cli categorize` retries it.

Local development runs an API, Vite server, and worker (`just dev-all` starts
all three). It uses `http://localhost:5173` by default, or
`http://localhost:5273` with an API on `:3100` if a tunnel occupies the default
ports. In production only `pnpm start:mcp` is needed, provided `pnpm build` has
produced `web/dist`.

## MCP tools

`list_services`, `list_invoices`, `get_invoice`, `get_invoice_pdf`,
`spend_summary` — all scoped to the org the presented API key belongs to; money
in minor units, per-currency aggregation, no FX conversion.

Claude Desktop's connector UI expects OAuth, which this MVP doesn't
implement — bridge with [mcp-remote](https://www.npmjs.com/package/mcp-remote)
in `claude_desktop_config.json` instead:

```json
{
  "mcpServers": {
    "invoice-mcp": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://<host>/mcp",
        "--header",
        "Authorization: Bearer <MCP_API_KEY>"
      ]
    }
  }
}
```

## Development

- `pnpm test` / `pnpm typecheck` / `pnpm lint` (or `just test` / `just clean`)
- `pnpm codegen` — regenerate `src/db/types.ts` after schema changes
- `tsx scripts/smoke-extract.ts [pdf]` — live LLM extraction smoke test
- `tsx scripts/make-fixture-pdf.ts` — regenerate the multi-page fixture PDF
- `tsx scripts/probe-mailbox.ts [query]` — read-only audit of discovery coverage:
  replays sender attribution over the live mailbox and lists which vendors would
  be found, which arrive via a billing alias, and which are already ingested

### Billing aliases and shared inboxes

Vendor invoices frequently arrive through a shared billing alias or Google Group
(`billing@`, `techteam@`, …) rather than direct to the connected mailbox. The
group re-sends the mail with **itself** as the RFC `From:` address, leaving the
vendor only in the display name (`"'OpenRouter, Inc' via Tech Team"`). Keying on
`From` alone therefore collapses every vendor behind an alias into one
pseudo-sender that no classifier will accept as a vendor.

`resolveSender` (`src/gmail/messages.ts`) prefers `X-Original-Sender` — which
Google sets on exactly these rewrites — and records the alias as
`ParsedMessage.deliveredVia`. Discovery additionally refuses to treat the org's
own addresses as vendors, so invoices you forward to yourself or to a
bookkeeping tool don't get ingested a second time.

## Deploy (Railway)

One repo, two services:

- **worker** — start `pnpm start:worker`
- **mcp** — start `pnpm start:mcp` (health check `/healthz`); serves the MCP
  endpoint, the dashboard API, and the built SPA from one port

Both services are connected to this repository on `main`, so **a push to `main`
deploys them**. `railway up` still works and deploys the working tree instead,
which is the escape hatch for testing a change that is not committed — but it
is no longer how a release happens.

Both build with `pnpm install && pnpm build` (which also builds `web/dist`).
Migrations run automatically at boot for **both** roles — the web service can
otherwise start before the worker has applied one and serve sign-ins against a
missing table — so no pre-deploy command is needed. A simultaneous deploy is
safe: node-pg-migrate locks with `pg_try_advisory_lock`, which does not wait, so
the loser is retried by `src/main.ts` rather than crashing.

Set env vars per `.env.example` (Railway injects database URLs for linked
Postgres/Mongo/Redis services). Both services need `BETTER_AUTH_SECRET`,
`AUTH_GOOGLE_CLIENT_ID`, `AUTH_GOOGLE_CLIENT_SECRET`, `PUBLIC_APP_URL` and
`ALLOWED_SIGNUP_EMAILS`; the mcp service additionally needs `PUBLIC_MCP_URL`
set to its public `https://<host>/mcp`. Build the SPA with
`VITE_PUBLIC_ORIGIN=https://<host>` so canonical, OG and sitemap URLs are
absolute and correct.

Sign-in uses a **separate** Google OAuth client from Gmail ingestion. The
ingestion client carries `gmail.readonly`, a restricted scope, so sharing it
would show a mailbox-access consent screen for a plain login. Register
`https://<host>/api/auth/callback/google` on the sign-in client.