Skip to content

Extracting Fields From a Credit Memo and Matching It to Its Invoice

10 min read · updated August 11, 2026

A credit memo is an invoice with the sign reversed and a pointer to another document. Both of those are where it goes wrong: the sign is expressed four different ways depending on who printed it, and the pointer is frequently to a document number that does not exist in the form it was written.

What makes a credit memo hard

On its own a credit memo is a simple document — issuer, date, number, a line or two, a total, some tax. It becomes hard because it is never processed on its own. Its purpose is to reduce an amount owed under another document, so a correct extraction is one that lands the credit against the right invoice, in the right amount, without exceeding what is still open on it.

Three things make that hard in practice. The reference to the original invoice is unreliable, sometimes absent and sometimes pointing at a purchase order instead. The amount has no consistent sign convention. And the remaining balance on the target invoice is not on either document — it is in the ledger, and it moves.

Sign conventions and how to normalise them

The same $1,210.00 credit appears in the wild as all of these:

1,210.00 CR        suffix notation, positive digits
-1,210.00          explicit negative
(1,210.00)         accounting parentheses
1,210.00           positive, with document type CREDIT MEMO
                   carrying the sign implicitly

The fourth is the dangerous one, because it parses cleanly as a positive number and only the document type says otherwise. A pipeline that reads the total and applies it without consulting the document type will add $1,210.00 to a customer’s balance instead of subtracting it, and the resulting statement is out by twice the credit.

So normalise deliberately, and record what you did. Extract the printed representation verbatim, the parsed absolute value, and a derived sign; derive the sign from the document type first and the notation second, and flag any case where the two disagree. A document headed CREDIT MEMO with a total of -1,210.00 is ambiguous: it may be double-negated, in which case it is a debit. That is a review item, not a parse.

Watch for mixed conventions inside one document, which is the case a currency-amount validation rule has to allow for. A credit memo issued for a return with a restocking fee will show a negative goods line and a positive fee line, and the total nets them. If your parser applies a document-level sign flip to every line you will invert the fee.

Matching the referenced invoice number

The field is variously labelled “Original Invoice”, “Applies To”, “Reference”, “Ref Doc” or just “Re:”, and its contents are frequently not an invoice number. Common cases, all real shapes of the problem:

  • Formatting drift. The invoice was issued as INV-0001234 and the memo references 1234, or INV 1234, or inv-1234. Normalise by stripping non-alphanumerics, uppercasing, and stripping a known prefix — but keep the raw value, because leading zeros can be significant in systems where 001234 and 1234 are different documents.
  • A purchase order number in the invoice field. Detectable when the value matches your PO format rather than your invoice format, and worth checking explicitly since it is a recoverable match through the PO rather than a failure.
  • Re-issue suffixes. 1234-A or 1234R where an invoice was corrected and re-sent. The suffix is meaningful and stripping it matches the wrong document.
  • Multiple references. One memo crediting three invoices, with the allocation either stated per line or not stated at all. Unallocated multi-invoice credits cannot be applied automatically and should not be guessed at.
  • No reference at all. An on-account credit, valid and common, applied at the customer level rather than to a document. This needs a status of its own rather than a failed match.
  • Number reuse across years. Vendors who restart sequences annually produce genuine ambiguity, resolvable only by constraining the candidate set by date and vendor.

Partial credits and proportional tax

The arithmetic check that catches the most real errors is that a partial credit must carry its proportional tax. Take an invoice for four units at $1,000.00 net, with VAT at 21%:

original invoice
  net            4 x 1,000.00 = 4,000.00
  VAT at 21%                  =   840.00
  gross                       = 4,840.00

credit memo for 1 unit returned
  net                         = 1,000.00
  VAT at 21%                  =   210.00
  gross                       = 1,210.00

check: credit_net / invoice_net = 1,000 / 4,000 = 0.25
       credit_vat / invoice_vat =   210 /   840 = 0.25   OK

a memo showing gross 1,210.00 with VAT stated as 0.00 fails:
       credit_vat / invoice_vat = 0 / 840 = 0.00  != 0.25

That last case is common and consequential, because a credit issued without its tax component leaves the tax over-declared. The ratio test is a good general check: for a proportional credit, the net ratio and the tax ratio should agree within rounding. Where they do not, either the credit is not proportional — a pure price adjustment on a zero-rated line, for instance — or the tax is wrong, and the document should say which.

Rounding tolerance matters here. Tax computed per line and tax computed on the document total can differ by a cent or two on a multi-line document, so compare with a tolerance of a minor unit or two rather than exactly. And check the tax rate itself against the original: a credit issued after a rate change should generally carry the rate that applied to the original supply, not today’s.

