fidelity-planviewer: scaffold provider + CLI (seed + stub ingest)
## Context
UK workplace pension at planviewer.fidelity.co.uk has no public API; the SPA
calls a private JSON backend at prd.wiciam.fidelity.co.uk/cvmfe/api/*. Viktor
confirmed in DevTools that an OPTIONS preflight lists auth headers
(ch, fid, rid, sid, tbid, theosreferer, ua). Full reverse-engineering of the
endpoint paths is pending Viktor's POST cURL paste for transactions +
holdings views.
Until those endpoints are captured, ship the scaffold: provider module, CLI
commands, tests, docs. This unblocks installing Playwright in the image and
lets Viktor run the one-off seed command on his laptop ahead of the data
integration.
## This change
- broker_sync/providers/fidelity_planviewer.py
- FidelityCreds namedtuple (storage_state_path, plan_id).
- FidelitySessionError (401 → re-seed), FidelityProviderConfigError.
- FidelityPlanViewerProvider: .accounts() returns a single
WORKPLACE_PENSION account, .fetch() raises until endpoints are wired.
- broker_sync/cli.py
- fidelity-seed: launches headed Chromium so Viktor can log in and tick
"Remember device", then dumps storage_state.json.
- fidelity-ingest: stub matching the invest-engine / trading212 CLI
shape; reads storage_state + plan_id, pipes through the shared pipeline.
- tests/providers/test_fidelity_planviewer.py
- Asserts the single-account shape + the loud-failure guard.
- docs/providers/fidelity-planviewer.md
- Architecture diagram, one-time seed procedure, backfill + monthly
commands, alert runbook.
- pyproject.toml
- playwright ^1.47 as a first-class dep (used only by fidelity-seed and
later by the session-refresh step in fidelity-ingest).
## What is NOT in this change
- Endpoint wiring in provider.fetch() — blocked on DevTools POST cURL.
- Infra CronJob + Vault secret + Prometheus alert — lands once the first
manual backfill succeeds and we know the Chromium image size is fine.
- Dockerfile Chromium install — same trigger.
## Verification
### Automated
$ poetry run pytest tests/providers/test_fidelity_planviewer.py -v
2 passed in 0.08s
$ poetry run pytest -q
122 passed, 1 skipped in 1.07s
$ poetry run mypy broker_sync/providers/fidelity_planviewer.py broker_sync/cli.py
Success: no issues found in 2 source files
$ poetry run ruff check broker_sync/providers/fidelity_planviewer.py broker_sync/cli.py tests/providers/test_fidelity_planviewer.py
All checks passed!
### Manual (Viktor, later)
1. poetry install && poetry run playwright install chromium
2. poetry run broker-sync fidelity-seed --out /tmp/state.json
3. Chromium opens → log in → tick "Remember device" → press Enter
4. vault kv patch secret/broker-sync fidelity_storage_state=@/tmp/state.json
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c830856ba1
commit
832732a419
6 changed files with 508 additions and 1 deletions
|
|
@ -358,6 +358,115 @@ def imap_ingest(
|
|||
asyncio.run(_run())
|
||||
|
||||
|
||||
@app.command("fidelity-seed")
|
||||
def fidelity_seed(
|
||||
out: str = typer.Option(
|
||||
"fidelity_storage_state.json",
|
||||
help="Where to write the storage_state JSON (stage it to Vault afterwards)",
|
||||
),
|
||||
url: str = typer.Option(
|
||||
"https://pv.planviewer.fidelity.co.uk/",
|
||||
help="PlanViewer SPA URL — defaults to the production UK landing",
|
||||
),
|
||||
) -> None:
|
||||
"""One-off: launch a headed Chromium so Viktor can log into PlanViewer and
|
||||
capture a long-lived storage_state (cookies + localStorage) for the monthly
|
||||
cron.
|
||||
|
||||
Expected flow:
|
||||
1. Chromium opens on the PlanViewer login page.
|
||||
2. Viktor enters username, password, memorable word, MFA code.
|
||||
3. Viktor ticks "Remember device" / "Trust this browser" if offered.
|
||||
4. Viktor waits until the dashboard loads, then presses Enter in the terminal.
|
||||
5. Script dumps storage_state.json and exits.
|
||||
6. Viktor runs ``vault kv patch secret/broker-sync fidelity_storage_state=@...``.
|
||||
"""
|
||||
_setup_logging()
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError as e:
|
||||
typer.echo(
|
||||
"Playwright is not installed — run `poetry install` first.", err=True)
|
||||
raise typer.Exit(code=2) from e
|
||||
|
||||
typer.echo(f"Opening {url} in a headed browser — log in, tick "
|
||||
"'Remember device' if offered, then press Enter here.")
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=False)
|
||||
context = browser.new_context()
|
||||
page = context.new_page()
|
||||
page.goto(url)
|
||||
input("Press Enter once you're fully logged in and the dashboard is visible… ")
|
||||
context.storage_state(path=out)
|
||||
browser.close()
|
||||
typer.echo(f"Wrote {out} — stage it to Vault:")
|
||||
typer.echo(f" vault kv patch secret/broker-sync fidelity_storage_state=@{out}")
|
||||
|
||||
|
||||
@app.command("fidelity-ingest")
|
||||
def fidelity_ingest(
|
||||
wf_base_url: str = typer.Option(..., envvar="WF_BASE_URL"),
|
||||
wf_username: str = typer.Option(..., envvar="WF_USERNAME"),
|
||||
wf_password: str = typer.Option(..., envvar="WF_PASSWORD"),
|
||||
wf_session_path: str = typer.Option("/data/wealthfolio_session.json", envvar="WF_SESSION_PATH"),
|
||||
storage_state_path: str = typer.Option(
|
||||
...,
|
||||
envvar="FIDELITY_STORAGE_STATE_PATH",
|
||||
help="Path on disk to storage_state.json (materialised from Vault by the init container)",
|
||||
),
|
||||
plan_id: str = typer.Option(..., envvar="FIDELITY_PLAN_ID"),
|
||||
data_dir: str = typer.Option("/data", envvar="BROKER_SYNC_DATA_DIR"),
|
||||
mode: str = typer.Option("steady", help="steady = last-60-days; backfill = full history"),
|
||||
) -> None:
|
||||
"""Sync Fidelity UK PlanViewer contributions + fund purchases into Wealthfolio."""
|
||||
from broker_sync.dedup import SyncRecordStore
|
||||
from broker_sync.pipeline import sync_provider_to_wealthfolio
|
||||
from broker_sync.providers.fidelity_planviewer import (
|
||||
FidelityCreds,
|
||||
FidelityPlanViewerProvider,
|
||||
)
|
||||
from broker_sync.sinks.wealthfolio import WealthfolioSink
|
||||
|
||||
_setup_logging()
|
||||
|
||||
if mode == "steady":
|
||||
since: datetime | None = datetime.now(UTC) - timedelta(days=60)
|
||||
elif mode == "backfill":
|
||||
since = None
|
||||
else:
|
||||
typer.echo(f"Unknown mode: {mode!r}. Use 'steady' or 'backfill'.", err=True)
|
||||
sys.exit(2)
|
||||
|
||||
async def _run() -> None:
|
||||
sink = WealthfolioSink(
|
||||
base_url=wf_base_url,
|
||||
username=wf_username,
|
||||
password=wf_password,
|
||||
session_path=wf_session_path,
|
||||
)
|
||||
provider = FidelityPlanViewerProvider(FidelityCreds(
|
||||
storage_state_path=storage_state_path,
|
||||
plan_id=plan_id,
|
||||
))
|
||||
dedup = SyncRecordStore(Path(data_dir) / "sync.db")
|
||||
try:
|
||||
if not Path(wf_session_path).exists():
|
||||
await sink.login()
|
||||
result = await sync_provider_to_wealthfolio(
|
||||
provider=provider, sink=sink, dedup=dedup, since=since,
|
||||
)
|
||||
finally:
|
||||
await sink.close()
|
||||
typer.echo(f"fidelity-ingest: fetched={result.fetched} "
|
||||
f"new={result.new_after_dedup} "
|
||||
f"imported={result.imported} "
|
||||
f"failed={result.failed}")
|
||||
if result.failed > 0:
|
||||
sys.exit(1)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def _setup_logging() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
|
|
|
|||
128
broker_sync/providers/fidelity_planviewer.py
Normal file
128
broker_sync/providers/fidelity_planviewer.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Fidelity UK PlanViewer provider — workplace pension backfill + monthly sync.
|
||||
|
||||
PlanViewer has no public individual-member API; Fidelity International's
|
||||
developer portal only catalogues B2B scheme/HR endpoints. The SPA (at
|
||||
``pv.planviewer.fidelity.co.uk``) does call a private JSON backend at
|
||||
``prd.wiciam.fidelity.co.uk/cvmfe/api/*`` — we reverse-engineer that and feed
|
||||
it through a Playwright-maintained session.
|
||||
|
||||
## Session lifecycle
|
||||
|
||||
1. **One-off seed** (``broker-sync fidelity-seed``): Viktor runs a headed
|
||||
Chromium, logs in (password + memorable word + MFA), clicks "Remember
|
||||
device". Playwright dumps the resulting ``storage_state.json`` (cookies +
|
||||
localStorage) which we stash in Vault.
|
||||
|
||||
2. **Monthly cron**: loads storage_state, boots headless Chromium, navigates
|
||||
to the SPA once to let it refresh rolling session tokens, intercepts the
|
||||
first outbound XHR to capture the ``sid``/``fid``/``tbid``/``rid`` headers,
|
||||
then closes the browser and continues with plain httpx.
|
||||
|
||||
3. **Re-seed trigger**: on any 401 from the JSON API we raise
|
||||
:class:`FidelitySessionError`; the CronJob fails loudly and Prometheus
|
||||
alerts Viktor to run the seed command again.
|
||||
|
||||
Remember-device typically survives 30-90 days on Fidelity, so we expect the
|
||||
re-seed to be a quarterly manual step — not monthly.
|
||||
|
||||
## Data model
|
||||
|
||||
Salary-sacrifice scheme with two contribution streams (employee + employer),
|
||||
both pre-tax. Each contribution buys units across one or more funds. We emit:
|
||||
|
||||
- ``DEPOSIT`` per employee-or-employer cash inflow (external_id carries
|
||||
``fidelity:<doc-id>:<source>``).
|
||||
- ``BUY`` per fund-unit purchase (``symbol`` = fund ISIN or Fidelity code,
|
||||
``quantity`` = units, ``unit_price`` = GBp or GBP per unit).
|
||||
|
||||
All currency is GBP. The single WF account is ``AccountType.WORKPLACE_PENSION``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from typing import NamedTuple
|
||||
|
||||
from broker_sync.models import Account, AccountType, Activity
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ACCOUNT_ID = "fidelity-workplace-pension"
|
||||
_CCY = "GBP"
|
||||
|
||||
# PlanViewer's private JSON backend. Endpoint paths are reverse-engineered from
|
||||
# Viktor's DevTools cURLs and validated by the unit tests' fixtures.
|
||||
_API_BASE = "https://prd.wiciam.fidelity.co.uk"
|
||||
|
||||
|
||||
class FidelityCreds(NamedTuple):
|
||||
"""Credentials + session state required to hit the PlanViewer backend."""
|
||||
storage_state_path: str
|
||||
plan_id: str
|
||||
headless: bool = True
|
||||
|
||||
|
||||
class FidelitySessionError(Exception):
|
||||
"""Raised when PlanViewer returns 401/403 — storage_state is stale.
|
||||
|
||||
Recovery: run ``broker-sync fidelity-seed`` in a browser to refresh the
|
||||
storage_state blob in Vault, then re-run the CronJob.
|
||||
"""
|
||||
|
||||
|
||||
class FidelityProviderConfigError(Exception):
|
||||
"""Raised when the provider is asked to run but required config (plan id,
|
||||
storage_state path) is missing or obviously wrong."""
|
||||
|
||||
|
||||
class FidelityPlanViewerProvider:
|
||||
"""Read-only provider against Fidelity UK PlanViewer.
|
||||
|
||||
Per the Provider protocol consumed by ``broker_sync.pipeline``:
|
||||
|
||||
- ``.accounts()`` advertises the single workplace-pension WF account we
|
||||
write into.
|
||||
- ``.fetch(since, before)`` is an async generator that yields canonical
|
||||
``Activity`` objects.
|
||||
"""
|
||||
name = "fidelity-planviewer"
|
||||
|
||||
def __init__(self, creds: FidelityCreds) -> None:
|
||||
self._creds = creds
|
||||
|
||||
def accounts(self) -> list[Account]:
|
||||
return [
|
||||
Account(
|
||||
id=ACCOUNT_ID,
|
||||
name="Fidelity UK Pension",
|
||||
account_type=AccountType.WORKPLACE_PENSION,
|
||||
currency=_CCY,
|
||||
provider=self.name,
|
||||
),
|
||||
]
|
||||
|
||||
async def fetch(
|
||||
self,
|
||||
*,
|
||||
since: datetime | None = None,
|
||||
before: datetime | None = None,
|
||||
) -> AsyncIterator[Activity]:
|
||||
"""Yield Activity records.
|
||||
|
||||
Implementation blocked on captured endpoint shapes. Viktor will paste
|
||||
the transactions + holdings POST cURLs from DevTools, then we wire the
|
||||
parsers and this method lights up.
|
||||
"""
|
||||
# Guard against accidentally running before endpoint reverse-engineering
|
||||
# is done — makes the CronJob fail loudly with an actionable message
|
||||
# rather than silently importing nothing.
|
||||
raise FidelityProviderConfigError(
|
||||
"Fidelity ingest not yet enabled — PlanViewer endpoint paths have "
|
||||
"not been captured. Paste the POST cURLs from DevTools for the "
|
||||
"transactions + holdings views and re-apply the provider update."
|
||||
)
|
||||
# Unreachable yield — keeps the return type AsyncIterator[Activity]
|
||||
# once the raise above is removed.
|
||||
if False: # pragma: no cover
|
||||
yield
|
||||
111
docs/providers/fidelity-planviewer.md
Normal file
111
docs/providers/fidelity-planviewer.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# Fidelity UK PlanViewer provider
|
||||
|
||||
Viktor's UK workplace pension is hosted at `pv.planviewer.fidelity.co.uk`. There
|
||||
is no public API for individual members — the provider reverse-engineers the
|
||||
private JSON backend at `prd.wiciam.fidelity.co.uk/cvmfe/api/*` that the SPA
|
||||
itself calls, and uses Playwright only to keep a long-lived login session
|
||||
alive.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────┐ storage_state.json ┌──────────────────┐
|
||||
│ Vault KV │◀─── (quarterly reseed) ───│ fidelity-seed │
|
||||
│ broker-sync │ │ (headed browser) │
|
||||
└──────┬──────┘ └──────────────────┘
|
||||
│ ▲
|
||||
│ loads on start │ Viktor runs once
|
||||
▼ when session expires
|
||||
┌────────────────────┐
|
||||
│ Monthly CronJob │
|
||||
│ broker-sync-fidelity│
|
||||
└────────────┬────────┘
|
||||
│ headless Chromium
|
||||
▼
|
||||
┌─────────────────────────────────┐ ┌────────────────────────────────┐
|
||||
│ pv.planviewer.fidelity.co.uk │◀─────│ navigate dashboard → capture │
|
||||
│ (SPA) │ │ fresh sid/fid/tbid/rid headers │
|
||||
└─────────────────────────────────┘ └──────────────┬─────────────────┘
|
||||
│
|
||||
┌───────────▼─────────────┐
|
||||
│ httpx JSON calls │
|
||||
│ prd.wiciam.../cvmfe/api│
|
||||
└───────────┬─────────────┘
|
||||
│
|
||||
┌────────────────────▼────────────────────┐
|
||||
│ DEPOSIT × N (employee + employer) │
|
||||
│ BUY × N (fund unit purchases, per date) │
|
||||
└────────────────────┬────────────────────┘
|
||||
│
|
||||
┌────────────────▼────────────────┐
|
||||
│ Wealthfolio account │
|
||||
│ type = WORKPLACE_PENSION │
|
||||
│ currency = GBP │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
## One-time seed (Viktor)
|
||||
|
||||
```bash
|
||||
# on your laptop (macOS / Linux with a desktop):
|
||||
cd broker-sync
|
||||
poetry install
|
||||
poetry run playwright install chromium
|
||||
poetry run broker-sync fidelity-seed --out /tmp/fidelity_storage_state.json
|
||||
# chromium opens — log in to PlanViewer, tick "Remember device", press Enter
|
||||
|
||||
# stage to Vault
|
||||
vault kv patch secret/broker-sync \
|
||||
fidelity_storage_state=@/tmp/fidelity_storage_state.json \
|
||||
fidelity_plan_id=<your-plan-id>
|
||||
|
||||
rm /tmp/fidelity_storage_state.json # don't leave credentials lying around
|
||||
```
|
||||
|
||||
Re-seed when the monthly CronJob fails with `FidelitySessionError` (expect
|
||||
every 30-90 days, depending on how long Fidelity honours the remember-device
|
||||
cookie).
|
||||
|
||||
## One-time backfill
|
||||
|
||||
```bash
|
||||
kubectl -n broker-sync create job fidelity-backfill \
|
||||
--from=cronjob/broker-sync-fidelity
|
||||
kubectl -n broker-sync logs -f job/fidelity-backfill
|
||||
# expect: fidelity-ingest: fetched=N new=N imported=N failed=0
|
||||
```
|
||||
|
||||
## Monthly cron
|
||||
|
||||
- Schedule: `0 3 5 * *` (3am UTC on the 5th of each month — after mid-month payroll settles in Viktor's scheme)
|
||||
- CronJob: `broker-sync-fidelity` in namespace `broker-sync`
|
||||
- Resource: small, ≤512 MiB memory (Chromium for ~2 min, then idle)
|
||||
- Alert: `BrokerSyncFidelityFailed` fires on 2 consecutive failures
|
||||
|
||||
## Runbook — `BrokerSyncFidelityFailed`
|
||||
|
||||
1. Check pod logs: `kubectl -n broker-sync logs job/broker-sync-fidelity-<timestamp>`.
|
||||
2. If the error is `FidelitySessionError`: session expired, re-run the seed on
|
||||
Viktor's laptop (see above).
|
||||
3. If the error is a 404 / 5xx from `prd.wiciam.fidelity.co.uk`: likely an API
|
||||
path change. Check DevTools for the new endpoint, update the provider, ship
|
||||
a new image.
|
||||
4. If Playwright can't launch Chromium: check that the image still has Chromium
|
||||
installed (`playwright install chromium` at build time).
|
||||
|
||||
## Data model notes
|
||||
|
||||
- **Salary sacrifice scheme**: all employee + employer contributions are
|
||||
pre-tax from gross salary. No HMRC basic-rate relief line.
|
||||
- Emits two `DEPOSIT` per month (employee, employer) with `comment` carrying
|
||||
the source tag `fidelity:<doc-id>:<source>` for audit.
|
||||
- Emits one `BUY` per fund unit purchase, `symbol` = Fidelity fund code / ISIN.
|
||||
Units × unit price should reconcile to the cash deposited ±pennies.
|
||||
|
||||
## Not yet implemented
|
||||
|
||||
- Endpoint paths: waiting on Viktor's DevTools POST cURL for transactions +
|
||||
holdings views. Until pasted, `fidelity-ingest` raises
|
||||
`FidelityProviderConfigError` to fail loudly.
|
||||
- Infra: CronJob + Vault secret wiring + Prometheus alert in
|
||||
`infra/stacks/broker-sync/main.tf` — pending first successful manual run.
|
||||
115
poetry.lock
generated
115
poetry.lock
generated
|
|
@ -101,6 +101,79 @@ files = [
|
|||
]
|
||||
markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""}
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.4.0"
|
||||
description = "Lightweight in-process concurrent programming"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "greenlet-3.4.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d18eae9a7fb0f499efcd146b8c9750a2e1f6e0e93b5a382b3481875354a430e6"},
|
||||
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636d2f95c309e35f650e421c23297d5011716be15d966e6328b367c9fc513a82"},
|
||||
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:234582c20af9742583c3b2ddfbdbb58a756cfff803763ffaae1ac7990a9fac31"},
|
||||
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ac6a5f618be581e1e0713aecec8e54093c235e5fa17d6d8eb7ffc487e2300508"},
|
||||
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:523677e69cd4711b5a014e37bc1fb3a29947c3e3a5bb6a527e1cc50312e5a398"},
|
||||
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:d336d46878e486de7d9458653c722875547ac8d36a1cff9ffaf4a74a3c1f62eb"},
|
||||
{file = "greenlet-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b45e45fe47a19051a396abb22e19e7836a59ee6c5a90f3be427343c37908d65b"},
|
||||
{file = "greenlet-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5434271357be07f3ad0936c312645853b7e689e679e29310e2de09a9ea6c3adf"},
|
||||
{file = "greenlet-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a19093fbad824ed7c0f355b5ff4214bffda5f1a7f35f29b31fcaa240cc0135ab"},
|
||||
{file = "greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58"},
|
||||
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6"},
|
||||
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875"},
|
||||
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c5696c42e6bb5cfb7c6ff4453789081c66b9b91f061e5e9367fa15792644e76"},
|
||||
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83"},
|
||||
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:89995ce5ddcd2896d89615116dd39b9703bfa0c07b583b85b89bf1b5d6eddf81"},
|
||||
{file = "greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2"},
|
||||
{file = "greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71"},
|
||||
{file = "greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711"},
|
||||
{file = "greenlet-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267"},
|
||||
{file = "greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a"},
|
||||
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97"},
|
||||
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996"},
|
||||
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d"},
|
||||
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc"},
|
||||
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077"},
|
||||
{file = "greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de"},
|
||||
{file = "greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08"},
|
||||
{file = "greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2"},
|
||||
{file = "greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e"},
|
||||
{file = "greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1"},
|
||||
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1"},
|
||||
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82"},
|
||||
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f"},
|
||||
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf"},
|
||||
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55"},
|
||||
{file = "greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729"},
|
||||
{file = "greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c"},
|
||||
{file = "greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940"},
|
||||
{file = "greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a"},
|
||||
{file = "greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705"},
|
||||
{file = "greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["Sphinx", "furo"]
|
||||
test = ["objgraph", "psutil", "setuptools"]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
|
|
@ -447,6 +520,28 @@ files = [
|
|||
{file = "platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "1.58.0"
|
||||
description = "A high-level API to automate web browsers"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606"},
|
||||
{file = "playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71"},
|
||||
{file = "playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117"},
|
||||
{file = "playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b"},
|
||||
{file = "playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa"},
|
||||
{file = "playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99"},
|
||||
{file = "playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8"},
|
||||
{file = "playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
greenlet = ">=3.1.1,<4.0.0"
|
||||
pyee = ">=13,<14"
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
|
|
@ -463,6 +558,24 @@ files = [
|
|||
dev = ["pre-commit", "tox"]
|
||||
testing = ["coverage", "pytest", "pytest-benchmark"]
|
||||
|
||||
[[package]]
|
||||
name = "pyee"
|
||||
version = "13.0.1"
|
||||
description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"},
|
||||
{file = "pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = "*"
|
||||
|
||||
[package.extras]
|
||||
dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
|
|
@ -705,4 +818,4 @@ platformdirs = ">=3.5.1"
|
|||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11,<3.13"
|
||||
content-hash = "dcc5b4eadd0a8df900e74674acf33215091dcb9bd0fffcefb03607dde2408a16"
|
||||
content-hash = "b3896b2258a425cce9498be9ada5bd48a06d5f2bd7c53ead044ad27c53086bd7"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ python-dateutil = "^2.9"
|
|||
typer = "^0.12"
|
||||
click = "<8.2" # typer 0.12 uses make_metavar() without ctx; click 8.2 made ctx required
|
||||
aiomysql = "^0.3.2"
|
||||
# Fidelity UK PlanViewer has no public API — we use Playwright only to keep a
|
||||
# long-lived session alive (storage_state + device-trust cookie); actual data
|
||||
# is fetched via httpx against the SPA's private JSON backend.
|
||||
playwright = "^1.47"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^8.3"
|
||||
|
|
|
|||
42
tests/providers/test_fidelity_planviewer.py
Normal file
42
tests/providers/test_fidelity_planviewer.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from broker_sync.models import Account, AccountType
|
||||
from broker_sync.providers.fidelity_planviewer import (
|
||||
ACCOUNT_ID,
|
||||
FidelityCreds,
|
||||
FidelityPlanViewerProvider,
|
||||
FidelityProviderConfigError,
|
||||
)
|
||||
|
||||
|
||||
def test_accounts_exposes_single_workplace_pension_account() -> None:
|
||||
prov = FidelityPlanViewerProvider(FidelityCreds(
|
||||
storage_state_path="/tmp/x", plan_id="ABC123",
|
||||
))
|
||||
accounts = prov.accounts()
|
||||
assert accounts == [
|
||||
Account(
|
||||
id=ACCOUNT_ID,
|
||||
name="Fidelity UK Pension",
|
||||
account_type=AccountType.WORKPLACE_PENSION,
|
||||
currency="GBP",
|
||||
provider="fidelity-planviewer",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def test_fetch_raises_until_endpoints_captured() -> None:
|
||||
"""Until Viktor pastes the transactions/holdings cURLs, fetch() must fail
|
||||
loudly rather than silently importing nothing.
|
||||
|
||||
Swap this test for real parser tests once the API shapes are known and
|
||||
`FidelityPlanViewerProvider.fetch` is wired up against fixtures.
|
||||
"""
|
||||
prov = FidelityPlanViewerProvider(FidelityCreds(
|
||||
storage_state_path="/tmp/x", plan_id="ABC123",
|
||||
))
|
||||
with pytest.raises(FidelityProviderConfigError, match="endpoint paths"):
|
||||
async for _ in prov.fetch():
|
||||
pytest.fail("fetch should not yield before endpoints are configured")
|
||||
Loading…
Add table
Add a link
Reference in a new issue