Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions efile_app/efile/api/filing_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@

from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request

from ..services.current_drafts import get_current_draft
from ..services.efsp_errors import describe_efsp_error
from ..services.efsp_payload import PayloadValidationError, prepare_efile_payload
from ..services.fee_quotes import fee_quote_summary, quote_from_efsp_response, record_fee_quote
from ..utils.case_data_utils import get_case_data
from ..utils.proxy_connection import get_headers
from .base import APIResponseMixin
Expand Down Expand Up @@ -192,11 +194,27 @@ def payment_fees(request):
response_data = response.json()

logger.info(f"Sending back: {response_data}")
# Kept on the draft with what it was priced on, so Review shows
# it only while it still describes the filing.
draft = get_current_draft(request, jurisdiction=jurisdiction_id, resume_latest=False)
quote = quote_from_efsp_response(response_data)
recorded = False
if draft is not None and quote is not None:
total, breakdown = quote
record_fee_quote(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The EFSP response here priced the efile_data captured before the network call, but record_fee_quote() computes its fingerprint from the draft after that call returns. If another tab changes a fee-affecting field while the (up to 60s) fee request is in flight, the old total can be stored with the new draft鈥檚 fingerprint and fee_quote_state() will report CURRENT鈥攄efeating the stale-quote/submission guard this PR adds. Could you snapshot the expected fingerprint before sending the fee request and only record the response if the current draft still matches it (or otherwise bind the stored fingerprint to the exact inputs/payload that was priced)? A test that mutates the draft inside the mocked requests.post would catch this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e342a77. This binds the quote to the draft state the payload was built from, not just to the state at the start of the request. Payment and Review now render a token of the draft's fee inputs (the fingerprint without the payment account, which is bound separately). The page sends that token with the fee request. The quote is recorded only if the draft still matches the token before the EFSP call and again, under a row lock, when it answers. If it doesn't match, the response has quote_superseded: true, nothing is stored, and the page asks the filer to reload. New tests: the draft changes inside the mocked requests.post, the request comes from an older page, and the request carries no token. The optional-services regression now takes its token from the Review page itself.

draft,
total,
breakdown,
payment_account_id=str(data.get("payment_account_id") or draft.selected_payment_account_id),
)
recorded = True
return JsonResponse(
{
"success": True,
"message": "Payment fees submitted successfully",
"api_response": response_data,
"quote_recorded": recorded,
"quote": fee_quote_summary(draft) if draft is not None else None,
}
)
else:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from efile.models import FilingDocument, FilingDraft, FilingParty, FilingPlan
from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY
from efile.services.fee_quotes import record_fee_quote
from efile.workflow import ExistingCase, WorkflowStepKey


Expand Down Expand Up @@ -61,7 +62,6 @@ def handle(self, *args, **options):
extracted_guesses={"document title": "Complaint", "case title": "Checker v. Example"},
selected_payment_account_id="a11y-payment-account",
selected_payment_account_name="Accessibility payment account",
quoted_fee_total="0.00",
)
FilingDocument.objects.create(
draft=draft,
Expand Down Expand Up @@ -96,6 +96,10 @@ def handle(self, *args, **options):
last_name="Example",
)

# A quote priced on this draft as it now stands, so Review shows it as
# current rather than asking the court again.
record_fee_quote(draft, "0.00", [])

# Let Django's own test client construct the authenticated session. This
# tracks framework changes to session-auth details without duplicating
# private authentication keys in this browser-only fixture command.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2026-09-28 23:37

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('efile', '0025_pending_activation'),
]

operations = [
migrations.AddField(
model_name='filingdraft',
name='quoted_fee_fingerprint',
field=models.CharField(blank=True, max_length=64),
),
]
3 changes: 3 additions & 0 deletions efile_app/efile/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ class Status(models.TextChoices):
# display the same numbers instead of telling the filer to go look again.
quoted_fee_total = models.CharField(max_length=50, blank=True)
quoted_fee_breakdown = models.JSONField(default=list, blank=True)
# What the quote was priced on (see efile.services.fee_quotes). A quote is
# only current while the draft still produces the same fingerprint.
quoted_fee_fingerprint = models.CharField(max_length=64, blank=True)

name_change_reason = models.TextField(blank=True)

Expand Down
157 changes: 157 additions & 0 deletions efile_app/efile/services/fee_quotes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""Whether the fee quote on a draft still describes the filing it would price.

