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 @@

{% translate "Choose how to pay court fees" %}

- -
{% translate "Payment method" %}
@@ -65,6 +63,7 @@

{% translate "Estimated court fees" %}

{{ case_data|json_script:"case-data" }} {{ new_toga_url|json_script:"new-toga-url" }} {{ selected_payment_account_id|json_script:"selected-payment-account-id" }} + {{ fee_inputs_token|json_script:"fee-inputs-token" }} {% endblock workflow_content %} {% block extra_js %} diff --git a/efile_app/efile/templates/efile/review.html b/efile_app/efile/templates/efile/review.html index 5399641c..c12244f8 100644 --- a/efile_app/efile/templates/efile/review.html +++ b/efile_app/efile/templates/efile/review.html @@ -196,25 +196,43 @@

{% translate "Payment" %}

aria-label="{% translate "Edit payment" %}">{% translate "Edit" %}

{{ draft.selected_payment_account_name }}

- {% if draft.selected_payment_account_type == "WV" %} - {% translate "This filing has an approved fee waiver. No payment will be charged." %} - {% elif draft.quoted_fee_total %} -
-
-
{% translate "Total" %}
-
- ${{ draft.quoted_fee_total }} -
-
-
- {% if draft.quoted_fee_breakdown %} -
{% if plan_missing_documents %} @@ -275,6 +293,8 @@

{% translate "Payment" %}

