Matching algorithm

Every GSTR-2B document and every books document becomes a Doc (see models.py). Matching happens only between documents with the same GSTIN and the same kind (invoice, credit/debit note, import). Credit notes never match invoices.

1. Normalisation

Invoice numbers are reduced to a comparison key (normalize_invoice):

  1. upper-case;
  2. remove fiscal-year tokens: 2024-25, 24-25, 2024/2025 (only when the second number really is the next year, so 90/26 survives);
  3. remove every non-alphanumeric character;
  4. strip leading zeros of each digit run;
  5. drop a generic prefix (INVOICE, INV, BILL, TI, ...).
Raw Key
INV/2024-25/0012 12
INV12 12
inv-0012 12
S90/26-27 S90
A/25-26/7 A7
VX260900123 VX260900123

Amounts are rounded half-up to paise; dates accept dd-mm-yyyy, ISO, dd-Mon-yy, Excel serials and profile-specific formats. GSTINs are validated with the mod-36 algorithm: each of the first 14 characters is mapped to 0-35, multiplied by 1 (odd position) or 2 (even), the product p becomes p // 36 + p % 36, the sum s gives check character (36 - s % 36) % 36.

2. Passes

Each pass considers only still-unmatched documents. Within a pass, all acceptable (2B, books) pairs are scored and assigned greedily from best to worst, so the closest partner wins regardless of file order.

# Name Accepts a pair when Score (lower wins)
1 exact same key, same date, taxable and each tax head within Rs 1 all equal
2 gstin_invoice same key (amounts, date, head may differ) amount difference + date gap
3 gstin_amount_date taxable and total tax within Rs 1, date within 3 days date gap + tax difference
4 fuzzy_invoice Levenshtein similarity >= 0.85, date within 45 days, taxable within 5%, and neither key is a prefix of the other 1 - similarity
5 one_to_many see below first subset found

Pass 4 has two guards that came from real failure modes. Sequential invoices from one vendor (...212 and ...218) are 90% similar, so a similar taxable value is required. A prefix relation (INV9 and INV9/1) is the signature of a split invoice and is left to pass 5.

One-to-many (pass 5)

For each unmatched 2B document, search the unmatched books documents of the same GSTIN for a subset whose sums match; then the reverse (one books document against several 2B documents). Bounds keep it cheap and predictable:

Worked example: 2B has INV9 for 10,000 + 1,800 tax. Books have INV9/1 (4,000 + 720) and INV9/2 (6,000 + 1,080). Pass 1 to 4 find nothing (prefix guard), pass 5 finds {INV9/1, INV9/2}: taxable 10,000 and tax 1,800, so one MATCHED record holds one 2B id and two books ids.

Documents left over become ONLY_IN_2B or ONLY_IN_BOOKS.

3. Status classification

A pair found by any of passes 1 to 4 is classified by compare, checking in this order and keeping all reasons found:

  1. TAX_HEAD_MISMATCH: one side charges IGST only and the other CGST+SGST only (place-of-supply problem);
  2. RATE_MISMATCH: taxable values agree but the effective rate differs (declared rt for 2B, otherwise tax/taxable snapped to the nearest GST slab);
  3. AMOUNT_MISMATCH: any of taxable, IGST, CGST, SGST, cess differs by more than Rs 1;
  4. INV_NO_MISMATCH: keys differ (found by pass 3 or 4);
  5. DATE_MISMATCH: dates differ;
  6. otherwise MATCHED.

Example: 2B TX-386 charges CGST 11,190.10 + SGST 11,190.10 on 447,603.98; books record IGST 22,380.20 on the same value. The pair is found by pass 2 and classified TAX_HEAD_MISMATCH.

4. ITC amounts per match

See gst-rules.md. For a paired match: claimable = min(books ITC, 2B ITC) (credit cannot exceed what 2B shows), zeroed for blocked, ineligible, time-barred or 2B-unavailable items, minus any 180-day reversal. at_risk is the booked ITC not supported by 2B. ONLY_IN_2B reports itc_unbooked. Credit notes carry a negative sign.

5. Carry-forward

After a run, every ONLY_IN_BOOKS document that is ITC-eligible, not reverse charge and not a credit note is stored in the SQLite carry_forward table with its origin period. The next run loads all pending items from earlier periods, adds them to the books side, and marks the ones that matched as resolved. Re-running a period is idempotent: items already resolved by that period are loaded again, and the period's own entries are rebuilt.

6. Explain

explain replays nothing: it reads the stored run. For paired documents it prints the side-by-side values, the reasons and the pass that matched. For unmatched documents it ranks same-GSTIN candidates on the other side (similarity, amount closeness, date gap) and shows what status a pairing would have had.