Adding an AI Document Workflow to an Existing SaaS Product: Specify It, Then Test the Checks
By Alvi Lika, Founder, Soatech
· 12 min read
How to specify a document extraction or drafting feature inside a SaaS product that already has users, and how to test that its checks catch the defects they target.
A customer uploads a PDF, a model reads it, and a structured record appears in your product. The demo takes an afternoon. The version you can put in front of paying accounts takes longer, mostly because of decisions the demo never had to make: what counts as a correct extraction, who looks at the uncertain ones, what happens when the model refuses or its response is cut off, and how you know the checks you wrote catch anything at all.
This guide is for SaaS teams adding a document feature — invoice or contract extraction, a drafted proposal, a summary that becomes a record — to a product that already has users, permissions and a release process. It ends with two things: a one-page specification and a small set of test fixtures for the checks.
The examples come from Wintura, the studio's own product, which turns a client brief into a proposal and a statement of work. Where a statement depends on Wintura's code, it says so; the case study lists the files each stage lives in.
Write the workflow as stages before choosing a model
Most document features have the same six stages. Writing them down first turns vague requirements ("it should be accurate") into decisions someone can make.
| Stage | Decide before building | In Wintura |
|---|---|---|
| Source | Formats, size limits, who may upload, how long files are kept | A brief arrives as a forwarded email, a pasted Slack thread or a PDF |
| Extraction | The output schema: fields, types, which are required, what "not found" looks like | JSON constrained to a fixed schema with structured outputs, then checked field by field |
| Validation | Rules beyond the schema: amounts that add up, dates in order, one currency | Quality checks whose verdict is stored on the proposal |
| Review | What a person sees, what blocks approval, who may approve | Commercial conflicts stored as advisories with a blocking flag |
| Approved output | When the result becomes a real record or a sent document, and what is fixed afterwards | On signature the PDF is sealed and the proposal rejects edits |
| Boundary | Which account owns every row the feature creates | Every customer-owned row carries an account id, checked in the query |
Several of these are product decisions rather than engineering ones. If the review stage reads "someone will look at it", the specification is not finished yet.
Let the schema do the cheap work
Major model APIs can now constrain a response to a JSON schema. Anthropic's Messages API, for example, takes the schema in output_config.format (structured outputs documentation). Wintura's analysis step uses structured outputs to constrain normal completed responses to the requested schema. Refusals and truncated responses still require explicit handling, and schema-valid values still need business validation.
The same documentation names the two exceptions: a response with stop_reason: "refusal" or stop_reason: "max_tokens" may not match the schema. Check the stop reason before parsing, and treat both as failures with their own handling rather than as JSON to repair. In Wintura's analysis step, a response cut off at the token limit is detected by name, logged, and replaced with a generic plan marked for manual review; any other response that does not parse, a refusal included, is logged with its stop reason and takes the same fallback. Refusals are not handled as a separate case there.
Three details matter more than the mechanism.
First, design the schema for honesty. Allow null, or an explicit "not found", for every field the source document might not contain. A schema that requires a due date pushes the model to supply one, whether or not the invoice has it.
Second, size the token limit for the whole response. If the model can think before answering, the thinking counts against the same limit, and a response cut off mid-object will not parse.
Third, a schema-valid response can still be wrong. A total of 1,200 is a valid number whether the invoice says 1,200 or 12,000. Validation and review exist for that gap.
Separate three kinds of check
Checks on extracted data fall into three groups, and they need different treatment.
- Shape. Required fields are present and have the right types. The schema handles most of this.
- Consistency. The document agrees with itself and with your data: line items add up to the total, the due date falls after the issue date, there is one currency, the supplier exists in this customer's account.
- Grounding. Every value can be traced to a place in the source. This is the hardest to automate, which is why the review screen should show each field next to the passage it came from.
Record each check's result as passed, failed or skipped, and store it with the record rather than only in logs. Keep "checked, nothing found" distinct from "never checked". In Wintura, an empty list of review advisories means the check ran and found nothing; a missing list means it did not run. Treating the two the same is how an outage in the checking step turns into a run of approvals nobody questioned.
Decide what blocks and what only warns
Give each rule one of two consequences:
- Blocking. The record cannot be approved until a person resolves the issue — for example, line items that do not add up to the total.
- Warning. The reviewer sees it, but approval is not held — for example, a supplier the account has not used before.
Wintura added blocking advisories after a specific failure, recorded in its code: when a client's deadline could not be met, or a published price disagreed with the stated budget, the generator had nowhere to put the conflict, so it wrote a fluent paragraph explaining it away. A model asked for a finished document will smooth over a contradiction unless the system gives the contradiction somewhere to go.
Decide who may approve, too. Approval is a write to the record, so it should use the same permission rules as editing that record, not a separate "reviewer" flag that bypasses them.
Test the checks with planted defects
A check that has never failed has not been tested. The fixture set that tests it is simple: one clean document, and for each rule a copy with a planted defect. Every rule must report its defect, and the clean document must pass every rule.
Here is a reduced version for invoice extraction. Amounts are integer cents, so the comparison is exact.
// check-invoice.ts
export type Extracted = {
totalCents: number | null;
currency: string | null;
issueDate: string | null; // YYYY-MM-DD
dueDate: string | null; // YYYY-MM-DD
lines: { amountCents: number }[];
};
export type Issue = { check: string; blocking: boolean; detail: string };
export function checkInvoice(x: Extracted): Issue[] {
const issues: Issue[] = [];
if (x.totalCents === null) {
issues.push({ check: "total_present", blocking: true, detail: "No total found in the document" });
} else {
const sum = x.lines.reduce((s, l) => s + l.amountCents, 0);
if (sum !== x.totalCents) {
issues.push({ check: "lines_sum_to_total", blocking: true, detail: `Lines sum to ${sum}, total is ${x.totalCents}` });
}
}
if (x.issueDate && x.dueDate && x.dueDate < x.issueDate) {
issues.push({ check: "due_after_issue", blocking: true, detail: "Due date is before the issue date" });
}
if (x.currency === null) {
issues.push({ check: "currency_present", blocking: false, detail: "Currency not stated" });
}
return issues;
}
// check-invoice.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { checkInvoice } from "./check-invoice.ts";
import type { Extracted } from "./check-invoice.ts";
const clean: Extracted = {
totalCents: 120000,
currency: "EUR",
issueDate: "2026-10-01",
dueDate: "2026-10-31",
lines: [{ amountCents: 100000 }, { amountCents: 20000 }],
};
// One planted defect per rule. Each must be reported by that rule and no other.
const planted: { name: string; doc: Extracted; check: string }[] = [
{ name: "missing total", doc: { ...clean, totalCents: null }, check: "total_present" },
{ name: "total contradicts the lines", doc: { ...clean, totalCents: 1200000 }, check: "lines_sum_to_total" },
{ name: "due date before issue date", doc: { ...clean, dueDate: "2026-09-01" }, check: "due_after_issue" },
{ name: "currency missing", doc: { ...clean, currency: null }, check: "currency_present" },
];
test("the clean fixture passes every check", () => {
assert.deepEqual(checkInvoice(clean), []);
});
for (const p of planted) {
test(`planted defect is reported: ${p.name}`, () => {
assert.deepEqual(
checkInvoice(p.doc).map((i) => i.check),
[p.check],
);
});
}
Both files run as shown on Node.js 24 with node --test, using its built-in type stripping; all five tests pass. This sample isolates each rule so every assertion names exactly one check, which keeps a failing test easy to read. In a real document one defect can legitimately trip several checks — a missing line item can break both the sum and a count — and that is not a fault in the checks; assert the set of checks you expect.
What a passing run shows is narrow: these checks detect these planted defects. It does not show they would catch every defect, and it says nothing about how accurately the model extracts or how often real documents contain such problems.
Wintura's repository has a self-test built this way. When we ran it on 7 November 2026 (commit e9dbf2c, no model calls, no database), it made 137 assertions and all passed. Thirty fixtures carried a planted defect — among them a deliverable missing from the contract text, a dollar amount inside a euro document and a penalty clause nobody authorised — and each was reported. That shows its checks detect those thirty fixtures. It does not show they would catch defects nobody planted, and it measures neither how accurately the model reads a brief nor how often its output contains such defects.
Measure extraction separately, and modestly
Planted-defect tests check your rules. They do not tell you how well the model reads documents. For that you need a different set: representative documents with customer data removed (under whatever agreement covers them), the correct values recorded by hand, and a comparison field by field.
Report the result per field rather than as one score; a good overall number can hide a field that is wrong half the time. Keep the set small enough that someone actually checks the answers, grow it when real documents reveal a case you had not anticipated, and run it again whenever the prompt, the model or the schema changes.
Fail in a way the user can act on
- Check the stop reason before parsing. A refusal or a response cut off at the token limit gets its own outcome and message (and, for truncation, a deliberate decision about whether to retry with a larger limit), not a generic parse error.
- Retry only failures that can succeed on a second attempt: rate limits, server errors and dropped connections, within a time budget shorter than your request limit. A request the API rejected as invalid will fail the same way again. Wintura's analysis step follows this rule.
- Never save partial output as a record. The user gets a draft with its issues listed, or nothing and a message saying what to do next.
- Record the model usage the API reports for each document, so a change in document length or price shows up before the invoice does.
Keep the feature inside your existing permissions
Uploaded files, extracted drafts and approved records belong to the account that uploaded them, and go through the same authorization checks as the records they become. Test that with two accounts, not one; the method is the same as for testing row-level security in a Supabase app.
Send documents only to a model provider your customers' agreements allow, and decide how long uploaded files and model inputs are kept.
Write down the limits of whatever isolation you use. Wintura scopes each query by account in application code rather than relying on database row-level security, and its case study says so. A limitation named in the specification is a decision; one discovered later is an incident.
A specification you can approve
Before building, the specification should answer each of these in a sentence or two:
- The workflow that changes, and who uses it
- Accepted formats, size limits and retention for source documents
- The output schema, with "not found" allowed wherever the source may be silent
- Each consistency rule, marked blocking or warning
- What the reviewer sees, and who may approve
- The fixture set: one clean document and one planted defect per rule
- The evaluation set, and which fields it measures
- Retry rules, the timeout, and the message a user sees on failure
- The account boundary, and the two-account test that proves it
- Who accepts the release, and in which environment
For turning that list into acceptance criteria a client and an engineer can both sign, see scoping a software project. For the wider release review of an AI-assisted feature, see what to check before AI-generated code reaches production.
What this guide does not cover
Choosing a model or provider, prompt design, optical character recognition for scanned documents, cost optimisation beyond tracking usage, and the legal obligations that attach to particular document types. Each depends on your documents and your customers more than on a general method.
Need an AI feature shipped inside an existing product?
How the Embedded Architect worksWorking on a feature this article touches? A 30-minute call is the quickest way to scope it.
Discuss your project