Building the matcher

The pipeline is five stages, and only the first involves a model.

  1. Extract with a constrained schema. Ask for the printed representation of every amount alongside the parsed value, and make the document type an enumeration rather than free text. Strict schema enforcement differs between providers, which is worth knowing before you rely on it — see structured output support.
    const schema = {
      type: "object",
      additionalProperties: false,
      required: ["doc_type", "doc_no", "doc_date", "currency",
                 "total_printed", "total_abs", "lines"],
      properties: {
        doc_type:   { enum: ["credit_memo", "debit_memo", "invoice"] },
        doc_no:     { type: "string" },
        doc_date:   { type: "string", format: "date" },
        currency:   { type: "string", minLength: 3, maxLength: 3 },
        reference:  {
          type: ["object", "null"],
          properties: {
            raw:  { type: "string" },
            kind: { enum: ["invoice", "purchase_order", "unknown"] }
          }
        },
        total_printed: { type: "string" },   // "(1,210.00)" as shown
        total_abs:     { type: "number" },   // 1210.00
        tax_abs:       { type: "number" },
        reason_code:   { enum: ["return", "price_adjustment",
                                "short_ship", "rebate", "other"] },
        lines: { type: "array", items: { type: "object" } }
      }
    };
  2. Normalise the sign. Derive it from the document type, cross-check against the notation, and flag disagreement rather than resolving it.
    function signedTotal(doc) {
      const byType = doc.doc_type === "credit_memo" ? -1 : 1;
      const s = doc.total_printed;
      const byNotation =
        /^\(.*\)$/.test(s.trim()) || /-/.test(s) || /\bCR\b/i.test(s)
          ? -1 : 1;
      if (byType !== byNotation) {
        return { value: byType * doc.total_abs, conflict: true };
      }
      return { value: byType * doc.total_abs, conflict: false };
    }
  3. Normalise and resolve the reference. Canonicalise, then look up within a candidate set constrained by vendor and by a date window, so that a reused number cannot match the wrong year.
    const canon = (s) =>
      s.toUpperCase().replace(/[^A-Z0-9]/g, "").replace(/^INV/, "");
    
    async function resolveInvoice(memo, ledger) {
      if (!memo.reference) return { status: "on_account" };
      const key = canon(memo.reference.raw);
      const hits = await ledger.findInvoices({
        vendor: memo.vendor_id,
        canonicalNo: key,
        issuedBefore: memo.doc_date,
        issuedAfter: minusMonths(memo.doc_date, 24),
      });
      if (hits.length === 1) return { status: "matched", invoice: hits[0] };
      if (hits.length === 0) return { status: "unmatched", key };
      return { status: "ambiguous", key, candidates: hits };
    }
  4. Run the assertions. Each returns a named failure rather than a boolean, so the review queue can route by reason.
    function checkCredit(memo, invoice) {
      const problems = [];
      const credit = Math.abs(memo.total_abs);
      const remaining =
        invoice.gross - invoice.paid - invoice.credits_applied;
    
      if (credit > remaining + 0.005)
        problems.push({ code: "exceeds_remaining_balance",
                        credit, remaining });
    
      if (memo.currency !== invoice.currency)
        problems.push({ code: "currency_mismatch" });
    
      if (memo.doc_date < invoice.doc_date)
        problems.push({ code: "credit_predates_invoice" });
    
      if (invoice.tax > 0 && memo.tax_abs === 0)
        problems.push({ code: "credit_without_tax" });
    
      const netRatio = memo.net_abs / invoice.net;
      const taxRatio = invoice.tax ? memo.tax_abs / invoice.tax : netRatio;
      if (Math.abs(netRatio - taxRatio) > 0.005)
        problems.push({ code: "tax_not_proportional",
                        netRatio, taxRatio });
    
      return problems;
    }
  5. Route on the result. Clean matches with no problems post automatically. exceeds_remaining_balance goes to accounts receivable, because it usually means a credit was already applied and this is a duplicate. ambiguous goes to a human with the candidate list attached. on_account posts to the customer rather than the document. Never let an unmatched credit post to the nearest invoice by amount — two invoices for the same amount is entirely ordinary, and amount-matching gets it wrong silently.

The duplicate case deserves the last word because it is the one that costs money. A vendor re-sending the same credit memo, or a memo scanned twice in a batch, produces two identical records that both pass every check individually. Deduplicate on issuer, document number and date before matching, and treat a second memo with the same number from the same issuer as a duplicate until somebody says otherwise.