End-to-End Data Flow

From a purchase-register upload to a risk-scored reconciliation report. Eight stages, each calling a specific module, this is the control flow for POST /reconcile.

← Architecture Overview Invoice Matching Logic → Reconciliation Engine → Reconciliation Workbench →
Stage-by-Stage Walkthrough

A single reconciliation run touches both services, watsonx.ai (optional), COS, and all 8 AI modules. Each stage below shows inputs, outputs, and which file does the work.

1

Upload

User uploads a purchase register (Excel / CSV), a GSTR-2B JSON from the GSTN portal, or PDF/image invoices.

API Gateway

Where it runs

  • server.tsPOST /api/sessions/:id/upload
  • storage/storage.service.ts puts the file in COS under a generated key
  • db/repositories/document.repo.ts creates a documents row (status=pending)

Supported formats

  • .xlsx / .xls / .csv → purchase register (auto-detected: Tally, Zoho, generic)
  • .json → GSTR-2B or GSTR-1 (auto-detected from JSON keys)
  • .pdf → PDF invoice (parsed with pdfplumber + watsonx LLM)
  • .jpg / .png / .tif → image invoice (requires watsonx OCR)
  • Unknown extensions → manual type dropdown lets user classify
IN: multipart file + doc_type + source_format OUT: { id, key, size, filename }
▼ ▼ ▼
2

Parse

Format-aware parsers turn the raw files into a unified ParsedInvoice shape.

Python AI

Excel / purchase register

  • excel_parser/format_detectors.py identifies Tally / Zoho / generic
  • excel_parser/column_mapper.py fuzzy-matches headers to canonical fields
  • excel_parser/parser.py runs per-row validation and produces a ParseReport

GSTR-2B JSON

  • gstr_parser/section_extractor.py pulls the B2B block from the GSTN envelope
  • gstr_parser/gstr2b_parser.py handles both post-Oct-2024 and legacy itms[] shapes
  • gstr_parser/deduplication.py assigns inv_uid and a record_hash

