diff --git a/efile_app/efile/api/filing_views.py b/efile_app/efile/api/filing_views.py
index b6f93179..e50791f9 100644
--- a/efile_app/efile/api/filing_views.py
+++ b/efile_app/efile/api/filing_views.py
@@ -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_inputs_token, 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
@@ -184,6 +186,13 @@ def payment_fees(request):
else:
logger.warning(f"No Tyler token found for jurisdiction '{jurisdiction_id}' in filing submission")
+ # The draft this request prices, as the page that built it saw it.
+ # Checked again when the EFSP answers: the request can take a
+ # minute, and the filing can be edited in another tab meanwhile.
+ draft = get_current_draft(request, jurisdiction=jurisdiction_id, resume_latest=False)
+ inputs_token = str(data.get("fee_inputs_token") or "")
+ priced_current_draft = draft is not None and bool(inputs_token) and fee_inputs_token(draft) == inputs_token
+
logger.info(f"Making request!: {url}")
response = requests.post(url, json=efile_data, headers=headers, timeout=60)
logger.info(f"Made request: {response.status_code}")
@@ -192,11 +201,30 @@ 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. A quote for a
+ # version of the filing that has since changed is not kept.
+ quote = quote_from_efsp_response(response_data)
+ recorded = False
+ if draft is not None and quote is not None and priced_current_draft:
+ total, breakdown = quote
+ recorded = record_fee_quote(
+ draft,
+ total,
+ breakdown,
+ payment_account_id=str(data.get("payment_account_id") or draft.selected_payment_account_id),
+ inputs_token=inputs_token,
+ )
return JsonResponse(
{
"success": True,
"message": "Payment fees submitted successfully",
"api_response": response_data,
+ "quote_recorded": recorded,
+ # The filing changed since the page priced it: ask again
+ # from a page that shows the current filing.
+ "quote_superseded": quote is not None and not recorded and draft is not None,
+ "quote": fee_quote_summary(draft) if draft is not None else None,
}
)
else:
diff --git a/efile_app/efile/management/commands/seed_accessibility_session.py b/efile_app/efile/management/commands/seed_accessibility_session.py
index 31b95c57..ad23de80 100644
--- a/efile_app/efile/management/commands/seed_accessibility_session.py
+++ b/efile_app/efile/management/commands/seed_accessibility_session.py
@@ -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
@@ -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,
@@ -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.
diff --git a/efile_app/efile/migrations/0026_filingdraft_quoted_fee_fingerprint.py b/efile_app/efile/migrations/0026_filingdraft_quoted_fee_fingerprint.py
new file mode 100644
index 00000000..2612b379
--- /dev/null
+++ b/efile_app/efile/migrations/0026_filingdraft_quoted_fee_fingerprint.py
@@ -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),
+ ),
+ ]
diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py
index dc079d23..ac0e2d96 100644
--- a/efile_app/efile/models.py
+++ b/efile_app/efile/models.py
@@ -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)
diff --git a/efile_app/efile/services/fee_quotes.py b/efile_app/efile/services/fee_quotes.py
new file mode 100644
index 00000000..a6b846a2
--- /dev/null
+++ b/efile_app/efile/services/fee_quotes.py
@@ -0,0 +1,191 @@
+"""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 django.db import transaction
+
+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_inputs_token(draft: FilingDraft) -> str:
+ """The draft's fee inputs as a page saw them, apart from the account.
+
+ Payment and Review build the fee request from the draft as it was when the
+ page was drawn, and send this back with it. A quote is only recorded while
+ the draft still gives the same token -- before the EFSP is asked, and again
+ once it answers -- so a total priced on an older version of the filing is
+ never stored as current. The account is left out because Payment asks for
+ an account the filer has not saved yet; it is bound when the quote is
+ recorded.
+ """
+
+ return fee_fingerprint(draft, payment_account_id="")
+
+
+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,
+ inputs_token: str | None = None,
+) -> bool:
+ """Store the quote with what it priced; False, storing nothing, if that is gone.
+
+ With `inputs_token`, the quote is only stored while the draft (read fresh,
+ under a row lock) still gives that token -- see fee_inputs_token.
+ """
+
+ with transaction.atomic():
+ FilingDraft.objects.select_for_update().filter(pk=draft.pk).first()
+ draft.refresh_from_db()
+ if inputs_token is not None and fee_inputs_token(draft) != inputs_token:
+ return False
+ _store_quote(draft, total, breakdown, payment_account_id)
+ return True
+
+
+def _store_quote(draft, total, breakdown, payment_account_id):
+ 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 [],
+ }
diff --git a/efile_app/efile/services/handoff.py b/efile_app/efile/services/handoff.py
index d5b0fffb..5e966354 100644
--- a/efile_app/efile/services/handoff.py
+++ b/efile_app/efile/services/handoff.py
@@ -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="",
diff --git a/efile_app/efile/services/submission_errors.py b/efile_app/efile/services/submission_errors.py
index d9a01213..5472a4c6 100644
--- a/efile_app/efile/services/submission_errors.py
+++ b/efile_app/efile/services/submission_errors.py
@@ -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(
@@ -22,5 +23,6 @@ class SubmissionErrorCode:
SubmissionErrorCode.EFILE_DATA_INVALID,
SubmissionErrorCode.COURT_ID_MISSING,
SubmissionErrorCode.PAYLOAD_VALIDATION_FAILED,
+ SubmissionErrorCode.FEE_QUOTE_STALE,
}
)
diff --git a/efile_app/efile/static/js/payment.js b/efile_app/efile/static/js/payment.js
index 4056f36c..4e8980a7 100644
--- a/efile_app/efile/static/js/payment.js
+++ b/efile_app/efile/static/js/payment.js
@@ -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);
@@ -137,14 +135,21 @@ const PaymentPage = {
const result = await apiUtils.post(PAYMENT_URLS.fees, {
efile_data: efileData,
confirm_submission: true,
- payment_account_id: selected.value
+ payment_account_id: selected.value,
+ fee_inputs_token: paymentJSON("fee-inputs-token")
}, {}, {
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_superseded) {
+ paymentMessages.showError(gettext("This filing changed while we were calculating fees, perhaps in another window. Reload this page to calculate them again."));
+ } else 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;
@@ -153,22 +158,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()
diff --git a/efile_app/efile/static/js/review.js b/efile_app/efile/static/js/review.js
index db6cf4f0..efdc64e4 100644
--- a/efile_app/efile/static/js/review.js
+++ b/efile_app/efile/static/js/review.js
@@ -20,14 +20,87 @@ const Messages = {
}
};
+const USABLE_FEE_STATES = ["current", "waived"];
+
const FilingHandler = {
+ feeQuoteState: "missing",
+
setSubmissionState(submitting) {
document.getElementById("loadingSpinner").style.display = submitting ? "block" : "none";
- document.getElementById("submitButton").disabled = submitting || !document.getElementById("confirm-filing").checked;
+ // Never against a total the filer has not seen for this filing: the
+ // button waits until the quote on the page is the current one.
+ document.getElementById("submitButton").disabled = submitting ||
+ !document.getElementById("confirm-filing").checked ||
+ !USABLE_FEE_STATES.includes(this.feeQuoteState);
},
setFeesState() {},
+ buildCurrentFilingData() {
+ const caseData = reviewJSON("case-data");
+ return this.buildEFilingData(
+ this.userDataFromCaseData(caseData),
+ caseData,
+ reviewJSON("upload-data"),
+ reviewJSON("payment-account-id")
+ );
+ },
+
+ showFeeQuote(quote) {
+ const amount = document.getElementById("fee-quote-amount");
+ const list = document.getElementById("fee-quote-breakdown");
+ amount.textContent = `$${quote.total}`;
+ list.replaceChildren(...(quote.breakdown || []).map((fee) => {
+ const item = document.createElement("li");
+ item.textContent = `${fee.label}: $${fee.amount}`;
+ return item;
+ }));
+ document.getElementById("fee-quote").dataset.state = quote.state;
+ document.getElementById("fee-quote-total").hidden = false;
+ document.getElementById("fee-quote-pending").hidden = true;
+ document.getElementById("fee-quote-error").hidden = true;
+ },
+
+ showFeeError(message) {
+ document.getElementById("fee-quote-pending").hidden = true;
+ document.getElementById("fee-quote-total").hidden = true;
+ document.getElementById("fee-quote-error-text").textContent = message;
+ document.getElementById("fee-quote-error").hidden = false;
+ },
+
+ // The quote saved on Payment no longer matches this filing (or there is
+ // none): ask the court again from here rather than sending the filer back
+ // to Payment to find out what changed.
+ async refreshFeeQuote() {
+ this.feeQuoteState = "stale";
+ this.setSubmissionState(false);
+ try {
+ const result = await apiUtils.post("/api/payment-fees/", {
+ efile_data: this.buildCurrentFilingData(),
+ confirm_submission: true,
+ payment_account_id: reviewJSON("payment-account-id"),
+ fee_inputs_token: reviewJSON("fee-inputs-token")
+ }, {}, {
+ timeout: ApiUtils.FEE_TIMEOUT_MS
+ });
+ const quote = result?.quote;
+ if (result?.success && result.quote_superseded) {
+ // This page shows an older version of the filing than the
+ // one saved now, so its fees would be for the wrong filing.
+ throw new Error(gettext("This filing changed while we were calculating fees, perhaps in another window. Reload this page to see the current filing."));
+ }
+ if (!result?.success || !result.quote_recorded || !quote || !USABLE_FEE_STATES.includes(quote.state)) {
+ throw new Error(gettext("The court did not return a fee total for this filing."));
+ }
+ this.feeQuoteState = quote.state;
+ this.showFeeQuote(quote);
+ } catch (error) {
+ this.feeQuoteState = "missing";
+ this.showFeeError(`${error?.serverMessage || error?.message || gettext("We could not calculate the court's fees.")} ${gettext("You cannot submit until the fees are known.")}`);
+ }
+ this.setSubmissionState(false);
+ },
+
async submitFiling() {
if (!document.getElementById("confirm-filing").checked) {
Messages.showError(gettext("Confirm that you reviewed the filing before you submit."));
@@ -36,14 +109,7 @@ const FilingHandler = {
Messages.hide();
this.setSubmissionState(true);
try {
- const caseData = reviewJSON("case-data");
- const userData = this.userDataFromCaseData(caseData);
- const efileData = this.buildEFilingData(
- userData,
- caseData,
- reviewJSON("upload-data"),
- reviewJSON("payment-account-id")
- );
+ const efileData = this.buildCurrentFilingData();
const result = await apiUtils.post("/api/submit-final-filing/", {
efile_data: efileData,
confirm_submission: true,
@@ -53,6 +119,10 @@ const FilingHandler = {
});
this.handleSubmissionResult(result);
} catch (error) {
+ if (error?.status === 412) {
+ // Something changed in another tab since this page loaded.
+ await this.refreshFeeQuote();
+ }
Messages.showError(error?.serverMessage || gettext("We could not submit the filing. Please try again."));
this.setSubmissionState(false);
}
@@ -62,6 +132,8 @@ const FilingHandler = {
Object.assign(FilingHandler, FilingPayload);
document.addEventListener("DOMContentLoaded", () => {
const confirmation = document.getElementById("confirm-filing");
+ FilingHandler.feeQuoteState = reviewJSON("fee-quote-state");
+ if (!USABLE_FEE_STATES.includes(FilingHandler.feeQuoteState)) FilingHandler.refreshFeeQuote();
confirmation.addEventListener("change", () => FilingHandler.setSubmissionState(false));
document.getElementById("submitButton").addEventListener("click", () => FilingHandler.submitFiling());
});
\ No newline at end of file
diff --git a/efile_app/efile/templates/efile/payment.html b/efile_app/efile/templates/efile/payment.html
index 03757858..110fe595 100644
--- a/efile_app/efile/templates/efile/payment.html
+++ b/efile_app/efile/templates/efile/payment.html
@@ -25,8 +25,6 @@