Skip to content
death.tech home
All recent changes

Read VAT and copy counts from court fee statements

3a8d893feat(probate)merged 1d ago4 review threads

Illustrative: based on a real change, edited for clarity and privacy.

src/app/services/fee_statement_parser.py+31 −12

Changes to src/app/services/fee_statement_parser.py. Lines with review comments are numbered.
@@
1010
1111_REFERENCE_RE = re.compile(r"(\d{6}\.\d{3})")
12added# Far above any real fee or copy count.
2 line 13, review thread 2added_MAX_DIGITS = 12
1214
1315# HMCTS month abbreviations, so parsing does not depend on locale.
1416# September is SEPT, not the three-letter SEP.
@@ def _parse_line(el: ET.Element) -> dict[str, Any]:
9597 memo_name = _text(el, "MemoName")
9698 fee_type, fee_label = _classify_fee(memo_name)
9799
98removed line: dict[str, Any] = {
100added return {
99101 "date": _parse_date(_text(el, "Date")),
100102 "amount": _format_money(_text(el, "Amount")),
103added "vat": _format_money(_text(el, "VatAmount")),
101104 "reference": _extract_reference(description),
102105 "fee_type": fee_type,
1 line 106, review thread 1added "fee_label": fee_label,
107added "copies": (
108added _parse_quantity(_text(el, "QuantityInvoiced"))
109added if fee_type == "copies"
110added else None
111added ),
103112 }
104removed if fee_type is None:
105removed line["fee_label"] = fee_label
106removed return line
107113
108114
109115def _extract_reference(description: str | None) -> str | None:
@@ def _extract_reference(description: str | None) -> str | None:
121127def _classify_fee(memo_name: str | None) -> tuple[str | None, str | None]:
122128 if memo_name is None:
123129 return None, None
124removed if (
125removed "issue probate" in memo_name
126removed or "CC-Application for a grant" in memo_name
127removed ):
130added lowered = memo_name.lower()
131added if "issue probate" in lowered or "cc-application for a grant" in lowered:
128132 return "court_fee", None
129removed if "copies probate" in memo_name:
133added if "copies probate" in lowered:
130134 return "copies", None
131135 return None, memo_name
132136
@@ def _date_from_parts(day_s: str, month_s: str, year_s: str) -> str | None:
163167 return parsed.date().isoformat()
164168
165169
170addeddef _parse_quantity(value: str | None) -> int | None:
171added quantity = _parse_number(value)
3 line 172, review thread 3added if quantity is None or quantity != quantity.to_integral_value():
173added return None
174added return int(quantity)
175added
176added
166177def _format_money(value: str | None) -> str | None:
178added amount = _parse_number(value)
179added return f"{amount:.2f}" if amount is not None else None
180added
181added
182addeddef _parse_number(value: str | None) -> Decimal | None:
183added """Reject huge exponents like 1e999999999 before they become huge."""
167184 if value is None:
168185 return None
169removed cleaned = value.replace(",", "").strip()
170186 try:
171removed return f"{Decimal(cleaned):.2f}"
172removed except (InvalidOperation, ValueError):
187added number = Decimal(value.replace(",", "").strip())
188added except InvalidOperation:
189added return None
4 line 190, review thread 4added if not number.is_finite() or number.adjusted() > _MAX_DIGITS:
173191 return None
192added return number
    1. probate team

      This saves us retyping copy counts from every statement. Could the fee label show on the case as well, so we can tell an application fee from extra copies at a glance?

    2. author

      The label comes through in this change. Showing it on the case is a separate change, so this one stays small. It’s next on the list.

    1. reviewer

      Is 12 digits enough headroom? The largest fee I can think of is nowhere near, but a test either side of the boundary would make the limit obvious to the next person.

    2. author

      Added tests at 12 and 13 digits. The comment on the constant says why it’s 12, too.

    1. reviewer

      What happens if a statement says 4.00 copies? Looks like that comes back as None, which reads as “no count” rather than four.

    2. author

      The statements we’ve seen never do this, so I’m happy to assume whole numbers.

    3. reviewer

      Fair enough if that’s all we’ve seen. Worth a test pinning the behaviour either way, so it’s a decision rather than an accident.

    4. author

      Changed our mind on this one: whole decimals like 4.00 now parse, and fractional ones return None, with tests for both.

    1. AI reviewP1

      Huge counts stall the worker

      Decimal happily accepts a compact value like 1e999999999. It passes the checks, then int(quantity) tries to build an enormous integer. Anyone who can upload a statement can tie up the extraction worker. Bound the size before converting.

    2. author

      Good catch. Money and quantities now share _parse_number, which rejects anything with more than 12 digits before the decimal point. _format_money had the same problem before this change (formatting 1e999999999 builds a string of about 1 GB), so that’s covered too, with tests.

We’d like to use PostHog to see how this site gets used. It sets cookies, so it stays off until you allow it; everything else works without. Privacy notice