A quote is asked of the EFSP on Payment and shown again on Review. Anything
the filer changes afterwards -- the court, the case type, a document's filing
type, an optional service -- can change what the court charges, and it can be
changed from any of half a dozen screens. Rather than have each of them
remember to clear the quote, the quote carries a fingerprint of everything it
was priced on, and is only ever treated as current while the draft still
matches it.
"""

from __future__ import annotations

import hashlib
import json
from decimal import Decimal, InvalidOperation
from typing import Any

from efile.models import FilingDocument, FilingDraft, FilingParty

WAIVER_ACCOUNT_TYPE = "WV"


class FeeQuoteState:
CURRENT = "current"
STALE = "stale"
MISSING = "missing"
WAIVED = "waived"


def fee_inputs(draft: FilingDraft, *, payment_account_id: str | None = None) -> dict[str, Any]:
"""Everything on the draft that the EFSP's fee calculation reads.

Deliberately generous: a field that turns out not to affect the fee costs
one extra fee request after it changes, while one left out lets a stale
total through. `payment_account_id` is the account the quote was asked
for, which on Payment is chosen before it is saved to the draft.
"""

documents = [
{
"role": document.role,
"filing_type": document.filing_type_code,
"document_type": document.document_type_code,
"component": document.filing_component_code,
"optional_services": sorted(
json.dumps(service, sort_keys=True, default=str)
for service in (document.requested_optional_services or [])
),
"amount_in_controversy_required": document.filing_requires_amount_in_controversy,
}
for document in FilingDocument.objects.filter(draft=draft).order_by("role", "sort_order", "pk")
]
parties = sorted(
(party.role, party.party_type, bool(party.is_filing_party), bool(party.organization_name))
for party in FilingParty.objects.filter(draft=draft)
)
return {
"jurisdiction": draft.jurisdiction,
"court": draft.court_code,
"case_category": draft.case_category_code,
"case_type": draft.case_type_code,
"existing_case": draft.existing_case,
"previous_case_id": draft.previous_case_id,
"amount_in_controversy": draft.amount_in_controversy,
"optional_services": json.dumps(draft.optional_services or [], sort_keys=True, default=str),
"payment_account": draft.selected_payment_account_id if payment_account_id is None else payment_account_id,
"documents": documents,
"parties": parties,
}


def fee_fingerprint(draft: FilingDraft, *, payment_account_id: str | None = None) -> str:
encoded = json.dumps(fee_inputs(draft, payment_account_id=payment_account_id), sort_keys=True, default=str)
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()


def fee_quote_state(draft: FilingDraft) -> str:
if draft.selected_payment_account_type == WAIVER_ACCOUNT_TYPE:
return FeeQuoteState.WAIVED
if not draft.quoted_fee_total:
return FeeQuoteState.MISSING
# A quote saved before fingerprints existed says nothing about what it
# priced, so it is treated like one that no longer matches.
if not draft.quoted_fee_fingerprint or draft.quoted_fee_fingerprint != fee_fingerprint(draft):
return FeeQuoteState.STALE
return FeeQuoteState.CURRENT


def fee_quote_is_usable(draft: FilingDraft) -> bool:
"""Whether the filing may be submitted against the quote the filer saw."""

return fee_quote_state(draft) in {FeeQuoteState.CURRENT, FeeQuoteState.WAIVED}


def invalidate_fee_quote(draft: FilingDraft, *, save: bool = True) -> None:
"""Forget the quote outright, for changes that make it meaningless."""

draft.quoted_fee_total = ""
draft.quoted_fee_breakdown = []
draft.quoted_fee_fingerprint = ""
if save:
draft.save(update_fields=["quoted_fee_total", "quoted_fee_breakdown", "quoted_fee_fingerprint", "updated_at"])


def quote_from_efsp_response(response: dict[str, Any]) -> tuple[str, list[dict[str, str]]] | None:
"""The total and itemized charges from an EFSP fee response, or None.

