Spaces:
Running
Running
| """ | |
| InvoiceForge AI β validators/amount_validator.py | |
| Invoice amount cross-validation. | |
| Checks: | |
| 1. Grand Total = Subtotal + GST Total (Β±1% tolerance for rounding) | |
| 2. Item subtotal sum β header subtotal | |
| 3. Individual item: Total = Qty Γ Rate - Discount + GST | |
| 4. GST components: CGST+SGST+IGST β declared GST Total | |
| Returns structured ValidationResult with errors, warnings, and confidence. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| from dataclasses import dataclass, field | |
| from typing import Any | |
| logger = logging.getLogger(__name__) | |
| ROUNDING_TOLERANCE: float = 1.0 # Absolute rupee tolerance for small invoices | |
| PERCENT_TOLERANCE: float = 0.01 # 1% relative tolerance | |
| class ValidationResult: | |
| """Result of invoice amount validation.""" | |
| is_valid: bool | |
| confidence: float | |
| errors: list[str] = field(default_factory=list) | |
| warnings: list[str] = field(default_factory=list) | |
| class AmountValidator: | |
| """ | |
| Cross-validates invoice amounts for consistency. | |
| Usage: | |
| validator = AmountValidator() | |
| result = validator.validate(items, totals_dict, header) | |
| """ | |
| def validate( | |
| self, | |
| items: list[dict], | |
| totals: dict, | |
| header: dict | None = None, | |
| ) -> ValidationResult: | |
| """ | |
| Validate all amount fields in the extracted invoice. | |
| Args: | |
| items: List of line item dicts with taxable_amount, gst_amount, total_amount. | |
| totals: Dict with subtotal, gstTotal, grandTotal, cgst, sgst, igst. | |
| header: Header dict (used for GSTIN validation trigger, optional). | |
| Returns: | |
| ValidationResult with is_valid, confidence, errors, warnings. | |
| """ | |
| errors: list[str] = [] | |
| warnings: list[str] = [] | |
| confidence_penalties: list[float] = [] | |
| subtotal = _safe(totals.get("subtotal", 0)) | |
| gst_total = _safe(totals.get("gstTotal", 0)) | |
| grand_total = _safe(totals.get("grandTotal", 0)) | |
| cgst = _safe(totals.get("cgst", 0)) | |
| sgst = _safe(totals.get("sgst", 0)) | |
| igst = _safe(totals.get("igst", 0)) | |
| # ββ Check 1: Grand Total = Subtotal + GST βββββββββββββββββββββββββ | |
| if grand_total > 0 and subtotal > 0: | |
| expected_grand = round(subtotal + gst_total, 2) | |
| diff = abs(grand_total - expected_grand) | |
| tolerance = max(ROUNDING_TOLERANCE, grand_total * PERCENT_TOLERANCE) | |
| if diff > tolerance: | |
| errors.append( | |
| f"Grand Total mismatch: declared {grand_total:.2f} " | |
| f"β Subtotal + GST = {expected_grand:.2f} " | |
| f"(diff {diff:.2f})." | |
| ) | |
| confidence_penalties.append(0.10) | |
| # ββ Check 2: Item subtotals sum β header subtotal βββββββββββββββββ | |
| if items and subtotal > 0: | |
| computed_sub = round( | |
| sum(_safe(i.get("taxable_amount", i.get("taxableAmount", 0))) for i in items), | |
| 2, | |
| ) | |
| diff = abs(computed_sub - subtotal) | |
| tolerance = max(ROUNDING_TOLERANCE, subtotal * PERCENT_TOLERANCE) | |
| if diff > tolerance and computed_sub > 0: | |
| warnings.append( | |
| f"Item subtotal sum ({computed_sub:.2f}) differs from " | |
| f"header subtotal ({subtotal:.2f}) by {diff:.2f}." | |
| ) | |
| confidence_penalties.append(0.05) | |
| # ββ Check 3: GST component sum β GST Total ββββββββββββββββββββββββ | |
| if gst_total > 0 and (cgst + sgst + igst) > 0: | |
| component_sum = round(cgst + sgst + igst, 2) | |
| diff = abs(component_sum - gst_total) | |
| tolerance = max(ROUNDING_TOLERANCE, gst_total * PERCENT_TOLERANCE) | |
| if diff > tolerance: | |
| warnings.append( | |
| f"GST component sum (CGST={cgst:.2f} + SGST={sgst:.2f} + " | |
| f"IGST={igst:.2f} = {component_sum:.2f}) differs from " | |
| f"GST Total ({gst_total:.2f})." | |
| ) | |
| confidence_penalties.append(0.03) | |
| # ββ Check 4: Individual item amounts βββββββββββββββββββββββββββββ | |
| for i, item in enumerate(items): | |
| tax_amount = _safe(item.get("taxable_amount", item.get("taxableAmount", 0))) | |
| gst_amount = _safe(item.get("gst_amount", item.get("gstAmount", 0))) | |
| total = _safe(item.get("total_amount", item.get("totalAmount", 0))) | |
| if tax_amount > 0 and gst_amount >= 0 and total > 0: | |
| expected_total = round(tax_amount + gst_amount, 2) | |
| diff = abs(total - expected_total) | |
| tolerance = max(ROUNDING_TOLERANCE, total * PERCENT_TOLERANCE) | |
| if diff > tolerance: | |
| desc = item.get("description", f"Item {i+1}")[:30] | |
| warnings.append( | |
| f"'{desc}': item total {total:.2f} β " | |
| f"taxable {tax_amount:.2f} + gst {gst_amount:.2f} = {expected_total:.2f}." | |
| ) | |
| confidence_penalties.append(0.02) | |
| # ββ Confidence calculation ββββββββββββββββββββββββββββββββββββββββ | |
| total_penalty = min(sum(confidence_penalties), 0.35) | |
| confidence = round(1.0 - total_penalty, 4) | |
| # Zero amounts are a data-quality warning (not hard error) | |
| if grand_total == 0 and subtotal == 0: | |
| warnings.append("All amount fields are zero β extraction may have failed.") | |
| confidence = min(confidence, 0.50) | |
| is_valid = len(errors) == 0 | |
| return ValidationResult( | |
| is_valid=is_valid, | |
| confidence=confidence, | |
| errors=errors, | |
| warnings=warnings, | |
| ) | |
| def validate_from_result(self, result: dict) -> ValidationResult: | |
| """ | |
| Convenience wrapper to validate directly from a full extraction result dict. | |
| Args: | |
| result: Full InvoiceForge result dict with 'items', 'totals', 'header'. | |
| """ | |
| return self.validate( | |
| items=result.get("items", []), | |
| totals=result.get("totals", {}), | |
| header=result.get("header"), | |
| ) | |
| def _safe(value: Any) -> float: | |
| """Safely convert to float.""" | |
| try: | |
| return float(value) | |
| except (TypeError, ValueError): | |
| return 0.0 | |