DEV Community

Subhendu Das
Subhendu Das

Posted on

GSTR-2B Reconciliation with Configurable Tolerances in GSTBot

The Problem: Exact Matching Breaks at Scale

Indian SMBs filing monthly GST returns face a recurring bottleneck: reconciling their purchase register against the auto-drafted GSTR-2B. The government portal provides invoice-level data, but vendor-side errors — typos in GSTIN, missing HSN codes, rounded tax amounts, date mismatches — cause exact-string matching to fail. Enterprise tools handle this with fuzzy logic, but they start at ₹50,000+/year. SMB-focused tools often skip reconciliation entirely and only generate GSTR-1/3B payloads.

GSTBot targets the middle: a FastAPI service that reconciles with configurable tolerances instead of requiring perfect matches.

How the Reconciliation Engine Works

The pipeline runs as a Celery task chain triggered after bulk invoice ingestion (PDF via pdfplumber, images via pytesseract, Excel via openpyxl). Each extracted invoice becomes a PurchaseInvoice row in PostgreSQL with fields: vendor_gstin, invoice_number, invoice_date, taxable_value, igst, cgst, sgst, hsn_code, place_of_supply.

GSTR-2B data is fetched from the GSTN API (or uploaded as JSON) and stored as GSTR2BInvoice rows with the same schema. The reconciliation job then runs a multi-pass match:

# services/reconciliation/matcher.py
from decimal import Decimal
from dataclasses import dataclass

@dataclass
class ToleranceConfig:
    gstin_exact: bool = True
    invoice_number_fuzzy: int = 2  # Levenshtein distance
    date_window_days: int = 3
    amount_pct: Decimal = Decimal('0.5')  # 0.5%
    hsn_prefix_len: int = 4

def match_invoices(purchase: PurchaseInvoice, gstr2b: GSTR2BInvoice, cfg: ToleranceConfig) -> MatchResult:
    checks = []

    # GSTIN must match exactly — regulatory requirement
    checks.append(purchase.vendor_gstin == gstr2b.vendor_gstin)

    # Invoice number: allow OCR typos
    checks.append(levenshtein(purchase.invoice_number, gstr2b.invoice_number) <= cfg.invoice_number_fuzzy)

    # Date within window
    checks.append(abs((purchase.invoice_date - gstr2b.invoice_date).days) <= cfg.date_window_days)

    # Amount within percentage tolerance
    for field in ('taxable_value', 'igst', 'cgst', 'sgst'):
        p_val = getattr(purchase, field)
        g_val = getattr(gstr2b, field)
        if p_val == 0 and g_val == 0:
            checks.append(True)
        elif p_val == 0 or g_val == 0:
            checks.append(False)
        else:
            pct_diff = abs(p_val - g_val) / max(p_val, g_val) * 100
            checks.append(pct_diff <= cfg.amount_pct)

    # HSN: match first 4 digits (chapter level)
    checks.append(purchase.hsn_code[:cfg.hsn_prefix_len] == gstr2b.hsn_code[:cfg.hsn_prefix_len])

    return MatchResult(matched=all(checks), details=checks)
Enter fullscreen mode Exit fullscreen mode

The ToleranceConfig is stored per-company in Redis and editable via the React settings page. Defaults come from GSTN's own guidance: GSTIN exact, invoice number ±2 chars, date ±3 days, amount ±0.5%, HSN chapter-level.

Exception Classification and Action Recommendations

When matched=False, the engine classifies the failure reason and suggests a fix. The MatchResult.details array maps to an enum:

# models/exception.py
class ExceptionType(Enum):
    GSTIN_MISMATCH = "gstin_mismatch"
    INVOICE_NUMBER_TYPO = "invoice_number_typo"
    DATE_OUT_OF_WINDOW = "date_out_of_window"
    AMOUNT_VARIANCE = "amount_variance"
    HSN_MISMATCH = "hsn_mismatch"
    MISSING_IN_GSTR2B = "missing_in_gstr2b"
    MISSING_IN_PURCHASE = "missing_in_purchase"

RECOMMENDED_ACTIONS = {
    ExceptionType.GSTIN_MISMATCH: "Verify vendor GSTIN on GST portal; request corrected invoice.",
    ExceptionType.INVOICE_NUMBER_TYPO: "Accept match — likely OCR/vendor typo.",
    ExceptionType.DATE_OUT_OF_WINDOW: "Check if invoice belongs to adjacent tax period.",
    ExceptionType.AMOUNT_VARIANCE: "Confirm rounding or rate difference; adjust if < 1%.",
    ExceptionType.HSN_MISMATCH: "Update HSN in purchase register to match GSTR-2B.",
    ExceptionType.MISSING_IN_GSTR2B: "Vendor hasn't filed GSTR-1; track for Rule 37 reversal.",
    ExceptionType.MISSING_IN_PURCHASE: "Invoice missing from your records; add to purchase register.",
}
Enter fullscreen mode Exit fullscreen mode

Each exception row appears in the React dashboard (packages/web/src/pages/Reconciliation.tsx) with a one-click "Accept" or "Reject" button that writes a ReconciliationDecision record. Accepted matches flow into the ITC eligible pool; rejected ones are flagged for Rule 37/42/43 reversal tracking.

Supplier Filing Health Scoring

Beyond the current month, GSTBot aggregates each vendor's match history into a health score:

-- migrations/012_supplier_health.sql
CREATE MATERIALIZED VIEW supplier_health AS
SELECT
  vendor_gstin,
  COUNT(*) AS total_invoices,
  SUM(CASE WHEN matched THEN 1 ELSE 0 END)::float / COUNT(*) AS match_rate,
  AVG(CASE WHEN matched THEN amount_pct_diff ELSE NULL END) AS avg_amount_variance,
  MAX(invoice_date) AS last_seen,
  CASE
    WHEN COUNT(*) >= 10 AND SUM(CASE WHEN matched THEN 1 ELSE 0 END)::float / COUNT(*) > 0.95 THEN 'green'
    WHEN COUNT(*) >= 5 AND SUM(CASE WHEN matched THEN 1 ELSE 0 END)::float / COUNT(*) > 0.80 THEN 'amber'
    ELSE 'red'
  END AS health_band
FROM reconciliation_decisions
GROUP BY vendor_gstin;
Enter fullscreen mode Exit fullscreen mode

The score refreshes nightly via a Celery beat task. Finance teams use it to prioritize vendor follow-ups before the GSTR-1 filing deadline.

From Reconciliation to Portal-Ready JSON

Once all exceptions are resolved, a single POST to /api/v1/returns/gstr-3b/prepare aggregates the matched ITC into the section-wise payload the GSTN portal expects (Table 4A, 4B, 4C). The same endpoint serves GSTR-1 B2B/B2C/CS/EXP tables. No manual CSV formatting required.

What It Looks Like in Practice

  1. Upload: Drag 200 vendor PDFs into the intake page. Celery workers extract ~1,800 line items in ~90 seconds (Redis queues, 4 workers).
  2. Reconcile: Click "Run Reconciliation". The matcher processes against the latest GSTR-2B JSON. 1,620 lines auto-match; 180 exceptions appear grouped by type.
  3. Resolve: Filter to "AMOUNT_VARIANCE". 140 are <0.3% rounding differences — bulk accept. 40 need vendor confirmation — export list to email.
  4. File: Generate GSTR-3B JSON. Download, upload to portal. Done.

The entire flow runs on the self-hosted stack (Docker Compose: postgres:16, redis:7, fastapi:latest, celery:5.3, react:18 + vite). No external SaaS dependency for the reconciliation logic itself.

Top comments (0)