Extracting Structured Fields From a Non-Profit Donation Receipt
9 min read · updated August 11, 2026
The amount on a donation receipt is the easy field. The field that decides whether the receipt is worth anything is a sentence about whether the donor got something back — and the hard part is that its absence and its negation look identical to a schema that models it as a string.
What a receipt actually has to carry
A charitable acknowledgment is not a free-form thank-you letter that happens to have a number on it. In the United States, the Internal Revenue Service sets out what a written acknowledgment must contain in Publication 1771, Charitable Contributions — Substantiation and Disclosure Requirements. A donor claiming a deduction for a single contribution of $250 or more needs a contemporaneous written acknowledgment from the organisation, and that acknowledgment has to state the amount of cash contributed, describe any non-cash property, and say whether the organisation provided any goods or services in return.
That gives you the field list, and it is shorter than most extraction schemas people write for this document:
- Organisation name and taxpayer identification number. A US employer identification number is formatted as two digits, a hyphen and seven digits. It has no check digit, which is worth knowing because it means you cannot validate it arithmetically the way you can an identifier that carries a checksum. The only check available is a lookup against the organisation record you already hold.
- Date of the contribution — and separately, the date of the acknowledgment itself. They are different dates and both matter, because “contemporaneous” is defined against the donor’s filing date, not against the gift.
- Amount of cash contributed, per contribution.
- A description of any non-cash property — a description, not a value. A compliant receipt for donated goods will describe “one used sofa, good condition” and put no dollar figure on it, because valuing donated property is the donor’s job. A schema with a required
noncash_valuefield will therefore be empty on correct receipts and populated on incorrect ones. - The goods-or-services statement. The subject of the rest of this page.
The goods-or-services field has three states
The obvious schema is a boolean: goods_or_services_provided, true or false. It is wrong, and the way it is wrong is silent. A model reading a receipt that simply never mentions goods or services will usually return false, because nothing was provided as far as the text goes. But “the receipt says none were provided” and “the receipt does not address it” are different facts about the world with different consequences, and the boolean collapses them.
Model it as a discriminated field with an explicit not-stated member:
{
"goods_or_services": {
"status": "none_provided | provided | intangible_religious | not_stated",
"description": "string | null",
"estimated_value": "decimal string | null",
"source_quote": "the sentence this was read from, verbatim"
}
}Four states, and each one is a different downstream decision. A receipt reading “no goods or services were provided in exchange for this contribution” is none_provided. One describing a tote bag with a stated good-faith estimate of its value is provided. One stating that the only benefit was an intangible religious benefit is its own category, because Publication 1771 treats it as one. And a receipt that says nothing at all is not_stated — a document that names an amount but cannot, on its own, do the job the donor needs it to do for a contribution at or above the threshold.
The source_quote field is not decoration. This is exactly the field where a reviewer needs to see the underlying sentence rather than the model’s reading of it, which is the argument for carrying a source location with every extracted field. An enum value of none_provided and a quote that reads “your gift entitles you to two tickets” is a caught error; without the quote it is a shipped one.
When the amount paid is not the amount given
A quid pro quo contribution is a payment that is part gift and part purchase — the classic case being a fundraising dinner ticket. The IRS requires the organisation to give the donor a written disclosure for quid pro quo contributions over $75, stating a good faith estimate of the value of what the donor received.
For extraction this means the receipt carries two numbers that are both amounts and are not interchangeable, plus a third that is implied:
Payment received 150.00 Good faith estimate of goods provided 60.00 Amount eligible to be treated as a gift 90.00 (derived: 150.00 - 60.00)
A schema with a single amount field will capture 150.00 — the largest, boldest, most prominent number on the page — and quietly discard the only figure that makes the document useful. Give the schema payment_amount and goods_estimated_value as separate required fields and derive the third rather than extracting it, so that the arithmetic is yours and not the model’s. If the receipt itself prints a deductible amount, extract it into a fourth field and compare: a mismatch between the printed figure and your derivation is a genuine finding about the document, and it is the kind of cross-field check that earns its keep.
One document, many gifts
Most organisations send a year-end statement listing every gift the donor made. It is one PDF, one letterhead, one signature — and from the point of view of substantiation it is not one contribution. A statement that lists twelve monthly gifts of $30 and one gift of $500 contains one contribution at or above the $250 threshold and twelve that are not, and the acknowledgment obligations attach per contribution.
So the top-level schema is not a receipt object with an amount. It is a document object with a list:
{
"organization": { "name": "...", "ein": "NN-NNNNNNN" },
"acknowledgment_date": "2027-01-14",
"period": { "start": "2026-01-01", "end": "2026-12-31" },
"contributions": [
{ "date": "2026-03-04", "amount": "30.00", "goods_or_services": { "status": "none_provided" } },
{ "date": "2026-11-19", "amount": "500.00", "goods_or_services": { "status": "none_provided" } }
],
"stated_total": "860.00"
}Two checks fall straight out of that shape. The contributions must sum to the stated total, to the cent; and any contribution at or above the threshold must carry a goods-or-services status that is not not_stated. The first catches a dropped row in a long list, which is the failure mode of every table that spans a page break. The second is the actual business rule. Both are ordinary post-extraction validation rather than anything the model is asked to do — the model reads, the code decides. That division is the subject of extracting first and reasoning afterwards, and this document is a clean example of why it is the right order.
Where the extraction goes wrong
- The narrative paragraph that mentions a number. “Your generosity, alongside 4,200 other donors, helped us serve 18,000 meals” contains two figures and neither is an amount. Constrain the amount fields to a currency pattern and anchor them to a labelled row rather than to any number on the page.
- The pledge reminder dressed as a receipt. A document headed “statement of your commitment” listing amounts outstanding is not an acknowledgment of anything received. Capture a
document_kinddiscriminator first and route on it; the structure of a multi-year pledge agreement is genuinely different. - The date that is the printing date. Year-end statements are batch-printed, so the most prominent date on the page is often the run date. If the letter is dated January and the contribution rows are dated the prior year, the contribution dates are the ones that matter and neither should overwrite the other.
- Rounded totals. A stated total that disagrees with the summed rows by a few cents is usually a display-rounding artefact in the donor system, not a missing gift. A disagreement of a whole dollar or more is a missing gift. Set the tolerance deliberately rather than letting an exact-equality assertion generate noise, and see writing a validation rule for a currency amount for how to normalise before comparing.
- Donor personal data travelling with the receipt. These documents carry a name and a home address, and sometimes the last four digits of a card. If they are going to a third-party model, the address is rarely needed for the fields above; strip it in ingestion rather than after the fact. That is ordinary redaction before extraction, and it is cheaper to do at the boundary than to justify later.