[18]
TIME TRACKER & INVOICING FOR LAW FIRMS
▒ TIER C · YEAR 2025 · STATUS: LIVE · LANGUAGES: TYPESCRIPT
B2B SaaS closing the time → rate → invoice → report loop
[FIG. 1] MISSION
I close the full business loop time → rate → invoice → report in one system instead of solving a slice of it. The law firm I built this for billed lawyers' time in spreadsheets and retyped the results into an invoicing system by hand — hours of work and errors built into the process. In my B2B SaaS lawyers log time per client or company, and the app multiplies it through a rate matrix: every staff × client pair has its own hourly rate, because a single hourly price does not describe a firm like this. From that come invoices synced both ways with Fakturownia, the local invoicing system, plus DOCX reports in several cuts — lawyer, master and per client — attached to the invoice. I gathered the requirements from a non-technical client and translated them into the data model myself, roles included: three roles (admin / staff / client) are isolated by Supabase RLS, so a client sees only their own billing, staff their own entries, and the admin everything. Vite + React + shadcn on the front, Supabase (auth + Postgres) on the back, deployed on Vercel.
[FIG. 2] ARCHITECTURE
hover a block to see its description
[FIG. 3] CHALLENGES
[+][CH-01]
The heart of the firm's billing is the rate matrix: the same lawyer has a different rate for every client, and clients split into individuals and companies — every staff × client and staff × company pair holds its own hourly rate. The hard part wasn't the multiplication but guaranteeing that at invoicing time every time entry picks the right rate from the right dimension — a mistake here isn't a bug, it's a wrong amount on a real law firm's invoice. I sealed it into one flow: time entries × the rate matrix aggregate into per-client invoice line items, with no manual intermediate step.
[+][CH-02]
Fakturownia has its own model of the world — and dirty data. The client sync must decide on its own who is a company and who is a private person (a three-step heuristic: the company field from the API → PESEL or a missing NIP with a first/last name → name patterns), and tax identifiers arrive in every format: NIPs with spaces and dashes, an 11-digit PESEL, foreign VAT like DE123456789. Naive digit-only cleaning lost the country code, so normalization has a separate branch for the two-letters-plus-5–12-digits pattern. Issuing the invoice itself proved a series of ambushes too: CORS in edge functions needed the Supabase client headers, occasional Fakturownia timeouts got a retry loop, and the amount is computed via the effective rate — per-client beats global — with line items grouped per lawyer and a database unique constraint blocking double-invoicing the same month.
[+][CH-03]
Three roles with conflicting interests in one database are arbitrated by RLS — and RLS has its traps. Policies checking the role read the profiles table, but the policy on profiles also reads profiles: recursion. I solved it with a SECURITY DEFINER get_current_user_role() function with a pinned search_path (without it, that's an attack vector). The best lesson came during permission hardening though: revoking EXECUTE on that function from anon and public was right — but a moment later I also revoked it from authenticated, breaking every RLS policy for logged-in users at once. The GRANT came back four minutes later, and the whole footgun is documented in the history rather than swept away — because a security mistake you don't hide is worth more than feigned infallibility.
[+][CH-04]
The project's most interesting bug was invisible: the list of companies to invoice for the current month was empty even though time entries existed. The cause turned out to be PostgREST's default 1000-row limit — the invoice wizard loaded all entries sorted ascending by date, so at ~1230 records it was precisely the newest ones getting cut off. I wrote the diagnosis down in a plan before the fix existed: cause, solution, file scope and a preventive rule. The fix changes the fetching strategy — entries load only for the selected period (a date range in the query) with a defensive limit — and the rule 'never fetch transactional tables wholesale' stayed in the project for good. Implicit platform limits are exactly the kind of knowledge tutorials don't teach.
[FIG. 4] AI LAYER
No model runs at runtime — the company/person client classification is a rule-based heuristic, not an LLM. I built the project AI-native on the Lovable platform, with point interventions via Codex and manual edge-function fixes — and the best patterns of that work show in the artifacts: the 1000-row-limit diagnosis was written down in a plan before the first line of the fix, together with a rule for the future: 'never fetch transactional tables wholesale without a date range'.
[FIG. 2A] THE RATE MATRIX
| ACME SP. Z O.O. | BETA S.A. | GAMMA SP.K. | |
|---|---|---|---|
| MEC. K. | |||
| APL. S. | |||
| PRAW. T. |
HOVER A CELL — THE AMOUNT FLOWS INTO THE INVOICE
[FIG. 5] GALLERY



