# ContractClaim: contracts and invoice chasing for solo freelancers

> ContractClaim is a multi-tenant invoice chaser and contract document generator for independent freelancers, sold once as source. The build that produced it shipped ten organization-scoped routes gated on a helper that admits exactly one role: client.

Source: https://saascode.ai/inside/road-to-contractclaim · Published: 2026-08-09 · Section: builds · Product: ContractClaim (https://saascode.ai/products/contractclaim)

---
ContractClaim is a multi-tenant invoice chaser and contract document generator for independent freelancers, sold once as source. The build that produced it shipped ten organization-scoped routes gated on a helper that admits exactly one role: `client`. The schema's role constraint enumerates four values — `super_admin`, `admin`, `org_admin`, `member`. There is no `client`. Every authenticated user received 403 on invoices, client records, and escalation endpoints; the dashboard loaded with six console errors. The production build compiled clean. The guard was not absent — it compared against a value the schema was designed never to produce.

## Starting point

The product was conceived as two capabilities in one codebase: invoice and client records with due-date monitoring and a tone-graded reminder engine that escalates across three stages as an invoice ages; and a generator for simple service agreements across three jurisdictions, producing a PDF. Three things were ruled out by definition before any code existed — no accounting ledger (it tracks receivables, not the operator's books), no legal representation or filing, no general e-signature platform.

One constraint was written as gating before implementation began: the two flows must stay architecturally and rhetorically separate. The reminder engine never references a contract the product generated; the contract flow never cites an outstanding invoice; each carries its own disclaimer. This was ratified on 2026-06-27 and enforced in code by requiring both service directories to import each other zero times.

The tenancy model runs three personas: the operator running the platform, the workspace owner, and an optional teammate with reduced scope. The person being chased receives email and nothing else — there is no end-customer login anywhere in the product.

## Reading the market

The planning documents asserted that incumbent accounts-receivable tools are priced for finance teams, leaving the solo business unserved. That claim did not survive research: a leading affordable chaser ships a tier named "Sole Proprietor" at $49/mo. The gap as briefed does not exist.

Three narrower claims did survive, and they drove real build decisions. The affordable chasers require an active Xero or QuickBooks Online subscription — one vendor's own documentation says so — which excludes anyone keeping books on a spreadsheet or nothing at all. None of the chasers generate agreements. None can be purchased outright; they are all subscriptions. Because ledger lock-in is the real exclusion, invoice intake could not assume a live accounting integration. Version one takes CSV and XLSX through the import layer; direct accounting API connections were declared and deferred.

The price foil for comparison is the live $69/mo tool. The $259/mo tool is a category landmark, not the comparison point — using only the most expensive incumbent would be a cherry-pick.

## The decisions that shaped it

**Two flows, enforced in the import graph.** The chaser services and the document services import each other zero times, and each file states this at the top. There is exactly one deliberate crossing: a flow-agnostic PDF renderer that both sides call with content they have already produced, and a direct data read from the agreements table by the case-packet handoff, which attaches a reference only when one exists. The user interface maintains the same separation — distinct navigation groups, different disclaimers, no cross-links between the two flows.

**A multi-day delay is a row, not a sleep.** Reminder cadence is three, ten, and twenty-one days past the due date. Each next stage is written as a future-dated row in the scheduled-task table, because a request handler cannot hold a multi-day timer. Sending a stage schedules the next one; a payment, a manual mark-paid, or an email bounce deactivates every pending row for that invoice. The daily sweep that drives them is capped at two hundred invoices per tick, oldest due first, and refuses to run without its shared secret.

**The event log cannot be edited by anyone using the product.** The table carries a SELECT policy and an INSERT policy and no UPDATE or DELETE policy at all — mutation is denied by default rather than by a maintained exception. The one path that does write to an existing row is the email delivery webhook, which patches the delivery and bounce columns through the service role; that is outside row-level security, and the code states it explicitly.

**No language model anywhere near the wording.** Reminder subject and body come from a database template with variable substitution, and the exact text sent is snapshotted into the event log. Document output works the same way. Searching the three relevant service directories for any AI provider reference returns four hits, all of them comments restating the prohibition. The stated reason is capability, not preference: there was no accumulated experience to draw on for generating text in this setting.

**The document flow refuses to produce anything until acknowledged.** Creating a document throws a named error unless the acknowledgement flag is exactly `true`, checked on the server rather than trusted from the client. Jurisdiction is a constrained column on templates, on documents, and on every event row.

**Reconciliation ran before anything was seeded, because the ordering was load-bearing.** The workspace-owner role and the collections flag category did not exist in the inherited schema constraints; the migration drops and re-adds both before creating a single table. The seed follows the same dependency order: plans first, the demo organization's subscription repointed to a new plan, inherited plans deleted only then — because the foreign key would refuse the delete otherwise. The seed file comments explain the sequence for whoever reads it later.

## What fought back

**A guard written for a persona the schema never contained.** The ten routes described in the lead were each gated on `requireClient()`. The fix added `requireOrgMember()` — which admits the full organization plane: `super_admin`, `admin`, `org_admin`, `member` — and repointed all ten routes. Owner-only operations kept the stricter gate they already had. What made the defect hard to find is structural: `requireClient()` is syntactically correct, it calls a real database query, and it returns a real 403 that is indistinguishable from a correct rejection. TypeScript saw a well-typed function call. The linter saw no violation. The build compiler saw a clean output. The guard is a runtime string comparison; runtime string comparisons are invisible to the static analysis chain. The original function still sits in the file, now unused.

**The same class of defect fixed once, missed at the second instance.** A route directory named `[invoiceId]` sat next to a sibling named `[id]` under the same parent. Next.js refused to start the development server; the production build compiled without complaint. The directory was renamed. A second directory created in the same route-setup work carried the identical naming conflict and was not caught. It survived the security pass, the design token application, and the commit that declared the build complete. It was found and fixed in a later session — two files renamed, zero content changed. The symptom that surfaced it was logged in the commit message; the exact mechanism is not reproducible from disk and is not asserted here.

**A union member with no producer.** The route work added `receivables` to the event-category union type. Two components in the operator plane type their colour maps as an exhaustive record over that union, so the compiler required both to be updated — but both belong to shared infrastructure, not to this product. The ownership check failed. The root cause was not the edit but the addition: no event ever emitted `receivables`, and the category did not appear in the selector. Removing it restored both shared files byte-for-byte.

**Three public pages that answered 200 and rendered key names.** They were server-rendered components calling a translation function whose store is populated in the browser. Server rendering produced literal translation keys where the copy should appear — every route returned a clean 200. The fix split each into a server shell for metadata and a client component for content.

**The primary colour shipped wrong, and the visual check confirmed the wrong value.** Planning documents set a navy primary before the design pages existed. The design pages, produced afterwards, used a warm near-black throughout — it appears in twenty-eight of them; navy appears once, on the landing page only. When design tokens were applied, the planning navy went in globally. The visual review then confirmed that navy had been correctly applied and no stray colors remained. The reversal came the following day, with the reasoning written into the decision log and an instruction not to revert it on a future restyle.

**Open at ship.** The certification column in the schema is the gate for a jurisdiction going live — three code paths enforce it — and the seed pre-fills it on all twelve templates, which satisfies the gate without satisfying the underlying review. The operator checklist carries per-jurisdiction certification as an unchecked item. Deployment region was not pinned during this build, logged as non-blocking with the remedy recorded. The login page links to a forgot-password route with no page behind it; the recovery flow itself works at a different path. A settings sub-navigation strip visible in the design is absent from the built page — adding it required a shared layout that was out of scope and was logged rather than fixed. Payment, email, scheduling, and cron credentials were not supplied during this build; those features are inert until configured.

## What shipped

Invoice and client records with due-date monitoring. A three-stage reminder engine whose delays are future-dated rows, driven by a daily sweep capped at two hundred invoices per tick, with pending stages cancelled on payment and the sequence halted on a bounce. An append-only event log recording which stage and which template went out, with delivery confirmation and bounce written back from the email provider. A live days-sales-outstanding view and daily snapshots for the trend. Jurisdiction-labelled document generation with a required acknowledgement before anything renders, PDF output, and a plan-gated case-packet handoff behind explicit consent, with a plan-upgrade fallback for the lower tier. CSV and XLSX intake. Two plan tiers read from the database. Multi-tenant isolation with row-level security on every table, four roles, and a cross-tenant operator plane with its own oversight surfaces.

The build ran for roughly four and a half hours of recorded wall-clock. Token and tool-use cost were not measured for this run and are not stated.

## See it

[See ContractClaim →](https://contractclaim.saascode.ai)

## Related reading

- [Getting-Paid Software for Freelancers: A Buyer's Guide 2026](https://saascode.ai/inside/getting-paid-software-for-freelancers-a-buyer-s-guide-2026.md)
- [ContractClaim vs Paidnice — Which Late-Payment Chaser Works Without an Accounting System](https://saascode.ai/inside/contractclaim-vs-paidnice-which-late-payment-chaser-works.md)