PDF / Image Invoice Extraction, Dual Mode

  • Mode 1: pdfplumber + watsonx LLM (default for .pdf), pdfplumber extracts text from native PDFs (free, no API cost for text extraction). The extracted text is then sent to watsonx.ai Mistral model which parses structured invoice fields (invoice #, GSTIN, amounts, line items). Only LLM token cost applies (covered by existing watsonx credits).
  • Mode 2: watsonx Text Extraction / OCR (for images, scanned PDFs), IBM watsonx.ai's built-in document extraction service with OCR. Handles scanned PDFs, JPG, PNG, and TIFF images. Requires a COS connection asset in the watsonx project. Pricing: 100 documents/month free, then $0.038/page.
  • Cost-optimisation strategy: PDFs always go through pdfplumber first (free). If pdfplumber cannot extract text (<50 chars, scanned/image-only PDF), the UI shows a "Retry with watsonx OCR" button so the user can explicitly opt in to the paid OCR path. Image files (.jpg/.png/.tif) go directly to watsonx OCR since pdfplumber cannot process them.
  • Endpoint: POST /parse/pdf?ocr_mode=pdfplumber|watsonx, accepts multipart file upload. Both modes return the same rows[] format used by the Excel parser, so downstream reconciliation and validation work identically.
  • Setup for watsonx OCR: One-time: create a COS connection asset in watsonx.ai project settings → Connections → IBM Cloud Object Storage, then set WATSONX_COS_CONNECTION_ID in the AI service's environment.
IN: .xlsx / .csv / .json / .pdf / .jpg / .png / .tif OUT: ParsedInvoice[] (books) + ParsedInvoice[] (gstr2b)
▼ ▼ ▼
3

Extract (watsonx.ai LLM, used by PDF parse pipeline)

watsonx.ai Mistral model turns raw invoice text into structured fields. Called automatically as part of the PDF/image parse pipeline (Stage 2), or standalone via the AI Extraction page.

watsonx.ai

Call path

  • watsonx/client.py returns a singleton ModelInference
  • watsonx/extraction.py builds the prompt and calls Mistral with temperature=0.1
  • 60-second watchdog via signal.alarm, returns ExtractionResponse
  • For PDFs: pdfplumber text → extract_invoice_fields(text)InvoiceFields
  • For images/scanned PDFs: watsonx OCR text → extract_invoice_fields(text)InvoiceFields

Output shape

  • Invoice # / date / supplier GSTIN / buyer GSTIN
  • Line items: HSN, qty, rate, taxable, CGST/SGST/IGST
  • Totals + raw model output retained for audit
  • Converted to rows[] format matching the Excel parser for downstream compatibility
IN: invoice text (from pdfplumber or watsonx OCR) OUT: rows[] with InvoiceFields data + _field_confidence
▼ ▼ ▼
watsonx OCR Pipeline, Timing Breakdown

End-to-end OCR extraction takes ~35-40 seconds. Here is where the time goes. The LLM does NOT see the image, it only sees the text that OCR already produced.

Pipeline Steps & Latency

Step 1  Upload file to COS                          ~1-2s   # boto3 PutObject
Step 2  Get/create COS connection asset              ~1-2s   # cached after first call
Step 3  watsonx OCR job                              ~20-30s # THE BIG ONE
        # Single job outputs both markdown AND tables_json simultaneously.
        # Adding TABLES_JSON to result_formats doesn't launch a second job
        #, it just tells the same job to also output table data.
Step 4  Polling loop                                 # polls every 3s until complete (included in step 3)
Step 5  Read results from COS                        ~1-2s   # download markdown + tables_json
Step 6  LLM field extraction (Mistral on text)       ~5-10s  # watsonx.ai inference
Step 7  Post-processing (merge + validate + score)   <1ms   # pure Python, no API calls
                                                    --------
                                           Total    ~35-40s

LLM Model Options for Field Extraction (Step 6)

# The LLM parses already-extracted text into structured JSON.
# It does NOT see the image. For this task, model size has diminishing returns.

mistral-small-3.1-24b (current)   24B   ~5-10s   # Good at structured JSON extraction
meta-llama/llama-3-3-70b-instruct  70B   ~10-20s  # Better reasoning, but 2x slower
ibm/granite-3-8b-instruct          8B    ~2-3s    # Faster, but may miss edge cases

Why Model Size Matters Less Than You Think

# The real quality bottleneck is the OCR step (Step 3).
# If OCR garbles a digit, no LLM can fix it.
#
# The tables_json cross-referencing (Step 7) is more
# valuable than upgrading the LLM, it catches OCR
# digit errors that no model size can fix.
#
# A 70B model might handle messy/ambiguous text slightly
# better (e.g. garbled columns), but adds ~10s to an
# already slow pipeline. Not worth the trade-off.
▼ ▼ ▼
4

Validate

Every parsed invoice is checked against GST rules. Failing rows are still included but flagged, reviewer sees them in the final report.

invoice_validator

Rules applied

  • gstin.py, 15-char structure + Luhn Mod 36 checksum
  • hsn_sac.py, 4 / 6 / 8-digit pattern, turnover-band warnings
  • tax_math.py, qty×rate = taxable, taxable×rate = tax, totals reconcile
  • tax_type.py, CGST+SGST for intra-state, IGST for inter-state
  • invoice_number.py, alphanumeric, ≤ 16 chars (Rule 46)

Output

  • A ValidationReport per invoice
  • Errors keyed by field path (e.g. supplier.gstin)
  • Non-blocking, invalid rows continue to normalization with flags
IN: ParsedInvoice[] OUT: (ParsedInvoice, ValidationReport)[]
▼ ▼ ▼
5

Normalize

Strip format noise from invoice numbers so that INV-00123/FY24 and inv 123 / fy24 reconcile in Pass 2.

invoice_normalizer

Transformations

  • Strip known prefixes: INV-, INV., TAX-
  • Unify separators (- / .)
  • Strip special chars & leading zeros
  • Case fold to upper
  • Transform audit trail retained on the record

Why this matters

  • Books often prefix invoice numbers (vendor-specific)
  • GSTR-2B strips prefixes at source
  • Without normalization, Pass 2 misses trivially matchable pairs
IN: ParsedInvoice.invoice_number OUT: NormalizedResult { original, normalized, transformations[] }
▼ ▼ ▼
6

Reconcile (5-pass engine)

Deterministic matching in descending strictness. See the dedicated Reconciliation Engine page for the full algorithm.

reconciliation

Pipeline within the engine

  • duplicate_detector.py removes exact dups, flags near-dups
  • Pass 1-5 in order: IRN → exact → fuzzy → amount+date → loose
  • auto_resolver.py promotes trivial mismatches back to MATCHED
  • cross_period.py retries residue against adjacent periods
  • confidence.py final per-entry confidence adjustment

Report fields per entry

  • match_category · MATCHED / MISMATCH / MISSING_IN_GSTR2B / MISSING_IN_BOOKS / PROBABLE
  • match_pass · which pass produced the match
  • match_confidence · 0.0-1.0
  • mismatch_types[] · TAXABLE_VALUE / TAX_AMOUNT / DATE / ...
  • auto_resolved · boolean
IN: books[] + gstr2b[] (normalized & validated) OUT: ReconciliationReport { summary, entries[] }
▼ ▼ ▼
7

Score ITC Risk

Turn reconciliation gaps into a prioritized action queue for the finance team.

itc_risk

Per-invoice scoring

  • itc_risk/scorer.py adds points per risk factor (missing in 2B, tax mismatch, cross-period, etc.)
  • Caps at 100 and assigns priority: CRITICAL / HIGH / MEDIUM / LOW
  • Each priority maps to an SLA: 3 / 7 / 15 / 30 days

Portfolio aggregation

  • itc_risk/aggregator.py sums ITC at risk (INR)
  • Counts per priority band
  • Top-N vendors by risk contribution, drives vendor outreach
IN: ReconciliationEntry[] OUT: InvoiceRiskScore[] + PortfolioRiskSummary
▼ ▼ ▼
8

Deliver Report

Combined reconciliation + risk payload returned to the API caller; persisted to the document's metadata.

API + DB

Persistence

  • documents.statuscompleted
  • documents.processed_at → now()
  • documents.metadata (JSONB) ← full report for audit

Response

  • Summary counts (matched, mismatched, missing, auto-resolved)
  • Top 20 CRITICAL entries with suggested action per entry
  • Portfolio risk KPIs (total ITC at risk, counts per band)
  • Link to CSV export for the CA team
IN: ReconciliationReport + risk scores OUT: HTTP 200 JSON + documents row updated
Core Data Models

A handful of Pydantic models carry state across the pipeline. Every stage reads or extends these.

ParsedInvoice

# gstr_parser / excel_parser output
supplier_gstin: str  # 15 chars
recipient_gstin: str
invoice_number: str
invoice_date: str    # DD-MM-YYYY
invoice_value: float
taxable_value: float
igst/cgst/sgst/cess: float
irn: str | None    # e-invoice only
inv_uid: str        # deterministic
record_hash: str    # change tracking

ReconciliationEntry

# reconciliation.engine output (per pair)
supplier_gstin: str
invoice_number: str
books_value: float | None
gstr2b_value: float | None
difference: float | None
match_category: MatchCategory
match_confidence: float
match_pass: str
mismatch_types: list[MismatchType]
auto_resolved: bool
books_record: dict | None
gstr2b_record: dict | None

InvoiceRiskScore

# itc_risk.scorer output
supplier_gstin: str
invoice_number: str
invoice_value: float
itc_amount: float
risk_score: int       # 0-100
risk_factors: list[str]
priority: RiskLevel    # CRITICAL | HIGH | MEDIUM | LOW
sla_days: int         # 3 / 7 / 15 / 30

ValidationReport

# invoice_validator output
is_valid: bool
errors: dict[str, list[str]]
  # field -> error messages
warnings: list[str]

# Errors are keyed by dotted field path:
# "supplier.gstin", "line_items[0].hsn_sac_code"...

ReconciliationReport

# reconcile() final payload
total_books_entries: int
total_gstr2b_entries: int
matched: int
mismatched: int
missing_in_gstr2b: int
missing_in_books: int
probable_matches: int
auto_resolved: int
duplicates_detected: int
entries: list[ReconciliationEntry]

Document (DB row)

# Node API postgres schema
id: UUID          # PK
filename: VARCHAR(255)
storage_uri: TEXT | null
doc_type: VARCHAR(50)
status: VARCHAR(20)
  # pending | processing | completed | error
metadata: JSONB
  # full reconciliation report here
uploaded_at: TIMESTAMPTZ
processed_at: TIMESTAMPTZ | null
Failure Modes & Graceful Paths

The system boots with missing backing services. Every boundary has a documented failure envelope.

DATABASE_NOT_CONFIGURED

Any /api/documents/* route returns 503 { error, detail }. The rest of the API still works. Health shows components.database = unavailable.

COS_NOT_CONFIGURED

/api/storage/* returns 503. Reconciliation paths that bypass upload (direct JSON) still work.

watsonx 401 / 502 / 504

main.py._do_extract maps IBM errors to explicit HTTP codes. Timeout is enforced by signal.alarm at 30 s / 60 s.

Excel format unknown

format_detectors.detect_format falls back to generic; column mapper produces a best-effort mapping. User sees unmapped columns in the ParseReport.

GSTIN checksum fail

Invoice is kept but flagged. Reconciliation still runs against the value it was given, the invalid GSTIN becomes a visible error in the reviewer UI.

Zero matches in any pass

Entry falls through to MISSING_IN_GSTR2B / MISSING_IN_BOOKS. Risk scorer flags it CRITICAL (+80). Reviewer queue sorts it to the top.

PDF: scanned/image-only

pdfplumber extracts <50 chars, returns 422 with message "Scanned or image-only PDF detected". UI shows a "Retry with watsonx OCR" button so user can explicitly opt in to the paid OCR path.

watsonx OCR not configured

If WATSONX_COS_CONNECTION_ID is not set, OCR mode returns 422 with setup instructions. Image uploads still show in the UI but cannot be processed until setup is complete.

PDF / Image Invoice Extraction, IBM watsonx Capabilities

A key IBM differentiator for the GST Co-Pilot. Two extraction modes provide cost-optimised document processing with enterprise OCR fallback.

Mode 1: pdfplumber + watsonx LLM (Default for PDFs)

Text extraction: FREE # pdfplumber, local, no API call
LLM parsing:     ~token cost # watsonx Mistral, already covered by credits
Scanned PDFs:   NOT SUPPORTED # falls back to OCR mode
Image files:    NOT SUPPORTED # pdfplumber is PDF-only

# Pipeline: .pdf → pdfplumber (text) → watsonx LLM (fields) → rows[]
# Used by: POST /parse/pdf?ocr_mode=pdfplumber

Mode 2: watsonx Text Extraction / OCR

OCR extraction: FREE (100 docs/mo) # then $0.038/page
LLM parsing:    ~token cost # same watsonx Mistral model
Scanned PDFs:  SUPPORTED # full OCR with IBM document extraction
Image files:   SUPPORTED # JPG, PNG, TIFF

# Pipeline: file → COS upload → watsonx OCR job → extracted text → LLM → rows[]
# Requires: COS connection asset in watsonx project
# Used by: POST /parse/pdf?ocr_mode=watsonx

Cost Optimisation Strategy

# 1. PDFs always try pdfplumber first (free)
# 2. If pdfplumber fails (<50 chars = scanned),
#    UI shows "Retry with watsonx OCR" button
# 3. User explicitly opts in to paid OCR path
# 4. Images (.jpg/.png/.tif) go directly to watsonx OCR
#    (pdfplumber cannot process them)
# 5. No automatic OCR for PDFs = zero surprise costs

Demo Verification Checklist

1. Upload sample-invoice-tcs.pdf to a session
2. Auto-detected as "PDF Invoice", pdfplumber mode
3. Click Upload & Parse → status transitions to parsed
4. Expand → see extracted fields (INV-2024-001234, TCS, etc.)
5. Upload unknown file (.txt) → manual type dropdown appears
6. Upload image (.jpg) → auto-detected as "Image Invoice"
# If watsonx COS configured:
7. Image parses via watsonx OCR → see pdf_ocr format
8. Force-fail a scanned PDF → "Retry with OCR" button appears

One-Time Setup: watsonx OCR

# In the IBM watsonx.ai console:
1. Add a connection to your object storage bucket
2. Copy the resulting connection asset id
3. Point the AI service at that connection

# The service reads storage endpoint, credentials and bucket
# from its environment. Nothing is baked into the image.