None when the response names no total: a quote that cannot be read is not
one to record, and Review then says the fee could not be determined.
"""

if not isinstance(response, dict):
return None
total = (response.get("feesCalculationAmount") or {}).get("value")
if total in (None, ""):
return None
try:
total = str(Decimal(str(total)).quantize(Decimal("0.01")))
except (InvalidOperation, ValueError):
return None
breakdown = []
for fee in response.get("allowanceCharge") or []:
if not isinstance(fee, dict) or not (fee.get("chargeIndicator") or {}).get("value"):
continue
breakdown.append(
{
"label": str((fee.get("allowanceChargeReason") or {}).get("value") or "Court fee"),
"amount": str((fee.get("amount") or {}).get("value") or "0.00"),
}
)
return total, breakdown


def record_fee_quote(
draft: FilingDraft,
total: str,
breakdown: list[dict[str, str]],
*,
payment_account_id: str | None = None,
) -> None:
draft.quoted_fee_total = total
draft.quoted_fee_breakdown = breakdown
draft.quoted_fee_fingerprint = fee_fingerprint(draft, payment_account_id=payment_account_id)
draft.save(update_fields=["quoted_fee_total", "quoted_fee_breakdown", "quoted_fee_fingerprint", "updated_at"])


def fee_quote_summary(draft: FilingDraft) -> dict[str, Any]:
"""What Review and the fee API tell the page about the quote."""

state = fee_quote_state(draft)
current = state == FeeQuoteState.CURRENT
return {
"state": state,
"total": draft.quoted_fee_total if current else "",
"breakdown": list(draft.quoted_fee_breakdown or []) if current else [],
}
1 change: 1 addition & 0 deletions efile_app/efile/services/handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ def create_correction(draft, detail, fields):
submission_response={},
quoted_fee_total="",
quoted_fee_breakdown=[],
quoted_fee_fingerprint="",
selected_payment_account_id="",
selected_payment_account_name="",
selected_payment_account_type="",
Expand Down
2 changes: 2 additions & 0 deletions efile_app/efile/services/submission_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class SubmissionErrorCode:
EFILE_DATA_INVALID = "submission_efile_data_invalid"
COURT_ID_MISSING = "submission_court_id_missing"
PAYLOAD_VALIDATION_FAILED = "submission_payload_validation_failed"
FEE_QUOTE_STALE = "submission_fee_quote_stale"


PRE_SUBMIT_ERROR_CODES = frozenset(
Expand All @@ -22,5 +23,6 @@ class SubmissionErrorCode:
SubmissionErrorCode.EFILE_DATA_INVALID,
SubmissionErrorCode.COURT_ID_MISSING,
SubmissionErrorCode.PAYLOAD_VALIDATION_FAILED,
SubmissionErrorCode.FEE_QUOTE_STALE,
}
)
26 changes: 6 additions & 20 deletions efile_app/efile/static/js/payment.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,6 @@ const PaymentPage = {
document.getElementById("selected-payment-account-name").value = selected.dataset.name;
document.getElementById("selected-payment-account-type").value = selected.dataset.type || "";
document.getElementById("paymentSection").hidden = true;
document.getElementById("quoted-fee-total").value = "";
document.getElementById("quoted-fee-breakdown").value = "";
this.feeQuoteReady = false;
paymentMessages.hide();
this.setFeesState(true);
Expand All @@ -142,9 +140,13 @@ const PaymentPage = {
timeout: ApiUtils.FEE_TIMEOUT_MS
});
if (currentRequestId !== this.quoteRequestId) return;
this.feeQuoteReady = Boolean(result?.success);
// The server keeps the quote for Review once it can read a total
// from it; one it could not read is not a quote to go on with.
this.feeQuoteReady = Boolean(result?.success && result.quote_recorded);
this.handleFeesResponse(result);
this.storeFeeQuote(result);
if (result?.success && !result.quote_recorded) {
paymentMessages.showError(gettext("The court did not return a fee total for this filing. Try again, or contact the court before you file."));
}
} catch (error) {
if (currentRequestId !== this.quoteRequestId) return;
this.feeQuoteReady = false;
Expand All @@ -153,22 +155,6 @@ const PaymentPage = {
}
},

// Persist the quote Review will later display, so it shows the same
// numbers the filer already saw here instead of sending them back to
// look them up again.
storeFeeQuote(result) {
if (!result?.success) return;
const response = result.api_response || {};
const fees = (response.allowanceCharge || [])
.filter((fee) => fee.chargeIndicator?.value)
.map((fee) => ({
label: fee.allowanceChargeReason?.value || gettext("Court fee"),
amount: fee.amount?.value || "0.00"
}));
document.getElementById("quoted-fee-total").value = response.feesCalculationAmount?.value || "0.00";
document.getElementById("quoted-fee-breakdown").value = JSON.stringify(fees);
},

async addAccount() {
const authData = await apiUtils.fetchJSON(PAYMENT_URLS.token, "GET", {
jurisdiction: apiUtils.getCurrentJurisdiction()
Expand Down
Loading
Loading