| """05: Rounded-amount ratio (ISA 240, Journal of Accountancy) — B/C level, invoice. |
| |
| Thresholds (based on ISA 240 + Journal of Accountancy 2018 fraud research): |
| * > 24.3% suspiciously rounded → MEDIUM |
| * > 14.7% rounded → LOW |
| * < 3 data points → skip (not statistically meaningful) |
| |
| A single amount is "suspiciously rounded" if: |
| * abs > 10_417 (parity watermark) AND |
| * abs % 10_000 == 0 (divisible by 10,000) |
| """ |
|
|
| from __future__ import annotations |
|
|
| from domain_checks.base import make_risk |
| from graph.states.pipeline_state import Risk |
| from utils.numbers import coerce_number |
|
|
|
|
| _REGULATION = "ISA 240" |
| _HIGH_RATIO = 0.243 |
| _LOW_RATIO = 0.147 |
|
|
|
|
| def _is_suspiciously_round(amount: float) -> bool: |
| """Suspiciously rounded if > 10,417 AND divisible by 10,000.""" |
| if amount == 0: |
| return False |
| abs_amount = abs(amount) |
| if abs_amount > 10_417 and abs_amount % 10_000 == 0: |
| return True |
| return False |
|
|
|
|
| class RoundedAmountsCheck: |
| check_id = "check_05_rounded_amounts" |
| regulation = _REGULATION |
| is_hu_specific = False |
| applies_to = {"invoice"} |
|
|
| def apply(self, extracted: dict) -> list[Risk]: |
| risks: list[Risk] = [] |
| amounts: list[float] = [] |
|
|
| |
| for item in (extracted.get("line_items") or []): |
| if not isinstance(item, dict): |
| continue |
| for field in ("total_net", "total_gross"): |
| val = coerce_number(item.get(field)) |
| if val is not None and val != 0: |
| amounts.append(val) |
|
|
| |
| for field in ("total_net", "total_gross"): |
| val = coerce_number(extracted.get(field)) |
| if val is not None and val != 0: |
| amounts.append(val) |
|
|
| if len(amounts) < 3: |
| return risks |
|
|
| round_count = sum(1 for a in amounts if _is_suspiciously_round(a)) |
| ratio = round_count / len(amounts) |
|
|
| if ratio > _HIGH_RATIO: |
| risks.append(make_risk( |
| description=( |
| f"High proportion of rounded amounts: {round_count}/{len(amounts)} " |
| f"({ratio:.0%})" |
| ), |
| severity="medium", |
| rationale=( |
| f"{ratio:.0%} of the amounts are suspiciously rounded " |
| f"(divisible by 10,000 and >10,000). Above 25% may indicate " |
| f"fraud (Journal of Accountancy, 2018)." |
| ), |
| regulation=_REGULATION, |
| source_check_id=self.check_id, |
| )) |
| elif ratio > _LOW_RATIO: |
| risks.append(make_risk( |
| description=( |
| f"Notable proportion of rounded amounts: {round_count}/{len(amounts)} " |
| f"({ratio:.0%})" |
| ), |
| severity="low", |
| rationale=( |
| f"{ratio:.0%} of the amounts are rounded. Above 15% is higher " |
| f"than the typical baseline." |
| ), |
| regulation=_REGULATION, |
| source_check_id=self.check_id, |
| )) |
|
|
| return risks |
|
|