Skip to content

Commit dcbc678

Browse files
Merge pull request #249 from SuffolkLITLab/fix/193-invalidate-stale-fee-quotes
Keep fee quotes tied to the filing they priced
2 parents 2d34087 + e342a77 commit dcbc678

21 files changed

Lines changed: 869 additions & 122 deletions

‎efile_app/efile/api/filing_views.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313

1414
from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request
1515

16+
from ..services.current_drafts import get_current_draft
1617
from ..services.efsp_errors import describe_efsp_error
1718
from ..services.efsp_payload import PayloadValidationError, prepare_efile_payload
19+
from ..services.fee_quotes import fee_inputs_token, fee_quote_summary, quote_from_efsp_response, record_fee_quote
1820
from ..utils.case_data_utils import get_case_data
1921
from ..utils.proxy_connection import get_headers
2022
from .base import APIResponseMixin
@@ -184,6 +186,13 @@ def payment_fees(request):
184186
else:
185187
logger.warning(f"No Tyler token found for jurisdiction '{jurisdiction_id}' in filing submission")
186188

189+
# The draft this request prices, as the page that built it saw it.
190+
# Checked again when the EFSP answers: the request can take a
191+
# minute, and the filing can be edited in another tab meanwhile.
192+
draft = get_current_draft(request, jurisdiction=jurisdiction_id, resume_latest=False)
193+
inputs_token = str(data.get("fee_inputs_token") or "")
194+
priced_current_draft = draft is not None and bool(inputs_token) and fee_inputs_token(draft) == inputs_token
195+
187196
logger.info(f"Making request!: {url}")
188197
response = requests.post(url, json=efile_data, headers=headers, timeout=60)
189198
logger.info(f"Made request: {response.status_code}")
@@ -192,11 +201,30 @@ def payment_fees(request):
192201
response_data = response.json()
193202

194203
logger.info(f"Sending back: {response_data}")
204+
# Kept on the draft with what it was priced on, so Review shows
205+
# it only while it still describes the filing. A quote for a
206+
# version of the filing that has since changed is not kept.
207+
quote = quote_from_efsp_response(response_data)
208+
recorded = False
209+
if draft is not None and quote is not None and priced_current_draft:
210+
total, breakdown = quote
211+
recorded = record_fee_quote(
212+
draft,
213+
total,
214+
breakdown,
215+
payment_account_id=str(data.get("payment_account_id") or draft.selected_payment_account_id),
216+
inputs_token=inputs_token,
217+
)
195218
return JsonResponse(
196219
{
197220
"success": True,
198221
"message": "Payment fees submitted successfully",
199222
"api_response": response_data,
223+
"quote_recorded": recorded,
224+
# The filing changed since the page priced it: ask again
225+
# from a page that shows the current filing.
226+
"quote_superseded": quote is not None and not recorded and draft is not None,
227+
"quote": fee_quote_summary(draft) if draft is not None else None,
200228
}
201229
)
202230
else:

‎efile_app/efile/management/commands/seed_accessibility_session.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from efile.models import FilingDocument, FilingDraft, FilingParty, FilingPlan
1111
from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY
12+
from efile.services.fee_quotes import record_fee_quote
1213
from efile.workflow import ExistingCase, WorkflowStepKey
1314

1415

@@ -61,7 +62,6 @@ def handle(self, *args, **options):
6162
extracted_guesses={"document title": "Complaint", "case title": "Checker v. Example"},
6263
selected_payment_account_id="a11y-payment-account",
6364
selected_payment_account_name="Accessibility payment account",
64-
quoted_fee_total="0.00",
6565
)
6666
FilingDocument.objects.create(
6767
draft=draft,
@@ -96,6 +96,10 @@ def handle(self, *args, **options):
9696
last_name="Example",
9797
)
9898

99+
# A quote priced on this draft as it now stands, so Review shows it as
100+
# current rather than asking the court again.
101+
record_fee_quote(draft, "0.00", [])
102+
99103
# Let Django's own test client construct the authenticated session. This
100104
# tracks framework changes to session-auth details without duplicating
101105
# private authentication keys in this browser-only fixture command.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.5 on 2026-09-28 23:37
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('efile', '0025_pending_activation'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='filingdraft',
15+
name='quoted_fee_fingerprint',
16+
field=models.CharField(blank=True, max_length=64),
17+
),
18+
]

‎efile_app/efile/models.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,9 @@ class Status(models.TextChoices):
245245
# display the same numbers instead of telling the filer to go look again.
246246
quoted_fee_total = models.CharField(max_length=50, blank=True)
247247
quoted_fee_breakdown = models.JSONField(default=list, blank=True)
248+
# What the quote was priced on (see efile.services.fee_quotes). A quote is
249+
# only current while the draft still produces the same fingerprint.
250+
quoted_fee_fingerprint = models.CharField(max_length=64, blank=True)
248251

