diff --git a/efile_app/efile/api/filing_views.py b/efile_app/efile/api/filing_views.py
index 380e5c13..1c5be240 100644
--- a/efile_app/efile/api/filing_views.py
+++ b/efile_app/efile/api/filing_views.py
@@ -14,7 +14,7 @@
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_errors import describe_efsp_error, efsp_error_problems, error_actions
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
@@ -161,6 +161,8 @@ def payment_fees(request):
case_data = get_case_data(request, jurisdiction_id)
court_id = case_data.get("court", "")
+ draft = get_current_draft(request, jurisdiction=jurisdiction_id, resume_latest=False)
+
# Must match what submit_final_filing sends, or fees are quoted
# against a payload that differs from the one actually filed.
try:
@@ -168,7 +170,14 @@ def payment_fees(request):
except PayloadValidationError as error:
# Known-bad payload: answer with the specific reason rather than
# letting the EFSP reply with a code-list error no filer can act on.
- return JsonResponse({"success": False, "error": str(error)}, status=400)
+ return JsonResponse(
+ {
+ "success": False,
+ "error": str(error),
+ "error_actions": error_actions(draft, efile_data, error.problems),
+ },
+ status=400,
+ )
url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction_id}/filingreview/courts/{court_id}/filing/fees"
@@ -188,7 +197,6 @@ def payment_fees(request):
# 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
@@ -234,6 +242,9 @@ def payment_fees(request):
{
"success": False,
"error": f"Could not get filing fees: {error_message}",
+ "error_actions": error_actions(
+ draft, efile_data, efsp_error_problems(response), message=error_message
+ ),
"api_status_code": response.status_code,
"api_response": response.text[:500] if response.text else "No response body",
},
diff --git a/efile_app/efile/forms.py b/efile_app/efile/forms.py
index d8a314eb..2b74cba5 100644
--- a/efile_app/efile/forms.py
+++ b/efile_app/efile/forms.py
@@ -2,6 +2,8 @@
from django.contrib.auth import get_user_model
from django.db.models import Q
+from efile.services.efsp_validation import state_choices
+from efile.services.postal_codes import USPS_STATES
from efile.utils.account_ids import jurisdiction_account_username
User = get_user_model()
@@ -28,6 +30,13 @@ class EFileRegistrationForm(forms.Form):
def __init__(self, *args, jurisdiction=None, **kwargs):
super().__init__(*args, **kwargs)
self.jurisdiction = jurisdiction
+ choices, source = state_choices(jurisdiction, "0")
+ self.fields["state"].choices = [("", "Select a state"), *choices]
+ if source == "efsp":
+ self.fields["state"].help_text = (
+ "Only locations accepted by the filing service are listed. If your address is not listed, "
+ "contact the court for filing instructions. Do not choose a different state."
+ )
# Legal Name
first_name = forms.CharField(
@@ -54,59 +63,7 @@ def __init__(self, *args, jurisdiction=None, **kwargs):
city = forms.CharField(
max_length=100, widget=forms.TextInput(attrs={"class": "form-control", "id": "city", "required": "required"})
)
- STATE_CHOICES = [
- ("", "Select a state"),
- ("AL", "Alabama"),
- ("AK", "Alaska"),
- ("AZ", "Arizona"),
- ("AR", "Arkansas"),
- ("CA", "California"),
- ("CO", "Colorado"),
- ("CT", "Connecticut"),
- ("DE", "Delaware"),
- ("FL", "Florida"),
- ("GA", "Georgia"),
- ("HI", "Hawaii"),
- ("ID", "Idaho"),
- ("IL", "Illinois"),
- ("IN", "Indiana"),
- ("IA", "Iowa"),
- ("KS", "Kansas"),
- ("KY", "Kentucky"),
- ("LA", "Louisiana"),
- ("ME", "Maine"),
- ("MD", "Maryland"),
- ("MA", "Massachusetts"),
- ("MI", "Michigan"),
- ("MN", "Minnesota"),
- ("MS", "Mississippi"),
- ("MO", "Missouri"),
- ("MT", "Montana"),
- ("NE", "Nebraska"),
- ("NV", "Nevada"),
- ("NH", "New Hampshire"),
- ("NJ", "New Jersey"),
- ("NM", "New Mexico"),
- ("NY", "New York"),
- ("NC", "North Carolina"),
- ("ND", "North Dakota"),
- ("OH", "Ohio"),
- ("OK", "Oklahoma"),
- ("OR", "Oregon"),
- ("PA", "Pennsylvania"),
- ("RI", "Rhode Island"),
- ("SC", "South Carolina"),
- ("SD", "South Dakota"),
- ("TN", "Tennessee"),
- ("TX", "Texas"),
- ("UT", "Utah"),
- ("VT", "Vermont"),
- ("VA", "Virginia"),
- ("WA", "Washington"),
- ("WV", "West Virginia"),
- ("WI", "Wisconsin"),
- ("WY", "Wyoming"),
- ]
+ STATE_CHOICES = [("", "Select a state"), *USPS_STATES.items()]
state = forms.ChoiceField(
choices=STATE_CHOICES,
widget=forms.Select(attrs={"class": "form-select", "id": "state", "required": "required"}),
diff --git a/efile_app/efile/services/efsp_errors.py b/efile_app/efile/services/efsp_errors.py
index fe2c199d..05f4fbcb 100644
--- a/efile_app/efile/services/efsp_errors.py
+++ b/efile_app/efile/services/efsp_errors.py
@@ -27,6 +27,11 @@
import json
import re
from collections.abc import Callable
+from urllib.parse import urlencode
+
+from django.urls import reverse
+
+from efile.services.efsp_validation import FIELD_LABELS
# Field names the EFSP uses, in the words the UI uses for them.
_FIELD_LABELS = {
@@ -53,6 +58,21 @@
# not just what went wrong. Patterns are deliberately specific substrings of
# the proxy's own wording so an unrelated message never matches by accident.
_KNOWN_MESSAGE_HINTS: list[tuple[re.Pattern, Callable[[re.Match], str]]] = [
+ (
+ re.compile(r"(?:doesn't|dosesn't|does not) support a state named", re.IGNORECASE),
+ lambda m: (
+ "Check the party's state or location against the address dropdown. "
+ "If the address is not supported, contact the court for filing instructions. "
+ "Do not choose a different state."
+ ),
+ ),
+ (
+ re.compile(r"PersonSurName is required or does not match regular expression", re.IGNORECASE),
+ lambda m: (
+ "Check the last names in the party information. If a party is a company or other organization, "
+ "choose Organization and enter its organization name."
+ ),
+ ),
(
re.compile(r"doesn't allow subsequent filing into non-indexed cases", re.IGNORECASE),
lambda m: (
@@ -126,6 +146,8 @@ def describe_efsp_error(response) -> str:
if not isinstance(body, dict):
return f"the court's filing service returned status {response.status_code}: {str(body)[:_MAX_RAW_BODY]}"
+ body = _unwrap_error(body)
+
problems = [
*(_describe_var(var, missing=False) for var in _var_list(body, "wrong_vars")),
*(_describe_var(var, missing=True) for var in _var_list(body, "required_vars")),
@@ -134,6 +156,9 @@ def describe_efsp_error(response) -> str:
if problems:
return "the court could not accept this filing: " + "; ".join(problems)
+ if body.get("name"):
+ return _describe_var(body, missing=False)
+
# Some errors do arrive as a plain message.
error = body.get("error") or body.get("message") or body.get("detail")
if error:
@@ -151,6 +176,14 @@ def describe_efsp_error(response) -> str:
message = f"{error_type}: {description}" if error_type else str(description)
return _with_hint(message)
+ # The proxy's collector includes general errors alongside wrong_vars.
+ errors = body.get("errors")
+ if isinstance(errors, list) and errors:
+ return "; ".join(
+ _with_hint(str(item.get("description") or item)) if isinstance(item, dict) else _with_hint(str(item))
+ for item in errors
+ )
+
return f"the court's filing service returned status {response.status_code}"
@@ -165,6 +198,23 @@ def _describe_var(var, *, missing: bool) -> str:
return ""
current = str(var.get("currentVal") or "").strip()
+ party_match = _PARTY_FIELD.fullmatch(name)
+ if party_match and party_match[3] in _PARTY_FIELDS:
+ collection, index, path = party_match.groups()
+ field = _PARTY_FIELDS[path]
+ label = FIELD_LABELS.get(field, field.replace("_", " "))
+ who = f"{'filing' if collection == 'users' else 'other'} party {int(index) + 1}"
+ description = str(var.get("description") or "")
+ length = re.search(r"can't exceed (\d+) characters", description)
+ regex_length = re.search(r"must match regex: \^\.\{0,(\d+)\}\$", description)
+ if not length:
+ length = regex_length
+ if length:
+ return f"{label} for {who} must be {length[1]} characters or fewer"
+ if missing or not current:
+ return f"{label.lower()} is required for {who}"
+ return f"{current!r} is not a {label.lower()} the court accepts for {who}"
+
match = _BUNDLE_FIELD.match(name)
if match:
index, name = match.groups()
@@ -184,3 +234,146 @@ def _describe_var(var, *, missing: bool) -> str:
if missing or not current:
return f"no {label} was given{where}"
return f"{current!r} is not a {label} this court accepts{where}"
+
+
+_PARTY_FIELD = re.compile(r"^(users|other_parties)\[(\d+)\]\.(.+)$")
+_PARTY_FIELDS = {
+ "name.first": "first_name",
+ "name.middle": "middle_name",
+ "name.last": "last_name",
+ "name.suffix": "suffix",
+ # NameDocassembleDeserializer adds name.suffix while already inside name.
+ "name.name.suffix": "suffix",
+ "email": "email",
+ "phone_number": "phone",
+ "mobile_number": "phone",
+ "address.address": "address_line_1",
+ "address.unit": "address_line_2",
+ "address.city": "city",
+ "address.state": "state",
+ "address.zip": "zip_code",
+ "party_type": "party_type",
+}
+_STATE_MESSAGE = re.compile(
+ r"(?:Opposing party|Filing party).*?(?:doesn't|dosesn't|does not) support a state named\s+['\"]?([^\s.'\"]+)",
+ re.IGNORECASE,
+)
+
+
+def efsp_error_problems(response):
+ """Retain field paths instead of making the browser parse rendered prose."""
+ try:
+ body = response.json()
+ except (ValueError, TypeError):
+ return []
+ if not isinstance(body, dict):
+ return []
+ body = _unwrap_error(body)
+ problems = []
+ for key in ("wrong_vars", "required_vars"):
+ for var in _var_list(body, key):
+ problems.append(
+ {"name": str(var.get("name") or ""), "message": _describe_var(var, missing=key == "required_vars")}
+ )
+ if body.get("name"):
+ problems.append({"name": str(body["name"]), "message": _describe_var(body, missing=False)})
+ return problems
+
+
+def _unwrap_error(body):
+ """InfoCollector wraps a single FilingError as {error: {...}}."""
+ nested = body.get("error")
+ return nested if isinstance(nested, dict) else body
+
+
+def _matching_party(wire, parties):
+ """A link is safe only when the rejected payload identifies exactly one row.
+
+ Array order is not draft order: users can be someone the filer acts for,
+ and other_parties excludes those users. Duplicate names remain ambiguous.
+ """
+ name = wire.get("name")
+ if not isinstance(name, dict) or not name.get("first"):
+ return None
+ organization = str(wire.get("person_type") or "").lower() in {"business", "organization"}
+ matches = []
+ for party in parties:
+ if organization:
+ same = party.organization_name == name.get("first")
+ else:
+ same = (
+ party.first_name == name.get("first")
+ and party.last_name == (name.get("last") or "")
+ and party.middle_name == (name.get("middle") or "")
+ and not party.organization_name
+ )
+ if same and party.party_type == (wire.get("party_type") or ""):
+ matches.append(party)
+ return matches[0] if len(matches) == 1 else None
+
+
+def error_actions(draft, payload, problems, *, message=""):
+ """Build local, draft-bound edit links only for confidently located fields."""
+ if draft is None or not isinstance(payload, dict):
+ return []
+ parties = list(draft.parties.all())
+ candidates = list(problems)
+ # Older proxy wording contains the rejected state, but no field path.
+ # Locate it only when exactly one outgoing party has that value. The
+ # message may already carry describe_efsp_error's hint after the state.
+ match = _STATE_MESSAGE.search(message)
+ if match:
+ state = match[1]
+ locations = []
+ for collection in ("users", "other_parties"):
+ rows = payload.get(collection)
+ if not isinstance(rows, list):
+ continue
+ for index, wire in enumerate(rows):
+ if (
+ isinstance(wire, dict)
+ and isinstance(wire.get("address"), dict)
+ and wire["address"].get("state") == state
+ ):
+ locations.append(f"{collection}[{index}].address.state")
+ if len(locations) == 1:
+ candidates.append({"name": locations[0], "message": "Check this party's state or location."})
+ actions = []
+ seen = set()
+ for problem in candidates:
+ match = _PARTY_FIELD.fullmatch(str(problem.get("name") or ""))
+ if not match or match[3] not in _PARTY_FIELDS:
+ continue
+ collection, index, path = match.groups()
+ rows = payload.get(collection)
+ if not isinstance(rows, list) or int(index) >= len(rows) or not isinstance(rows[int(index)], dict):
+ continue
+ wire = rows[int(index)]
+ party = _matching_party(wire, parties)
+ if party is None or party.role not in {"filer", "other"}:
+ continue
+ field = _PARTY_FIELDS[path]
+ # The filer's court role is collected on a separate screen, not on
+ # Your information. Do not offer a field link that cannot focus it.
+ if party.role == "filer" and field == "party_type":
+ continue
+ if field == "first_name" and party.organization_name:
+ field = "organization_name"
+ if (party.pk, field) in seen:
+ continue
+ seen.add((party.pk, field))
+ view = "your_information" if party.role == "filer" else "party_details"
+ params = {"draft": draft.pk, "return_to": "review", "focus": field}
+ if party.role != "filer":
+ params["party"] = party.pk
+ url = reverse(view, kwargs={"jurisdiction": draft.jurisdiction}) + "?" + urlencode(params)
+ label = FIELD_LABELS.get(field, field.replace("_", " "))
+ display_name = party.organization_name or " ".join(filter(None, [party.first_name, party.last_name]))
+ actions.append(
+ {
+ "url": url,
+ "label": f"Edit {label.lower()} for {display_name}",
+ "message": problem.get("message") or f"Check {label.lower()}.",
+ }
+ )
+ return actions
diff --git a/efile_app/efile/services/efsp_payload.py b/efile_app/efile/services/efsp_payload.py
index 8b86c181..ae8f300a 100644
--- a/efile_app/efile/services/efsp_payload.py
+++ b/efile_app/efile/services/efsp_payload.py
@@ -13,6 +13,8 @@
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
+from efile.services.efsp_validation import party_validation, validate_party
+
logger = logging.getLogger(__name__)
# Labels the UI has historically stored in place of a real court filing-component
@@ -35,6 +37,10 @@ class PayloadValidationError(Exception):
the message can be specific. Views turn this into a 400 carrying the message.
"""
+ def __init__(self, message, *, problems=None):
+ super().__init__(message)
+ self.problems = problems or []
+
class _EfspLookups:
"""Cached, time-budgeted GETs against the EFSP code lists for one payload.
@@ -126,6 +132,7 @@ def prepare_efile_payload(efile_data, jurisdiction_id, court_id):
"""
lookups = _EfspLookups()
_clean_case_identifiers(efile_data)
+ validate_party_formats(efile_data, jurisdiction_id, court_id)
validate_lower_court(efile_data, jurisdiction_id, court_id, lookups=lookups)
_drop_empty_cross_references(efile_data)
substitute_test_document_urls(efile_data)
@@ -136,6 +143,53 @@ def prepare_efile_payload(efile_data, jurisdiction_id, court_id):
return efile_data
+def validate_party_formats(payload, jurisdiction, court):
+ """Catch invalid extracted/saved values even if a people form was bypassed."""
+ metadata_by_country = {}
+ problems = []
+ for collection in ("users", "other_parties"):
+ parties = payload.get(collection)
+ if not isinstance(parties, list):
+ continue
+ for index, party in enumerate(parties):
+ if not isinstance(party, dict):
+ continue
+ address = party.get("address") or {}
+ name = party.get("name") or {}
+ if not isinstance(address, dict) or not isinstance(name, dict):
+ continue
+ country = str(address.get("country") or "US").upper()
+ if country not in metadata_by_country:
+ metadata_by_country[country] = party_validation(jurisdiction, court, country)
+ metadata = metadata_by_country[country]
+ organization = str(party.get("person_type") or "").lower() in {"business", "organization"}
+ values = {
+ "first_name": name.get("first"),
+ "middle_name": name.get("middle"),
+ "last_name": name.get("last"),
+ "organization_name": name.get("first"),
+ "email": party.get("email"),
+ "phone": party.get("phone_number") or party.get("mobile_number"),
+ "state": address.get("state"),
+ }
+ errors = validate_party(values, metadata, organization=organization, address_started=bool(address))
+ paths = {
+ "first_name": "name.first",
+ "middle_name": "name.middle",
+ "last_name": "name.last",
+ "organization_name": "name.first",
+ "phone": "phone_number",
+ "email": "email",
+ "state": "address.state",
+ }
+ for field, message in errors.items():
+ problems.append({"name": f"{collection}[{index}].{paths[field]}", "message": message})
+ if problems:
+ raise PayloadValidationError(
+ "Check the party information: " + "; ".join(p["message"] for p in problems), problems=problems
+ )
+
+
def _clean_case_identifiers(efile_data):
"""New cases have neither a previous case ID nor a court-assigned docket number."""
if not str(efile_data.get("previous_case_id") or "").strip() or efile_data.get("user_started_case") is True:
diff --git a/efile_app/efile/services/efsp_validation.py b/efile_app/efile/services/efsp_validation.py
new file mode 100644
index 00000000..45c97d9d
--- /dev/null
+++ b/efile_app/efile/services/efsp_validation.py
@@ -0,0 +1,224 @@
+"""Court code lists and text constraints shared by forms and outgoing filings.
+
+The datafield endpoint returns Java regexes. Only portable expressions are
+evaluated locally; the EFSP remains the authority for unsupported expressions.
+Match by search, as the proxy's DataFieldRow.matchRegex uses Matcher.find().
+"""
+
+import logging
+import re
+import time
+from urllib.parse import quote
+
+import requests
+from django.conf import settings
+from django.core.cache import cache
+
+from efile.services.postal_codes import USPS_STATES
+
+logger = logging.getLogger(__name__)
+FIELD_CODES = {
+ "first_name": "PartyFirstName",
+ "middle_name": "PartyMiddleName",
+ "last_name": "PartyLastName",
+ "organization_name": "PartyBusinessName",
+ "email": "PartyEmail",
+ "phone": "PartyPhone",
+}
+FIELD_LABELS = {
+ "first_name": "First name",
+ "middle_name": "Middle name",
+ "last_name": "Last name",
+ "organization_name": "Organization name",
+ "email": "Email",
+ "phone": "Phone",
+ "state": "State",
+ "zip_code": "ZIP code",
+}
+_LENGTH = re.compile(r"\^\.\{(\d+),(\d+)\}\$")
+
+
+class RuleLookups:
+ """Bound lookup latency for a page, with fresh and last-known-good caches."""
+
+ def __init__(self):
+ self.deadline = time.monotonic() + 6
+
+ def get(self, jurisdiction, court, suffix, expected):
+ if not jurisdiction or not court:
+ return None
+ url = (
+ f"{settings.EFSP_URL.rstrip('/')}/jurisdictions/{quote(jurisdiction, safe='')}"
+ f"/codes/courts/{quote(court, safe='')}/{suffix}"
+ )
+ key = f"efsp-rule:{url}"
+ cached = cache.get(key)
+ if cached is not None:
+ return cached
+ if cache.get(f"{key}:unavailable"):
+ return cache.get(f"{key}:last-good")
+ remaining = self.deadline - time.monotonic()
+ if remaining > 0:
+ try:
+ response = requests.get(url, timeout=min(2, remaining)) # nosec B113
+ if response.status_code == 200:
+ value = response.json()
+ valid = isinstance(value, expected)
+ if expected is list and valid:
+ valid = all(isinstance(code, str) and code.strip() for code in value)
+ if expected is dict and valid:
+ valid = value.get("code") in {"", suffix.rsplit("/", 1)[-1]}
+ if valid:
+ cache.set(key, value, 3600)
+ cache.set(f"{key}:last-good", value, 7 * 86400)
+ return value
+ except (requests.RequestException, ValueError):
+ logger.debug("Could not load EFSP validation metadata for %s", suffix)
+ # Short negative cache prevents one unavailable service causing a series
+ # of slow page loads. Empty lists are real results, not outages.
+ cached = cache.get(f"{key}:last-good")
+ if cached is not None:
+ cache.set(key, cached, 60)
+ else:
+ cache.set(f"{key}:unavailable", True, 60)
+ return cached
+
+
+def state_choices(jurisdiction, court, country="US", *, lookups=None):
+ lookups = lookups or RuleLookups()
+ codes = lookups.get(jurisdiction, court, f"countries/{quote(country, safe='')}/states", list)
+ if isinstance(codes, list) and all(isinstance(code, str) and code.strip() for code in codes):
+ labels = USPS_STATES if country == "US" else {}
+ return sorted({(code, labels.get(code, code)) for code in codes}, key=lambda item: item[1]), "efsp"
+ # USPS codes are only meaningful for US addresses. Keep foreign data intact
+ # rather than suggesting US states as foreign provinces.
+ return list(USPS_STATES.items()) if country == "US" else [], "usps" if country == "US" else "unavailable"
+
+
+def portable_regex(pattern):
+ """Return a shared Python/JS expression, or None for Java-only constructs."""
+ if not isinstance(pattern, str) or not pattern or len(pattern) > 1000:
+ return None
+ # Java flags, atomic groups, class intersections, possessive quantifiers,
+ # Unicode categories, quoting and Java anchor/escape semantics differ.
+ if re.search(r"(? body?.error)
+ const serverBody = await response.clone().json()
.catch(() => null);
+ const serverMessage = serverBody?.error;
const error = new Error(serverMessage || `HTTP error! status: ${response.status}`);
error.status = response.status;
error.serverMessage = serverMessage || null;
+ error.errorActions = serverBody?.error_actions || [];
throw error;
}
diff --git a/efile_app/efile/static/js/filing-error-actions.js b/efile_app/efile/static/js/filing-error-actions.js
new file mode 100644
index 00000000..80dfdfb1
--- /dev/null
+++ b/efile_app/efile/static/js/filing-error-actions.js
@@ -0,0 +1,20 @@
+// The API supplies local edit links. Keep upstream/user text out of HTML.
+window.FilingErrorActions = {
+ render(id, actions = []) {
+ const list = document.getElementById(id);
+ if (!list) return;
+ list.replaceChildren();
+ for (const action of Array.isArray(actions) ? actions : []) {
+ if (typeof action.url !== "string" || !action.url.startsWith("/jurisdiction/")) continue;
+ const item = document.createElement("li");
+ const message = document.createElement("span");
+ message.textContent = `${action.message || ""} `;
+ const link = document.createElement("a");
+ link.href = action.url;
+ link.textContent = action.label || gettext("Edit this field");
+ item.append(message, link);
+ list.appendChild(item);
+ }
+ list.hidden = list.children.length === 0;
+ }
+};
\ No newline at end of file
diff --git a/efile_app/efile/static/js/filing-payload.js b/efile_app/efile/static/js/filing-payload.js
index aef24a36..e3b2b0fa 100644
--- a/efile_app/efile/static/js/filing-payload.js
+++ b/efile_app/efile/static/js/filing-payload.js
@@ -409,7 +409,7 @@ const FilingPayload = {
window.location.href = result.redirect_url || `/jurisdiction/${jurisdiction}/filing-confirmation/`;
}, 2000);
} else {
- Messages.showError(result?.error || "An error occurred during submission.");
+ Messages.showError(result?.error || "An error occurred during submission.", result?.error_actions);
this.setSubmissionState(false);
}
},
@@ -449,7 +449,7 @@ const FilingPayload = {
infoElem.appendChild(receipt);
document.getElementById("paymentSection").removeAttribute("hidden");
} else {
- Messages.showError(result?.error || "An error occurred when calculating fees.");
+ Messages.showError(result?.error || "An error occurred when calculating fees.", result?.error_actions);
}
this.setFeesState(false);
}
diff --git a/efile_app/efile/static/js/party-validation.js b/efile_app/efile/static/js/party-validation.js
new file mode 100644
index 00000000..9656731a
--- /dev/null
+++ b/efile_app/efile/static/js/party-validation.js
@@ -0,0 +1,79 @@
+// Rules originate in court metadata. Server validation also runs without JS.
+(() => {
+ const data = document.getElementById("party-validation-rules");
+ if (!data) return;
+ const form = document.getElementById("your-information-form") || document.getElementById("party-details-form");
+ const rules = JSON.parse(data.textContent);
+ const normalizePhone = (value) => {
+ let normalized = value.replace(/[-()]/g, "").trim();
+ if (normalized.includes("+")) {
+ if (normalized.startsWith("+1") || normalized.startsWith("+0")) {
+ normalized = normalized.replaceAll("+1", "+1 ").replaceAll("+0", "+0 ");
+ }
+ return normalized;
+ }
+ return normalized.replaceAll(" ", "");
+ };
+ const validate = (field, rule) => {
+ const input = form.elements.namedItem(field);
+ if (!input) return;
+ if (input.disabled || input.closest("[hidden]")) {
+ input.setCustomValidity("");
+ return;
+ }
+ let invalid = false;
+ if (input.value && rule.regex) {
+ try {
+ // No "u" flag: it rejects identity escapes such as \- that
+ // portable_regex accepts and Python treats as literals.
+ const regex = new RegExp(rule.regex);
+ invalid = !regex.test(input.value) && !(field === "phone" && regex.test(normalizePhone(input.value)));
+ } catch {
+ /* Java expressions unsupported by this browser stay server validated. */
+ }
+ }
+ const message = invalid ? rule.message : "";
+ input.setCustomValidity(message);
+ input.setAttribute("aria-invalid", String(invalid));
+ const feedback = document.getElementById(`error_${field}`);
+ if (feedback) {
+ feedback.textContent = message;
+ feedback.hidden = !message;
+ }
+ };
+ Object.entries(rules).forEach(([field, rule]) => {
+ const input = form.elements.namedItem(field);
+ if (!input) return;
+ if (rule.required && ["email", "phone"].includes(field)) input.required = true;
+ input.addEventListener("input", () => validate(field, rule));
+ input.addEventListener("blur", () => validate(field, rule));
+ // Pre-filled/extracted values deserve the same validation as typing.
+ validate(field, rule);
+ });
+ form.addEventListener("submit", (event) => {
+ Object.entries(rules).forEach(([field, rule]) => validate(field, rule));
+ if (!form.checkValidity()) {
+ event.preventDefault();
+ form.reportValidity();
+ }
+ });
+ // Clear validity on hidden name fields when switching to an organization.
+ form.addEventListener("change", () => {
+ Object.entries(rules).forEach(([field, rule]) => validate(field, rule));
+ });
+ document.addEventListener("DOMContentLoaded", () => {
+ const field = new URLSearchParams(window.location.search).get("focus");
+ // Only addressable form fields; never interpret a URL as a selector.
+ const id = field === "suffix" ? "suffix" : `id_${field}`;
+ const input = field ? document.getElementById(id) : null;
+ if (input && form.contains(input)) {
+ const address = input.closest("#party-address-fields");
+ if (address?.hidden) {
+ const toggle = document.getElementById("add-party-address");
+ toggle.checked = true;
+ toggle.dispatchEvent(new Event("change"));
+ }
+ input.focus();
+ }
+ });
+})();
\ No newline at end of file
diff --git a/efile_app/efile/static/js/payment.js b/efile_app/efile/static/js/payment.js
index 0fd7169a..7ae5b710 100644
--- a/efile_app/efile/static/js/payment.js
+++ b/efile_app/efile/static/js/payment.js
@@ -23,8 +23,9 @@ const paymentMessages = {
document.getElementById("errorMessage").hidden = true;
document.getElementById("successMessage").hidden = true;
},
- showError(message) {
+ showError(message, actions = []) {
document.getElementById("errorText").textContent = message;
+ window.FilingErrorActions?.render("filing-error-actions", actions);
const box = document.getElementById("errorMessage");
box.hidden = false;
box.scrollIntoView({
@@ -271,7 +272,7 @@ const PaymentPage = {
} catch (error) {
if (currentRequestId !== this.quoteRequestId) return;
this.feeQuoteReady = false;
- paymentMessages.showError(error?.serverMessage || gettext("We could not calculate fees. Please try again."));
+ paymentMessages.showError(error?.serverMessage || gettext("We could not calculate fees. Please try again."), error?.errorActions);
this.setFeesState(false);
}
},
diff --git a/efile_app/efile/static/js/review.js b/efile_app/efile/static/js/review.js
index 82bcc0f7..64278123 100644
--- a/efile_app/efile/static/js/review.js
+++ b/efile_app/efile/static/js/review.js
@@ -5,8 +5,9 @@ const Messages = {
document.getElementById("errorMessage").hidden = true;
document.getElementById("successMessage").hidden = true;
},
- showError(message) {
+ showError(message, actions = []) {
document.getElementById("errorText").textContent = message;
+ window.FilingErrorActions?.render("filing-error-actions", actions);
const box = document.getElementById("errorMessage");
box.hidden = false;
box.scrollIntoView({
@@ -95,15 +96,17 @@ const FilingHandler = {
document.getElementById("fee-quote-total").hidden = false;
document.getElementById("fee-quote-pending").hidden = true;
document.getElementById("fee-quote-error").hidden = true;
+ window.FilingErrorActions?.render("fee-error-actions", []);
},
- showFeeError(message) {
+ showFeeError(message, actions = []) {
this.setFreeFilingHelp(false);
document.getElementById("submit-button-label").textContent = gettext("Submit filing");
document.getElementById("fee-quote-breakdown").replaceChildren();
document.getElementById("fee-quote-pending").hidden = true;
document.getElementById("fee-quote-total").hidden = true;
document.getElementById("fee-quote-error-text").textContent = message;
+ window.FilingErrorActions?.render("fee-error-actions", actions);
document.getElementById("fee-quote-error").hidden = false;
},
@@ -136,7 +139,7 @@ const FilingHandler = {
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.showFeeError(`${error?.serverMessage || error?.message || gettext("We could not calculate the court's fees.")} ${gettext("You cannot submit until the fees are known.")}`, error?.errorActions);
}
this.setSubmissionState(false);
},
@@ -164,7 +167,7 @@ const FilingHandler = {
// 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."));
+ Messages.showError(error?.serverMessage || gettext("We could not submit the filing. Please try again."), error?.errorActions);
this.setSubmissionState(false);
}
}
diff --git a/efile_app/efile/templates/efile/partials/state_choices.html b/efile_app/efile/templates/efile/partials/state_choices.html
new file mode 100644
index 00000000..0b77852d
--- /dev/null
+++ b/efile_app/efile/templates/efile/partials/state_choices.html
@@ -0,0 +1,28 @@
+{% load i18n %}
+{% if state_source == "unavailable" %}
+
+{% else %}
+
+{% endif %}
+
+ {% if state_source == "efsp" %}
+ {% translate "Only locations accepted by the filing service are listed. If your address is not listed, contact the court for filing instructions. Do not choose a different state." %}
+ {% elif state_source == "usps" %}
+ {% translate "The filing service's location list is unavailable. USPS locations, including territories and military addresses, are shown. The filing service will check whether it accepts your address." %}
+ {% else %}
+ {% translate "The filing service's list of states or provinces is unavailable. Enter your address as written; the filing service will check whether it accepts it." %}
+ {% endif %}
+
diff --git a/efile_app/efile/templates/efile/party_details.html b/efile_app/efile/templates/efile/party_details.html
index 316bfd80..8391b269 100644
--- a/efile_app/efile/templates/efile/party_details.html
+++ b/efile_app/efile/templates/efile/party_details.html
@@ -1,6 +1,7 @@
{% extends "efile/workflow_base.html" %}
{% load static %}
{% load i18n %}
+{% load efsp_validation %}
{% block title %}
{% translate "Party details" %}
{% endblock title %}
@@ -28,6 +29,7 @@
@@ -97,29 +99,36 @@ {% translate "Name" %}
+ {% if party_kind != "organization" %}required{% endif %}
+ {% validation_attrs "first_name" %}>
+ {% field_feedback "first_name" %}
@@ -130,7 +139,9 @@ {% translate "Organization" %}
+ {% if party_kind == "organization" %}required{% endif %}
+ {% validation_attrs "organization_name" %}>
+ {% field_feedback "organization_name" %}
@@ -172,41 +183,49 @@
+ {% if address_required %}required{% endif %}
+ {% validation_attrs "address_line_1" %}>
+ {% field_feedback "address_line_1" %}
@@ -273,6 +296,8 @@
{% include "efile/components/claim_party_dialog.html" %}
{% endblock workflow_content %}
{% block extra_js %}
+ {{ validation_rules|json_script:"party-validation-rules" }}
+
{% endblock extra_js %}
diff --git a/efile_app/efile/templates/efile/payment.html b/efile_app/efile/templates/efile/payment.html
index e2c8bdab..b40e0c01 100644
--- a/efile_app/efile/templates/efile/payment.html
+++ b/efile_app/efile/templates/efile/payment.html
@@ -152,6 +152,8 @@ {% translate "Confirmed fees" %}
@@ -179,6 +181,7 @@
{% translate "Confirmed fees" %}
{{ fee_estimate.is_zero|json_script:"estimated-zero-fees" }}
{% endblock workflow_content %}
{% block extra_js %}
+
diff --git a/efile_app/efile/templates/efile/register.html b/efile_app/efile/templates/efile/register.html
index f1f6c09e..95c25f8a 100644
--- a/efile_app/efile/templates/efile/register.html
+++ b/efile_app/efile/templates/efile/register.html
@@ -86,6 +86,7 @@ {% translate "Create an eFile account
{{ form.state.label_tag }} {{ form.state }}
+ {% if form.state.help_text %}
{{ form.state.help_text }}
{% endif %}
{% if form.state.errors %}
{{ form.state.errors }}
{% endif %}
diff --git a/efile_app/efile/templates/efile/review.html b/efile_app/efile/templates/efile/review.html
index eafe3f2b..e261125e 100644
--- a/efile_app/efile/templates/efile/review.html
+++ b/efile_app/efile/templates/efile/review.html
@@ -253,10 +253,12 @@
{% translate "Payment" %}
{% translate "We are calculating the court's fees for this filing." %}
{% endif %}
-
+
{% endif %}
@@ -290,6 +292,8 @@ {% translate "Payment" %}
{% endif %}
{% translate "Before you submit your filing"
{{ fee_inputs_token|json_script:"fee-inputs-token" }}
{% endblock workflow_content %}
{% block extra_js %}
+
{% endblock extra_js %}
diff --git a/efile_app/efile/templates/efile/your_information.html b/efile_app/efile/templates/efile/your_information.html
index 91afa884..4f7d7331 100644
--- a/efile_app/efile/templates/efile/your_information.html
+++ b/efile_app/efile/templates/efile/your_information.html
@@ -1,6 +1,7 @@
{% extends "efile/workflow_base.html" %}
{% load static %}
{% load i18n %}
+{% load efsp_validation %}
{% load ui_text %}
{% block title %}
{% translate "Your information" %}
@@ -25,14 +26,18 @@
{% translate "Name" %}
name="first_name"
value="{{ filer.first_name }}"
autocomplete="given-name"
- required />
+ required
+ {% validation_attrs "first_name" %}>
+ {% field_feedback "first_name" %}
@@ -62,14 +70,18 @@ {% translate "Mailing address" %}
name="address_line_1"
value="{{ filer.address_line_1 }}"
autocomplete="address-line1"
- required />
+ required
+ {% validation_attrs "address_line_1" %}>
+ {% field_feedback "address_line_1" %}
@@ -108,15 +120,23 @@ {% translate "Contact information" %}
name="email"
value="{{ filer.email }}"
autocomplete="email"
- required />
+ required
+ {% validation_attrs "email" %}>
+ {% field_feedback "email" %}
@@ -139,5 +159,7 @@ {% translate "Contact information" %}
{% endblock workflow_content %}
{% block extra_js %}
+ {{ validation_rules|json_script:"party-validation-rules" }}
+
{% endblock extra_js %}
diff --git a/efile_app/efile/templatetags/efsp_validation.py b/efile_app/efile/templatetags/efsp_validation.py
new file mode 100644
index 00000000..062ce67f
--- /dev/null
+++ b/efile_app/efile/templatetags/efsp_validation.py
@@ -0,0 +1,34 @@
+"""Accessible field feedback for the people forms."""
+
+from django import template
+from django.utils.html import format_html, format_html_join
+
+register = template.Library()
+
+
+@register.simple_tag(takes_context=True)
+def validation_attrs(context, field):
+ attrs = {"id": "suffix" if field == "suffix" else f"id_{field}", "aria-describedby": f"help_{field} error_{field}"}
+ if context.get("field_errors", {}).get(field):
+ attrs["aria-invalid"] = "true"
+ rule = context.get("validation_rules", {}).get(field, {})
+ if field in {"email", "phone"} and rule.get("required"):
+ attrs["required"] = "required"
+ return format_html_join(" ", '{}="{}"', attrs.items())
+
+
+@register.simple_tag(takes_context=True)
+def field_feedback(context, field):
+ rule = context.get("validation_rules", {}).get(field, {})
+ help_text = rule.get("help", "")
+ if rule.get("max_length") is not None:
+ help_text = f"{help_text} {rule['max_length']} characters or fewer.".strip()
+ error = context.get("field_errors", {}).get(field, "")
+ return format_html(
+ '{}{}',
+ field,
+ help_text,
+ field,
+ "" if error else "hidden",
+ error,
+ )
diff --git a/efile_app/efile/tests/conftest.py b/efile_app/efile/tests/conftest.py
index 43f03ba2..9824b5db 100644
--- a/efile_app/efile/tests/conftest.py
+++ b/efile_app/efile/tests/conftest.py
@@ -35,3 +35,9 @@ def court_requirements(monkeypatch):
def appellate_code_lists(monkeypatch):
"""Appellate tests opt into specific catalogs; other tests never call EFSP."""
monkeypatch.setattr("efile.services.appeals.code_list", lambda jurisdiction, path, lookups=None: None)
+
+
+@pytest.fixture(autouse=True)
+def efsp_validation_metadata(monkeypatch):
+ """Validation lookup tests opt into metadata; other tests use USPS fallback."""
+ monkeypatch.setattr("efile.services.efsp_validation.RuleLookups.get", lambda *args: None)
diff --git a/efile_app/efile/tests/fixtures/efsp_validation_2026-10-02.json b/efile_app/efile/tests/fixtures/efsp_validation_2026-10-02.json
new file mode 100644
index 00000000..cb535097
--- /dev/null
+++ b/efile_app/efile/tests/fixtures/efsp_validation_2026-10-02.json
@@ -0,0 +1,432 @@
+{
+ "base_url": "https://efile-test.suffolklitlab.org/jurisdictions/",
+ "fetched_on": "2026-10-02",
+ "responses": [
+ {
+ "path": "illinois/codes/courts/cook:cvd1/countries/US/states",
+ "body": [
+ "TX",
+ "CO",
+ "AL",
+ "AK",
+ "AZ",
+ "AR",
+ "CA",
+ "CT",
+ "DE",
+ "DC",
+ "FL",
+ "GA",
+ "HI",
+ "ID",
+ "IL",
+ "IN",
+ "IA",
+ "KS",
+ "KY",
+ "LA",
+ "ME",
+ "MD",
+ "MA",
+ "MI",
+ "MN",
+ "MS",
+ "MO",
+ "MT",
+ "NE",
+ "NV",
+ "NH",
+ "NJ",
+ "NM",
+ "NY",
+ "NC",
+ "ND",
+ "OH",
+ "OK",
+ "OR",
+ "PA",
+ "RI",
+ "SC",
+ "SD",
+ "TN",
+ "UT",
+ "VT",
+ "VA",
+ "WA",
+ "WV",
+ "WI",
+ "WY",
+ "PR",
+ "GU",
+ "VI"
+ ]
+ },
+ {
+ "path": "illinois/codes/courts/cook:cvd1/datafields/PartyPhone",
+ "body": {
+ "code": "PartyPhone",
+ "name": "Party edit window: Phone Number",
+ "isvisible": true,
+ "isrequired": false,
+ "helptext": "",
+ "ghosttext": "",
+ "contextualhelpdata": "",
+ "validationmessage": "",
+ "regularexpression": "^.{0,20}$",
+ "defaultvalueexpression": "",
+ "isreadonly": false,
+ "location": "0"
+ }
+ },
+ {
+ "path": "illinois/codes/courts/cook:cvd1/datafields/PartyEmail",
+ "body": {
+ "code": "PartyEmail",
+ "name": "Party edit window: Email",
+ "isvisible": true,
+ "isrequired": false,
+ "helptext": "",
+ "ghosttext": "",
+ "contextualhelpdata": "",
+ "validationmessage": "",
+ "regularexpression": null,
+ "defaultvalueexpression": "",
+ "isreadonly": false,
+ "location": "0"
+ }
+ },
+ {
+ "path": "illinois/codes/courts/cook:cvd1/datafields/PartyFirstName",
+ "body": {
+ "code": "PartyFirstName",
+ "name": "Party edit window: First Name",
+ "isvisible": true,
+ "isrequired": true,
+ "helptext": "",
+ "ghosttext": "",
+ "contextualhelpdata": "",
+ "validationmessage": "",
+ "regularexpression": null,
+ "defaultvalueexpression": "",
+ "isreadonly": false,
+ "location": "0"
+ }
+ },
+ {
+ "path": "illinois/codes/courts/cook:cvd1/datafields/PartyLastName",
+ "body": {
+ "code": "PartyLastName",
+ "name": "Party edit window: Last Name",
+ "isvisible": true,
+ "isrequired": true,
+ "helptext": "",
+ "ghosttext": "",
+ "contextualhelpdata": "",
+ "validationmessage": "",
+ "regularexpression": null,
+ "defaultvalueexpression": "",
+ "isreadonly": false,
+ "location": "0"
+ }
+ },
+ {
+ "path": "illinois/codes/courts/cook:cvd1/datafields/PartyBusinessName",
+ "body": {
+ "code": "PartyBusinessName",
+ "name": "Party edit window: Business Name",
+ "isvisible": true,
+ "isrequired": true,
+ "helptext": "",
+ "ghosttext": "",
+ "contextualhelpdata": "",
+ "validationmessage": "",
+ "regularexpression": null,
+ "defaultvalueexpression": "",
+ "isreadonly": false,
+ "location": "0"
+ }
+ },
+ {
+ "path": "massachusetts/codes/courts/0705:LA/countries/US/states",
+ "body": [
+ "TX",
+ "CO",
+ "AL",
+ "AK",
+ "AZ",
+ "AR",
+ "CA",
+ "CT",
+ "DE",
+ "DC",
+ "FL",
+ "GA",
+ "HI",
+ "ID",
+ "IL",
+ "IN",
+ "IA",
+ "KS",
+ "KY",
+ "LA",
+ "ME",
+ "MD",
+ "MA",
+ "MI",
+ "MN",
+ "MS",
+ "MO",
+ "MT",
+ "NE",
+ "NV",
+ "NH",
+ "NJ",
+ "NM",
+ "NY",
+ "NC",
+ "ND",
+ "OH",
+ "OK",
+ "OR",
+ "PA",
+ "RI",
+ "SC",
+ "SD",
+ "TN",
+ "UT",
+ "VT",
+ "VA",
+ "WA",
+ "WV",
+ "WI",
+ "WY"
+ ]
+ },
+ {
+ "path": "massachusetts/codes/courts/0705:LA/datafields/PartyPhone",
+ "body": {
+ "code": "PartyPhone",
+ "name": "Party edit window: Phone Number",
+ "isvisible": true,
+ "isrequired": false,
+ "helptext": "",
+ "ghosttext": "Ex: 1234567890",
+ "contextualhelpdata": "",
+ "validationmessage": "Do not use hyphens or other characters--just numbers",
+ "regularexpression": "^(\\+0?1\\s)?\\(?\\d{3}\\)?\\d{3}\\d{4}$",
+ "defaultvalueexpression": "",
+ "isreadonly": false,
+ "location": "0705:LA"
+ }
+ },
+ {
+ "path": "massachusetts/codes/courts/0705:LA/datafields/PartyEmail",
+ "body": {
+ "code": "PartyEmail",
+ "name": "PartyEmail",
+ "isvisible": true,
+ "isrequired": false,
+ "helptext": "",
+ "ghosttext": "Example: someone@domain.com",
+ "contextualhelpdata": "",
+ "validationmessage": "Please enter a valid email address: Example: someone@domain.com",
+ "regularexpression": "^(.+)@(.+)\\.(.+)",
+ "defaultvalueexpression": "",
+ "isreadonly": false,
+ "location": "0705:LA"
+ }
+ },
+ {
+ "path": "massachusetts/codes/courts/0705:LA/datafields/PartyFirstName",
+ "body": {
+ "code": "PartyFirstName",
+ "name": "Party edit window: First Name",
+ "isvisible": true,
+ "isrequired": true,
+ "helptext": "",
+ "ghosttext": "",
+ "contextualhelpdata": "",
+ "validationmessage": "You must enter a First Name for your party.",
+ "regularexpression": "^.{0,50}$",
+ "defaultvalueexpression": "",
+ "isreadonly": false,
+ "location": "0705:LA"
+ }
+ },
+ {
+ "path": "massachusetts/codes/courts/0705:LA/datafields/PartyLastName",
+ "body": {
+ "code": "PartyLastName",
+ "name": "Party edit window: Last Name",
+ "isvisible": true,
+ "isrequired": true,
+ "helptext": "",
+ "ghosttext": "",
+ "contextualhelpdata": "",
+ "validationmessage": "You must enter a last name for your party.",
+ "regularexpression": "^.{0,50}$",
+ "defaultvalueexpression": "",
+ "isreadonly": false,
+ "location": "0705:LA"
+ }
+ },
+ {
+ "path": "massachusetts/codes/courts/0705:LA/datafields/PartyBusinessName",
+ "body": {
+ "code": "PartyBusinessName",
+ "name": "Party edit window: Business Name",
+ "isvisible": true,
+ "isrequired": true,
+ "helptext": "",
+ "ghosttext": "",
+ "contextualhelpdata": "",
+ "validationmessage": "",
+ "regularexpression": "^.{0,254}$",
+ "defaultvalueexpression": "",
+ "isreadonly": false,
+ "location": "0705:LA"
+ }
+ },
+ {
+ "path": "vermont/codes/courts/6000/countries/US/states",
+ "body": [
+ "TX",
+ "CO",
+ "AL",
+ "AK",
+ "AZ",
+ "AR",
+ "CA",
+ "CT",
+ "DE",
+ "DC",
+ "FL",
+ "GA",
+ "HI",
+ "ID",
+ "IL",
+ "IN",
+ "IA",
+ "KS",
+ "KY",
+ "LA",
+ "ME",
+ "MD",
+ "MA",
+ "MI",
+ "MN",
+ "MS",
+ "MO",
+ "MT",
+ "NE",
+ "NV",
+ "NH",
+ "NJ",
+ "NM",
+ "NY",
+ "NC",
+ "ND",
+ "OH",
+ "OK",
+ "OR",
+ "PA",
+ "RI",
+ "SC",
+ "SD",
+ "TN",
+ "UT",
+ "VT",
+ "VA",
+ "WA",
+ "WV",
+ "WI",
+ "WY"
+ ]
+ },
+ {
+ "path": "vermont/codes/courts/6000/datafields/PartyPhone",
+ "body": {
+ "code": "PartyPhone",
+ "name": "Party edit window: Phone Number",
+ "isvisible": true,
+ "isrequired": false,
+ "helptext": "",
+ "ghosttext": "",
+ "contextualhelpdata": "",
+ "validationmessage": "",
+ "regularexpression": null,
+ "defaultvalueexpression": "",
+ "isreadonly": false,
+ "location": "6000"
+ }
+ },
+ {
+ "path": "vermont/codes/courts/6000/datafields/PartyEmail",
+ "body": {
+ "code": "",
+ "name": "PartyEmail",
+ "isvisible": false,
+ "isrequired": false,
+ "helptext": "",
+ "ghosttext": "",
+ "contextualhelpdata": "",
+ "validationmessage": "",
+ "regularexpression": null,
+ "defaultvalueexpression": "",
+ "isreadonly": false,
+ "location": ""
+ }
+ },
+ {
+ "path": "vermont/codes/courts/6000/datafields/PartyFirstName",
+ "body": {
+ "code": "PartyFirstName",
+ "name": "Party edit window: First Name",
+ "isvisible": true,
+ "isrequired": true,
+ "helptext": "",
+ "ghosttext": "",
+ "contextualhelpdata": "",
+ "validationmessage": "",
+ "regularexpression": null,
+ "defaultvalueexpression": "",
+ "isreadonly": false,
+ "location": "6000"
+ }
+ },
+ {
+ "path": "vermont/codes/courts/6000/datafields/PartyLastName",
+ "body": {
+ "code": "PartyLastName",
+ "name": "Party edit window: Last Name",
+ "isvisible": true,
+ "isrequired": true,
+ "helptext": "",
+ "ghosttext": "",
+ "contextualhelpdata": "",
+ "validationmessage": "",
+ "regularexpression": null,
+ "defaultvalueexpression": "",
+ "isreadonly": false,
+ "location": "6000"
+ }
+ },
+ {
+ "path": "vermont/codes/courts/6000/datafields/PartyBusinessName",
+ "body": {
+ "code": "PartyBusinessName",
+ "name": "Party edit window: Business Name",
+ "isvisible": true,
+ "isrequired": true,
+ "helptext": "",
+ "ghosttext": "",
+ "contextualhelpdata": "",
+ "validationmessage": "",
+ "regularexpression": null,
+ "defaultvalueexpression": "",
+ "isreadonly": false,
+ "location": "6000"
+ }
+ }
+ ]
+}
diff --git a/efile_app/efile/tests/test_efsp_validation.py b/efile_app/efile/tests/test_efsp_validation.py
new file mode 100644
index 00000000..93321681
--- /dev/null
+++ b/efile_app/efile/tests/test_efsp_validation.py
@@ -0,0 +1,310 @@
+"""Use actual EFSP data to exercise prevention, outages and precise edit links."""
+
+import json
+from pathlib import Path
+from unittest.mock import Mock, patch
+from urllib.parse import parse_qs, urlsplit
+
+import pytest
+import requests
+from django.core.cache import cache
+from django.forms import ChoiceField
+from django.urls import reverse
+
+from efile.models import FilingDraft, FilingParty
+from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY
+from efile.services.efsp_errors import describe_efsp_error, efsp_error_problems, error_actions
+from efile.services.efsp_payload import PayloadValidationError, validate_party_formats
+from efile.services.efsp_validation import party_validation, portable_regex, state_choices, validate_party
+from efile.services.postal_codes import USPS_STATES
+
+SAMPLES = json.loads((Path(__file__).parent / "fixtures/efsp_validation_2026-10-02.json").read_text())
+RESPONSES = {row["path"]: row["body"] for row in SAMPLES["responses"]}
+
+
+@pytest.fixture(autouse=True)
+def efsp_validation_metadata(monkeypatch, settings):
+ """Override the suite's fallback fixture with recorded live responses."""
+ settings.EFSP_URL = "https://efile-test.suffolklitlab.org"
+ cache.clear()
+
+ def get(url, **kwargs):
+ path = url.split("/jurisdictions/", 1)[-1].replace("%3A", ":")
+ return Mock(status_code=200 if path in RESPONSES else 404, json=lambda: RESPONSES.get(path))
+
+ monkeypatch.setattr("efile.services.efsp_validation.requests.get", get)
+ yield
+ cache.clear()
+
+
+def test_usps_fallback_includes_all_62_postal_locations():
+ choices, source = state_choices("unknown", "unknown")
+ assert source == "usps"
+ assert len(choices) == 62
+ assert set(dict(choices)) == set(USPS_STATES)
+ assert {"DC", "AA", "AE", "AP", "AS", "GU", "MP", "PR", "VI", "FM", "MH", "PW"} <= set(dict(choices))
+
+
+@pytest.mark.parametrize(
+ ("jurisdiction", "court", "count"),
+ [
+ ("illinois", "cook:cvd1", 54),
+ ("massachusetts", "0705:LA", 51),
+ ("vermont", "6000", 51),
+ ],
+)
+def test_recorded_state_choices_match_the_efsp_exactly(jurisdiction, court, count):
+ choices, source = state_choices(jurisdiction, court)
+ assert source == "efsp"
+ assert len(choices) == count
+ assert set(dict(choices)) == set(RESPONSES[f"{jurisdiction}/codes/courts/{court}/countries/US/states"])
+ assert not {"AA", "AE", "AP"}.intersection(dict(choices))
+
+
+def test_empty_list_means_no_supported_locations_rather_than_fallback(monkeypatch):
+ monkeypatch.setattr(
+ "efile.services.efsp_validation.requests.get", lambda *a, **k: Mock(status_code=200, json=lambda: [])
+ )
+ assert state_choices("vermont", "6000") == ([], "efsp")
+
+
+def test_failed_refresh_uses_last_good_then_usps_when_no_cache(monkeypatch):
+ choices = state_choices("massachusetts", "0705:LA")
+ for key in [
+ "efsp-rule:https://efile-test.suffolklitlab.org/jurisdictions/massachusetts/codes/courts/0705%3ALA/countries/US/states"
+ ]:
+ cache.delete(key)
+ get = Mock(side_effect=requests.Timeout)
+ monkeypatch.setattr("efile.services.efsp_validation.requests.get", get)
+ assert state_choices("massachusetts", "0705:LA") == choices
+ assert state_choices("illinois", "new-court")[1] == "usps"
+ calls = get.call_count
+ assert state_choices("illinois", "new-court")[1] == "usps"
+ assert get.call_count == calls
+
+
+def test_malformed_states_fall_back_and_are_not_kept_as_last_good(monkeypatch):
+ monkeypatch.setattr(
+ "efile.services.efsp_validation.requests.get", lambda *a, **k: Mock(status_code=200, json=lambda: [{}])
+ )
+ assert state_choices("illinois", "cook:cvd1")[1] == "usps"
+ assert not cache.get(
+ "efsp-rule:https://efile-test.suffolklitlab.org/jurisdictions/illinois/codes/courts/cook%3Acvd1/countries/US/states:last-good"
+ )
+
+
+def test_rules_cache_does_not_cross_courts_or_efsp_environments(settings, monkeypatch):
+ assert party_validation("massachusetts", "0705:LA")["validation_rules"]["first_name"]["max_length"] == 50
+ assert party_validation("illinois", "cook:cvd1")["validation_rules"]["first_name"]["max_length"] is None
+ settings.EFSP_URL = "https://another-efsp.example"
+ monkeypatch.setattr("efile.services.efsp_validation.requests.get", Mock(side_effect=requests.Timeout))
+ assert state_choices("massachusetts", "0705:LA")[1] == "usps"
+
+
+@pytest.mark.parametrize("phone", ["1234567890", "(123) 456-7890", "+1 1234567890", "+1(123)456-7890", ""])
+def test_massachusetts_phone_validation_matches_proxy_normalization(phone):
+ errors = validate_party({"phone": phone}, party_validation("massachusetts", "0705:LA"))
+ assert "phone" not in errors
+
+
+def test_live_messages_examples_and_length_limits():
+ metadata = party_validation("massachusetts", "0705:LA")
+ errors = validate_party({"first_name": "a" * 51, "email": "invalid", "phone": "123"}, metadata)
+ assert errors["first_name"] == "First name must be 50 characters or fewer."
+ assert errors["email"] == "Please enter a valid email address: Example: someone@domain.com"
+ assert errors["phone"] == "Do not use hyphens or other characters--just numbers"
+ assert metadata["validation_rules"]["phone"]["help"] == "Ex: 1234567890"
+
+
+def test_organization_rules_use_business_name_instead_of_person_name():
+ errors = validate_party(
+ {"organization_name": "a" * 100, "last_name": "b" * 51},
+ party_validation("massachusetts", "0705:LA"),
+ organization=True,
+ )
+ assert not errors
+
+
+@pytest.mark.parametrize(
+ "pattern", [r"(?i)abc", r"\p{L}+", r"\Qabc\E", r"[a-z&&[^x]]", r"\Aabc\z", r"\d++", r"[\s]", "["]
+)
+def test_java_only_or_invalid_regexes_are_deferred_to_efsp(pattern):
+ assert portable_regex(pattern) is None
+
+
+def test_unanchored_regex_uses_search_like_the_proxy():
+ metadata = {
+ "validation_rules": {"email": {"regex": "foo", "message": "bad"}},
+ "state_choices": [],
+ "state_source": "unavailable",
+ }
+ assert not validate_party({"email": "afoo@b"}, metadata)
+
+
+def test_payload_validation_catches_extracted_values_before_fees_or_submission():
+ payload = {"other_parties": [{"name": {"first": "a" * 51, "last": "Lee"}, "address": {"state": "AE"}}]}
+ with pytest.raises(PayloadValidationError) as exc:
+ validate_party_formats(payload, "massachusetts", "0705:LA")
+ assert isinstance(exc.value, PayloadValidationError)
+ assert {p["name"] for p in exc.value.problems} == {"other_parties[0].name.first", "other_parties[0].address.state"}
+
+
+@pytest.fixture
+def draft(client, django_user_model):
+ user = django_user_model.objects.create_user(username="validation-user", tyler_jurisdiction="massachusetts")
+ draft = FilingDraft.objects.create(
+ user=user, jurisdiction="massachusetts", court_code="0705:LA", workflow_version=2
+ )
+ client.force_login(user)
+ session = client.session
+ session[CURRENT_DRAFT_SESSION_KEY] = draft.pk
+ session["jurisdiction"] = "massachusetts"
+ session["auth_tokens"] = {"TYLER-TOKEN-MASSACHUSETTS": "token"}
+ session.save()
+ return draft
+
+
+@pytest.mark.django_db
+def test_filer_form_preserves_invalid_values_and_returns_field_errors(client, draft):
+ with patch("efile.views.your_information.cached_account_profile", return_value=None):
+ response = client.post(
+ reverse("your_information", kwargs={"jurisdiction": draft.jurisdiction}),
+ {
+ "first_name": "a" * 51,
+ "last_name": "Lee",
+ "address_line_1": "1 Main Street",
+ "city": "APO",
+ "state": "AE",
+ "zip_code": "09001",
+ "email": "lee@example.com",
+ "phone": "123",
+ },
+ )
+ assert response.status_code == 200
+ assert set(response.context["field_errors"]) == {"first_name", "phone", "state"}
+ assert response.context["filer"].first_name == "a" * 51
+ assert not draft.parties.get(role="filer").first_name
+ content = response.content.decode()
+ assert 'value="AE" selected disabled' in content
+ assert "Do not choose a different state." in content
+ assert 'aria-invalid="true"' in content
+
+
+@pytest.mark.django_db
+def test_other_party_form_validates_names_and_keeps_attempted_contact_values(client, draft):
+ party = FilingParty.objects.create(
+ draft=draft, role="other", first_name="Morgan", last_name="Lee", party_type="DEF"
+ )
+ with patch("efile.views.party_details.get_party_types", return_value=[{"code": "DEF", "name": "Defendant"}]):
+ response = client.post(
+ reverse("party_details", kwargs={"jurisdiction": draft.jurisdiction}) + f"?party={party.pk}",
+ {
+ "party_type": "DEF",
+ "first_name": "a" * 51,
+ "last_name": "Lee",
+ "email": "new@example.com",
+ "phone": "123",
+ },
+ )
+ assert response.status_code == 200
+ assert set(response.context["field_errors"]) == {"first_name", "phone"}
+ assert response.context["party"].email == "new@example.com"
+ party.refresh_from_db()
+ assert party.first_name == "Morgan"
+
+
+@pytest.mark.django_db
+def test_edit_links_follow_payload_parties_and_preserve_draft_identity(draft):
+ FilingParty.objects.create(draft=draft, role="filer", first_name="Sam", last_name="Jones")
+ target = FilingParty.objects.create(
+ draft=draft, role="other", first_name="Lee", last_name="Smith", party_type="DEF", sort_order=9
+ )
+ payload = {"users": [{"name": {"first": "Lee", "last": "Smith"}, "party_type": "DEF"}]}
+ actions = error_actions(draft, payload, [{"name": "users[0].address.state", "message": "Bad state"}])
+ assert len(actions) == 1
+ query = parse_qs(urlsplit(actions[0]["url"]).query)
+ assert query == {"party": [str(target.pk)], "draft": [str(draft.pk)], "return_to": ["review"], "focus": ["state"]}
+ assert "Lee Smith" in actions[0]["label"]
+
+
+@pytest.mark.django_db
+def test_ambiguous_parties_and_unknown_field_paths_do_not_get_guessed_links(draft):
+ for index in range(2):
+ FilingParty.objects.create(
+ draft=draft, role="other", sort_order=index, first_name="Lee", last_name="Smith", party_type="DEF"
+ )
+ payload = {"other_parties": [{"name": {"first": "Lee", "last": "Smith"}, "party_type": "DEF"}]}
+ assert error_actions(draft, payload, [{"name": "other_parties[0].address.state"}]) == []
+ assert error_actions(draft, payload, [{"name": "other_parties[999].address.state"}, {"name": "unknown"}]) == []
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("described", [False, True])
+def test_legacy_state_message_locates_unique_rejected_value(draft, described):
+ FilingParty.objects.create(draft=draft, role="other", first_name="Lee", last_name="Smith", party_type="DEF")
+ payload = {
+ "other_parties": [{"name": {"first": "Lee", "last": "Smith"}, "party_type": "DEF", "address": {"state": "BAD"}}]
+ }
+ message = "Opposing party dosesn't support a state named BAD"
+ if described:
+ # The views pass the described message, which appends a hint sentence.
+ response = Mock(status_code=400, text=message)
+ response.json.return_value = {"error": message}
+ message = describe_efsp_error(response)
+ assert message != "Opposing party dosesn't support a state named BAD"
+ actions = error_actions(draft, payload, [], message=message)
+ assert len(actions) == 1
+ assert "focus=state" in actions[0]["url"]
+
+
+def test_nested_proxy_error_and_name_length_regex_are_parsed():
+ response = Mock(status_code=400)
+ response.json.return_value = {
+ "error": {"name": "users[0].name.last", "currentVal": "a" * 51, "description": ": must match regex: ^.{0,50}$"}
+ }
+ assert "50 characters or fewer" in describe_efsp_error(response)
+ assert efsp_error_problems(response)[0]["name"] == "users[0].name.last"
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("prevalidate", [True, False])
+def test_fee_errors_return_precise_actions_for_both_local_and_upstream_rejections(client, draft, prevalidate):
+ party = FilingParty.objects.create(draft=draft, role="other", first_name="Lee", last_name="Smith", party_type="DEF")
+ payload = {
+ "other_parties": [
+ {
+ "name": {"first": "Lee", "last": "Smith"},
+ "party_type": "DEF",
+ "address": {"state": "AE" if prevalidate else "MA"},
+ }
+ ],
+ "al_court_bundle": [],
+ }
+ response = Mock(status_code=400, text="Rejected")
+ response.json.return_value = {"wrong_vars": [{"name": "other_parties[0].name.last", "currentVal": "Smith"}]}
+ with patch("efile.api.filing_views.requests.post", return_value=response) as post:
+ result = client.post(
+ reverse("api:payment_fees"), json.dumps({"efile_data": payload}), content_type="application/json"
+ )
+ assert result.status_code == 400
+ actions = result.json()["error_actions"]
+ assert len(actions) == 1
+ params = parse_qs(urlsplit(actions[0]["url"]).query)
+ assert params["party"] == [str(party.pk)]
+ assert params["focus"] == ["state" if prevalidate else "last_name"]
+ assert post.called is not prevalidate
+
+
+def test_registration_uses_system_state_codes_and_explains_missing_addresses():
+ from efile.forms import EFileRegistrationForm
+
+ with patch("efile.forms.state_choices", return_value=([("DC", "District of Columbia")], "efsp")):
+ form = EFileRegistrationForm(jurisdiction="massachusetts")
+ field = form.fields["state"]
+ assert isinstance(field, ChoiceField)
+ assert list(field.choices) == [("", "Select a state"), ("DC", "District of Columbia")]
+ assert "Do not choose a different state" in form.fields["state"].help_text
+
+
+def test_foreign_addresses_never_get_usps_choices_during_an_outage():
+ assert state_choices("massachusetts", "0705:LA", "CA") == ([], "unavailable")
diff --git a/efile_app/efile/tests/test_people_flow.py b/efile_app/efile/tests/test_people_flow.py
index 3704892a..34b624b0 100644
--- a/efile_app/efile/tests/test_people_flow.py
+++ b/efile_app/efile/tests/test_people_flow.py
@@ -725,9 +725,9 @@ def test_your_information_defaults_the_state_to_this_jurisdiction(client, django
with patch("efile.views.your_information.cached_account_profile", return_value=None):
response = client.get(reverse("your_information", kwargs={"jurisdiction": jurisdiction}))
- state_input = re.search(r']*name="state"[^>]*>', response.content.decode())
- assert state_input is not None
- assert f'value="{state_code}"' in state_input.group()
+ content = response.content.decode()
+ assert re.search(r'