SME Financial OS - Financial Operating System for Small Businesses

Turborepo monorepo with Next.js 15, Prisma, Postgres. Full dashboard, demo mode, Czech localization.

TurborepoNext.js 15PrismaPostgreSQL

Brief

A Czech small business (tradespeople, e-shops, freelance studios, small restaurants) typically juggles 5+ tools:

  • accounting SaaS (Pohoda, Money S3, FlexiBee)
  • invoicing app (Fakturoid, iDoklad)
  • expense tracker (often Excel)
  • cash-flow planner (more Excel)
  • payroll (an outside accountant + email)

Every tool has its own login, its own export logic, its own format. Monday morning, the owner logs into 5 places to answer "how much cash do I have and what's coming in this month." They have no time for it - so they hand it to an accountant who bills 800 CZK/h to glue it together by hand.

SME Financial OS unifies the read layer of all of it. It doesn't try to replace the accounting SaaS or the invoicer - it consolidates data from existing tools into one dashboard for the owner: current cash, forecast, upcoming invoices, tax liabilities, reserves estimate. Plus a demo mode that lets prospects verify the value without signup.

Shipped in 14 days from kickoff to live demo (pilot customer). AI usage 80 % - Claude Code wrote most of the boilerplate (Prisma schema, API routes, dashboard components); I made the architecture decisions, the validation logic, and the Czech tax edge cases.

Architecture: Turborepo monorepo

apps/
├── dashboard/         ← Next.js 15 (App Router, RSC)
├── api/               ← Hono on Vercel Functions, inbound integrations
└── marketing/         ← Astro static site, sales/landing
packages/
├── db/                ← Prisma schema, migrations
├── ui/                ← shadcn/ui based components, Tailwind
├── integrations/      ← FIO, Komerční, Pohoda, Money S3 adapters
└── domain/            ← business logic (VAT, cash-flow forecast, reports)

The Turborepo pipeline caches builds for db/domain/ui (changes there are rare), so pnpm build on the dashboard takes 18 s instead of 70 s cold.

Multi-tenant scoping in Prisma: every record has an orgId foreign key, queries are wrapped in a withOrg() helper that enforces the filter and throws a runtime error if it's missing. No row-level security in the DB - I considered Postgres RLS, but for a 1–2 dev team a query-helper-driven approach is faster to ship and equally safe.

Demo mode: full sample data

The key differentiator. SMB customers are suspicious: they won't hand email and password to a new SaaS until they've seen it actually work. A demo with real-looking data (3 months, 200+ transactions, 12 invoices, 8 vendors, 4 employees on payroll) solves the "show, don't tell" handicap.

// packages/db/prisma/seed-demo.ts
import { faker, fakerCS } from '@faker-js/faker';
import { prisma } from './client';
 
export async function seedDemoOrg() {
  const org = await prisma.organization.create({
    data: { id: 'demo-org', name: 'Demo Truhlářství s.r.o.', ico: '12345678' },
  });
 
  // 200 transactions across 3 months, realistic descriptions and amounts
  const txs = Array.from({ length: 200 }, () => ({
    orgId: org.id,
    occurredAt: fakerCS.date.recent({ days: 90 }),
    amountCzk: faker.number.float({ min: -45_000, max: 280_000, multipleOf: 0.01 }),
    counterparty: fakerCS.company.name(),
    category: faker.helpers.arrayElement(['materials', 'wages', 'utilities', 'services', 'revenue', 'loan']),
    bankRef: faker.string.alphanumeric(10),
  }));
  await prisma.transaction.createMany({ data: txs });
 
  // 12 invoices, 4 received, 8 issued, 3 overdue
  await prisma.invoice.createMany({ data: generateInvoices(org.id) });
 
  return org;
}

The demo is ephemeral - sessions get a cookie session ID mapped to a demo orgId. After 24h a cleanup job deletes demo rows. No signup, no credit card. CTA at the end of the demo flow: "Want this same dashboard for your own business? Connect your account" → real signup.

Bank statement integration

Three ways the customer gets data in:

Bank / ToolMethodFrequency
FIO Bankatokens for REST APIevery 2h
Komerční BankaOAuth2, Open Banking PSD2daily
Other (CSOB, Moneta...)manual CSV uploadad-hoc
Pohoda exportXML importweekly
Money S3 exportXML importweekly

FIO API is the best - token-based, JSON output, no throttling. Komerční Banka requires the full OAuth2 flow with redirect URI and token refresh every 30 minutes, but the Open Banking standard is predictable. Manual CSV is the fallback for the rest.