249252
name_change_reason = models.TextField(blank=True)
250253

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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+
}

‎efile_app/efile/services/handoff.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,7 @@ def create_correction(draft, detail, fields):
596596
submission_response={},
597597
quoted_fee_total="",
598598
quoted_fee_breakdown=[],
599+
quoted_fee_fingerprint="",
599600
selected_payment_account_id="",
600601
selected_payment_account_name="",
601602
selected_payment_account_type="",

‎efile_app/efile/services/submission_errors.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class SubmissionErrorCode:
1111
EFILE_DATA_INVALID = "submission_efile_data_invalid"
1212
COURT_ID_MISSING = "submission_court_id_missing"
1313
PAYLOAD_VALIDATION_FAILED = "submission_payload_validation_failed"
14+
FEE_QUOTE_STALE = "submission_fee_quote_stale"
1415

1516

1617
PRE_SUBMIT_ERROR_CODES = frozenset(
@@ -22,5 +23,6 @@ class SubmissionErrorCode:
2223
SubmissionErrorCode.EFILE_DATA_INVALID,
2324
SubmissionErrorCode.COURT_ID_MISSING,
2425
SubmissionErrorCode.PAYLOAD_VALIDATION_FAILED,
26+
SubmissionErrorCode.FEE_QUOTE_STALE,
2527
}
2628
)

‎efile_app/efile/static/js/payment.js‎

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,6 @@ const PaymentPage = {
124124
document.getElementById("selected-payment-account-name").value = selected.dataset.name;
125125
document.getElementById("selected-payment-account-type").value = selected.dataset.type || "";
126126
document.getElementById("paymentSection").hidden = true;
127-
document.getElementById("quoted-fee-total").value = "";
128-
document.getElementById("quoted-fee-breakdown").value = "";
129127
this.feeQuoteReady = false;
130128
paymentMessages.hide();
131129
this.setFeesState(true);
@@ -137,14 +135,21 @@ const PaymentPage = {
137135
const result = await apiUtils.post(PAYMENT_URLS.fees, {
138136
efile_data: efileData,
139137
confirm_submission: true,
140-
payment_account_id: selected.value
138+
payment_account_id: selected.value,
139+
fee_inputs_token: paymentJSON("fee-inputs-token")
141140
}, {}, {
142141
timeout: ApiUtils.FEE_TIMEOUT_MS
143142
});
144143
if (currentRequestId !== this.quoteRequestId) return;
145-
this.feeQuoteReady = Boolean(result?.success);
144+
// The server keeps the quote for Review once it can read a total
145+
// from it; one it could not read is not a quote to go on with.
146+
this.feeQuoteReady = Boolean(result?.success && result.quote_recorded);
146147
this.handleFeesResponse(result);
147-
this.storeFeeQuote(result);
148+
if (result?.success && result.quote_superseded) {
149+
paymentMessages.showError(gettext("This filing changed while we were calculating fees, perhaps in another window. Reload this page to calculate them again."));
150+
} else if (result?.success && !result.quote_recorded) {
151+
paymentMessages.showError(gettext("The court did not return a fee total for this filing. Try again, or contact the court before you file."));
152+
}
148153
} catch (error) {
149154
if (currentRequestId !== this.quoteRequestId) return;
150155
this.feeQuoteReady = false;
@@ -153,22 +158,6 @@ const PaymentPage = {
153158
}
154159
},
155160

156-
// Persist the quote Review will later display, so it shows the same
157-
// numbers the filer already saw here instead of sending them back to
158-
// look them up again.
159-
storeFeeQuote(result) {
160-
if (!result?.success) return;
161-
const response = result.api_response || {};
162-
const fees = (response.allowanceCharge || [])
163-
.filter((fee) => fee.chargeIndicator?.value)
164-
.map((fee) => ({
165-
label: fee.allowanceChargeReason?.value || gettext("Court fee"),
166-
amount: fee.amount?.value || "0.00"
167-
}));
168-
document.getElementById("quoted-fee-total").value = response.feesCalculationAmount?.value || "0.00";
169-
document.getElementById("quoted-fee-breakdown").value = JSON.stringify(fees);
170-
},
171-
172161
async addAccount() {
173162
const authData = await apiUtils.fetchJSON(PAYMENT_URLS.token, "GET", {
174163
jurisdiction: apiUtils.getCurrentJurisdiction()

0 commit comments

Comments
 (0)