Skip to main content

The problem

Your invoice extraction prompt returns this:
{
  "vendor": "Acme Supplies",
  "invoiceNumber": "INV-1042",
  "lineItems": [
    { "description": "Hardware", "quantity": 2, "unitPrice": 50, "amount": 100 }
  ],
  "subtotal": 100,
  "tax": 10,
  "total": 105,
  "currency": "USD"
}
The JSON is valid. The arithmetic is not: 100 + 10 = 110, not 105. A schema can enforce numbers, but it cannot tell you that invoice totals agree.

The contract

import { z } from "zod";
import { defineContract } from "@withboundary/contract";

const lineItemSchema = z.object({
  description: z.string(),
  quantity: z.number(),
  unitPrice: z.number(),
  amount: z.number(),
});

export const invoiceSchema = z.object({
  vendor: z.string(),
  invoiceNumber: z.string(),
  date: z.string(),
  lineItems: z.array(lineItemSchema),
  subtotal: z.number(),
  tax: z.number(),
  total: z.number(),
  currency: z.string(),
});

export const invoiceContract = defineContract({
  name: "invoice-extraction",
  schema: invoiceSchema,
  rules: [
    {
      name: "has_line_items",
      description: "Every invoice has at least one line item",
      fields: ["lineItems"],
      check: (invoice) =>
        invoice.lineItems.length > 0 || "invoice must include at least one line item",
    },
    {
      name: "line_items_sum_to_subtotal",
      description: "Line item amounts sum to subtotal",
      fields: ["lineItems", "subtotal"],
      check: (invoice) => {
        const sum = invoice.lineItems.reduce((total, item) => total + item.amount, 0);
        return Math.abs(sum - invoice.subtotal) < 0.01
          || `line items sum to ${sum}, but subtotal is ${invoice.subtotal}`;
      },
    },
    {
      name: "subtotal_plus_tax_equals_total",
      description: "Subtotal plus tax equals total within a cent",
      fields: ["subtotal", "tax", "total"],
      check: (invoice) => {
        const expected = invoice.subtotal + invoice.tax;
        return Math.abs(expected - invoice.total) < 0.01
          || `subtotal ${invoice.subtotal} plus tax ${invoice.tax} is ${expected}, but total is ${invoice.total}`;
      },
    },
    {
      name: "line_item_math_consistent",
      description: "Each line item amount equals quantity times unit price",
      fields: ["lineItems"],
      check: (invoice) => {
        const bad = invoice.lineItems.find(
          (item) => Math.abs(item.quantity * item.unitPrice - item.amount) >= 0.01,
        );

        return !bad
          || `${bad.description}: ${bad.quantity} x ${bad.unitPrice} should equal ${bad.quantity * bad.unitPrice}, but amount is ${bad.amount}`;
      },
    },
  ],
});

Run the model

const result = await invoiceContract.accept(async (attempt) => {
  const response = await callYourLLM({
    messages: [
      {
        role: "user",
        content: [
          "Extract invoice data as JSON.",
          attempt.instructions,
          invoiceText,
        ].join("\n\n"),
      },
      ...attempt.repairs,
    ],
  });

  return response.text;
});
The repair loop is useful for extraction because the model often has enough source context to fix arithmetic or missing fields once the violation is explicit.

Accept or reject

if (result.ok) {
  await accounting.createEntry(result.data);
  return { status: "accepted", invoice: result.data };
}

await invoiceReviewQueue.add({
  sourceDocumentId,
  reason: result.error.message,
  attempts: result.error.attempts,
});

return { status: "needs_review" };
Only accepted invoices should reach accounting. Rejected invoices still give you the attempt history, including which arithmetic rule failed and the values that caused it.

When to use this pattern

  • Invoice and receipt extraction
  • Quote-to-order workflows
  • Expense report validation
  • Any AI-extracted financial record with totals, rates, or line items