Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
28 changes: 28 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_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
Expand Down Expand Up @@ -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}")
Expand All @@ -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:
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
191 changes: 191 additions & 0 deletions efile_app/efile/services/fee_quotes.py
Original file line number Diff line number Diff line change
@@ -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 [],
}
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,
}
)
31 changes: 10 additions & 21 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 @@ -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;
Expand All @@ -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()
Expand Down
Loading
Loading