// packages/integrations/src/fio.ts
export async function syncFio(orgId: string, token: string, since: Date) {
  const url = `https://www.fio.cz/ib_api/rest/periods/${token}/${formatDate(since)}/${formatDate(new Date())}/transactions.json`;
  const res = await fetch(url);
  if (!res.ok) throw new IntegrationError('fio', res.status);
 
  const data = await res.json();
  const transactions = data.accountStatement.transactionList.transaction.map(mapFioToTx);
 
  // upsert by bankRef (idempotent - re-sync doesn't create duplicates)
  await prisma.$transaction(
    transactions.map((tx: Tx) =>
      prisma.transaction.upsert({
        where: { orgId_bankRef: { orgId, bankRef: tx.bankRef } },
        create: { orgId, ...tx },
        update: { ...tx, updatedAt: new Date() },
      }),
    ),
  );
}

upsert on the composite key (orgId, bankRef) is critical - using plain createMany, a re-sync after a crash would create duplicates and the cash-flow forecast would lie.

Cash-flow forecast

The headline value-add feature. The dashboard shows a 90-day forecast chart built from two inputs:

  1. Recurring transactions (utilities, wages, leasing) - detected via z-test on monthly amounts ± 5 %
  2. Open invoices - projected onto their due date, weighted by a paymentLikelihood factor based on customer history
// packages/domain/src/forecast.ts
export function forecast90d(orgId: string): CashFlowForecast {
  const recurring = detectRecurring(orgId); // monthly patterns
  const invoices = getOpenInvoices(orgId);
  const today = startOfDay(new Date());
 
  const days: ForecastDay[] = [];
  let runningBalance = getCurrentBalance(orgId);
 
  for (let i = 0; i < 90; i++) {
    const date = addDays(today, i);
    let dayDelta = 0;
 
    for (const r of recurring) {
      if (matchesDayPattern(r, date)) dayDelta += r.amountCzk;
    }
    for (const inv of invoices) {
      if (isSameDay(inv.dueDate, date)) {
        dayDelta += inv.totalCzk * inv.paymentLikelihood;
      }
    }
 
    runningBalance += dayDelta;
    days.push({ date, delta: dayDelta, balance: runningBalance });
  }
 
  return { days, lowPoint: minBy(days, 'balance'), netChange90d: last(days).balance - getCurrentBalance(orgId) };
}

It's not ML - it's a deterministic projection. For small businesses it's enough, because their cash flow has rhythm (wages on the 15th and 25th, VAT on the 25th, utilities on the 10th) and per-counterparty paymentLikelihood covers 90 % of cases.

Czech localization

This is why Czech-built SaaS beats Stripe Atlas variants: it's not just translations, it's VAT semantics, formats, edge cases.

  • VAT 21 % / 12 % / 0 % rates (the 2024 change moved 15 → 12). Cash-flow has to handle both VAT-payer (quarterly/monthly returns, mandatory remittance on the 25th) and non-payer (compute / mark the rate but no remittance).
  • Invoice format: variable symbol max 10 digits, SPD QR support (CZ standard).
  • Invoice numbering: YYYYMMDD-NN (20260203-01) or legacy YYYY/NNN (2026/001). Helper nextInvoiceNumber(orgId, format) lives in domain/.
  • Mobile-first. The SMB owner checks finances on mobile, sitting in a van on a job. Dashboard at 360px width is the primary breakpoint, desktop is secondary. Stats cards stack vertically, charts simplified.

Performance budget

MetricTargetMeasured
Dashboard p95 (12 months of data)< 1 s820 ms
RSC streaming first byte< 200 ms145 ms
Server action mutation (invoice update)< 500 ms240 ms
Forecast compute (90 days × 12 months tx)< 300 ms180 ms

RSC + Server Components mean the first 5 stats cards (cash, due invoices, VAT liability, wages) render server-side and stream, while the chart component (client-side Recharts) hydrates only for interaction. No useEffect-fetch dance.

Lessons

  • SMB doesn't trust polished UI. The first mockup was too "Linear/Vercel-pretty" - the pilot customer told me "looks expensive, how much will it be per month?" I dialed the aesthetic back to utilitarian, blue/grey/green palette, larger fonts, less whitespace. A tradesman convinced me when he said "Pohoda is ugly but reliable, you look like a startup that runs out of money in 6 months."
  • The VAT non-payer flag is load-bearing. Half of pilot customers were not VAT payers, so the entire UI layer for "VAT liabilities" has to hide. A poorly designed boolean flag breaks 5 parts of the app at once.
  • Demo > freemium. A freemium tier (5 transactions free) was the first idea. A demo with fully populated data + "no credit card needed" converts 3× better, because it removes any signup friction. Customers who want a real account click after they've seen value.
  • Mobile-first changes API design. Server actions instead of client fetch + useState dancing are the key to mobile p95 - less JS, smaller bundles, fewer waterfalls. App Router + RSC was clearly the right choice.
  • 80 % AI usage doesn't mean 80 % automation. Claude wrote most of the code, but architecture decisions (mono vs micro, RLS vs query helper, demo strategy) were mine. AI gives you speed, not direction.