feat(portal): add EdFinancial servicer support (account number + DOB device verification, parser)#16
Conversation
…device verification, parser) Closes mattebad#8 ## What Adds support for **EdFinancial** as a servicer provider. EdFinancial's portal differs from the existing Nelnet-style flow in two ways, and this PR handles both. Every change is gated to `provider == "edfinancial"`, so other servicers (Nelnet, Aidvantage, MOHELA, etc.) are completely unaffected. ## Why (addresses mattebad#8) As reported in mattebad#8, two things break the existing flow for EdFinancial: 1. **New-device identity challenge.** On a cookie-less / unrecognized device, EdFinancial does not email an MFA code. Instead it shows a step-up page asking for (account number **or** SSN) and date of birth, so the login form's username field is never found and the email MFA poller waits for a code that never arrives. 2. **Different portal structure.** After login the authenticated app is server-rendered at `myaccount.edfinancial.studentaid.gov`, not the Nelnet-style `Group:` layout, so the existing loan/payment parsing finds nothing. Per the maintainer's note in mattebad#8 (no SSN-centric workflow), **account number + DOB is the primary, documented path**. SSN is supported only as an optional, repr-hidden fallback — it is never required and is not the default. ## What changed **Device verification** (`client.py`, `selectors.py`, `config.py`) - New `_maybe_complete_device_verification` fills and submits the challenge, then proceeds to the dashboard. It is a no-op for any non-edfinancial provider. - Selectors targeted (from the challenge page in mattebad#8): - Account number: `#account-number` / `input[name="taccountNumber"]` - SSN (optional fallback): `input[name="tSSN1"]`, `tSSN2`, `tSSN3` - Date of birth: `#dob1`/`input[name="tmonth"]`, `#dob2`/`input[name="tday"]`, `input[name="tyear"]` - Submit: `#Submit` - These fields are keystroke-masked, so the automation types character-by-character rather than using `fill`. - After the challenge, login can land directly on the dashboard, whose numeric inputs can look like a code field. Added an edfinancial-gated "already logged in" guard so a logged-in page is not misread as an MFA prompt. For other providers this guard is always false, so their MFA path is unchanged. - The portal's click-through agree page before the login prompt is handled by the existing post-login readiness wait. - New optional config: `SERVICER_ACCOUNT_NUMBER`, `SERVICER_DOB`, and (optional) `SERVICER_SSN`, all repr-hidden so they do not leak into logs. Passing the challenge once persists a trusted session and the portal generally stops challenging (~90 days). **EdFinancial parser** (`client.py`) - `discover_loan_groups`, `_extract_loans`, and `_extract_payment_allocations` each branch to an `_edf_*` implementation only for edfinancial; the existing code path is untouched. - Balances, rates, and due dates are parsed from `/Loans/AllLoanDetails`. - Per-loan payment allocations are parsed from `/AccountHistory/ViewHistory` using the "By Loan" / "Life of Loan" view. That view prepends an expand/disclosure cell that shifts every column, so the row parser locates the real date cell and maps columns relative to it (this also skips detail/expansion rows, avoiding double-counting). **Docs/templates** - `env.example`, `portal.env.example`, and `README.md` document the new variables and add a troubleshooting entry for the new-device challenge. ## Safety / blast radius - All behavioral changes are behind a `provider == "edfinancial"` (or `!= "edfinancial"`) check derived from the portal host. No existing provider's behavior changes. - The new config fields default to empty and are optional. SSN is never required. ## Testing Validated read-only against a live EdFinancial account (no writes to Monarch): - Loan-group discovery returns the correct groups. - Loan balances, interest rates, and due dates match the portal. - Per-loan payment allocations parse correctly, and the per-date splits reconcile to the expected monthly autopay total. ## Notes - Only read-only paths were exercised; the balance/transaction-writing `sync` was not run against a live Monarch account in this validation. - Happy to share sanitized selector/flow detail on mattebad#8 if useful; raw portal HTML is omitted since it contains account PII.
…chant Manually-entered transactions may use a different merchant name than the sync-created ones (e.g. "EdFinancial" vs "Student Loan Payment"), causing the guard to miss existing transactions and create duplicates. Since the guard already scopes by account_id, date+amount is specific enough to identify a duplicate payment on a loan account. Adds require_merchant_match=False default; callers can opt back in to strict matching if needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Hey thank you for the contribution. I should have some time this week to check this out and review |
|
Thanks for the EdFinancial work here — this is very close, and I’d like to get it over the line with a few targeted changes. Requested changes before merge:
I’m very supportive of adding EdFinancial, and the provider-gated approach is solid. Main things are preserving idempotency safety and making fallback behavior deterministic. |
|
Quick clarification on duplicate guard behavior preference:
I’m good with opt-in loose mode for users who intentionally edit merchant names, but I don’t want loose matching as default behavior. |
…SSN fallback, tests Duplicate guard: - Restore strict (date + amount + merchant) as the default behavior. - Add opt-in MONARCH_DUPLICATE_GUARD_LOOSE_MATCH; loose mode tries strict first and falls back to date+amount only if strict misses, logging clearly when a loose match is used to skip a create. EdFinancial device verification: - Replace up-front account#/SSN pick with a true fallback: try account number + DOB first, auto-retry with SSN + DOB if the challenge does not clear, and fail fast with an actionable error when neither is configured/clears. Tests + docs: - Add unit tests for duplicate-guard semantics and EdFinancial device verification (_parse_dob + full fallback matrix); add EdFinancial-gated portal smoke case. - Fix PortalCredentials indentation nit in cli.py. - Document the new knobs in README, env.example, portal.env.example, config.example.yaml. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the detailed review! I've pushed changes addressing all four points. 1. Duplicate guard — strict by default, loose as opt-in Reworked
The post-create recovery lookup stays strict (it's matching a txn we just created with the exact merchant). 2. EdFinancial — true account# → SSN fallback Replaced the up-front "account# OR SSN" pick with a real fallback flow:
Factored the fill/submit into a 3. Tests
Full suite: 4. Code quality Fixed the indentation in the Also documented the two new knobs ( |
|
Great, I think this looks merge ready at this point bar 1 nitpick on my end. Can we just add a SSN length guard, so if a user accidentally misses a number we don't waste their time running the automation only to find out they fat fingered an extra number or missed one. |
Address PR mattebad#16 review: validate that an optional SERVICER_SSN, if set, is exactly 9 digits (ignoring dashes/spaces) at config load instead of failing mid-run on the portal device-verification challenge. A fat-fingered extra/missing digit now raises a clear error before any browser launches. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Added a length guard in the |
Closes #8
What
Adds support for EdFinancial as a servicer provider. EdFinancial's portal differs from the existing Nelnet-style flow in two ways, and this PR handles both.
Every change is gated to
provider == "edfinancial", so other servicers (Nelnet, Aidvantage, MOHELA, etc.) are completely unaffected.Why (addresses #8)
As reported in #8, two things break the existing flow for EdFinancial:
myaccount.edfinancial.studentaid.gov, not the Nelnet-styleGroup:layout, so the existing loan/payment parsing finds nothing.Per the maintainer's note in #8 (no SSN-centric workflow), account number + DOB is the primary, documented path. SSN is supported only as an optional, repr-hidden fallback (it's never required and is not the default).
What changed
Device verification (
client.py,selectors.py,config.py)_maybe_complete_device_verificationfills and submits the challenge, then proceeds to the dashboard. It is a no-op for any non-edfinancial provider.#account-number/input[name="taccountNumber"]input[name="tSSN1"],tSSN2,tSSN3#dob1/input[name="tmonth"],#dob2/input[name="tday"],input[name="tyear"]#Submitfill.SERVICER_ACCOUNT_NUMBER,SERVICER_DOB, and (optional)SERVICER_SSN, all repr-hidden so they do not leak into logs.EdFinancial parser (
client.py)discover_loan_groups,_extract_loans, and_extract_payment_allocationseach branch to an_edf_*implementation only for edfinancial; the existing code path is untouched./Loans/AllLoanDetails./AccountHistory/ViewHistoryusing the "By Loan" / "Life of Loan" view. That view prepends an expand/disclosure cell that shifts every column, so the row parser locates the real date cell and maps columns relative to it (this also skips detail/expansion rows, avoiding double-counting).Docs/templates
env.example,portal.env.example, andREADME.mddocument the new variables and add a troubleshooting entry for the new-device challenge.Safety / blast radius
provider == "edfinancial"(or!= "edfinancial") check derived from the portal host. No existing provider's behavior changes.Testing
Validated read-only against a live EdFinancial account (no writes to Monarch):
Notes
syncwas not run against a live Monarch account in this validation.