합계 9,300, 카드 4,300, 상품권 5,000: 영수증 total은 덧셈 문제가 아니었습니다

영수증 OCR에서 total을 찾는 가장 단순한 방법은 무엇일까요?
가장 큰 숫자를 고르면 될 것 같습니다. 아니면 아래쪽에 있는 마지막 금액을 고르면 될 것 같습니다.
다음처럼 synthetic test input을 만들면 그 규칙의 빈틈이 드러납니다.
합계 9,300
상품권 결제금액: 5,000
신용카드 결제금액: 4,300원
가장 마지막 숫자는 4,300입니다.
카드와 상품권 금액을 더하면 9,300이 됩니다.
그렇다면 total은 무엇일까요?
현재 parser contract가 고르는 정답은 명시적으로 적힌 합계 9,300입니다.
이 test에서 카드 4,300과 상품권 5,000은 total이 아니라 결제 기여분으로 취급됩니다.
둘을 다시 더해 total을 만들 수도 있지만, 그 산술은 input에 직접 적힌 total label보다 약한 근거입니다.
제가 만든 receipt-evidence parser에서 이 규칙을 고치며 배운 것은 “숫자를 잘 파싱하는 법”이 아니었습니다.
같은 모양의 숫자라도 영수증에서 맡은 역할이 다르다는 것이었습니다.
영수증에는 amount가 아니라 amount 역할이 여러 개 있습니다
한 장에 나타나는 금액 후보를 나누면 금세 복잡해집니다.
합계 9,300: 영수증이 선언한 paid total신용카드 결제금액 4,300: 카드가 부담한 settlement contribution상품권 결제금액 5,000: 비카드 tender가 부담한 contribution부가세 845: total 안에 포함된 다른 figure할인금액 -600: 가격을 낮춘 adjustment잔액 10,000: 이번 구매가 아니라 payment instrument에 남은 balance승인번호 1234: 금액처럼 생겼지만 돈이 아닌 identifier
정규식 하나로 “숫자 찾기”를 하면 이 역할은 모두 사라집니다.
parser가 보는 것은 다음과 비슷한 후보 목록뿐입니다.
9300, 5000, 4300, 845, 600, 10000, 1234
여기서 max()를 부르면 잔액이 total이 될 수 있습니다.
마지막 값을 고르면 승인번호가 total이 될 수 있습니다.
카드와 상품권을 더하면 이번에는 맞지만, refund나 실패한 결제 시도가 끼는 순간 새 규칙이 필요합니다.
그래서 total selection은 arithmetic보다 먼저 label semantics를 읽어야 합니다.
명시적 total과 결제 기여분을 같은 후보로 두지 않았습니다
현재 selectTotal()은 OCR line을 아래에서 위로 훑습니다.
아래쪽에 있는 카드 결제 line을 먼저 만날 수 있습니다. 하지만 카드 payment label이라고 즉시 반환하지 않습니다.
if (CARD_PAYMENT_TOTAL_LABEL.test(line.text)) {
const paymentAmount = cardPaymentAmount(line.text, currency, labelledAmount === null ? amountMinor : null);
if (paymentAmount === null) continue;
cardPayment ??= { amountMinor: paymentAmount, evidence: value };
continue;
}
return { amountMinor, evidence: value };
카드 결제액은 cardPayment에 보류합니다.
그보다 위에서 일반 합계나 TOTAL을 찾으면 즉시 그 값을 반환합니다.
끝까지 명시적 total을 찾지 못했을 때만 보류한 card payment를 fallback으로 사용합니다.
이 작은 continue가 우선순위를 만듭니다.
explicit total
> deferred card payment
> currency-marked amount-only fallback
상품권은 또 다르게 처리합니다.
isTenderPaymentLine()은 상품권, gift card, voucher, coupon, 포인트 같은 label과 payment action이 함께 있는 line을 찾습니다.
그 line은 paid total 후보가 아니라 별도의 tender contribution으로 분류돼 total scan에서 제외됩니다.
즉 카드와 상품권을 모두 “total 비슷한 것”으로 한 배열에 넣고 score를 매기지 않습니다. 역할이 다른 단계로 분리합니다.
explicit total이 이기는 규칙을 먼저 red로 만들었습니다
이번 글을 검증하며 현재 parser에 일부러 잘못된 기대를 넣었습니다.
const result = selectTotal(
evidenceLines("합계 9,300\n" + "상품권 결제금액: 5,000\n" + "신용카드 결제금액: 4,300원\n"),
"KRW"
);
assert.equal(result?.amountMinor, 4300);
“마지막 카드 결제액이 total일 것”이라는 sentinel은 예상대로 실패했습니다.
sentinel expected card contribution 4300, got 9300
그다음 실제 계약을 검사했습니다.
assert.equal(result?.amountMinor, 9300);
assert.equal(result?.evidence.text, "합계 9,300");
이 검사는 통과했습니다.
현재 total.test.ts의 focused test 20개도 통과했습니다.
그 suite에는 한국어와 영어의 explicit total, gift certificate, card approval metadata, 실패한 카드 시도, split label, letter-spaced Korean label, VAT row, currency glyph 같은 경계가 들어 있습니다.
여기서 20이라는 숫자는 parser 전체의 품질 점수가 아닙니다. 현재 한 test file에 선언된 20개 total-selection behavior가 실행됐다는 뜻입니다.
승인번호 1234는 마지막 amount가 아닙니다
다음 line은 더 까다롭습니다.
신용카드 결제금액: 4,300원 승인번호: 1234
일반 amount parser는 한 line에 숫자가 여러 개 있으면 마지막 amount를 반환합니다.
이 line 전체를 그대로 넘기면 4,300이 아니라 1,234를 얻습니다.
정규식이 고장 난 것이 아닙니다. parser는 “마지막 숫자를 달라”는 계약을 정확히 수행했습니다. 잘못된 것은 card-payment line을 일반 amount line처럼 읽은 호출자입니다.
현재 구현은 승인번호, reference, authorization, approval, auth, balance, fee 같은 metadata marker를 먼저 찾습니다.
function cardPaymentAmount(line: string, currency: Currency, splitAmount: number | null): number | null {
if (CARD_PAYMENT_FAILURE_LABEL.test(line)) return null;
const metadata = CARD_PAYMENT_NON_PAYMENT_LABEL.exec(line);
if (metadata !== null) {
return labeledAmount(line.slice(0, metadata.index), currency);
}
return labeledAmount(line, currency) ?? splitAmount;
}
금액을 읽을 때만 metadata 앞 segment를 사용합니다.
신용카드 결제금액: 4,300원 | 승인번호: 1234
<-------- amount segment -------> <--- metadata --->
metadata-only line이라면 amount segment에 금액이 없으므로 후보를 버립니다.
신용카드 결제금액 승인번호: 1234
1234가 positive integer라는 이유만으로 돈이 되지 않습니다.
parse segment는 잘라도 evidence line은 자르지 않았습니다
여기서 한 가지 유혹이 생깁니다.
metadata 앞까지만 잘라 파싱했으니 evidence도 이렇게 저장하면 깔끔해 보입니다.
신용카드 결제금액: 4,300원
하지만 이 regression test가 parser에 전달한 전체 OCR line은 이것입니다.
신용카드 결제금액: 4,300원 승인번호: 1234
현재 selectTotal()은 금액을 계산할 때는 앞 segment만 읽지만, 반환하는 evidence는 원래 OcrEvidence line을 그대로 유지합니다.
export interface SelectedTotal {
amountMinor: number;
evidence: OcrEvidence;
}
cardPayment ??= {
amountMinor: paymentAmount,
evidence: value,
};
이 구분이 중요합니다.
- parsing view: metadata 앞까지만 읽어
amountMinor: 4300을 계산합니다. - evidence view: OCR이 실제 인식한 전체 line을 보존합니다.
evidence를 잘라 저장하면 결과는 더 예뻐 보일 수 있습니다. 대신 provenance가 약해집니다.
UI가 원문 line을 찾거나 image box에 연결하려 할 때, parser가 만든 축약 문장은 원본에 존재하지 않을 수 있습니다.
독자는 승인번호가 함께 찍혔다는 맥락도 잃습니다.
정답을 설명하기 위한 evidence가 정답에 맞게 편집돼서는 안 됩니다.
돈은 float가 아니라 integer minor unit입니다
선택된 결과는 number 하나가 아니라 역할과 단위를 가진 contract로 이동합니다.
export interface Money {
amountMinor: number;
currency: "KRW" | "USD";
}
이 프로젝트의 규칙은 KRW를 whole won, USD를 cent 단위 integer로 보관하는 것입니다.
KRW 9,300 -> amountMinor: 9300
USD $12.99 -> amountMinor: 1299
amountMinor만으로 이 값이 total인지, tender인지, tax인지는 알 수 없습니다.
그래서 total selection이 먼저 역할을 정하고, 그 결과와 evidence를 함께 넘깁니다.
숫자 표현과 숫자 의미는 별도 문제입니다.
- integer minor unit은 rounding과 단위 표현을 다룹니다.
- label selection은 어떤 figure가 paid total인지 다룹니다.
- evidence는 그 판단을 독자가 다시 확인할 수 있게 합니다.
세 문제를 하나의 “amount parser”로 뭉치면 edge case가 늘 때마다 정규식이 모든 책임을 떠안습니다.
실패한 카드 시도도 total 후보가 아닙니다
카드 line에는 정상 결제만 나타나지 않습니다.
Credit Card Payment Declined $12.99
Credit Card Payment Failed $12.99
Credit Card Payment Voided $12.99
Credit Card Payment Reversed $12.99
현재 구현은 declined, failed, voided, reversed를 card-payment failure label로 다룹니다.
이 중 하나가 있으면 cardPaymentAmount()는 null을 반환합니다.
amount가 분명히 찍혀 있어도 이번 receipt의 paid total이라는 뜻은 아닙니다.
이 규칙도 “가장 큰 숫자”나 “가장 마지막 숫자”로는 표현할 수 없습니다. transaction status를 읽어야 합니다.
그리고 규칙을 무한히 넓히지도 않았습니다.
한국어 card pattern은 현재 관찰된 신용카드 결제금액 형태를 대상으로 합니다.
새 label을 support하려면 실제 fixture와 regression이 먼저 와야 합니다.
모든 카드 표현을 추측으로 enumerating하는 것은 robustness가 아니라 새로운 false positive 목록을 만드는 일입니다.
total selection은 rank가 아니라 evidence policy였습니다
처음에는 amount 후보에 score를 주면 될 것 같았습니다.
큰 숫자 +3
아래쪽 +2
통화 기호 +2
TOTAL label +5
하지만 score는 서로 다른 역할을 같은 축 위에 놓습니다. 잔액이 아주 크거나 approval number가 아래쪽에 있으면 잘못된 후보가 다시 올라옵니다.
현재 규칙은 hierarchy에 가깝습니다.
- OCR line을 아래에서 위로 훑으며
total,grand total,결제금액,합계, 영어 card-payment label에 해당하는 후보를 찾습니다. - gift certificate 같은 tender contribution과 다른 figure를 이름 붙인 line은 total 후보에서 제외합니다.
- 유효한 card payment는 즉시 반환하지 않고 deferred fallback으로 보관합니다.
- 그 밖의 유효한 explicit total 후보를 만나면 해당 amount를 반환합니다.
- approval/reference/balance/fee는 card amount를 자르는 metadata boundary로 보고, failed card attempt는 버립니다.
- 역방향 scan이 아무 explicit total도 반환하지 못하면 보관한 card payment를 먼저 사용하고, 그것도 없을 때 currency-marked amount-only line을 마지막 fallback으로 사용합니다.
- 어떤 경로로 골라도 원래 OCR evidence line을 함께 반환합니다.
이 hierarchy는 한 번에 모든 영수증을 이해하지 않습니다. 현재 관찰하고 test한 역할만 분리합니다.
그 정도가 오히려 안전했습니다.
영수증 total은 숫자 문제가 아닙니다.
“어떤 숫자인가?”를 묻기 전에 “이 숫자가 무엇이라고 인쇄됐는가?”를 물어야 합니다.
그리고 parser가 답을 골랐다면 금액만 내보내지 말아야 합니다. 독자가 다시 읽을 수 있는 원래 line도 함께 내보내야 합니다.
9,300 = 5,000 + 4,300이라는 산수는 맞습니다.
하지만 이 test case에서 가장 강한 근거는 산수가 아니라 input에 적혀 있던 두 글자였습니다.
합계
다음 글도 받아보세요.
글을 끝까지 읽으셨다면, 다음 글은 받은편지함이나 RSS 리더에서 만나보세요.