Skip to content

Extracting Expense Categories From a Corporate Expense Report

9 min read · updated August 11, 2026

Nothing on an expense report says which general ledger account a line belongs to. The category is not a field you extract; it is a classification you assign from a list your finance system already owns, and treating it as free text is what produces a chart of accounts with nine spellings of “client entertainment”.

The category set is not yours to invent

Every organisation running expense reports already has an expense type list, mapped to general ledger accounts, and often to tax treatments and to cost centres. The extraction target is a value from that list. That single framing decision changes the design: the list goes into the request, the output is constrained to it, and a line the model cannot place comes back as unclassified rather than as an invented category.

Constrain it in the schema rather than in the prose of the prompt. A string enumeration is one of the things structured output is genuinely good at holding, though how strictly it is held differs by provider and by mode — structured output support and JSON mode versus structured outputs cover where the guarantee actually bites, and testing enum constraint compliance covers proving it. Two practical notes for this document specifically: keep an explicit unclassified member in the enumeration so refusal to guess is expressible, and keep the list short enough to fit the request without dominating it. A hundred-and-forty-entry expense type list is better presented as a two-level classification — a dozen groups, then the leaf within the chosen group — than as one flat enumeration the model reads as a wall.

What this page is not about is whether the expense is allowed. Policy evaluation — a receipt over a limit, alcohol on a client meal, a hotel above the city cap — is a separate step that runs on the structured output, with its own rules and its own audit requirements. Mixing it into the extraction prompt produces a model that quietly declines to categorise things it thinks are against policy.

One line, several categories

This is the part that makes expense categorisation different from ordinary text classification, and it is why a schema of one category per line is wrong. Real receipts are composite.

  • A hotel folio is a room rate, occupancy and city taxes, a resort or destination fee, parking, restaurant and bar charges posted to the room, laundry and possibly a no-show charge. These map to at least three different expense types and, in many organisations, different tax treatments.
  • An airline receipt is base fare, carrier-imposed charges, government taxes, seat selection, baggage and a change fee. A change fee is frequently its own expense type because it is reported on separately.
  • A single card transaction at a warehouse retailer can be office supplies, catering and a piece of equipment above the capitalisation threshold, on one receipt.

So model a line as a header with a total and a list of allocations, each with a category and an amount, and impose the obvious check: the allocations must sum to the line total, which is a cross-field amount validation rule in its simplest form. That check is the whole value of the structure. When it fails, either a folio row was missed or an amount was misread, and either way the failure is visible without anybody comparing the output to the document by eye.

{
  "line_id": "L7",
  "merchant": "<hotel name>",
  "date": "2026-03-14",
  "total": 412.60,
  "currency": "USD",
  "allocations": [
    {"category": "lodging",        "amount": 318.00},
    {"category": "lodging_tax",    "amount":  46.10},
    {"category": "meals",          "amount":  38.50},
    {"category": "parking",        "amount":  10.00}
  ]
}

318.00 + 46.10 + 38.50 + 10.00 is 412.60, and if it were not, this record would not be written. Where the folio genuinely cannot be decomposed — a scanned summary showing only a total — the honest output is a single allocation to the dominant category with a flag, not four fabricated ones that happen to add up.

Merchant name, card code and receipt text disagree

There are usually three signals available and they carry different information. The merchant name from the receipt is specific but ambiguous, because a general retailer sells everything and a hotel restaurant bills under the hotel’s name. The line item text on the receipt is the most informative and the least reliably legible. And when the expense came from a corporate card feed there is a merchant category code, assigned by the acquirer to the merchant rather than to the purchase, which is coarse and occasionally simply wrong for what was bought.

The productive move is to treat a disagreement between the code-derived category and the text-derived category as a review signal in its own right, separate from model confidence. A card code indicating a restaurant against receipt text that reads like a hardware purchase is worth a human glance even if the model was perfectly confident; a card code and receipt text that agree can raise the bar for auto-approval. This is a cheaper and better-calibrated signal than a model’s self-reported certainty, for the reasons set out in extraction confidence and calibrating extraction confidence.

Amounts, currency and tax

A foreign expense line has four numbers where a domestic one has one: the transaction amount, its currency, the exchange rate applied, and the amount in the reporting currency. The check is that amount times rate equals the reported amount, within a rounding, and it catches the two common failures — a rate read upside down, and a currency inferred from the country of travel rather than from the receipt.

  • The decimal comma. This and the three items below it are the document-specific face of the currency amount validation rule. A European receipt prints 1.234,56 and a naive parse yields either 1.23 or 123456. The separator convention is a property of the document, so infer it once per document from the pattern across all amounts rather than per field, and refuse the document if the amounts are internally inconsistent.
  • Currencies without minor units. A Japanese yen amount has no decimal part. A parser that assumes two decimals divides it by a hundred, and the resulting line is small enough that no threshold catches it.
  • Recoverable tax. Where value-added tax is reclaimable, the net and the tax must be separate fields, because the category applies to the net and the tax goes to a recovery account. A gross-only extraction cannot be unwound later without the receipt.
  • Non-reimbursable lines. Personal charges on a corporate card stay on the report and are marked. Dropping them breaks the reconciliation against the card statement.

Duplicates that are not duplicates

The same expense often appears twice: once from the card feed and once from a photographed receipt the employee attached. Matching them is worth doing, on date, merchant and amount with a small tolerance for the date, since posting date and transaction date differ by a day or two.

But two coffees at the same shop on the same day for the same amount is an ordinary occurrence, and so is a hotel charging the same nightly rate on consecutive lines. A deduplication rule that deletes is therefore wrong; the rule should merge with evidence, or flag. The decision of what to do with a suspected duplicate belongs to a reviewer and to policy, and the extractor’s job is to surface the pair with the fields that make it decidable — both source records, both dates, and whether one of them has an attached image.