Midday is an open-source platform that handles the operational overhead of running a freelance or solo business — bank connections, transaction categorisation, receipt ingestion, invoicing, time tracking, and a financial assistant. 14k stars, actively maintained, AGPL-3.0.
Why I starred it
Most freelancer finance tools are either SaaS-only black boxes or CLI hacks duct-taped together. Midday is something rarer: a full-stack, self-hostable application where you can actually read the code that handles your money. What caught my eye wasn't the feature list — it was the architecture. The repo is a proper turborepo monorepo with discrete packages for each concern (packages/banking, packages/inbox, packages/jobs), each with a clean interface. That's the kind of structure that suggests the authors thought about it before writing it.
How it works
The banking abstraction sits in packages/banking/src/index.ts. The Provider class is a thin dispatcher — it takes a provider string at construction time and routes every method call to the appropriate concrete implementation. Four providers are implemented: Plaid (US/Canada), Teller (US), GoCardLess (EU), and EnableBanking (newer EU alternative). The exhaustive switch with a never type check on the default branch is a small but meaningful detail — adding a new provider without handling it is a compile error, not a runtime surprise.
// packages/banking/src/index.ts
constructor(params: ProviderParams) {
switch (params.provider) {
case "gocardless":
this.#provider = new GoCardLessProvider();
break;
case "teller":
this.#provider = new TellerProvider();
break;
case "plaid":
this.#provider = new PlaidProvider();
break;
case "enablebanking":
this.#provider = new EnableBankingProvider();
break;
default: {
const exhaustiveCheck: never = params.provider;
throw new Error(`Unknown banking provider: ${exhaustiveCheck}`);
}
}
}
The Magic Inbox — the receipt-to-transaction matching feature — is where things get interesting. The matching runs as a Trigger.dev background job in packages/jobs/src/tasks/inbox/match-transactions-bidirectional.ts. It runs in two phases: forward matching (find an inbox item for each new transaction) and reverse matching (find transactions for pending inbox items that didn't match in phase 1). Each match gets a composite confidence score broken into amountScore, currencyScore, dateScore, and nameScore. If the aggregate clears the auto-match threshold it commits automatically; otherwise it creates a suggestion for manual review. The job tracks claimed IDs with a local Set to prevent the same inbox item from being claimed by two transactions in the same run.
Transaction enrichment uses Gemini 2.5 Flash Lite via the Vercel AI SDK (generateObject with output: "array"), batching 50 transactions per LLM call and using a Zod schema to enforce structured output. One notable design choice: even when a batch fails, the job marks every transaction as enrichment_completed. The comment explains why — enrichment_completed signals that the process finished, not that it succeeded. It's defensive but correct: without this, a failed enrichment would leave the UI stuck in a loading state indefinitely.
// packages/jobs/src/tasks/transactions/enrich-transaction.ts
const { object } = await generateObject({
model: google("gemini-2.5-flash-lite"),
prompt,
output: "array",
schema: enrichmentSchema,
temperature: 0.1,
});
The API server (apps/api) runs on Hono with @hono/zod-openapi for typed route definitions and tRPC layered on top for the dashboard's internal calls. Both live on the same Hono instance — tRPC mounted at /trpc and the REST routes under /api. Supabase handles auth, database, and realtime. Background jobs go through Trigger.dev. The inbox connector (packages/inbox/src/connector.ts) encrypts OAuth tokens before persisting them with a @midday/encryption package and handles token refresh transparently on getAttachments failure.
Using it
The project requires Bun. Local setup is straightforward once you have a Supabase project and at least one banking provider credential:
git clone https://github.com/midday-ai/midday
bun install
# copy .env-example for apps/dashboard and apps/api
bun run dev
The dashboard lives at apps/dashboard (Next.js), the API at apps/api (Hono + Bun), and the background worker at apps/worker. Docker Compose is included for the test environment. The CLI (packages/cli) is a newer addition — it exposes a local interface to the same tRPC surface the dashboard uses.
Rough edges
The docs site at docs.midday.ai is sparse. Local dev setup is doable but involves wiring together Supabase, multiple .env files, and at minimum a Plaid or Teller sandbox credential before you see anything useful. There's no docker compose up that brings everything online without external dependencies.
The AGPL-3.0 license is worth reading carefully. Commercial deployments need a separate license from the maintainers. For a self-hosted personal setup it's fine; for a product built on top of it, you'll want to talk to them first.
Test coverage is uneven. The inbox package has utils.test.ts but the banking adapters and the bidirectional matching logic don't have corresponding test files in the repo as of this writing. The matching algorithm carries enough edge cases that I'd want tests around the confidence scoring specifically.
The mcp-apps package in the API suggests an embedded MCP server is either live or in progress — the apps/api/src/mcp/tools directory exists but the tooling isn't documented.
Bottom line
Midday is a serious piece of software, not a weekend project. The architecture is clean, the provider abstraction handles a genuinely hard multi-geography bank integration problem, and the bidirectional inbox matching is more thoughtful than I expected. If you're a freelancer who wants full ownership of your financial data — or an engineer who wants to study how a production-grade financial operations product is structured — it's worth cloning.