{{ case_data|json_script:"case-data" }} {{ upload_data|json_script:"upload-data" }} {{ draft.selected_payment_account_id|json_script:"payment-account-id" }} + {{ fee_quote.state|json_script:"fee-quote-state" }} + {{ fee_inputs_token|json_script:"fee-inputs-token" }} {% endblock workflow_content %} {% block extra_js %} diff --git a/efile_app/efile/tests/test_durable_drafts.py b/efile_app/efile/tests/test_durable_drafts.py index 3e1c6d74..88ee2493 100644 --- a/efile_app/efile/tests/test_durable_drafts.py +++ b/efile_app/efile/tests/test_durable_drafts.py @@ -13,6 +13,7 @@ write_upload_data, ) from efile.services.efsp_payload import PayloadValidationError +from efile.services.fee_quotes import record_fee_quote from efile.workflow import WorkflowStepKey, get_workflow_step_choices @@ -38,6 +39,8 @@ def _prepare_submission(client, draft, jurisdiction="illinois"): """Populate the draft (the source of truth) and session so submit can run.""" write_case_data(draft, {"court": "cook:cd"}) write_upload_data(draft, {"files": {"lead": {"url": "https://example.com/petition.pdf"}}}) + # Submission is only offered against a quote that still prices the filing. + record_fee_quote(draft, "0.00", []) session = client.session session[CURRENT_DRAFT_SESSION_KEY] = draft.pk session["jurisdiction"] = jurisdiction diff --git a/efile_app/efile/tests/test_end_to_end_new_flow_states.py b/efile_app/efile/tests/test_end_to_end_new_flow_states.py index a91ef00a..5699826c 100644 --- a/efile_app/efile/tests/test_end_to_end_new_flow_states.py +++ b/efile_app/efile/tests/test_end_to_end_new_flow_states.py @@ -388,6 +388,7 @@ def fake_download(_key, destination): { "selected_payment_account": "waiver-account-1", "selected_payment_account_name": "Fee Waiver", + "selected_payment_account_type": "WV", }, ) assert pay_resp.status_code == 302 diff --git a/efile_app/efile/tests/test_fee_quotes.py b/efile_app/efile/tests/test_fee_quotes.py new file mode 100644 index 00000000..1059a785 --- /dev/null +++ b/efile_app/efile/tests/test_fee_quotes.py @@ -0,0 +1,438 @@ +"""A fee quote is only current while the draft still matches what it priced (#193).""" + +import json +import re +from unittest.mock import patch + +import pytest +from django.urls import reverse + +from efile.models import FilingDocument, FilingDraft, FilingParty +from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY +from efile.services.fee_quotes import ( + FeeQuoteState, + fee_fingerprint, + fee_quote_state, + invalidate_fee_quote, + quote_from_efsp_response, + record_fee_quote, +) +from efile.workflow import WorkflowStepKey + +REVIEW_URL = reverse("case_review", kwargs={"jurisdiction": "illinois"}) +SUBMIT_URL = reverse("submit_final_filing") +FEES_URL = reverse("api:payment_fees") + + +def efsp_fee_response(total, *fees): + return { + "feesCalculationAmount": {"value": total}, + "allowanceCharge": [ + { + "chargeIndicator": {"value": True}, + "allowanceChargeReason": {"value": label}, + "amount": {"value": amount}, + } + for label, amount in fees + ], + } + + +class FakeResponse: + def __init__(self, status_code, payload): + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) + self.headers = {"Content-Type": "application/json"} + + def json(self): + return self._payload + + +@pytest.fixture +def draft(client, django_user_model): + user = django_user_model.objects.create_user(username="fee-user", tyler_jurisdiction="illinois") + draft = FilingDraft.objects.create( + user=user, + jurisdiction="illinois", + workflow_version=2, + current_step=WorkflowStepKey.REVIEW, + existing_case="new", + court_code="cook:law1", + court_name="Circuit Court of Cook County", + case_category_code="civil", + case_category_name="Civil", + case_type_code="contract", + case_type_name="Contract", + document_checklist_acknowledged=True, + selected_payment_account_id="pay-123", + selected_payment_account_name="Card ending in 4242", + selected_payment_account_type="CC", + ) + FilingDocument.objects.create( + draft=draft, + role=FilingDocument.Role.LEAD, + sort_order=0, + name="Complaint.pdf", + filing_type_code="complaint", + filing_type_name="Complaint", + document_type_code="public", + document_type_name="Public", + requested_optional_services=["certified-copy"], + ) + FilingParty.objects.create( + draft=draft, + role="filer", + sort_order=0, + party_type="PLA", + party_type_name="Plaintiff", + first_name="Jordan", + last_name="Taylor", + email="jordan@example.com", + address_line_1="123 Main Street", + city="Springfield", + state="IL", + zip_code="62701", + is_filing_party=True, + ) + client.force_login(user) + session = client.session + session[CURRENT_DRAFT_SESSION_KEY] = draft.pk + session["jurisdiction"] = "illinois" + session["auth_tokens"] = {"TYLER-TOKEN-ILLINOIS": "test-token"} + session.save() + return draft + + +def page_token(client): + """The fee-inputs token Review hands its script, as a filer's browser has it.""" + + content = client.get(REVIEW_URL).content.decode() + return json.loads( + re.search(r'', content).group(1) + ) + + +def quote_fees(client, total="118.00", *fees, token=None, during=None): + """Ask the fee API, as Payment and Review do, with the EFSP answering `total`. + + `token` defaults to the one the Review page carries now; `during` runs while + the EFSP is being asked, as an edit in another tab would. + """ + + token = page_token(client) if token is None else token + answer = FakeResponse(200, efsp_fee_response(total, *fees)) + + def efsp(*args, **kwargs): + if during: + during() + return answer + + with ( + patch("efile.api.filing_views.prepare_efile_payload"), + patch("efile.api.filing_views.requests.post", side_effect=efsp), + ): + return client.post( + FEES_URL, + data=json.dumps( + {"efile_data": {"al_court_bundle": []}, "payment_account_id": "pay-123", "fee_inputs_token": token} + ), + content_type="application/json", + ) + + +def submit(client): + with ( + patch("efile.views.session_api.prepare_efile_payload", return_value={"al_court_bundle": {}}), + patch("requests.post", return_value=FakeResponse(200, {"filing_id": "ENV-1"})) as post, + ): + response = client.post( + SUBMIT_URL, + data=json.dumps({"confirm_submission": True, "efile_data": {"al_court_bundle": {}}}), + content_type="application/json", + ) + return response, post + + +# --- What counts as fee-affecting --------------------------------------------- + + +def _set(draft, **values): + FilingDraft.objects.filter(pk=draft.pk).update(**values) + draft.refresh_from_db() + + +def _lead(draft): + return draft.documents.get(role="lead") + + +FEE_AFFECTING_EDITS = { + "court": lambda draft: _set(draft, court_code="cook:cvd1"), + "case category": lambda draft: _set(draft, case_category_code="family"), + "case type": lambda draft: _set(draft, case_type_code="tort"), + "new or existing case": lambda draft: _set(draft, existing_case="existing"), + "existing case identity": lambda draft: _set(draft, previous_case_id="case-9"), + "amount in controversy": lambda draft: _set(draft, amount_in_controversy="12000"), + "payment account": lambda draft: _set(draft, selected_payment_account_id="pay-456"), + "filing type": lambda draft: FilingDocument.objects.filter(pk=_lead(draft).pk).update(filing_type_code="answer"), + "filing component": lambda draft: FilingDocument.objects.filter(pk=_lead(draft).pk).update( + filing_component_code="attachment" + ), + "optional service removed": lambda draft: FilingDocument.objects.filter(pk=_lead(draft).pk).update( + requested_optional_services=[] + ), + "optional service added": lambda draft: FilingDocument.objects.filter(pk=_lead(draft).pk).update( + requested_optional_services=["certified-copy", "service-by-mail"] + ), + "document added": lambda draft: FilingDocument.objects.create( + draft=draft, role=FilingDocument.Role.SUPPORTING, sort_order=0, filing_type_code="exhibit" + ), + "party added": lambda draft: FilingParty.objects.create( + draft=draft, role="other", sort_order=1, party_type="DEF", first_name="Acme" + ), +} + + +@pytest.mark.django_db +@pytest.mark.parametrize("edit", FEE_AFFECTING_EDITS.values(), ids=FEE_AFFECTING_EDITS.keys()) +def test_every_fee_affecting_edit_makes_the_quote_stale(draft, edit): + record_fee_quote(draft, "118.00", []) + assert fee_quote_state(draft) == FeeQuoteState.CURRENT + + edit(draft) + + draft.refresh_from_db() + assert fee_quote_state(draft) == FeeQuoteState.STALE + + +@pytest.mark.django_db +def test_edits_that_do_not_price_the_filing_leave_the_quote_current(draft): + record_fee_quote(draft, "118.00", []) + + _set(draft, case_title="Taylor v. Acme", notice_email="notices@example.com", name_change_reason="Marriage") + FilingParty.objects.filter(draft=draft).update(address_line_1="9 Elm Street", phone="312-555-0100") + FilingDocument.objects.filter(draft=draft).update(name="Renamed.pdf", courtesy_copy_email="copy@example.com") + + draft.refresh_from_db() + assert fee_quote_state(draft) == FeeQuoteState.CURRENT + + +@pytest.mark.django_db +def test_a_quote_saved_before_fingerprints_is_treated_as_stale(draft): + _set(draft, quoted_fee_total="118.00", quoted_fee_breakdown=[], quoted_fee_fingerprint="") + + assert fee_quote_state(draft) == FeeQuoteState.STALE + + +@pytest.mark.django_db +def test_a_waiver_needs_no_quote_and_no_quote_is_missing(draft): + assert fee_quote_state(draft) == FeeQuoteState.MISSING + _set(draft, selected_payment_account_type="WV") + assert fee_quote_state(draft) == FeeQuoteState.WAIVED + + +@pytest.mark.django_db +def test_invalidate_forgets_the_quote(draft): + record_fee_quote(draft, "118.00", [{"label": "Filing fee", "amount": "118.00"}]) + invalidate_fee_quote(draft) + draft.refresh_from_db() + assert (draft.quoted_fee_total, draft.quoted_fee_breakdown, draft.quoted_fee_fingerprint) == ("", [], "") + + +@pytest.mark.django_db +def test_the_quote_is_priced_for_the_account_it_was_asked_for(draft): + """Payment asks with an account the filer has not saved yet.""" + + record_fee_quote(draft, "118.00", [], payment_account_id="pay-456") + assert fee_quote_state(draft) == FeeQuoteState.STALE + _set(draft, selected_payment_account_id="pay-456") + assert draft.quoted_fee_fingerprint == fee_fingerprint(draft) + assert fee_quote_state(draft) == FeeQuoteState.CURRENT + + +def test_reading_the_efsp_fee_response(): + assert quote_from_efsp_response(efsp_fee_response("118", ("Filing fee", "100.00"), ("E-filing fee", "18.00"))) == ( + "118.00", + [{"label": "Filing fee", "amount": "100.00"}, {"label": "E-filing fee", "amount": "18.00"}], + ) + assert quote_from_efsp_response({"allowanceCharge": []}) is None + assert quote_from_efsp_response({"feesCalculationAmount": {"value": "lots"}}) is None + + +# --- The fee API records the quote -------------------------------------------- + + +@pytest.mark.django_db +def test_the_fee_api_records_the_quote_with_what_it_priced(client, draft): + response = quote_fees(client, "118.00", ("Filing fee", "118.00")) + + body = response.json() + assert body["quote_recorded"] is True + assert body["quote"] == { + "state": "current", + "total": "118.00", + "breakdown": [{"label": "Filing fee", "amount": "118.00"}], + } + draft.refresh_from_db() + assert draft.quoted_fee_total == "118.00" + assert fee_quote_state(draft) == FeeQuoteState.CURRENT + + +@pytest.mark.django_db +def test_an_edit_while_the_efsp_is_pricing_keeps_the_old_answer_out(client, draft): + """The reviewer's case: another tab changes the filing mid-request.""" + + response = quote_fees(client, "118.00", during=lambda: _set(draft, case_type_code="tort")) + + body = response.json() + assert body["quote_recorded"] is False + assert body["quote_superseded"] is True + draft.refresh_from_db() + assert draft.quoted_fee_total == "" + assert fee_quote_state(draft) == FeeQuoteState.MISSING + + +@pytest.mark.django_db +def test_a_request_built_from_an_older_page_is_not_recorded(client, draft): + token = page_token(client) + FilingDocument.objects.filter(draft=draft).update(requested_optional_services=[]) + + body = quote_fees(client, "128.00", token=token).json() + + assert (body["quote_recorded"], body["quote_superseded"]) == (False, True) + draft.refresh_from_db() + assert fee_quote_state(draft) == FeeQuoteState.MISSING + + +@pytest.mark.django_db +def test_a_request_that_does_not_say_what_it_priced_is_not_recorded(client, draft): + body = quote_fees(client, "118.00", token="").json() + + assert body["quote_recorded"] is False + draft.refresh_from_db() + assert fee_quote_state(draft) == FeeQuoteState.MISSING + + +@pytest.mark.django_db +def test_an_edit_after_recording_still_makes_the_quote_stale(client, draft): + quote_fees(client, "118.00") + _set(draft, court_code="cook:cvd1") + + assert fee_quote_state(draft) == FeeQuoteState.STALE + + +@pytest.mark.django_db +def test_a_fee_answer_without_a_total_is_not_recorded(client, draft): + with ( + patch("efile.api.filing_views.prepare_efile_payload"), + patch("efile.api.filing_views.requests.post", return_value=FakeResponse(200, {"allowanceCharge": []})), + ): + response = client.post( + FEES_URL, + data=json.dumps( + { + "efile_data": {"al_court_bundle": []}, + "payment_account_id": "pay-123", + "fee_inputs_token": page_token(client), + } + ), + content_type="application/json", + ) + + assert response.json()["quote_recorded"] is False + draft.refresh_from_db() + assert fee_quote_state(draft) == FeeQuoteState.MISSING + + +# --- Review and submission ---------------------------------------------------- + + +@pytest.mark.django_db +def test_review_does_not_show_a_stale_total_as_current(client, draft): + record_fee_quote(draft, "118.00", [{"label": "Filing fee", "amount": "118.00"}]) + FilingDocument.objects.filter(draft=draft).update(requested_optional_services=[]) + + content = client.get(REVIEW_URL).content.decode() + + assert 'data-state="stale"' in content + assert "$118.00" not in content + assert "changed since they were calculated" in content + assert '' in content + + +@pytest.mark.django_db +def test_review_shows_a_current_total(client, draft): + record_fee_quote(draft, "118.00", [{"label": "Filing fee", "amount": "118.00"}]) + + content = client.get(REVIEW_URL).content.decode() + + assert 'data-state="current"' in content + assert "$118.00" in content + + +@pytest.mark.django_db +@pytest.mark.parametrize("quoted", [False, True], ids=["no quote", "stale quote"]) +def test_submission_is_refused_without_a_current_quote(client, draft, quoted): + if quoted: + record_fee_quote(draft, "118.00", []) + _set(draft, case_type_code="tort") + + response, post = submit(client) + + assert response.status_code == 412 + assert response.json()["error_code"] == "submission_fee_quote_stale" + post.assert_not_called() + draft.refresh_from_db() + assert draft.status == FilingDraft.Status.DRAFT + + +@pytest.mark.django_db +def test_a_fee_waiver_submits_without_a_quote(client, draft): + _set(draft, selected_payment_account_type="WV") + + response, post = submit(client) + + assert response.status_code == 200, response.content + post.assert_called_once() + + +@pytest.mark.django_db +def test_removing_an_optional_service_is_requoted_from_review_without_visiting_payment(client, draft): + """The optional-services regression: no trip back to Payment is needed.""" + + quote_fees(client, "128.00", ("Filing fee", "118.00"), ("Certified copy", "10.00")) + lead = _lead(draft) + + # The filer goes back to Organize documents from Review and drops the copy. + organized = client.post( + reverse("organize_documents", kwargs={"jurisdiction": "illinois"}) + "?return_to=review", + { + "documents": [ + { + "id": lead.pk, + "filing_type": "complaint", + "filing_type_name": "Complaint", + "document_type": "public", + "requested_optional_services": [], + } + ], + "main_document_id": lead.pk, + }, + content_type="application/json", + ) + assert organized.status_code == 200 + + # Review no longer offers the old total, and will not submit against it. + review = client.get(REVIEW_URL).content.decode() + assert 'data-state="stale"' in review + assert "$128.00" not in review + assert submit(client)[0].status_code == 412 + + # What review.js does on that page: ask for fees again, from Review. + requoted = quote_fees(client, "118.00", ("Filing fee", "118.00")).json() + assert requoted["quote"]["state"] == "current" + assert requoted["quote"]["total"] == "118.00" + + response, post = submit(client) + assert response.status_code == 200 + post.assert_called_once() diff --git a/efile_app/efile/tests/test_filing_integrity.py b/efile_app/efile/tests/test_filing_integrity.py index 7ddb9051..37b31047 100644 --- a/efile_app/efile/tests/test_filing_integrity.py +++ b/efile_app/efile/tests/test_filing_integrity.py @@ -16,6 +16,7 @@ normalize_document_evidence, normalize_extracted_fields, ) +from efile.services.fee_quotes import record_fee_quote @pytest.mark.parametrize( @@ -236,6 +237,7 @@ def test_organization_and_submission_preserve_primary_type_and_confirmation_iden draft.refresh_from_db() assert (draft.filing_type_code, draft.filing_type_name) == ("27959", "Complaint") + record_fee_quote(draft, "0.00", []) # Starting another filing in the shared session cannot redirect submission. other = FilingDraft.objects.create(user=draft.user, jurisdiction="illinois") session = client.session diff --git a/efile_app/efile/tests/test_review_submit_flow.py b/efile_app/efile/tests/test_review_submit_flow.py index e00e27c4..58b7678f 100644 --- a/efile_app/efile/tests/test_review_submit_flow.py +++ b/efile_app/efile/tests/test_review_submit_flow.py @@ -3,6 +3,7 @@ from efile.models import FilingDocument, FilingDraft, FilingParty from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY +from efile.services.fee_quotes import record_fee_quote from efile.workflow import WorkflowStepKey @@ -173,26 +174,24 @@ def test_payment_account_types_proxies_the_courts_type_list(client, submission_d @pytest.mark.django_db -def test_payment_persists_account_type_and_quoted_fees(client, submission_draft): +def test_payment_persists_account_type_but_not_a_posted_fee_total(client, submission_draft): + """The quote comes from the fee API, which recorded what it priced; a form field cannot set it.""" + response = client.post( reverse("payment", kwargs={"jurisdiction": "illinois"}), { "selected_payment_account": "pay-123", "selected_payment_account_name": "Card ending in 4242", "selected_payment_account_type": "CC", - "quoted_fee_total": "125.00", - "quoted_fee_breakdown": '[{"label": "Filing fee", "amount": "100.00"}, {"label": "Technology fee", "amount": "25.00"}]', + "quoted_fee_total": "1.00", + "quoted_fee_breakdown": '[{"label": "Filing fee", "amount": "1.00"}]', }, ) assert response.status_code == 302 submission_draft.refresh_from_db() assert submission_draft.selected_payment_account_type == "CC" - assert submission_draft.quoted_fee_total == "125.00" - assert submission_draft.quoted_fee_breakdown == [ - {"label": "Filing fee", "amount": "100.00"}, - {"label": "Technology fee", "amount": "25.00"}, - ] + assert submission_draft.quoted_fee_total == "" @pytest.mark.django_db @@ -234,17 +233,10 @@ def test_review_shows_previously_calculated_fee_total(client, submission_draft): submission_draft.selected_payment_account_id = "pay-123" submission_draft.selected_payment_account_name = "Card ending in 4242" submission_draft.selected_payment_account_type = "CC" - submission_draft.quoted_fee_total = "125.00" - submission_draft.quoted_fee_breakdown = [{"label": "Filing fee", "amount": "125.00"}] submission_draft.save( - update_fields=[ - "selected_payment_account_id", - "selected_payment_account_name", - "selected_payment_account_type", - "quoted_fee_total", - "quoted_fee_breakdown", - ] + update_fields=["selected_payment_account_id", "selected_payment_account_name", "selected_payment_account_type"] ) + record_fee_quote(submission_draft, "125.00", [{"label": "Filing fee", "amount": "125.00"}]) response = client.get(reverse("case_review", kwargs={"jurisdiction": "illinois"})) diff --git a/efile_app/efile/views/handoff.py b/efile_app/efile/views/handoff.py index d0a0056a..89bca63e 100644 --- a/efile_app/efile/views/handoff.py +++ b/efile_app/efile/views/handoff.py @@ -18,6 +18,7 @@ from efile.models import FilingDraft, HandoffDocumentUpdate, InterviewHandoff from efile.services.current_drafts import attach_current_draft from efile.services.draft_urls import draft_url +from efile.services.fee_quotes import invalidate_fee_quote from efile.services.filings import describe_filing_detail, fetch_filing_detail from efile.services.handoff import ( HandoffError, @@ -397,14 +398,14 @@ def replace_documents(request): {"sha256": doc["sha256"], "source": source}, ) draft.correction_fields = [path for path in draft.correction_fields if path != "documents"] - draft.quoted_fee_total = "" - draft.quoted_fee_breakdown = [] + invalidate_fee_quote(draft, save=False) draft.selected_payment_account_id = "" draft.save( update_fields=[ "correction_fields", "quoted_fee_total", "quoted_fee_breakdown", + "quoted_fee_fingerprint", "selected_payment_account_id", "updated_at", ] diff --git a/efile_app/efile/views/payment.py b/efile_app/efile/views/payment.py index c110fcd8..bd20b003 100644 --- a/efile_app/efile/views/payment.py +++ b/efile_app/efile/views/payment.py @@ -1,5 +1,3 @@ -import json - from django.conf import settings from django.contrib import messages from django.shortcuts import redirect, render @@ -9,6 +7,7 @@ from efile.models import FilingDocument, FilingParty from efile.services.current_drafts import ensure_current_draft from efile.services.drafts import draft_snapshot, read_case_data +from efile.services.fee_quotes import fee_inputs_token from efile.services.people import filing_parties from ..workflow import WorkflowStepKey, get_step_url, get_workflow_context @@ -46,26 +45,18 @@ def efile_payment(request, jurisdiction): if not account_id: messages.error(request, "Choose a payment method to continue.") else: - try: - fee_breakdown = json.loads(request.POST.get("quoted_fee_breakdown") or "[]") - except json.JSONDecodeError: - fee_breakdown = [] - if not isinstance(fee_breakdown, list): - fee_breakdown = [] - draft.selected_payment_account_id = account_id draft.selected_payment_account_name = account_name or "Selected payment method" draft.selected_payment_account_type = request.POST.get("selected_payment_account_type", "").strip() - draft.quoted_fee_total = request.POST.get("quoted_fee_total", "").strip() - draft.quoted_fee_breakdown = fee_breakdown + # The fee quote itself is not read from this form: the fee API + # recorded it on the draft, with what it was priced on, when it + # answered (see efile.services.fee_quotes). draft.current_step = WorkflowStepKey.REVIEW draft.save( update_fields=[ "selected_payment_account_id", "selected_payment_account_name", "selected_payment_account_type", - "quoted_fee_total", - "quoted_fee_breakdown", "current_step", "updated_at", ] @@ -78,6 +69,9 @@ def efile_payment(request, jurisdiction): "case_data": read_case_data(draft), "filing_draft": draft_snapshot(draft), "selected_payment_account_id": draft.selected_payment_account_id, + # Sent back with the fee request, so the quote is only kept if it + # priced the filing as this page shows it (see fee_inputs_token). + "fee_inputs_token": fee_inputs_token(draft), } context.update(get_workflow_context(WorkflowStepKey.PAYMENT, jurisdiction, draft)) return render(request, "efile/payment.html", context) diff --git a/efile_app/efile/views/review.py b/efile_app/efile/views/review.py index 70ed6474..7c655443 100644 --- a/efile_app/efile/views/review.py +++ b/efile_app/efile/views/review.py @@ -6,6 +6,7 @@ from efile.services.current_drafts import ensure_current_draft from efile.services.drafts import draft_snapshot, read_case_data, read_upload_data from efile.services.extracted_parties import party_display_name +from efile.services.fee_quotes import fee_inputs_token, fee_quote_summary from efile.services.filing_plans import documents_missing_from_envelope from efile.services.people import get_case_questions @@ -87,6 +88,8 @@ def case_review(request, jurisdiction): "upload_data": read_upload_data(draft), "filing_draft": draft_snapshot(draft), "draft": draft, + "fee_quote": fee_quote_summary(draft), + "fee_inputs_token": fee_inputs_token(draft), "filer": filer, "parties": other_parties, # Who the filing is on behalf of, when that is not the filer. Worth diff --git a/efile_app/efile/views/submission.py b/efile_app/efile/views/submission.py index e4f08af5..b68be839 100644 --- a/efile_app/efile/views/submission.py +++ b/efile_app/efile/views/submission.py @@ -9,8 +9,9 @@ from efile.models import FilingDraft from efile.services.current_drafts import clear_current_draft, get_current_draft +from efile.services.fee_quotes import fee_quote_is_usable from efile.services.filing_plans import mark_attached_items_filed -from efile.services.submission_errors import PRE_SUBMIT_ERROR_CODES +from efile.services.submission_errors import PRE_SUBMIT_ERROR_CODES, SubmissionErrorCode from .confirmation import LAST_SUBMITTED_DRAFT_SESSION_KEY from .session_api import submit_final_filing as legacy_submit_final_filing @@ -78,6 +79,20 @@ def submit_final_filing(request): jurisdiction = request.session.get("jurisdiction") draft = get_current_draft(request, jurisdiction=jurisdiction, resume_latest=False) + # The filer agreed to a total on Review. If anything that prices the filing + # changed since, that total is not the one they would be charged, so the + # page has to show the new one before anything reaches the court. + if draft is not None and not fee_quote_is_usable(draft): + return JsonResponse( + { + "success": False, + "error_code": SubmissionErrorCode.FEE_QUOTE_STALE, + "error": "The court's fees for this filing changed or could not be confirmed. " + "Check the new total on this page before you submit.", + }, + status=412, + ) + # Claim the draft before forwarding so a concurrent request can't file twice. if draft is not None and not _claim_for_submission(draft): return JsonResponse( diff --git a/efile_app/tests/test-optional-services-e2e.spec.js b/efile_app/tests/test-optional-services-e2e.spec.js index 886baa23..8c4ee3f9 100644 --- a/efile_app/tests/test-optional-services-e2e.spec.js +++ b/efile_app/tests/test-optional-services-e2e.spec.js @@ -292,46 +292,17 @@ test('adding and removing optional services dynamically updates calculated fees page.locator('#save-document-details').click(), ]); - // Step 12: Navigate to Payment page to recalculate fee without optional service - console.log('Step 12: Re-checking Payment page fee WITHOUT optional service...'); - const editPaymentLink = page.locator('a[aria-label="Edit payment"], a[href$="/payment/"]'); - await Promise.all([ - page.waitForURL(/\/payment\//, { - timeout: 120000 - }), - editPaymentLink.first().click(), - ]); - - const bankAccountAgain = page.locator('input[name="paymentMethod"][data-type="BankAccount"], input[name="paymentMethod"][value="d44fd7ed-6683-48e1-a670-f7964e5bba4d"]'); - await expect(bankAccountAgain.first()).toBeAttached({ - timeout: 120000 - }); - await bankAccountAgain.first().check(); - - await expect(paymentInfo).toContainText('256.25', { + // Step 12: Review recalculates on its own. The saved $262.25 quote no longer + // prices this filing, so it is not shown; the page asks the court again and + // offers Submit only once the new total is on screen. No trip to Payment. + console.log('Step 12: Waiting for Review to recalculate the fee WITHOUT optional service...'); + await expect(page.locator('#fee-quote')).not.toContainText('262.25'); + await expect(page.locator('.review-fee-total')).toContainText('256.25', { timeout: 180000 }); - await expect(paymentInfo).not.toContainText('Optional Service Fee'); - - const paymentInfoWithoutService = await paymentInfo.innerText(); - console.log('Payment Info WITHOUT Optional Service:\n' + paymentInfoWithoutService); - console.log('✓ Verified: Fee without optional service reduced by $6.00 to $256.25 ($256 initiation + $0.25 convenience fee)!'); - - // Step 13: Proceed back to Review page and verify updated lower fee - console.log('Step 13: Proceeding to Review page to verify updated fee quote...'); - await Promise.all([ - page.waitForURL(/\/review\//, { - timeout: 120000 - }), - page.locator('#submitButton').click(), - ]); - - await expect(page.locator('.review-fee-total')).toBeVisible({ - timeout: 120000 - }); - const reviewTotalWithoutService = await page.locator('.review-fee-total').innerText(); - console.log('Review Total WITHOUT Optional Service:\n' + reviewTotalWithoutService); - expect(reviewTotalWithoutService).toContain('256.25'); await expect(page.locator('.review-fee-breakdown')).not.toContainText('Optional Service Fee'); - console.log('✓ Verified: Review page updated to $256.25 total with Optional Service Fee removed.'); + await expect(page.locator('#fee-quote')).toHaveAttribute('data-state', 'current'); + await page.locator('#confirm-filing').check(); + await expect(page.locator('#submitButton')).toBeEnabled(); + console.log('✓ Verified: Review requoted $256.25 with the Optional Service Fee removed, without revisiting Payment.'); }); \ No newline at end of file