The Problem
The same invoice appears differently in two data sources. The system must recognize they refer to the same real-world transaction.
Purchase Register (Books)
What your accountant entered
Invoice #INV-2026/00042
Supplier GSTIN29AABCM1234F1Z5
Date15-04-2026
Total1,18,000.00
SourceTally ERP export
vs
GSTR-2B (Government Portal)
What the supplier filed
Invoice #202600042
Supplier GSTIN29AABCM1234F1Z5
Date15-04-2026
Total1,18,000.00
SourceGST Portal auto-draft
These are the same invoice. The supplier filed 202600042 on the GST portal, but your Tally export has it as INV-2026/00042 with a prefix and slashes. Without normalization, the system would flag this as "Missing in GSTR-2B", a false ITC risk alert.
What Makes Two Invoices "The Same"?
Four fields identify an invoice. The system uses different combinations at each pass, trading strictness for recall.
supplier_gstin
The anchor in every pass. Same supplier must have issued both records. 15-char alphanumeric, always exact match.
invoice_number
The primary identity. Used in passes 1-3. Normalized before comparison (prefix strip, separator removal, case fold).
invoice_value
The amount anchor. Used in passes 2, 4, and 5. Tolerance: exact (±Rs 1), loose (±1-2%).
invoice_date
Date proximity check. Used only in Pass 4 (±7 days). Catches same-amount invoices from the same supplier in the same week.
Matching Key Matrix
What each pass checks. Every pass requires GSTIN to match exactly, the other fields vary.
| Pass |
GSTIN |
Invoice Reference (IRN) |
Invoice Number |
Invoice Value |
Invoice Date |
Confidence |
Needs Review? |
| Pass 1 · IRN |
, |
Exact |
, |
, |
, |
1.00 |
No |
| Pass 2 · Exact |
Exact |
, |
Exact (normalized) |
± Rs 1 |
, |
0.95 |
No |
| Pass 3 · Fuzzy |
Exact |
, |
Fuzzy ≥ 85% |
, |
, |
0.80 |
No |
| Pass 4 · Amt+Date |
Exact |
, |
, |
± 2% |
± 7 days |
0.65 |
Yes |
| Pass 5 · Loose |
Exact |
, |
, |
± 1% |
, |
0.45 |
Always |
Invoice Number Normalization Pipeline
Before Pass 2 compares invoice numbers, both sides go through this 6-step normalization. The goal: remove formatting noise so that the underlying number can be compared.
1
Strip Whitespace
Leading and trailing spaces are removed. Tally exports often pad invoice numbers.
INV-2026/001
→
INV-2026/001
trim
2
Strip Known Prefixes
Removes common invoice prefixes that ERPs add but the GST portal strips. Case-insensitive match. Recognized: INV-, INV/, BILL-, BILL/, TAX-, TAX/.
INV-2026/001
→
2026/001
stripped_prefix:INV-
3
Remove Separators
Hyphens (-), slashes (/), dots (.), and spaces are all deleted. Different ERP systems use different separators for the same number.
2026/001
→
2026001
normalized_separators
4
Strip Special Characters
Everything except a-z, A-Z, 0-9 is removed. Catches stray #, @, unicode characters.
2026001#A
→
2026001A
stripped_special_chars
5
Strip Leading Zeros
Tally often pads with zeros (00042). The GST portal usually doesn't. Minimum result: 0.
00042
→
42
stripped_leading_zeros
6
Case Fold to Uppercase
Final step. inv and INV become identical after this.
abc123def
→
ABC123DEF
case_folded
Real-World Matching Examples
Common scenarios from GST reconciliation showing which pass catches each case and why.
E-Invoice with IRN
BooksINV-2026/1001, IRN: abc...xyz
GSTR-2B20261001, IRN: abc...xyz
Pass 1 · IRN_EXACT
1.00
Prefix + Separator Difference
BooksINV-2026/00042
GSTR-2B202600042
NormalizedBoth → 202600042
Pass 2 · EXACT_MATCH
0.95
Leading Zeros Stripped
BooksBILL-00123
GSTR-2B123
NormalizedBoth → 123
Pass 2 · EXACT_MATCH
0.95
OCR Typo (I → 1)
BooksINV-0426-789
GSTR-2B1NV0426789
Normalized0426789 vs 1NV0426789 (87% similar)
Pass 3 · FUZZY_INVOICE
0.80
Completely Different Numbers
BooksPO-2026-0099 (purchase order #)
GSTR-2BSI-88712 (sales invoice #)
ButSame GSTIN, amount ±1.5%, date ±2 days
Pass 4 · AMOUNT_DATE
0.65
Last Resort, Amount Only
BooksGRN-44521, 15-Apr-2026, Rs 45,000
GSTR-2BTI/2026/891, 01-Apr-2026, Rs 45,200
Match onSame GSTIN + amount within 0.4%
Pass 5 · LOOSE_MATCH
0.45
The Waterfall in Action
Illustrative breakdown for a typical month with ~200 book entries and ~195 GSTR-2B entries. Each pass eats from the unmatched pool, leftovers flow down.
Match Waterfall (illustrative, 200 books vs 195 GSTR-2B)
What Happens to Unmatched Invoices?
After all 5 passes complete, leftover invoices with no match are classified into two buckets. These are the entries that need human attention, and the ITC risk scorer ensures the most critical ones surface first.
⚠
MISSING_IN_GSTR2B
Book entry with no match in government data
You recorded a purchase and claimed ITC, but the supplier has not filed this invoice on the GST portal. Until the supplier files, the government does not recognize this ITC, it can be reversed during assessment.
Common causes:
- › Supplier hasn't filed their GSTR-1 yet (late filing)
- › Supplier filed under a different GSTIN (branch vs HO)
- › Invoice number entered completely differently by supplier
- › Bogus invoice, supplier doesn't exist or didn't issue it
Risk Impact
+80 risk points → almost always CRITICAL priority → 3-day SLA for vendor follow-up
📋
MISSING_IN_BOOKS
GSTR-2B entry with no match in purchase register
The supplier filed an invoice for you on the portal, but it's not in your books. You may be missing out on legitimate ITC, or the supplier filed incorrectly.
Common causes:
- › Purchase not yet recorded in Tally/Zoho (data entry lag)
- › Invoice booked in wrong period (e.g. March books, April GSTR-2B)
- › Supplier filed an invoice to the wrong buyer GSTIN
- › Duplicate filing by supplier, same invoice, two entries
Action Required
Verify with supplier. If legitimate, record the purchase to claim ITC. If wrong, no action needed, it doesn't affect your returns.
After Classification, The Action Pipeline
Step 1
ITC Risk Scoring
Each unmatched entry gets a 0-100 risk score based on factors
Step 2
Priority Assignment
CRITICAL (71-100), HIGH (51-70), MEDIUM (31-50), LOW (0-30)
Step 3
SLA Assignment
CRITICAL: 3 days, HIGH: 7 days, MEDIUM: 15 days, LOW: 30 days
Step 4
Reviewer Queue
Finance team sees sorted list: critical first, with suggested actions
Risk Score Factors for Unmatched Invoices
MISSING_IN_GSTR2B (base)
+80
ITC_NOT_AVAILABLE (2B flag)
+90
HIGH_VALUE_INVOICE (≥ Rs 1L)
+10
VENDOR_LOW_COMPLIANCE (grade C)
+15
Example: An invoice worth Rs 2,50,000 from a grade-C vendor that's missing in GSTR-2B would score:
80 (missing) + 10 (high value) + 15 (low compliance) = 105 → capped at 100 → CRITICAL, 3-day SLA
Before Giving Up, Cross-Period Matching
Before classifying an invoice as missing, the engine runs one more check: cross-period matching. It looks for the unmatched invoice in GSTR-2B data from adjacent filing periods (default: 2 months back, 1 month forward).
This catches a very common scenario: the supplier filed the invoice in March, but your books recorded it in April. Or the supplier was late and it shows up in May's GSTR-2B.
Current: April 2026
Searches: Feb, Mar, May
Match confidence
0.90 - (0.05 × months offset)
Risk score addition
+20 for CROSS_PERIOD_MATCH
Why Not Just Fuzzy-Match Everything?
A common question. Here's why the cascading 5-pass design matters.
Confidence Matters for ITC
Input Tax Credit worth lakhs rides on match quality. A Pass 1 (IRN) match means the government already verified the invoice. A Pass 5 (loose) match means only the amounts roughly align, the CA needs to verify manually. Confidence scores drive the review queue priority.
False Positives Are Expensive
If everything was fuzzy-matched, two invoices from the same supplier with similar amounts but different dates could get incorrectly paired. The stricter passes prevent this, Pass 2 requires the invoice number to match exactly (after normalization), catching 70%+ of cases with high confidence before fuzzy logic even runs.
Performance
Fuzzy matching is O(n×m), every book entry vs every GSTR-2B entry. Exact matching uses hash indexes, O(n). By catching 70%+ in Passes 1-2, the expensive fuzzy pass only runs on the 30% residue.
Source Files
| invoice_normalizer/normalizer.py | 6-step normalization pipeline |
| invoice_normalizer/matcher.py | similarity_score() and find_best_match() using rapidfuzz |
| invoice_normalizer/models.py | NormalizerConfig, NormalizedResult, MatchResult |
| reconciliation/passes/irn_match.py | Pass 1, IRN exact match |
| reconciliation/passes/exact_match.py | Pass 2, GSTIN + normalized inv # + value |
| reconciliation/passes/fuzzy_match.py | Pass 3, GSTIN + fuzzy inv # (rapidfuzz ≥ 0.85) |
| reconciliation/passes/amount_date_match.py | Pass 4, GSTIN + amount ±2% + date ±7 days |
| reconciliation/passes/loose_match.py | Pass 5, GSTIN + amount ±1% only |