|
| 1 | +"""Whether the fee quote on a draft still describes the filing it would price. |
| 2 | +
|
| 3 | +A quote is asked of the EFSP on Payment and shown again on Review. Anything |
| 4 | +the filer changes afterwards -- the court, the case type, a document's filing |
| 5 | +type, an optional service -- can change what the court charges, and it can be |
| 6 | +changed from any of half a dozen screens. Rather than have each of them |
| 7 | +remember to clear the quote, the quote carries a fingerprint of everything it |
| 8 | +was priced on, and is only ever treated as current while the draft still |
| 9 | +matches it. |
| 10 | +""" |
| 11 | + |
| 12 | +from __future__ import annotations |
| 13 | + |
| 14 | +import hashlib |
| 15 | +import json |
| 16 | +from decimal import Decimal, InvalidOperation |
| 17 | +from typing import Any |
| 18 | + |
| 19 | +from django.db import transaction |
| 20 | + |
| 21 | +from efile.models import FilingDocument, FilingDraft, FilingParty |
| 22 | + |
| 23 | +WAIVER_ACCOUNT_TYPE = "WV" |
| 24 | + |
| 25 | + |
| 26 | +class FeeQuoteState: |
| 27 | + CURRENT = "current" |
| 28 | + STALE = "stale" |
| 29 | + MISSING = "missing" |
| 30 | + WAIVED = "waived" |
| 31 | + |
| 32 | + |
| 33 | +def fee_inputs(draft: FilingDraft, *, payment_account_id: str | None = None) -> dict[str, Any]: |
| 34 | + """Everything on the draft that the EFSP's fee calculation reads. |
| 35 | +
|
| 36 | + Deliberately generous: a field that turns out not to affect the fee costs |
| 37 | + one extra fee request after it changes, while one left out lets a stale |
| 38 | + total through. `payment_account_id` is the account the quote was asked |
| 39 | + for, which on Payment is chosen before it is saved to the draft. |
| 40 | + """ |
| 41 | + |
| 42 | + documents = [ |
| 43 | + { |
| 44 | + "role": document.role, |
| 45 | + "filing_type": document.filing_type_code, |
| 46 | + "document_type": document.document_type_code, |
| 47 | + "component": document.filing_component_code, |
| 48 | + "optional_services": sorted( |
| 49 | + json.dumps(service, sort_keys=True, default=str) |
| 50 | + for service in (document.requested_optional_services or []) |
| 51 | + ), |
| 52 | + "amount_in_controversy_required": document.filing_requires_amount_in_controversy, |
| 53 | + } |
| 54 | + for document in FilingDocument.objects.filter(draft=draft).order_by("role", "sort_order", "pk") |
| 55 | + ] |
| 56 | + parties = sorted( |
| 57 | + (party.role, party.party_type, bool(party.is_filing_party), bool(party.organization_name)) |
| 58 | + for party in FilingParty.objects.filter(draft=draft) |
| 59 | + ) |
| 60 | + return { |
| 61 | + "jurisdiction": draft.jurisdiction, |
| 62 | + "court": draft.court_code, |
| 63 | + "case_category": draft.case_category_code, |
| 64 | + "case_type": draft.case_type_code, |
| 65 | + "existing_case": draft.existing_case, |
| 66 | + "previous_case_id": draft.previous_case_id, |
| 67 | + "amount_in_controversy": draft.amount_in_controversy, |
| 68 | + "optional_services": json.dumps(draft.optional_services or [], sort_keys=True, default=str), |
| 69 | + "payment_account": draft.selected_payment_account_id if payment_account_id is None else payment_account_id, |
| 70 | + "documents": documents, |
| 71 | + "parties": parties, |
| 72 | + } |
| 73 | + |
| 74 | + |
| 75 | +def fee_fingerprint(draft: FilingDraft, *, payment_account_id: str | None = None) -> str: |
| 76 | + encoded = json.dumps(fee_inputs(draft, payment_account_id=payment_account_id), sort_keys=True, default=str) |
| 77 | + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() |
| 78 | + |
| 79 | + |
| 80 | +def fee_inputs_token(draft: FilingDraft) -> str: |
| 81 | + """The draft's fee inputs as a page saw them, apart from the account. |
| 82 | +
|
| 83 | + Payment and Review build the fee request from the draft as it was when the |
| 84 | + page was drawn, and send this back with it. A quote is only recorded while |
| 85 | + the draft still gives the same token -- before the EFSP is asked, and again |
| 86 | + once it answers -- so a total priced on an older version of the filing is |
| 87 | + never stored as current. The account is left out because Payment asks for |
| 88 | + an account the filer has not saved yet; it is bound when the quote is |
| 89 | + recorded. |
| 90 | + """ |
| 91 | + |
| 92 | + return fee_fingerprint(draft, payment_account_id="") |
| 93 | + |
| 94 | + |
| 95 | +def fee_quote_state(draft: FilingDraft) -> str: |
| 96 | + if draft.selected_payment_account_type == WAIVER_ACCOUNT_TYPE: |
| 97 | + return FeeQuoteState.WAIVED |
| 98 | + if not draft.quoted_fee_total: |
| 99 | + return FeeQuoteState.MISSING |
| 100 | + # A quote saved before fingerprints existed says nothing about what it |
| 101 | + # priced, so it is treated like one that no longer matches. |
| 102 | + if not draft.quoted_fee_fingerprint or draft.quoted_fee_fingerprint != fee_fingerprint(draft): |
| 103 | + return FeeQuoteState.STALE |
| 104 | + return FeeQuoteState.CURRENT |
| 105 | + |
| 106 | + |
| 107 | +def fee_quote_is_usable(draft: FilingDraft) -> bool: |
| 108 | + """Whether the filing may be submitted against the quote the filer saw.""" |
| 109 | + |
| 110 | + return fee_quote_state(draft) in {FeeQuoteState.CURRENT, FeeQuoteState.WAIVED} |
| 111 | + |
| 112 | + |
| 113 | +def invalidate_fee_quote(draft: FilingDraft, *, save: bool = True) -> None: |
| 114 | + """Forget the quote outright, for changes that make it meaningless.""" |
| 115 | + |
| 116 | + draft.quoted_fee_total = "" |
| 117 | + draft.quoted_fee_breakdown = [] |
| 118 | + draft.quoted_fee_fingerprint = "" |
| 119 | + if save: |
| 120 | + draft.save(update_fields=["quoted_fee_total", "quoted_fee_breakdown", "quoted_fee_fingerprint", "updated_at"]) |
| 121 | + |
| 122 | + |
| 123 | +def quote_from_efsp_response(response: dict[str, Any]) -> tuple[str, list[dict[str, str]]] | None: |
| 124 | + """The total and itemized charges from an EFSP fee response, or None. |
| 125 | +
|
| 126 | + None when the response names no total: a quote that cannot be read is not |
| 127 | + one to record, and Review then says the fee could not be determined. |
| 128 | + """ |
| 129 | + |
| 130 | + if not isinstance(response, dict): |
| 131 | + return None |
| 132 | + total = (response.get("feesCalculationAmount") or {}).get("value") |
| 133 | + if total in (None, ""): |
| 134 | + return None |
| 135 | + try: |
| 136 | + total = str(Decimal(str(total)).quantize(Decimal("0.01"))) |
| 137 | + except (InvalidOperation, ValueError): |
| 138 | + return None |
| 139 | + breakdown = [] |
| 140 | + for fee in response.get("allowanceCharge") or []: |
| 141 | + if not isinstance(fee, dict) or not (fee.get("chargeIndicator") or {}).get("value"): |
| 142 | + continue |
| 143 | + breakdown.append( |
| 144 | + { |
| 145 | + "label": str((fee.get("allowanceChargeReason") or {}).get("value") or "Court fee"), |
| 146 | + "amount": str((fee.get("amount") or {}).get("value") or "0.00"), |
| 147 | + } |
| 148 | + ) |
| 149 | + return total, breakdown |
| 150 | + |
| 151 | + |
| 152 | +def record_fee_quote( |
| 153 | + draft: FilingDraft, |
| 154 | + total: str, |
| 155 | + breakdown: list[dict[str, str]], |
| 156 | + *, |
| 157 | + payment_account_id: str | None = None, |
| 158 | + inputs_token: str | None = None, |
| 159 | +) -> bool: |
| 160 | + """Store the quote with what it priced; False, storing nothing, if that is gone. |
| 161 | +
|
| 162 | + With `inputs_token`, the quote is only stored while the draft (read fresh, |
| 163 | + under a row lock) still gives that token -- see fee_inputs_token. |
| 164 | + """ |
| 165 | + |
| 166 | + with transaction.atomic(): |
| 167 | + FilingDraft.objects.select_for_update().filter(pk=draft.pk).first() |
| 168 | + draft.refresh_from_db() |
| 169 | + if inputs_token is not None and fee_inputs_token(draft) != inputs_token: |
| 170 | + return False |
| 171 | + _store_quote(draft, total, breakdown, payment_account_id) |
| 172 | + return True |
| 173 | + |
| 174 | + |
| 175 | +def _store_quote(draft, total, breakdown, payment_account_id): |
| 176 | + draft.quoted_fee_total = total |
| 177 | + draft.quoted_fee_breakdown = breakdown |
| 178 | + draft.quoted_fee_fingerprint = fee_fingerprint(draft, payment_account_id=payment_account_id) |
| 179 | + draft.save(update_fields=["quoted_fee_total", "quoted_fee_breakdown", "quoted_fee_fingerprint", "updated_at"]) |
| 180 | + |
| 181 | + |
| 182 | +def fee_quote_summary(draft: FilingDraft) -> dict[str, Any]: |
| 183 | + """What Review and the fee API tell the page about the quote.""" |
| 184 | + |
| 185 | + state = fee_quote_state(draft) |
| 186 | + current = state == FeeQuoteState.CURRENT |
| 187 | + return { |
| 188 | + "state": state, |
| 189 | + "total": draft.quoted_fee_total if current else "", |
| 190 | + "breakdown": list(draft.quoted_fee_breakdown or []) if current else [], |
| 191 | + } |
0 commit comments