Extracting Payment Terms Text Into a Structured Field
10 min read · updated August 11, 2026
Payment terms are a small, dirty language with maybe fifteen productive forms and a long tail of prose. That shape argues for a parser first and a model second — the parser is free, exact and testable, and the model handles what is left.
The grammar you are actually parsing
Collect a few thousand distinct terms strings from real invoices and they cluster hard. The forms that carry most of the volume are:
- Plain net.
Net 30,NET30,30 days,n/30,30 Tage netto,à 30 jours. - Early discount.
2/10 net 30,2% 10 net 30,2/10 n/30,3% Skonto 14 Tage, 30 Tage netto. - Month-anchored.
Net 30 EOM,Net 15 MFI,15th prox,end of month following invoice. - Base-shifted.
30 days from delivery,30 days from receipt of invoice,30 days from statement date. - Immediate.
Due on receipt,COD,Payable immediately,Prepaid,Paid by card.
The last group matters more than its frequency suggests, because “prepaid” and “paid by card” mean no payment is due at all, and a pipeline that maps them to zero net days schedules a duplicate payment. Model them as a settlement status, not as a term of zero days.
Two properties of this language make a rule-based parser the right first stage rather than a fallback. It is small: the productive forms number in the low tens, and a new supplier almost never invents a genuinely new one, it just spells an existing one differently. And it is adversarial to probabilistic parsing in a particular way, because every form is mostly digits, so a model that misreads the structure still produces well-formed output with plausible numbers in it. There is no fluency signal to tell you it went wrong. A regex either matches or does not, and the not-matching is the useful part.
One more distinction is worth drawing before any code. Terms describe when payment is due and, sometimes, what is payable if it is early. They do not describe how much is owed. An early discount percentage is not an allowance and must never reduce the invoice total — the reasoning is in extracting line-item discounts and rebates. Keeping the two in separate branches of the schema is what stops a downstream system from applying a discount that has not been earned.
The target structure is small. Note that it carries the raw string forever, and that the due date is not in it — deriving one needs a base date the terms string does not contain, which is the whole argument of extracting due dates from invoices that do not state one.
type PaymentTerms = {
raw: string;
netDays: number | null;
base: "invoice_date" | "delivery" | "receipt" | "statement" | "eom" | "mfi";
anchorDay: number | null; // for MFI / prox: the day of the following month
earlyDiscount: { percent: number; withinDays: number } | null;
settlement: "due" | "prepaid" | "cod" | null;
parsedBy: "rules" | "model";
confidence: "exact" | "review";
};A deterministic parser for the common forms
Normalise first, then run ordered patterns from most specific to least. Ordering matters: the discount pattern must run before the plain-net pattern, or 2/10 net 30 matches as a bare net 30 and the discount is lost silently.
function normalise(s: string): string {
return s
.toLowerCase()
.replace(/[\u2013\u2014]/g, "-") // en/em dash to hyphen
.replace(/\s+/g, " ")
.replace(/\bn\/(\d+)/g, "net $1") // n/30 -> net 30
.trim();
}
const RULES: Array<[RegExp, (m: RegExpMatchArray) => Partial<PaymentTerms>]> = [
// 2/10 net 30 | 2% 10 net 30 | 2/10 n/30
[/(\d+(?:[.,]\d+)?)\s*[%\/]\s*(\d+)\s*,?\s*net\s*(\d+)/,
(m) => ({
earlyDiscount: { percent: parseFloat(m[1].replace(",", ".")),
withinDays: Number(m[2]) },
netDays: Number(m[3]), base: "invoice_date",
})],
// 3% skonto 14 tage, 30 tage netto
[/(\d+(?:[.,]\d+)?)\s*%\s*skonto\s*(\d+)\s*tage.*?(\d+)\s*tage\s*netto/,
(m) => ({
earlyDiscount: { percent: parseFloat(m[1].replace(",", ".")),
withinDays: Number(m[2]) },
netDays: Number(m[3]), base: "invoice_date",
})],
// net 30 eom | 30 days end of month
[/net\s*(\d+)\s*(?:eom|end of month)/,
(m) => ({ netDays: Number(m[1]), base: "eom" })],
// net 15 mfi | 15th prox
[/(?:net\s*(\d+)\s*mfi|(\d+)(?:st|nd|rd|th)?\s*prox)/,
(m) => ({ netDays: null, base: "mfi",
anchorDay: Number(m[1] ?? m[2]) })],
// 30 days from delivery / receipt of invoice
[/(\d+)\s*days?\s*(?:from|after)\s*(delivery|receipt|statement)/,
(m) => ({ netDays: Number(m[1]),
base: m[2] === "delivery" ? "delivery"
: m[2] === "statement" ? "statement" : "receipt" })],
// net 30 | 30 days | 30 tage netto
[/(?:net\s*(\d+)|(\d+)\s*(?:days?|tage|jours|dagen))/,
(m) => ({ netDays: Number(m[1] ?? m[2]), base: "invoice_date" })],
// due on receipt / prepaid / cod
[/due on receipt|payable immediately|sofort/, () => ({ netDays: 0, base: "invoice_date" })],
[/prepaid|paid by card|paid in advance/, () => ({ settlement: "prepaid" })],
[/\bcod\b|cash on delivery/, () => ({ settlement: "cod" })],
];Two details in there are easy to get wrong. The dash normalisation matters because typeset invoices use en dashes in ranges and a hyphen class in a regex will not match one. And parsing the discount percentage with a comma decimal is necessary for continental strings like 2,5% Skonto, which is the same separator problem described in extracting multi-currency line items.
Where the parser stops and the model starts
Whatever your rule set, some proportion of strings will not match. They are prose: conditional cut-offs, instalment schedules, retention clauses, terms that reference a contract by number. These are the strings worth spending a model call on, and only these — running every string through a model costs money on the 85% the parser already handles exactly, and replaces an exact answer with a probable one.
Send the unmatched string with the same target schema, ask for a null rather than a guess, and require it to quote the substring it used:
Extract payment terms from this text into the schema.
Rules:
- Copy numbers exactly. Do not compute a due date.
- If a field is not stated, return null. Do not infer a default.
- "evidence" must be a verbatim substring of the input.
Input: "Payment due 25th of the month following delivery; invoices
received after the 20th roll to the subsequent month."That last constraint is the useful one. An evidence string that does not appear verbatim in the input is a cheap, deterministic signal that the model has drifted, and it costs one includes call to check. The general form of that idea is covered in extraction prompts.
Validating by re-rendering
The strongest check available is a round trip. Write a small function that renders the structured object back into canonical terms text, then compare that rendering against the normalised input. It will not match character for character, and it does not need to: what you are testing is that every number in the original appears in the rendering and vice versa.
function render(t: PaymentTerms): string {
const parts: string[] = [];
if (t.earlyDiscount)
parts.push(`${t.earlyDiscount.percent}/${t.earlyDiscount.withinDays}`);
if (t.netDays !== null) parts.push(`net ${t.netDays}`);
if (t.base === "eom") parts.push("eom");
if (t.base === "mfi") parts.push(`${t.anchorDay} mfi`);
return parts.join(" ");
}
function numbersMatch(input: string, t: PaymentTerms): boolean {
const nums = (s: string) => (s.match(/\d+(?:[.,]\d+)?/g) ?? [])
.map((n) => n.replace(",", "."));
const a = new Set(nums(normalise(input)));
const b = new Set(nums(render(t)));
return [...b].every((n) => a.has(n));
}A number in the output that is not in the input is a fabricated figure, and this catches it without a golden dataset. The reverse direction — a number in the input that is not in the output — is a weaker signal, because contract references and invoice numbers legitimately contain digits, but it is a good candidate filter for review.
Putting it together
- Normalise the raw terms string. Keep the original untouched on the record; every later dispute is settled by looking at it.
- Run the ordered rule list. First match wins. Set
parsedBy: "rules"andconfidence: "exact". - On no match, call the model once with the schema and the evidence-substring constraint. Set
parsedBy: "model". - Verify the evidence substrings appear verbatim, then run
numbersMatch. Downgrade toconfidence: "review"on either failure and let the threshold decide the routing. - Assert plausibility:
withinDaysmust be less than or equal tonetDays, discount percent must be under about 15, andnetDaysover 180 is almost always a misread. Downgrade rather than reject — unusual terms exist. - Persist the whole object including
rawandparsedBy. That record is a per-field audit trail: when a rule changes later you can reparse the corpus and diff against what you stored, which is the only way to know whether a rule change helped.
The reason to build it in this order is that the rule set becomes an asset. Every new form you meet is one regex and one test case, and the model call rate falls over time instead of staying flat.