diff --git a/pyproject.toml b/pyproject.toml index 4995e5f..9133dcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,6 +109,7 @@ ignore = [ # (Matrix, PointsPath) — the attr names ARE the contract here. "src/flight_cli/wire.py" = ["N815"] "src/flight_cli/pp/models.py" = ["N815"] +"src/flight_cli/providers/seats_aero/models.py" = ["N815"] [tool.ruff.format] docstring-code-format = true diff --git a/src/flight_cli/_config.py b/src/flight_cli/_config.py index fa7b591..378ace4 100644 --- a/src/flight_cli/_config.py +++ b/src/flight_cli/_config.py @@ -45,16 +45,26 @@ def provider_options(name: str, *, config: dict[str, Any] | None = None) -> dict """Pull the `[providers.]` table out of config, or {} if absent. Caller is responsible for type-coercing the values (lists stay as lists, - strings stay as strings — TOML's native typing is preserved).""" + strings stay as strings — TOML's native typing is preserved). + + `name` is normalized through the alias map, AND all section keys in the + config are normalized before lookup — so `[providers.sa]` and + `[providers.seats-aero]` (and any of the aliased spellings) both work.""" cfg = config if config is not None else load() providers_any: Any = cfg.get("providers", {}) if not isinstance(providers_any, dict): return {} providers_dict = cast("dict[str, Any]", providers_any) - section_any: Any = providers_dict.get(name, {}) - if not isinstance(section_any, dict): - return {} - return cast("dict[str, Any]", section_any) + target = canonical_provider(name) + # Walk all sections and pick whichever matches the canonical of `name`. + # Reverse lookup makes the user's config-file spelling forgiving without + # forcing them to know about the canonical names. + for section_name, section_any in providers_dict.items(): + if canonical_provider(section_name) != target: + continue + if isinstance(section_any, dict): + return cast("dict[str, Any]", section_any) + return {} def parse_provider_opt_overrides(raw: list[str]) -> dict[str, dict[str, Any]]: @@ -63,6 +73,17 @@ def parse_provider_opt_overrides(raw: list[str]) -> dict[str, dict[str, Any]]: `pp.airlines=United,Delta` → `{"pp": {"airlines": ["United", "Delta"]}}`. A bare scalar (no comma) stays a string; comma-list becomes list[str]. + Provider names are normalized through the alias map — `sa.cabins=Business` + and `seats-aero.cabins=Business` both land at + `{"seats-aero": {"cabins": "Business"}}` so downstream consumers don't + have to know about user-visible spellings. + + Note: seats.aero's brand contains a dot, but `seats.aero.cabins=X` parses + as provider="seats" / key="aero.cabins" (split on first dot). Users who + want the seats.aero brand spelling should write `'seats.aero.cabins=X'` + AND know that we'll split incorrectly; the documented forms are + `seats-aero` or `sa`. + Repeated keys for the same provider merge at the outer dict; same inner key wins last (caller is responsible for order if it matters). """ @@ -75,8 +96,8 @@ def parse_provider_opt_overrides(raw: list[str]) -> dict[str, dict[str, Any]]: if "." not in path: msg = f"--provider-opt {item!r} missing '.'; expected 'provider.key=value'" raise ValueError(msg) - provider, key = path.split(".", 1) - provider = provider.strip() + provider_raw, key = path.split(".", 1) + provider = canonical_provider(provider_raw) key = key.strip() if not provider or not key: msg = f"--provider-opt {item!r}: provider and key must be non-empty" @@ -156,6 +177,42 @@ def cache_disabled(*, config: dict[str, Any] | None = None) -> bool: return False +# ─────────────────────────── provider names & aliases ──────────────────── + +# Canonical provider names used by the registry, config sections, and the +# --providers CLI filter. The aliases below normalize a user-friendly spelling +# (case-insensitive) onto the canonical name. Adding a new provider: add the +# canonical name + any aliases here, register the provider in registry.py, +# done. The naming is intentionally asymmetric — `pp` is the company's own +# short identifier (everyone in points-world calls it PP), so the canonical +# is `pp` and `pointspath` is the alias. Seats.aero has no widely-used +# short form, so the canonical is `seats-aero` and `sa` is the short alias. +_PROVIDER_ALIASES: dict[str, str] = { + "pp": "pp", + "pointspath": "pp", + "points-path": "pp", + "seats-aero": "seats-aero", + "seatsaero": "seats-aero", + "seats.aero": "seats-aero", + "sa": "seats-aero", +} + + +def canonical_provider(name: str) -> str: + """Normalize a user-supplied provider name to its canonical form. + + Case-insensitive. Unknown names pass through unchanged (lowercased) so + upstream code can still emit clean error messages naming the typo.""" + return _PROVIDER_ALIASES.get(name.strip().lower(), name.strip().lower()) + + +def known_canonical_providers() -> set[str]: + """Set of canonical provider names. Useful for `--providers X` validation + where X must match at least one known provider before we can sensibly + construct an instance.""" + return set(_PROVIDER_ALIASES.values()) + + # ─────────────────────────── provider options ───────────────────────────── diff --git a/src/flight_cli/cli.py b/src/flight_cli/cli.py index 506df6f..2bb26e1 100644 --- a/src/flight_cli/cli.py +++ b/src/flight_cli/cli.py @@ -315,6 +315,20 @@ def pp_cabins(self) -> str | None: return ",".join(str(x) for x in cast("list[Any]", v)) return str(v) + def seats_sources(self) -> tuple[str, ...] | None: + """Seats.aero mileage-program filter (`--provider-opt seats-aero.sources=...`). + + Maps to the API's `sources=` query param. None = no filter (return all + programs the route is monitored on). The canonical provider key is + `seats-aero`; user-facing aliases (`sa`, `seatsaero`, `seats.aero`) + are normalized at parse time so we only need to read one key here.""" + v: Any = self.provider_opts.get("seats-aero", {}).get("sources") + if v is None: + return None + if isinstance(v, list): + return tuple(str(x).strip() for x in cast("list[Any]", v)) + return (str(v).strip(),) + def _resolve_providers( # noqa: PLR0912 — single-purpose validator + merge; splitting hurts readability *, @@ -360,15 +374,20 @@ def _resolve_providers( # noqa: PLR0912 — single-purpose validator + merge; s if legacy_pp_only: awards_only = True - # --providers parsing. + # --providers parsing. Normalize each entry through the alias map so + # `--providers sa,pointspath` ends up the same as `--providers seats-aero,pp`. provider_filter: tuple[str, ...] | None = None if providers is not None: - provider_filter = tuple(p.strip() for p in providers.split(",") if p.strip()) + provider_filter = tuple( + _config.canonical_provider(p) for p in providers.split(",") if p.strip() + ) if not provider_filter: err.print("[red]--providers cannot be empty.[/]") raise typer.Exit(2) - # Build provider_opts: config.toml < --provider-opt CLI. + # Build provider_opts: config.toml < --provider-opt CLI. Section names in + # the config are normalized through the alias map so user-facing spellings + # (`[providers.sa]`) land at the canonical key the registry expects. try: config = _config.load() except (OSError, ValueError) as e: @@ -379,7 +398,7 @@ def _resolve_providers( # noqa: PLR0912 — single-purpose validator + merge; s if isinstance(providers_section, dict): for name, opts in cast("dict[str, Any]", providers_section).items(): if isinstance(opts, dict): - base_opts[name] = dict(cast("dict[str, Any]", opts)) + base_opts[_config.canonical_provider(name)] = dict(cast("dict[str, Any]", opts)) try: cli_opts = _config.parse_provider_opt_overrides(list(provider_opt)) @@ -409,27 +428,40 @@ def _resolve_providers( # noqa: PLR0912 — single-purpose validator + merge; s def _should_run_awards(sel: ProviderSelection) -> bool: """Award providers run iff (a) not --cash-only, (b) at least one is - configured, and (c) the filter (if any) names at least one configured - provider. Mirrors the old _should_run_pp semantics for the PP-only era; - generalizes naturally once more providers exist. + configured globally, and (c) the filter (if any) names at least one + configured provider. + + Provider-blind by construction: every known provider has an + is_configured() check imported below; adding a new provider is one + import + one entry in the `known` map. """ if sel.cash_only: return False - # Today we only know about PP. has_any_configured() is provider-blind by - # construction (see providers/registry.py) so this stays correct when - # work-2eoa adds seats.aero. + # Lazy-imported to avoid the registry/CLI import cycle and to keep PP's + # token-load (which touches disk) out of the cli module top-level. + from .providers.pointspath.provider import is_configured as pp_is_configured # noqa: PLC0415 from .providers.registry import has_any_configured # noqa: PLC0415 + from .providers.seats_aero.auth import is_configured as seats_is_configured # noqa: PLC0415 + + known: dict[str, bool] = { + "pp": pp_is_configured(), + "seats-aero": seats_is_configured(), + } if not has_any_configured(): if sel.awards_only: err.print( "[red]--awards-only set but no award provider is configured.[/] " - "Run `flight auth pp login --tokens-file ...` first.", + "Run `flight auth pp login` or `flight auth seats-aero key ` first.", ) raise typer.Exit(2) return False - if sel.provider_filter is not None and "pp" not in sel.provider_filter: - # Filter excludes PP; nothing else is configured yet. + # Filter matches at least one configured provider? Values are already + # canonical (normalized by _resolve_providers), so a direct membership + # check is enough here. None filter ⇒ "all enabled", short-circuits to True. + if sel.provider_filter is not None and not any( + known.get(name, False) for name in sel.provider_filter + ): if sel.awards_only: err.print( f"[red]--awards-only set but --providers={sel.provider_filter} " @@ -702,6 +734,7 @@ def _run_matrix_path( pp_only=sel.awards_only, json_out=json_out, provider_filter=sel.provider_filter, + seats_sources=sel.seats_sources(), ) _emit_urls(search, matrix_url=matrix_url, google_url=google_url) @@ -774,6 +807,7 @@ def _run_gflight_path( pp_only=awards_only, json_out=json_out, provider_filter=sel.provider_filter if sel is not None else None, + seats_sources=sel.seats_sources() if sel is not None else None, ) diff --git a/src/flight_cli/pp/cli.py b/src/flight_cli/pp/cli.py index d7010b4..6b21898 100644 --- a/src/flight_cli/pp/cli.py +++ b/src/flight_cli/pp/cli.py @@ -10,7 +10,7 @@ import json import sys -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path # noqa: TC003 - typer evaluates annotations at runtime from typing import TYPE_CHECKING, Annotated, Any @@ -49,7 +49,7 @@ auth_app = typer.Typer( add_completion=False, no_args_is_help=True, - help="Auth helpers per provider. Today: PointsPath only.", + help="Auth helpers per provider.", ) pp_auth_app = typer.Typer( add_completion=False, @@ -58,6 +58,94 @@ ) auth_app.add_typer(pp_auth_app, name="pp") +# Seats.aero sub-app is registered inline here to avoid a circular import +# (providers/seats_aero/auth.py is leaner than pp/auth.py and we keep the +# Typer wiring in this file rather than fanning out per-provider auth CLIs). +from ..providers.seats_aero import auth as _seats_auth # noqa: E402 + +seats_auth_app = typer.Typer( + add_completion=False, + no_args_is_help=True, + help="Seats.aero API key management.", +) +# Canonical mount point. `sa` is registered below as a convenience alias — +# same sub-app, different name. Users can type either `flight auth seats-aero` +# or `flight auth sa`; both reach the same commands. +auth_app.add_typer(seats_auth_app, name="seats-aero") +auth_app.add_typer(seats_auth_app, name="sa") + + +@seats_auth_app.command("key") +def seats_key(api_key: Annotated[str, typer.Argument(help="Partner API key (pro_...)")]) -> None: + """Save a Seats.aero Pro API key to ~/.config/flight-cli/seats.json. + + Overwrites any existing value. The file is written with 0600 perms. + Alternative: set the SEATS_AERO_API_KEY env var, which takes precedence + over the on-disk value at runtime. + """ + _seats_auth.save_key(api_key) + console.print(f"[green]Saved Seats.aero key to {_seats_auth.KEY_PATH}.[/]") + + +@seats_auth_app.command("whoami") +def seats_whoami() -> None: + """Show whether a key is configured and probe the current quota. + + Hits /partnerapi/search with a tiny query to grab the latest + X-RateLimit-Remaining header. Uses ~1 of your 1000/day quota. + """ + key = _seats_auth.load_key() + if key is None: + err.print("[yellow]No Seats.aero key configured.[/]") + err.print(f"Run `flight auth seats-aero key ` or set {_seats_auth.API_KEY_ENV}.") + raise typer.Exit(1) + source = ( + "env" if _seats_auth.os.environ.get(_seats_auth.API_KEY_ENV) else str(_seats_auth.KEY_PATH) + ) + console.print(f"[green]Seats.aero key configured[/] (source: {source})") + + # Probe the quota. We import lazily to avoid pulling httpx unless asked. + from ..providers.seats_aero.client import SeatsAeroClient, SeatsAeroError # noqa: PLC0415 + + async def _probe() -> None: + async with SeatsAeroClient(api_key=key) as c: + try: + # Smallest-possible call: JFK-LHR, today, take=1, no trips. + await c.search( + origin="JFK", + destination="LHR", + start_date=datetime.now(UTC).date().isoformat(), + end_date=datetime.now(UTC).date().isoformat(), + include_trips=False, + take=1, + ) + except SeatsAeroError as e: + err.print(f"[red]Probe failed: HTTP {e.status}[/]") + raise typer.Exit(1) from e + rl = c.last_rate_limit + if rl is None: + console.print("[yellow]No rate-limit headers returned.[/]") + else: + console.print( + f"Quota: {rl.remaining}/{rl.limit} remaining (resets in {rl.reset_seconds}s)" + ) + + anyio.run(_probe) + + +@seats_auth_app.command("logout") +def seats_logout() -> None: + """Delete the on-disk Seats.aero key file. + + Does not affect SEATS_AERO_API_KEY env var (which takes precedence + when set). After logout, the provider's `is_configured()` returns + False and `flight search --providers seats` will error. + """ + if _seats_auth.clear_key(): + console.print(f"[green]Deleted {_seats_auth.KEY_PATH}.[/]") + else: + console.print("[yellow]No on-disk Seats.aero key to delete.[/]") + @pp_auth_app.command("login") def pp_login( @@ -196,19 +284,36 @@ def run_pp_for_search( pp_only: bool = False, json_out: bool = False, provider_filter: tuple[str, ...] | None = None, + seats_sources: tuple[str, ...] | None = None, ) -> None: """Run award augmentation through the provider registry, join against - `res`'s cash itineraries, render. Today the registry hands back - PointsPath only; future providers (seats.aero) join via the same path. + `res`'s cash itineraries, render. Registry hands back any configured + providers (PointsPath, Seats.aero, ...); the matcher is provider-blind. Errors are non-fatal — print and continue so the user still sees their cash results. + + The `pp_only` arg is named for historical reasons; today it means + "render in awards-only mode" — applies to whatever providers were + selected, not just PP. """ - try: - get_valid_tokens() # validate + refresh up-front, surface a clear error - except PPAuthError as e: - err.print(f"[red]--pp: {e}[/]") - return + # PP tokens are required only if PP is actually going to run. Skip the + # pre-flight check when the filter excludes PP — otherwise a seats-only + # invocation errors here before seats even gets a chance to run. + pp_in_filter = provider_filter is None or any( + p.strip().lower() == "pp" for p in provider_filter + ) + if pp_in_filter: + try: + get_valid_tokens() # validate + refresh up-front, surface a clear error + except PPAuthError as e: + # Soft-warn if PP fails but other providers might still run. + # When PP is the only target (filter explicitly == "pp"), it's + # a hard error; otherwise log and continue. + if provider_filter == ("pp",): + err.print(f"[red]--pp: {e}[/]") + return + err.print(f"[yellow]PointsPath skipped: {e}[/]") cabin_list = tuple(_normalize_cabin(c) for c in _parse_csv(cabins, DEFAULT_CABINS)) explicit_airlines = _parse_csv(airlines, ()) if airlines else None @@ -227,6 +332,7 @@ async def _go() -> tuple[list[list[AwardFlight]], list[Any]]: num_passengers=num_passengers, cabins=cabin_list, pp_airlines=explicit_airlines, + seats_sources=seats_sources, cash_hints_per_leg=cash_hints_per_leg, provider_filter=provider_filter, ) diff --git a/src/flight_cli/providers/registry.py b/src/flight_cli/providers/registry.py index 21e9082..1a2d478 100644 --- a/src/flight_cli/providers/registry.py +++ b/src/flight_cli/providers/registry.py @@ -18,6 +18,8 @@ from .pointspath.provider import PointsPathProvider from .pointspath.provider import is_configured as pp_is_configured +from .seats_aero.auth import is_configured as seats_is_configured +from .seats_aero.provider import SeatsAeroProvider if TYPE_CHECKING: from structlog.stdlib import BoundLogger @@ -31,6 +33,7 @@ async def _construct_enabled( *, pp_airlines: tuple[str, ...] | None = None, + seats_sources: tuple[str, ...] | None = None, provider_filter: tuple[str, ...] | None = None, ) -> list[AwardProvider]: """Build the list of enabled provider instances. @@ -53,12 +56,25 @@ async def _construct_enabled( out.append(await PointsPathProvider.create(explicit_airlines=pp_airlines)) except Exception as e: # noqa: BLE001 — per-provider failures are non-fatal log.warning("provider_init_failed", provider="PointsPath", error=str(e)) + allow_seats = provider_filter is None or _matches(provider_filter, "seats-aero") + if allow_seats and seats_is_configured(): + try: + out.append(await SeatsAeroProvider.create(explicit_airlines=seats_sources)) + except Exception as e: # noqa: BLE001 — per-provider failures are non-fatal + log.warning("provider_init_failed", provider="Seats.aero", error=str(e)) return out -def _matches(filter_: tuple[str, ...], name: str) -> bool: - """Case-insensitive membership test for the provider filter.""" - return any(p.strip().lower() == name.lower() for p in filter_) +def _matches(filter_: tuple[str, ...], canonical: str) -> bool: + """Case-insensitive membership test using the canonical-name alias map. + + Filter entries are normalized through `canonical_provider`, so callers + pass canonical names (`"pp"`, `"seats-aero"`) and match user-supplied + aliases (`"pointspath"`, `"sa"`) without restating the alias table here. + """ + from .._config import canonical_provider # noqa: PLC0415 — avoid import cycle + + return any(canonical_provider(p) == canonical for p in filter_) async def _gather_one_leg( @@ -102,6 +118,7 @@ async def gather_awards( cabins: tuple[str, ...], num_passengers: int = 1, pp_airlines: tuple[str, ...] | None = None, + seats_sources: tuple[str, ...] | None = None, cash_hints_per_leg: list[tuple[CashFlightHint, ...]] | None = None, provider_filter: tuple[str, ...] | None = None, ) -> tuple[list[list[AwardFlight]], list[AwardProvider]]: @@ -122,6 +139,7 @@ async def gather_awards( """ providers = await _construct_enabled( pp_airlines=pp_airlines, + seats_sources=seats_sources, provider_filter=provider_filter, ) per_leg: list[list[AwardFlight]] = [] @@ -144,6 +162,7 @@ async def gather_awards( def has_any_configured() -> bool: """Cheap check: is at least one provider's auto-enable predicate true? - Used by the CLI's `--pp-only`/`--no-pp` gating in `_should_run_pp` so we - can keep that decision provider-blind.""" - return pp_is_configured() + Used by the CLI's `_should_run_awards` gating so the decision stays + provider-blind. Adding a third provider here is the same one-line + or-in.""" + return pp_is_configured() or seats_is_configured() diff --git a/src/flight_cli/providers/seats_aero/__init__.py b/src/flight_cli/providers/seats_aero/__init__.py new file mode 100644 index 0000000..004fd20 --- /dev/null +++ b/src/flight_cli/providers/seats_aero/__init__.py @@ -0,0 +1,15 @@ +"""Seats.aero AwardProvider — second provider alongside PointsPath. + +Complementary coverage: + - Wider airline catalog (American, British, United, Virgin Atlantic, + Finnair, plus many programs not in PointsPath's enabled-airlines tier) + - Per-program-per-day availability with optional trip-level detail + - 1000 calls/day Pro tier; X-RateLimit-Remaining on every response + +Auth: Partner-Authorization header. Key sources, in order: + - SEATS_AERO_API_KEY env var (highest priority — useful for one-off shells) + - ~/.config/flight-cli/seats.json (persistent, set via `flight auth seats key`) + +Registry integration goes through providers/registry.py:_construct_enabled. +See providers/base.py for the AwardProvider Protocol contract. +""" diff --git a/src/flight_cli/providers/seats_aero/auth.py b/src/flight_cli/providers/seats_aero/auth.py new file mode 100644 index 0000000..ad291aa --- /dev/null +++ b/src/flight_cli/providers/seats_aero/auth.py @@ -0,0 +1,92 @@ +"""Seats.aero auth — much simpler than PointsPath. + +Just a static API key (`Partner-Authorization: pro_xxxxxxxx` header). Two +source paths, in order: + 1. SEATS_AERO_API_KEY env var — useful for one-off shells / CI / curl + scripts that already source from sops. + 2. ~/.config/flight-cli/seats.json — persistent, set by + `flight auth seats-aero key ` (or its `sa` alias). 0600 perms. + +No refresh, no expiry tracking. The Pro tier key is stable until rotated +on the seats.aero side; we surface 401 errors at request time rather than +trying to validate proactively. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any, cast + +API_KEY_ENV = "SEATS_AERO_API_KEY" +CONFIG_DIR = Path.home() / ".config" / "flight-cli" +KEY_PATH = CONFIG_DIR / "seats.json" + + +class SeatsAuthError(Exception): + """No key configured (env unset + on-disk file missing).""" + + +def load_key() -> str | None: + """Resolve the API key from env → file → None. + + Returns None when neither source is configured — callers gate on this + to decide whether to construct the provider (registry's `is_configured`) + or to error out (e.g. --providers seats explicitly requested).""" + env = os.environ.get(API_KEY_ENV) + if env: + return env.strip() + if not KEY_PATH.exists(): + return None + try: + raw: Any = json.loads(KEY_PATH.read_text()) + except (json.JSONDecodeError, OSError): + return None + if not isinstance(raw, dict): + return None + data = cast("dict[str, Any]", raw) + key: Any = data.get("api_key") + if isinstance(key, str) and key: + return key.strip() + return None + + +def save_key(key: str) -> None: + """Persist the API key to ~/.config/flight-cli/seats.json with 0600 perms. + + The directory is created if missing. Existing contents are replaced + (no merge — this is a single-key file).""" + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + KEY_PATH.write_text(json.dumps({"api_key": key.strip()}, indent=2)) + KEY_PATH.chmod(0o600) + + +def clear_key() -> bool: + """Delete the on-disk key file. Returns True if a file was removed, + False if nothing was there. Does not touch the env var.""" + if KEY_PATH.exists(): + KEY_PATH.unlink() + return True + return False + + +def is_configured() -> bool: + """True iff an API key is available from env or file. Used by the + registry's auto-enable check — provider-blind, parallel to + pp.provider.is_configured.""" + return load_key() is not None + + +def get_key() -> str: + """Like load_key but raises when nothing's configured. Use this from + code paths that should hard-error if seats isn't set up + (e.g. inside the provider's search_leg).""" + key = load_key() + if key is None: + msg = ( + f"No Seats.aero API key configured. Set ${API_KEY_ENV} or run " + "`flight auth seats-aero key `." + ) + raise SeatsAuthError(msg) + return key diff --git a/src/flight_cli/providers/seats_aero/client.py b/src/flight_cli/providers/seats_aero/client.py new file mode 100644 index 0000000..a6b4bb9 --- /dev/null +++ b/src/flight_cli/providers/seats_aero/client.py @@ -0,0 +1,191 @@ +"""Async HTTP client for the Seats.aero Pro API. + +Single endpoint matters for award augmentation: + GET /partnerapi/search route + date → award availability (+ trips) + +Auth: `Partner-Authorization: ` header (not Bearer). Surface 401 +loudly — there's no token refresh dance like PointsPath; a bad key is a +config problem. + +Rate limit: Pro tier is 1000 calls/day. Every 200 response includes: + X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (seconds). +We expose remaining via the client's `last_rate_limit` attribute so +`flight auth seats whoami` can show it. + +Pagination: response has `hasMore` + `cursor`. We auto-paginate inside +the client when callers want all results; for the augmenter, we cap at a +small N since we only need top-N awards per leg. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import httpx +import structlog + +from .auth import get_key +from .models import CachedSearchResponse, SeatsAvailabilityItem + +if TYPE_CHECKING: + from types import TracebackType + + from structlog.stdlib import BoundLogger + +log: BoundLogger = structlog.get_logger(__name__) # pyright: ignore[reportAny] + +API_BASE = "https://seats.aero/partnerapi" + + +@dataclass +class RateLimit: + """Snapshot of the most recent response's quota headers. None when no + request has run yet, or when the headers weren't present (e.g. 401).""" + + limit: int + remaining: int + reset_seconds: int + + +class SeatsAeroError(Exception): + """Wraps non-2xx responses with status + body excerpt for diagnostics.""" + + def __init__(self, status: int, body: str) -> None: + self.status = status + self.body = body + super().__init__(f"Seats.aero HTTP {status}: {body[:200]}") + + +class SeatsAeroClient: + """Thin async wrapper. Use as an async context manager: + + async with SeatsAeroClient() as c: + page = await c.search(origin="JFK", destination="LHR", + start_date="2026-08-15", + end_date="2026-08-15", + include_trips=True) + + The client owns one httpx.AsyncClient internally; close() on exit. + """ + + def __init__(self, *, timeout: float = 30.0, api_key: str | None = None) -> None: + # Resolve the key lazily at construction so test code can pass a + # fake key directly without going through env/file machinery. + self._api_key = api_key if api_key is not None else get_key() + self._client = httpx.AsyncClient( + timeout=timeout, + headers={ + "Partner-Authorization": self._api_key, + "Accept": "application/json", + "User-Agent": "flight-cli/0 (+https://github.com/ak2k/flight-cli)", + }, + base_url=API_BASE, + ) + self.last_rate_limit: RateLimit | None = None + + async def __aenter__(self) -> SeatsAeroClient: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + _ = exc_type, exc, tb + await self._client.aclose() + + async def search( + self, + *, + origin: str, + destination: str, + start_date: str, + end_date: str, + include_trips: bool = True, + take: int = 500, + cursor: int | None = None, + sources: tuple[str, ...] | None = None, + cabins: tuple[str, ...] | None = None, + carriers: tuple[str, ...] | None = None, + only_direct_flights: bool = False, + ) -> CachedSearchResponse: + """One `/search` call. Returns the parsed page; caller decides + whether to paginate via `search_all` based on `.hasMore`. + + `sources` filters by mileage program (e.g. ("aeroplan", "united")); + `cabins` restricts cabin classes; `carriers` filters by operating + carrier IATA. Empty/None tuples mean "no filter". + """ + params: dict[str, str] = { + "origin_airport": origin, + "destination_airport": destination, + "start_date": start_date, + "end_date": end_date, + "include_trips": "true" if include_trips else "false", + "take": str(take), + } + if cursor is not None: + params["cursor"] = str(cursor) + if sources: + params["sources"] = ",".join(sources) + if cabins: + params["cabins"] = ",".join(cabins) + if carriers: + params["carriers"] = ",".join(carriers) + if only_direct_flights: + params["only_direct_flights"] = "true" + + r = await self._client.get("/search", params=params) + self._record_rate_limit(r) + if r.status_code != 200: # noqa: PLR2004 — explicit HTTP 200 check + raise SeatsAeroError(r.status_code, r.text) + return CachedSearchResponse.model_validate(r.json()) + + async def search_all( + self, + *, + origin: str, + destination: str, + start_date: str, + end_date: str, + include_trips: bool = True, + max_pages: int = 10, + **filters: str | tuple[str, ...] | bool, + ) -> list[SeatsAvailabilityItem]: + """Walk pagination via `cursor` until hasMore=False or max_pages hit. + + `max_pages` caps how many pages we'll fetch — Seats.aero's Pro tier + has a 1000/day quota, and an unbounded loop on a noisy route can + chew through it fast. The augmenter caller stays at 1-2 pages. + """ + out: list[SeatsAvailabilityItem] = [] + cursor: int | None = None + for _ in range(max_pages): + page = await self.search( + origin=origin, + destination=destination, + start_date=start_date, + end_date=end_date, + include_trips=include_trips, + cursor=cursor, + **filters, # type: ignore[arg-type] + ) + out.extend(page.data) + if not page.hasMore or page.cursor is None: + break + cursor = page.cursor + return out + + def _record_rate_limit(self, r: httpx.Response) -> None: + """Parse X-RateLimit-* headers if present. Silently no-op on 401 + or other responses that don't carry the headers.""" + try: + self.last_rate_limit = RateLimit( + limit=int(r.headers["x-ratelimit-limit"]), + remaining=int(r.headers["x-ratelimit-remaining"]), + reset_seconds=int(r.headers["x-ratelimit-reset"]), + ) + except (KeyError, ValueError): + self.last_rate_limit = None diff --git a/src/flight_cli/providers/seats_aero/models.py b/src/flight_cli/providers/seats_aero/models.py new file mode 100644 index 0000000..9047818 --- /dev/null +++ b/src/flight_cli/providers/seats_aero/models.py @@ -0,0 +1,122 @@ +"""Pydantic shapes for the Seats.aero /partnerapi/search response. + +Mirrors seats.aero's PascalCase JSON attrs verbatim (same DIVERGE Profile-B +treatment as pp/models.py and wire.py — these are reverse-engineered upstreams +where the attr names ARE the contract). N815 is suppressed per-file in +pyproject.toml because seats.aero ships PascalCase, not snake_case. + +The top-level shape is paginated: + {count, cursor, data, hasMore, moreURL} + +Each `data` item is one (Route, Date, Source/program) tuple with per-cabin +(Y/W/J/F) availability columns and an optional AvailabilityTrips array +populated when the request includes `include_trips=true`. + +The fields we don't use we let Pydantic ignore — `extra="ignore"` covers the +"Raw" variants (pre-filter availability), CreatedAt/UpdatedAt timestamps, +and ID fields that are useless to us. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, field_validator + + +class _Loose(BaseModel): + # DIVERGE Profile-B edge: same justification as pp/models.py._Loose — + # seats.aero is an undocumented reverse-engineered upstream; we capture + # the fields we use and let the rest pass through. + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + +def _none_to_empty_list(v: Any) -> Any: + """seats.aero returns null for `AvailabilityTrips` when include_trips + isn't requested, and for `Connections` on direct flights. Coerce to [] + so consumers don't crash.""" + return [] if v is None else v + + +class SeatsRoute(_Loose): + """`Route` sub-object on each availability item.""" + + OriginAirport: str + DestinationAirport: str + Source: str # program slug: "american", "british", "united", etc. + + +class SeatsAvailabilityTrip(_Loose): + """Trip-level detail inside `AvailabilityTrips` (only present when + include_trips=true was sent). + + A single Trip = one journey (possibly multi-segment), one cabin, one + program. `FlightNumbers` is the comma-joined list of flight numbers + in segment order (e.g. "AA4671, BA216" for a JFK-IAD-LHR journey). + """ + + OriginAirport: str + DestinationAirport: str + DepartsAt: str # ISO 8601 UTC, e.g. "2026-08-15T06:30:00Z" + ArrivesAt: str + Cabin: str # lowercase: "economy", "premium", "business", "first" + FlightNumbers: str # comma list, in segment order + Carriers: str # comma list of marketing carrier IATA codes + Connections: list[str] = [] # connection airport codes, empty for direct + + _connections_none_to_empty = field_validator("Connections", mode="before")(_none_to_empty_list) + MileageCost: int + TotalTaxes: int # in cents + RemainingSeats: int # often 0; seats.aero data freshness is imperfect + Stops: int + Source: str # program slug, redundant with parent availability item + + +class SeatsAvailabilityItem(_Loose): + """One row in the `data` array. + + Per-cabin (Y/W/J/F) availability columns hold the aggregated cheapest + price across all trips on that (route, date, program). We use the + AvailabilityTrips array (when present) for the actual flight-level + pricing; the wide columns are useful for "is this cabin available at + all" gut-checks but lose flight detail. + + MileageCost columns are returned as strings (with thousands separators + sometimes); the *Raw variants are integers. We pick the raw ones to + avoid parsing the string form. + """ + + Date: str # "YYYY-MM-DD" + Route: SeatsRoute + Source: str # mirrors Route.Source, kept for forward-compat + + YAvailable: bool = False + WAvailable: bool = False + JAvailable: bool = False + FAvailable: bool = False + + YMileageCostRaw: int = 0 + WMileageCostRaw: int = 0 + JMileageCostRaw: int = 0 + FMileageCostRaw: int = 0 + + YTotalTaxesRaw: int = 0 # in cents + WTotalTaxesRaw: int = 0 + JTotalTaxesRaw: int = 0 + FTotalTaxesRaw: int = 0 + + TaxesCurrency: str = "USD" + + AvailabilityTrips: list[SeatsAvailabilityTrip] = [] + + _trips_none_to_empty = field_validator("AvailabilityTrips", mode="before")(_none_to_empty_list) + + +class CachedSearchResponse(_Loose): + """Top-level paginated response from GET /partnerapi/search.""" + + count: int = 0 + data: list[SeatsAvailabilityItem] = [] + hasMore: bool = False + cursor: int | None = None + moreURL: str | None = None diff --git a/src/flight_cli/providers/seats_aero/provider.py b/src/flight_cli/providers/seats_aero/provider.py new file mode 100644 index 0000000..0ddba75 --- /dev/null +++ b/src/flight_cli/providers/seats_aero/provider.py @@ -0,0 +1,252 @@ +"""Seats.aero AwardProvider — implements the AwardProvider Protocol. + +Maps `AvailabilityTrip` (per-cabin, per-program, per-flight-set) → AwardFlight +(one per unique flight-set, with multiple cabins inside). Grouping is by +`(FlightNumbers, DepartsAt, Source)` so a single physical journey priced in +Y/W/J/F across the same program collapses to one AwardFlight with all four +cabins listed. + +What this provider does NOT do that PointsPath does: + - No transfer-partner bank metadata (seats.aero doesn't expose it). + `funding_banks` is left empty; the render shows "—". + - No miles-to-cash valuation. `miles_to_cash_ratio = 0.0` and the per- + cabin ¢/mi column reads as missing. + +These are intentional gaps — fill them in via work-rq94 / work-eb7k if +the per-program valuation lands as a separate workstream. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import structlog + +from ..base import AwardFlight, CabinAward +from .auth import SeatsAuthError, is_configured +from .client import SeatsAeroClient, SeatsAeroError + +if TYPE_CHECKING: + from structlog.stdlib import BoundLogger + + from ...pp.client import CashFlightHint + from ..base import LegQuery + from .models import SeatsAvailabilityTrip + +log: BoundLogger = structlog.get_logger(__name__) # pyright: ignore[reportAny] + + +# Mileage program slug → display name. Seats.aero uses lowercased no-space +# slugs ("aeroplan", "virginatlantic"); we render them in the same casing as +# PointsPath's airline labels so the columns look consistent in the joined +# table. Unknowns fall back to title-case of the slug. +_PROGRAM_LABELS: dict[str, str] = { + "aeroplan": "Air Canada Aeroplan", + "american": "American Airlines", + "british": "British Airways", + "delta": "Delta SkyMiles", + "etihad": "Etihad Guest", + "finnair": "Finnair Plus", + "flyingblue": "Flying Blue", + "gol": "Smiles", + "ihg": "IHG Rewards", + "iberia": "Iberia Plus", + "jetblue": "JetBlue TrueBlue", + "lifemiles": "Avianca LifeMiles", + "marriott": "Marriott Bonvoy", + "qantas": "Qantas Frequent Flyer", + "qatar": "Qatar Privilege Club", + "saudia": "Saudia AlFursan", + "singapore": "Singapore KrisFlyer", + "tap": "TAP Miles&Go", + "turkish": "Turkish Miles&Smiles", + "united": "United MileagePlus", + "virginatlantic": "Virgin Atlantic Flying Club", +} + + +# Seats.aero cabin slug → CabinAward.cabin string. Aligns with PointsPath's +# "Economy"/"Business"/"First"/"Premium" labels so the renderer doesn't +# have to disambiguate by provider. +_CABIN_LABELS: dict[str, str] = { + "economy": "Economy", + "premium": "Premium", + "business": "Business", + "first": "First", +} + + +def _program_label(slug: str) -> str: + return _PROGRAM_LABELS.get(slug, slug.title()) + + +def _cabin_label(slug: str) -> str: + return _CABIN_LABELS.get(slug.lower(), slug.title()) + + +def _first_flight_number(flight_numbers: str) -> str: + """`"AA4671, BA216"` → `"AA4671"`. The matcher keys on the first + marketing flight number, same convention as PointsPath. Multi-segment + journeys with later legs on different carriers get matched by the + first leg only — this loses some matches but stays consistent with + the rest of the join pipeline.""" + return flight_numbers.split(",", 1)[0].strip() + + +def _group_trips_to_awards( + trips: list[SeatsAvailabilityTrip], + *, + tax_currency: str, +) -> list[AwardFlight]: + """Collapse per-cabin trips into AwardFlights. + + Group key: (FlightNumbers, DepartsAt, Source). Same flight set on the + same departure time priced via the same program merges into one award + with multiple CabinAward entries. The trip's RemainingSeats and Stops + are taken from the first trip in each group (they're per-flight-set, + not per-cabin). + + Skips trips whose cabin we can't recognise — they're emitted as a + distinct AwardFlight under the fallback _cabin_label rule rather than + dropped silently. + """ + grouped: dict[tuple[str, str, str], AwardFlight] = {} + for t in trips: + key = (t.FlightNumbers, t.DepartsAt, t.Source) + af = grouped.get(key) + cabin_award = CabinAward( + cabin=_cabin_label(t.Cabin), + miles=t.MileageCost, + tax_usd=t.TotalTaxes / 100.0, # seats.aero returns cents + tax_currency=tax_currency, + ) + if af is None: + grouped[key] = AwardFlight( + origin=t.OriginAirport, + destination=t.DestinationAirport, + departure=t.DepartsAt, + arrival=t.ArrivesAt, + flight_number=_first_flight_number(t.FlightNumbers), + num_connections=t.Stops, + provider="Seats.aero", + program=_program_label(t.Source), + miles_to_cash_ratio=0.0, + funding_banks=[], + cabins=[cabin_award], + ) + else: + af.cabins.append(cabin_award) + return list(grouped.values()) + + +class SeatsAeroProvider: + """AwardProvider implementation wrapping SeatsAeroClient. + + Construct via `await SeatsAeroProvider.create(...)`. The factory does + a load_key() up-front so missing-key errors surface before the registry + hands the provider out. + """ + + name: str = "Seats.aero" + + def __init__( + self, + client: SeatsAeroClient, + *, + sources: tuple[str, ...] | None = None, + ) -> None: + self._client = client + # Optional program filter (seats.aero's `sources=` query param). + # When None, the API returns all programs the route is monitored on. + self._sources = sources + + @classmethod + async def create( + cls, + *, + explicit_airlines: tuple[str, ...] | None = None, + ) -> SeatsAeroProvider: + """Build a configured provider. + + `explicit_airlines` reuses the PointsPath kwarg shape so the registry + can call both providers with the same signature. For seats.aero, + the value is forwarded as `sources=` (the mileage-program filter, + not a marketing carrier filter — the API has separate params for + each and the user-facing concept maps more naturally to programs). + Caller wanting to filter by operating carrier should use the + --provider-opt seats.carriers=... path that the CLI threads through. + """ + if not is_configured(): + msg = "Seats.aero is not configured." + raise SeatsAuthError(msg) + client = SeatsAeroClient() + # explicit_airlines from the legacy --pp-airlines wiring is the + # closest concept seats.aero has to "filter by program"; the + # provider-opt path (seats.airlines=...) lands here through the + # registry's per-provider option dispatch. + return cls(client, sources=explicit_airlines) + + async def aclose(self) -> None: + # SeatsAeroClient owns the underlying httpx.AsyncClient; close + # explicitly via its context manager exit semantics. + await self._client.__aexit__(None, None, None) + + @property + def enabled(self) -> bool: + # Once construct() has succeeded, enabled is implied. The Protocol + # demands a property; we keep this dynamic in case future tiers + # (e.g. trial quota exhausted) need to flip it. + return True + + async def search_leg( + self, + leg: LegQuery, + *, + cabins: tuple[str, ...], + num_passengers: int = 1, + cash_hints: tuple[CashFlightHint, ...] = (), + ) -> list[AwardFlight]: + """One call to seats.aero per leg, with `include_trips=true` so we + get flight-level detail that the matcher can join against. + + `cabins` is the user's requested set ("Economy", "Business"). We + lowercase + pass to seats.aero's `cabins=` filter, which the API + treats as "results must have these cabins available." That lets us + avoid pulling thousands of unrequested-cabin entries on a noisy + route. + + `cash_hints` is ignored — seats.aero has no Google-flight-ID echo + mechanism. The matcher falls back to flight#+date / route+time as + usual. + """ + _ = num_passengers, cash_hints + cabin_slugs = tuple(c.lower() for c in cabins) if cabins else None + try: + page = await self._client.search( + origin=leg.origin, + destination=leg.destination, + start_date=leg.date, + end_date=leg.date, + include_trips=True, + cabins=cabin_slugs, + sources=self._sources, + ) + except SeatsAeroError as e: + # Provider-level failures (auth, network, schema) are non-fatal: + # log + return [] so the registry can move on to other providers. + log.warning("seats_aero_search_failed", error=str(e), status=e.status) + return [] + except Exception as e: # noqa: BLE001 — propagate-to-registry pattern + log.warning("seats_aero_search_failed", error=str(e)) + return [] + + # Collect all trips across all (program, date) availability items + # for this leg. The matcher is provider-blind: it gets a flat list + # of AwardFlight and joins by (flight#, date) / (route, time). + all_trips: list[SeatsAvailabilityTrip] = [] + tax_currency = "USD" + for item in page.data: + if item.TaxesCurrency: + tax_currency = item.TaxesCurrency + all_trips.extend(item.AvailabilityTrips) + return _group_trips_to_awards(all_trips, tax_currency=tax_currency) diff --git a/tests/fixtures/seats_aero/cached_search_no_trips.json b/tests/fixtures/seats_aero/cached_search_no_trips.json new file mode 100644 index 0000000..4ab4850 --- /dev/null +++ b/tests/fixtures/seats_aero/cached_search_no_trips.json @@ -0,0 +1,508 @@ +{ + "data": [ + { + "ID": "32tcOo2hM0ujj10omekYkCKEM0e", + "RouteID": "2HmZ2Xah52QzyddIB7JAM28jKqs", + "Route": { + "ID": "2HmZ2Xah52QzyddIB7JAM28jKqs", + "OriginAirport": "JFK", + "OriginRegion": "North America", + "DestinationAirport": "LHR", + "DestinationRegion": "Europe", + "Distance": 3446, + "Source": "american" + }, + "Date": "2026-08-15", + "ParsedDate": "2026-08-15T00:00:00Z", + "YAvailable": true, + "WAvailable": true, + "JAvailable": true, + "FAvailable": true, + "YAvailableRaw": true, + "WAvailableRaw": true, + "JAvailableRaw": true, + "FAvailableRaw": true, + "YMileageCost": "30000", + "WMileageCost": "40000", + "JMileageCost": "57500", + "FMileageCost": "98500", + "YMileageCostRaw": 30000, + "WMileageCostRaw": 40000, + "JMileageCostRaw": 57500, + "FMileageCostRaw": 98500, + "YDirectMileageCost": 30000, + "WDirectMileageCost": 40000, + "JDirectMileageCost": 57500, + "FDirectMileageCost": 98500, + "YDirectMileageCostRaw": 30000, + "WDirectMileageCostRaw": 40000, + "JDirectMileageCostRaw": 57500, + "FDirectMileageCostRaw": 98500, + "TaxesCurrency": "USD", + "YTotalTaxes": 560, + "WTotalTaxes": 48350, + "JTotalTaxes": 73350, + "FTotalTaxes": 560, + "YTotalTaxesRaw": 560, + "WTotalTaxesRaw": 48350, + "JTotalTaxesRaw": 73350, + "FTotalTaxesRaw": 560, + "YDirectTotalTaxes": 560, + "WDirectTotalTaxes": 48350, + "JDirectTotalTaxes": 73350, + "FDirectTotalTaxes": 560, + "YDirectTotalTaxesRaw": 560, + "WDirectTotalTaxesRaw": 48350, + "JDirectTotalTaxesRaw": 73350, + "FDirectTotalTaxesRaw": 560, + "YRemainingSeats": 0, + "WRemainingSeats": 0, + "JRemainingSeats": 0, + "FRemainingSeats": 0, + "YRemainingSeatsRaw": 0, + "WRemainingSeatsRaw": 0, + "JRemainingSeatsRaw": 0, + "FRemainingSeatsRaw": 0, + "YDirectRemainingSeats": 0, + "WDirectRemainingSeats": 0, + "JDirectRemainingSeats": 0, + "FDirectRemainingSeats": 0, + "YDirectRemainingSeatsRaw": 0, + "WDirectRemainingSeatsRaw": 0, + "JDirectRemainingSeatsRaw": 0, + "FDirectRemainingSeatsRaw": 0, + "YAirlines": "AA, BA", + "WAirlines": "AA, BA", + "JAirlines": "AA, BA", + "FAirlines": "AA", + "YAirlinesRaw": "AA, BA", + "WAirlinesRaw": "AA, BA", + "JAirlinesRaw": "AA, BA", + "FAirlinesRaw": "AA", + "YDirectAirlines": "AA", + "WDirectAirlines": "AA, BA", + "JDirectAirlines": "AA, BA", + "FDirectAirlines": "AA", + "YDirectAirlinesRaw": "AA", + "WDirectAirlinesRaw": "AA, BA", + "JDirectAirlinesRaw": "AA, BA", + "FDirectAirlinesRaw": "AA", + "YDirect": true, + "WDirect": true, + "JDirect": true, + "FDirect": true, + "YDirectRaw": true, + "WDirectRaw": true, + "JDirectRaw": true, + "FDirectRaw": true, + "Source": "american", + "CreatedAt": "2025-09-19T00:52:02.843771Z", + "UpdatedAt": "2026-05-17T09:05:16.363002Z", + "AvailabilityTrips": null + }, + { + "ID": "32tcNqMwZis7w54bsEBsQyzbwzc", + "RouteID": "2EQhenZqHPHOifrLTtauoXhys1G", + "Route": { + "ID": "2EQhenZqHPHOifrLTtauoXhys1G", + "OriginAirport": "JFK", + "OriginRegion": "North America", + "DestinationAirport": "LHR", + "DestinationRegion": "Europe", + "Distance": 3446, + "Source": "virginatlantic" + }, + "Date": "2026-08-15", + "ParsedDate": "2026-08-15T00:00:00Z", + "YAvailable": true, + "WAvailable": true, + "JAvailable": true, + "FAvailable": false, + "YAvailableRaw": true, + "WAvailableRaw": true, + "JAvailableRaw": true, + "FAvailableRaw": false, + "YMileageCost": "12000", + "WMileageCost": "110000", + "JMileageCost": "72000", + "FMileageCost": "0", + "YMileageCostRaw": 12000, + "WMileageCostRaw": 110000, + "JMileageCostRaw": 72000, + "FMileageCostRaw": 0, + "YDirectMileageCost": 12000, + "WDirectMileageCost": 110000, + "JDirectMileageCost": 72000, + "FDirectMileageCost": 0, + "YDirectMileageCostRaw": 12000, + "WDirectMileageCostRaw": 110000, + "JDirectMileageCostRaw": 72000, + "FDirectMileageCostRaw": 0, + "TaxesCurrency": "USD", + "YTotalTaxes": 16380, + "WTotalTaxes": 29860, + "JTotalTaxes": 70310, + "FTotalTaxes": 0, + "YTotalTaxesRaw": 16380, + "WTotalTaxesRaw": 29860, + "JTotalTaxesRaw": 70310, + "FTotalTaxesRaw": 0, + "YDirectTotalTaxes": 16380, + "WDirectTotalTaxes": 29860, + "JDirectTotalTaxes": 70310, + "FDirectTotalTaxes": 0, + "YDirectTotalTaxesRaw": 16380, + "WDirectTotalTaxesRaw": 29860, + "JDirectTotalTaxesRaw": 70310, + "FDirectTotalTaxesRaw": 0, + "YRemainingSeats": 9, + "WRemainingSeats": 8, + "JRemainingSeats": 9, + "FRemainingSeats": 0, + "YRemainingSeatsRaw": 9, + "WRemainingSeatsRaw": 9, + "JRemainingSeatsRaw": 9, + "FRemainingSeatsRaw": 0, + "YDirectRemainingSeats": 9, + "WDirectRemainingSeats": 8, + "JDirectRemainingSeats": 9, + "FDirectRemainingSeats": 0, + "YDirectRemainingSeatsRaw": 9, + "WDirectRemainingSeatsRaw": 9, + "JDirectRemainingSeatsRaw": 9, + "FDirectRemainingSeatsRaw": 0, + "YAirlines": "VS", + "WAirlines": "VS", + "JAirlines": "VS", + "FAirlines": "", + "YAirlinesRaw": "VS", + "WAirlinesRaw": "VS", + "JAirlinesRaw": "VS", + "FAirlinesRaw": "", + "YDirectAirlines": "VS", + "WDirectAirlines": "VS", + "JDirectAirlines": "VS", + "FDirectAirlines": "", + "YDirectAirlinesRaw": "VS", + "WDirectAirlinesRaw": "VS", + "JDirectAirlinesRaw": "VS", + "FDirectAirlinesRaw": "", + "YDirect": true, + "WDirect": true, + "JDirect": true, + "FDirect": false, + "YDirectRaw": true, + "WDirectRaw": true, + "JDirectRaw": true, + "FDirectRaw": false, + "Source": "virginatlantic", + "CreatedAt": "2025-09-19T00:51:54.944344Z", + "UpdatedAt": "2026-05-17T11:40:47.334458Z", + "AvailabilityTrips": null + }, + { + "ID": "3CdQfcjqj4yH9sCoooZlog7phv7", + "RouteID": "3CdF5gV6GxS5pn5rzuRPeQ8yqmz", + "Route": { + "ID": "3CdF5gV6GxS5pn5rzuRPeQ8yqmz", + "OriginAirport": "JFK", + "OriginRegion": "North America", + "DestinationAirport": "LHR", + "DestinationRegion": "Europe", + "Distance": 3446, + "Source": "british" + }, + "Date": "2026-08-15", + "ParsedDate": "2026-08-15T00:00:00Z", + "YAvailable": true, + "WAvailable": true, + "JAvailable": true, + "FAvailable": false, + "YAvailableRaw": true, + "WAvailableRaw": true, + "JAvailableRaw": true, + "FAvailableRaw": false, + "YMileageCost": "33000", + "WMileageCost": "66000", + "JMileageCost": "99000", + "FMileageCost": "0", + "YMileageCostRaw": 33000, + "WMileageCostRaw": 66000, + "JMileageCostRaw": 99000, + "FMileageCostRaw": 0, + "YDirectMileageCost": 33000, + "WDirectMileageCost": 66000, + "JDirectMileageCost": 99000, + "FDirectMileageCost": 0, + "YDirectMileageCostRaw": 33000, + "WDirectMileageCostRaw": 66000, + "JDirectMileageCostRaw": 99000, + "FDirectMileageCostRaw": 0, + "TaxesCurrency": "USD", + "YTotalTaxes": 0, + "WTotalTaxes": 0, + "JTotalTaxes": 0, + "FTotalTaxes": 0, + "YTotalTaxesRaw": 0, + "WTotalTaxesRaw": 0, + "JTotalTaxesRaw": 0, + "FTotalTaxesRaw": 0, + "YDirectTotalTaxes": 0, + "WDirectTotalTaxes": 0, + "JDirectTotalTaxes": 0, + "FDirectTotalTaxes": 0, + "YDirectTotalTaxesRaw": 0, + "WDirectTotalTaxesRaw": 0, + "JDirectTotalTaxesRaw": 0, + "FDirectTotalTaxesRaw": 0, + "YRemainingSeats": 5, + "WRemainingSeats": 9, + "JRemainingSeats": 9, + "FRemainingSeats": 0, + "YRemainingSeatsRaw": 5, + "WRemainingSeatsRaw": 9, + "JRemainingSeatsRaw": 9, + "FRemainingSeatsRaw": 0, + "YDirectRemainingSeats": 5, + "WDirectRemainingSeats": 9, + "JDirectRemainingSeats": 9, + "FDirectRemainingSeats": 0, + "YDirectRemainingSeatsRaw": 5, + "WDirectRemainingSeatsRaw": 9, + "JDirectRemainingSeatsRaw": 9, + "FDirectRemainingSeatsRaw": 0, + "YAirlines": "BA", + "WAirlines": "BA", + "JAirlines": "BA", + "FAirlines": "", + "YAirlinesRaw": "BA", + "WAirlinesRaw": "BA", + "JAirlinesRaw": "BA", + "FAirlinesRaw": "", + "YDirectAirlines": "BA", + "WDirectAirlines": "BA", + "JDirectAirlines": "BA", + "FDirectAirlines": "", + "YDirectAirlinesRaw": "BA", + "WDirectAirlinesRaw": "BA", + "JDirectAirlinesRaw": "BA", + "FDirectAirlinesRaw": "", + "YDirect": true, + "WDirect": true, + "JDirect": true, + "FDirect": false, + "YDirectRaw": true, + "WDirectRaw": true, + "JDirectRaw": true, + "FDirectRaw": false, + "Source": "british", + "CreatedAt": "2026-04-20T19:22:29.314809Z", + "UpdatedAt": "2026-05-12T09:49:27.064025Z", + "AvailabilityTrips": null + }, + { + "ID": "31YYRX4HZKx7ABZqZrLiZBZUqy4", + "RouteID": "2zQJdmmVGkSBaCRy8dcc6VFcjUp", + "Route": { + "ID": "2zQJdmmVGkSBaCRy8dcc6VFcjUp", + "OriginAirport": "JFK", + "OriginRegion": "North America", + "DestinationAirport": "LHR", + "DestinationRegion": "Europe", + "Distance": 3446, + "Source": "finnair" + }, + "Date": "2026-08-15", + "ParsedDate": "2026-08-15T00:00:00Z", + "YAvailable": false, + "WAvailable": false, + "JAvailable": true, + "FAvailable": false, + "YAvailableRaw": false, + "WAvailableRaw": false, + "JAvailableRaw": true, + "FAvailableRaw": false, + "YMileageCost": "0", + "WMileageCost": "0", + "JMileageCost": "99000", + "FMileageCost": "0", + "YMileageCostRaw": 0, + "WMileageCostRaw": 0, + "JMileageCostRaw": 99000, + "FMileageCostRaw": 0, + "YDirectMileageCost": 0, + "WDirectMileageCost": 0, + "JDirectMileageCost": 99000, + "FDirectMileageCost": 0, + "YDirectMileageCostRaw": 0, + "WDirectMileageCostRaw": 0, + "JDirectMileageCostRaw": 99000, + "FDirectMileageCostRaw": 0, + "TaxesCurrency": "USD", + "YTotalTaxes": 0, + "WTotalTaxes": 0, + "JTotalTaxes": 140850, + "FTotalTaxes": 0, + "YTotalTaxesRaw": 0, + "WTotalTaxesRaw": 0, + "JTotalTaxesRaw": 140850, + "FTotalTaxesRaw": 0, + "YDirectTotalTaxes": 0, + "WDirectTotalTaxes": 0, + "JDirectTotalTaxes": 140850, + "FDirectTotalTaxes": 0, + "YDirectTotalTaxesRaw": 0, + "WDirectTotalTaxesRaw": 0, + "JDirectTotalTaxesRaw": 140850, + "FDirectTotalTaxesRaw": 0, + "YRemainingSeats": 0, + "WRemainingSeats": 0, + "JRemainingSeats": 6, + "FRemainingSeats": 0, + "YRemainingSeatsRaw": 0, + "WRemainingSeatsRaw": 0, + "JRemainingSeatsRaw": 6, + "FRemainingSeatsRaw": 0, + "YDirectRemainingSeats": 0, + "WDirectRemainingSeats": 0, + "JDirectRemainingSeats": 6, + "FDirectRemainingSeats": 0, + "YDirectRemainingSeatsRaw": 0, + "WDirectRemainingSeatsRaw": 0, + "JDirectRemainingSeatsRaw": 6, + "FDirectRemainingSeatsRaw": 0, + "YAirlines": "", + "WAirlines": "", + "JAirlines": "BA", + "FAirlines": "", + "YAirlinesRaw": "", + "WAirlinesRaw": "", + "JAirlinesRaw": "BA", + "FAirlinesRaw": "", + "YDirectAirlines": "", + "WDirectAirlines": "", + "JDirectAirlines": "BA", + "FDirectAirlines": "", + "YDirectAirlinesRaw": "", + "WDirectAirlinesRaw": "", + "JDirectAirlinesRaw": "BA", + "FDirectAirlinesRaw": "", + "YDirect": false, + "WDirect": false, + "JDirect": true, + "FDirect": false, + "YDirectRaw": false, + "WDirectRaw": false, + "JDirectRaw": true, + "FDirectRaw": false, + "Source": "finnair", + "CreatedAt": "2025-08-20T15:07:44.742647Z", + "UpdatedAt": "2026-05-17T10:16:12.6237Z", + "AvailabilityTrips": null + }, + { + "ID": "34YB3UIdpEJIFp0bzxhKwpJvKmv", + "RouteID": "34WkUFcgLONHYJPCrpKcB6Aye5S", + "Route": { + "ID": "34WkUFcgLONHYJPCrpKcB6Aye5S", + "OriginAirport": "JFK", + "OriginRegion": "North America", + "DestinationAirport": "LHR", + "DestinationRegion": "Europe", + "Distance": 3446, + "Source": "united" + }, + "Date": "2026-08-15", + "ParsedDate": "2026-08-15T00:00:00Z", + "YAvailable": true, + "WAvailable": false, + "JAvailable": true, + "FAvailable": false, + "YAvailableRaw": true, + "WAvailableRaw": false, + "JAvailableRaw": true, + "FAvailableRaw": false, + "YMileageCost": "44000", + "WMileageCost": "0", + "JMileageCost": "88000", + "FMileageCost": "0", + "YMileageCostRaw": 44000, + "WMileageCostRaw": 0, + "JMileageCostRaw": 88000, + "FMileageCostRaw": 0, + "YDirectMileageCost": 0, + "WDirectMileageCost": 0, + "JDirectMileageCost": 115000, + "FDirectMileageCost": 0, + "YDirectMileageCostRaw": 0, + "WDirectMileageCostRaw": 0, + "JDirectMileageCostRaw": 115000, + "FDirectMileageCostRaw": 0, + "TaxesCurrency": "USD", + "YTotalTaxes": 4029, + "WTotalTaxes": 0, + "JTotalTaxes": 2610, + "FTotalTaxes": 0, + "YTotalTaxesRaw": 4029, + "WTotalTaxesRaw": 0, + "JTotalTaxesRaw": 2610, + "FTotalTaxesRaw": 0, + "YDirectTotalTaxes": 0, + "WDirectTotalTaxes": 0, + "JDirectTotalTaxes": 560, + "FDirectTotalTaxes": 0, + "YDirectTotalTaxesRaw": 0, + "WDirectTotalTaxesRaw": 0, + "JDirectTotalTaxesRaw": 560, + "FDirectTotalTaxesRaw": 0, + "YRemainingSeats": 9, + "WRemainingSeats": 0, + "JRemainingSeats": 4, + "FRemainingSeats": 0, + "YRemainingSeatsRaw": 9, + "WRemainingSeatsRaw": 0, + "JRemainingSeatsRaw": 4, + "FRemainingSeatsRaw": 0, + "YDirectRemainingSeats": 0, + "WDirectRemainingSeats": 0, + "JDirectRemainingSeats": 1, + "FDirectRemainingSeats": 0, + "YDirectRemainingSeatsRaw": 0, + "WDirectRemainingSeatsRaw": 0, + "JDirectRemainingSeatsRaw": 1, + "FDirectRemainingSeatsRaw": 0, + "YAirlines": "LX", + "WAirlines": "", + "JAirlines": "B6, LX", + "FAirlines": "", + "YAirlinesRaw": "LX", + "WAirlinesRaw": "", + "JAirlinesRaw": "B6, LX", + "FAirlinesRaw": "", + "YDirectAirlines": "", + "WDirectAirlines": "", + "JDirectAirlines": "B6", + "FDirectAirlines": "", + "YDirectAirlinesRaw": "", + "WDirectAirlinesRaw": "", + "JDirectAirlinesRaw": "B6", + "FDirectAirlinesRaw": "", + "YDirect": false, + "WDirect": false, + "JDirect": true, + "FDirect": false, + "YDirectRaw": false, + "WDirectRaw": false, + "JDirectRaw": true, + "FDirectRaw": false, + "Source": "united", + "CreatedAt": "2025-10-25T08:05:30.588691Z", + "UpdatedAt": "2026-05-17T11:40:53.830936Z", + "AvailabilityTrips": null + } + ], + "count": 5, + "hasMore": true, + "moreURL": "/partnerapi/search?take=5&skip=5&origin_airport=JFK&destination_airport=LHR&cursor=1779021105&start_date=2026-08-15&end_date=2026-08-15", + "cursor": 1779021105 +} diff --git a/tests/fixtures/seats_aero/cached_search_with_trips.json b/tests/fixtures/seats_aero/cached_search_with_trips.json new file mode 100644 index 0000000..9f0fe09 --- /dev/null +++ b/tests/fixtures/seats_aero/cached_search_with_trips.json @@ -0,0 +1 @@ +{"data":[{"ID":"32tcOo2hM0ujj10omekYkCKEM0e","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","Route":{"ID":"2HmZ2Xah52QzyddIB7JAM28jKqs","OriginAirport":"JFK","OriginRegion":"North America","DestinationAirport":"LHR","DestinationRegion":"Europe","Distance":3446,"Source":"american"},"Date":"2026-08-15","ParsedDate":"2026-08-15T00:00:00Z","YAvailable":true,"WAvailable":true,"JAvailable":true,"FAvailable":true,"YAvailableRaw":true,"WAvailableRaw":true,"JAvailableRaw":true,"FAvailableRaw":true,"YMileageCost":"30000","WMileageCost":"40000","JMileageCost":"57500","FMileageCost":"98500","YMileageCostRaw":30000,"WMileageCostRaw":40000,"JMileageCostRaw":57500,"FMileageCostRaw":98500,"YDirectMileageCost":30000,"WDirectMileageCost":40000,"JDirectMileageCost":57500,"FDirectMileageCost":98500,"YDirectMileageCostRaw":30000,"WDirectMileageCostRaw":40000,"JDirectMileageCostRaw":57500,"FDirectMileageCostRaw":98500,"TaxesCurrency":"USD","YTotalTaxes":560,"WTotalTaxes":48350,"JTotalTaxes":73350,"FTotalTaxes":560,"YTotalTaxesRaw":560,"WTotalTaxesRaw":48350,"JTotalTaxesRaw":73350,"FTotalTaxesRaw":560,"YDirectTotalTaxes":560,"WDirectTotalTaxes":48350,"JDirectTotalTaxes":73350,"FDirectTotalTaxes":560,"YDirectTotalTaxesRaw":560,"WDirectTotalTaxesRaw":48350,"JDirectTotalTaxesRaw":73350,"FDirectTotalTaxesRaw":560,"YRemainingSeats":0,"WRemainingSeats":0,"JRemainingSeats":0,"FRemainingSeats":0,"YRemainingSeatsRaw":0,"WRemainingSeatsRaw":0,"JRemainingSeatsRaw":0,"FRemainingSeatsRaw":0,"YDirectRemainingSeats":0,"WDirectRemainingSeats":0,"JDirectRemainingSeats":0,"FDirectRemainingSeats":0,"YDirectRemainingSeatsRaw":0,"WDirectRemainingSeatsRaw":0,"JDirectRemainingSeatsRaw":0,"FDirectRemainingSeatsRaw":0,"YAirlines":"AA, BA","WAirlines":"AA, BA","JAirlines":"AA, BA","FAirlines":"AA","YAirlinesRaw":"AA, BA","WAirlinesRaw":"AA, BA","JAirlinesRaw":"AA, BA","FAirlinesRaw":"AA","YDirectAirlines":"AA","WDirectAirlines":"AA, BA","JDirectAirlines":"AA, BA","FDirectAirlines":"AA","YDirectAirlinesRaw":"AA","WDirectAirlinesRaw":"AA, BA","JDirectAirlinesRaw":"AA, BA","FDirectAirlinesRaw":"AA","YDirect":true,"WDirect":true,"JDirect":true,"FDirect":true,"YDirectRaw":true,"WDirectRaw":true,"JDirectRaw":true,"FDirectRaw":true,"Source":"american","CreatedAt":"2025-09-19T00:52:02.843771Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","AvailabilityTrips":[{"ID":"3DqThAfl3zAyU2N8VoyKUDVoISH","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1130,"Stops":1,"Carriers":"AA, BA","RemainingSeats":0,"MileageCost":57500,"TotalTaxes":73800,"TotalSegmentDistance":3882,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["IAD"],"Aircraft":["Embraer E175","Boeing 777"],"FlightNumbers":"AA4671, BA216","DepartsAt":"2026-08-15T06:30:00Z","Cabin":"business","ArrivesAt":"2026-08-16T06:20:00Z","CreatedAt":"2026-05-17T09:05:16.363002Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi2zf1KUHatQTlPXkaLwJnsq4","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1160,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Boeing 737 MAX 8","Boeing 777-300ER"],"FlightNumbers":"AA2643, AA730","DepartsAt":"2026-08-15T06:35:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T06:55:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi36jUNSfNOb96gY88EkTii8P","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1290,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Boeing 737 MAX 8","Boeing 777-200"],"FlightNumbers":"AA2643, AA732","DepartsAt":"2026-08-15T06:35:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T09:05:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi36zIZZD5MmaBlWDOgwTcsdv","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1290,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":116000,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Boeing 737 MAX 8","Boeing 777-200"],"FlightNumbers":"AA2643, AA732","DepartsAt":"2026-08-15T06:35:00Z","Cabin":"business","ArrivesAt":"2026-08-16T09:05:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi36h1bgPwDAbkC9GS2nyYpcO","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1264,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA475, AA20","DepartsAt":"2026-08-15T06:56:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T09:00:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi35qnkG3rt5zKzjrtIhLNi5U","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1264,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":114500,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA475, AA20","DepartsAt":"2026-08-15T06:56:00Z","Cabin":"business","ArrivesAt":"2026-08-16T09:00:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi30baNqOgzdkYJdwF4Htmxnk","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1264,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":124500,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA475, AA20","DepartsAt":"2026-08-15T06:56:00Z","Cabin":"first","ArrivesAt":"2026-08-16T09:00:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi372ZO9OtykVFxRw3dDRSPu2","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1075,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA1444, AA730","DepartsAt":"2026-08-15T08:00:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T06:55:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi36Yx4fjQrW0uNtH3B7sjwNe","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1430,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":1120,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA1444, AA734","DepartsAt":"2026-08-15T08:00:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T12:50:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi34JuEgIBnHYaDI6LYK8jNpA","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1205,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Boeing 737-800","Boeing 777-200"],"FlightNumbers":"AA1444, AA732","DepartsAt":"2026-08-15T08:00:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T09:05:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi32aw2WtjbqNnfot9llbVK1f","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1430,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":104000,"TotalTaxes":1120,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA1444, AA734","DepartsAt":"2026-08-15T08:00:00Z","Cabin":"business","ArrivesAt":"2026-08-16T12:50:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi34DDHMy5dobykZOow1dvkt1","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1205,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":115500,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Boeing 737-800","Boeing 777-200"],"FlightNumbers":"AA1444, AA732","DepartsAt":"2026-08-15T08:00:00Z","Cabin":"business","ArrivesAt":"2026-08-16T09:05:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi33ouDVxmZUAEUPkozrtHnJP","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1385,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":1120,"TotalSegmentDistance":4684,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["ORD"],"Aircraft":["Embraer E175","Boeing 787-9"],"FlightNumbers":"AA4464, AA98","DepartsAt":"2026-08-15T08:10:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T12:15:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi316cDsHtSZaPevIgvJ88Xi3","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1385,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":113000,"TotalTaxes":1120,"TotalSegmentDistance":4684,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["ORD"],"Aircraft":["Embraer E175","Boeing 787-9"],"FlightNumbers":"AA4464, AA98","DepartsAt":"2026-08-15T08:10:00Z","Cabin":"business","ArrivesAt":"2026-08-16T12:15:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Dmh0rwlOW4ygXP3hTXCs1uhR02","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1025,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 787-9"],"FlightNumbers":"AA2234, AA50","DepartsAt":"2026-08-15T08:15:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T06:20:00Z","CreatedAt":"2026-05-16T00:55:31.90029Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi30GEXouJfkRvekZ43F0Ajyz","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1185,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA2234, AA20","DepartsAt":"2026-08-15T08:15:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T09:00:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi33jdJYMFNPT7e7s3YwBC5oj","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1185,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":115500,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA2234, AA20","DepartsAt":"2026-08-15T08:15:00Z","Cabin":"business","ArrivesAt":"2026-08-16T09:00:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi33zAvwlFJubXnezJVxKkltV","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1185,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":125500,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA2234, AA20","DepartsAt":"2026-08-15T08:15:00Z","Cabin":"first","ArrivesAt":"2026-08-16T09:00:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Dmh0rQVBrXn7XIgYF4CxuYD82T","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1190,"Stops":1,"Carriers":"AA, BA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":31300,"TotalSegmentDistance":3882,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["IAD"],"Aircraft":["Embraer E175","Boeing 777"],"FlightNumbers":"AA4586, BA1290","DepartsAt":"2026-08-15T09:00:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T09:50:00Z","CreatedAt":"2026-05-16T00:55:31.90029Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3DqThAS9PgW75ALu9IcskjqEKFc","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":980,"Stops":1,"Carriers":"AA, BA","RemainingSeats":0,"MileageCost":57500,"TotalTaxes":73800,"TotalSegmentDistance":3882,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["IAD"],"Aircraft":["Embraer E175","Boeing 777"],"FlightNumbers":"AA4586, BA216","DepartsAt":"2026-08-15T09:00:00Z","Cabin":"business","ArrivesAt":"2026-08-16T06:20:00Z","CreatedAt":"2026-05-17T09:05:16.363002Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Dmh0tPiIr6vPTMETz9QTTeNreQ","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1190,"Stops":1,"Carriers":"AA, BA","RemainingSeats":0,"MileageCost":57500,"TotalTaxes":73800,"TotalSegmentDistance":3882,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["IAD"],"Aircraft":["Embraer E175","Boeing 777"],"FlightNumbers":"AA4586, BA1290","DepartsAt":"2026-08-15T09:00:00Z","Cabin":"business","ArrivesAt":"2026-08-16T09:50:00Z","CreatedAt":"2026-05-16T00:55:31.90029Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi35zqH0Jf5zJ7kSup6Kmfsfc","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":425,"Stops":0,"Carriers":"AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 777-300ER"],"FlightNumbers":"AA142","DepartsAt":"2026-08-15T09:35:00Z","Cabin":"economy","ArrivesAt":"2026-08-15T21:40:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi36AAIjAVHFGuC2atpxO6y3H","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":425,"Stops":0,"Carriers":"AA","RemainingSeats":0,"MileageCost":70000,"TotalTaxes":560,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 777-300ER"],"FlightNumbers":"AA142","DepartsAt":"2026-08-15T09:35:00Z","Cabin":"premium","ArrivesAt":"2026-08-15T21:40:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi32KEaDml9OPPkNwndaV8ist","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":425,"Stops":0,"Carriers":"AA","RemainingSeats":0,"MileageCost":85000,"TotalTaxes":560,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 777-300ER"],"FlightNumbers":"AA142","DepartsAt":"2026-08-15T09:35:00Z","Cabin":"business","ArrivesAt":"2026-08-15T21:40:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi34eQrccLknMQ342idphcWLD","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":425,"Stops":0,"Carriers":"AA","RemainingSeats":0,"MileageCost":98500,"TotalTaxes":560,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 777-300ER"],"FlightNumbers":"AA142","DepartsAt":"2026-08-15T09:35:00Z","Cabin":"first","ArrivesAt":"2026-08-15T21:40:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi35BRujeDgPrv1YMU3yg4hFl","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1265,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":4684,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["ORD"],"Aircraft":["Embraer E175","Boeing 787-9"],"FlightNumbers":"AA4725, AA98","DepartsAt":"2026-08-15T10:10:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T12:15:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi33CbF5aQYAj22I41c42QFjq","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1265,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":110500,"TotalTaxes":560,"TotalSegmentDistance":4684,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["ORD"],"Aircraft":["Embraer E175","Boeing 787-9"],"FlightNumbers":"AA4725, AA98","DepartsAt":"2026-08-15T10:10:00Z","Cabin":"business","ArrivesAt":"2026-08-16T12:15:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3DqThD26un56L7yCXLUZ54jINAC","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1061,"Stops":1,"Carriers":"AA, BA","RemainingSeats":0,"MileageCost":57500,"TotalTaxes":73800,"TotalSegmentDistance":4543,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CVG"],"Aircraft":["Embraer E175","Boeing 777"],"FlightNumbers":"AA4609, BA120","DepartsAt":"2026-08-15T11:24:00Z","Cabin":"business","ArrivesAt":"2026-08-16T10:05:00Z","CreatedAt":"2026-05-17T09:05:16.363002Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi35aZOXrd1eJXziY1n3oC2aG","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":990,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA1880, AA20","DepartsAt":"2026-08-15T11:30:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T09:00:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi2zNQEk9Vok11Vrf4m4RtvdU","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1180,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA1880, AA80","DepartsAt":"2026-08-15T11:30:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T12:10:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi34u3Vwffubv3MF3GHcFfPYK","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":990,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":119500,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA1880, AA20","DepartsAt":"2026-08-15T11:30:00Z","Cabin":"business","ArrivesAt":"2026-08-16T09:00:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi301kXd7bpejNBpjqZd8opKL","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1180,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":119500,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA1880, AA80","DepartsAt":"2026-08-15T11:30:00Z","Cabin":"business","ArrivesAt":"2026-08-16T12:10:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi346bi8JF7oeokiBd4nakOQk","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":990,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":129500,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA1880, AA20","DepartsAt":"2026-08-15T11:30:00Z","Cabin":"first","ArrivesAt":"2026-08-16T09:00:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi35btzJs6sZUErvTTncvEzoI","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1180,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":129500,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA1880, AA80","DepartsAt":"2026-08-15T11:30:00Z","Cabin":"first","ArrivesAt":"2026-08-16T12:10:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi30gZdI9uZbBf3WuNQd06NAS","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":969,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Boeing 737-800","Boeing 777-200"],"FlightNumbers":"AA493, AA732","DepartsAt":"2026-08-15T11:56:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T09:05:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi2zs7iXKqOwrmCMyt51CmLmg","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":839,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA493, AA730","DepartsAt":"2026-08-15T11:56:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T06:55:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi36f7riqD32llowuVdrKm4E3","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1194,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA493, AA734","DepartsAt":"2026-08-15T11:56:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T12:50:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi30K1hyYRoGVJBiEGwDZhgjq","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1194,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":113000,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA493, AA734","DepartsAt":"2026-08-15T11:56:00Z","Cabin":"business","ArrivesAt":"2026-08-16T12:50:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi32XRwLsmG6OWRulY8IS4qvg","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":969,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":125000,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Boeing 737-800","Boeing 777-200"],"FlightNumbers":"AA493, AA732","DepartsAt":"2026-08-15T11:56:00Z","Cabin":"business","ArrivesAt":"2026-08-16T09:05:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi32Ta3cDP0CWrV5JUfDlsg1R","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1055,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":7414,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["PHX"],"Aircraft":["Boeing 737 MAX 8","Boeing 777-200"],"FlightNumbers":"AA2829, AA194","DepartsAt":"2026-08-15T12:30:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T11:05:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi36CBvDDnzgKRAT70JeCrmsX","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1055,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":146500,"TotalTaxes":560,"TotalSegmentDistance":7414,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["PHX"],"Aircraft":["Boeing 737 MAX 8","Boeing 777-200"],"FlightNumbers":"AA2829, AA194","DepartsAt":"2026-08-15T12:30:00Z","Cabin":"business","ArrivesAt":"2026-08-16T11:05:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi31lRHrLe4V3RBxC6P2DM5YP","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":790,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":4294,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["RDU"],"Aircraft":["Embraer E175","Boeing 777-200"],"FlightNumbers":"AA4378, AA174","DepartsAt":"2026-08-15T13:30:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T07:40:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi34AlS4aM2koM6eTQJwJVnQQ","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":977,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA1953, AA80","DepartsAt":"2026-08-15T14:53:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T12:10:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi32TXkNwOCxw5NHSrUyBXNsD","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1112,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA1953, AA78","DepartsAt":"2026-08-15T14:53:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T14:25:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi35BJqIFgKYMTvBlanIsjoVI","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1112,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":138500,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA1953, AA78","DepartsAt":"2026-08-15T14:53:00Z","Cabin":"business","ArrivesAt":"2026-08-16T14:25:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi33PjeeWsBDI5ltADuxJY1VU","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":977,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":150000,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA1953, AA80","DepartsAt":"2026-08-15T14:53:00Z","Cabin":"business","ArrivesAt":"2026-08-16T12:10:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi303CnzdV1vOqqm16PIBTp7R","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":1112,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":155000,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA1953, AA78","DepartsAt":"2026-08-15T14:53:00Z","Cabin":"first","ArrivesAt":"2026-08-16T14:25:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi36vYLW0ZUHQ9opxk3EUHFTz","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":977,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":160000,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA1953, AA80","DepartsAt":"2026-08-15T14:53:00Z","Cabin":"first","ArrivesAt":"2026-08-16T12:10:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi32vCtlWYO03S6gzDMiifPz4","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":975,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":4684,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["ORD"],"Aircraft":["Boeing 737 MAX 8","Boeing 787-9"],"FlightNumbers":"AA788, AA98","DepartsAt":"2026-08-15T15:00:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T12:15:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Dj8i45OcnDGswvuUC2ND4ZZ7wx","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":870,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":4684,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["ORD"],"Aircraft":["Boeing 737 MAX 8","Boeing 787-9"],"FlightNumbers":"AA788, AA54","DepartsAt":"2026-08-15T15:00:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T10:30:00Z","CreatedAt":"2026-05-14T18:44:01.582757Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3DqThDtYKvnNf49ayyMNq7Qo0yH","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":915,"Stops":1,"Carriers":"AA, BA","RemainingSeats":0,"MileageCost":57500,"TotalTaxes":73800,"TotalSegmentDistance":4684,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["ORD"],"Aircraft":["Boeing 737 MAX 8","Boeing 777-300ER"],"FlightNumbers":"AA788, BA296","DepartsAt":"2026-08-15T15:00:00Z","Cabin":"business","ArrivesAt":"2026-08-16T11:15:00Z","CreatedAt":"2026-05-17T09:05:16.363002Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi307ffiLLgEVtvYS4pknXoW9","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":975,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":106000,"TotalTaxes":560,"TotalSegmentDistance":4684,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["ORD"],"Aircraft":["Boeing 737 MAX 8","Boeing 787-9"],"FlightNumbers":"AA788, AA98","DepartsAt":"2026-08-15T15:00:00Z","Cabin":"business","ArrivesAt":"2026-08-16T12:15:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi31YYZ4GjKbTXM0Tvi9KiTsa","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":967,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA377, AA734","DepartsAt":"2026-08-15T15:43:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T12:50:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi34rbk4N8RR40qxXvvqIArat","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":742,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Boeing 737-800","Boeing 777-200"],"FlightNumbers":"AA377, AA732","DepartsAt":"2026-08-15T15:43:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T09:05:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi32oU0TMvhQu7vEXd7i05q4c","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":967,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":106500,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA377, AA734","DepartsAt":"2026-08-15T15:43:00Z","Cabin":"business","ArrivesAt":"2026-08-16T12:50:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi356Zu1LA1LYCb3yPvWyEYUc","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":742,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":118500,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Boeing 737-800","Boeing 777-200"],"FlightNumbers":"AA377, AA732","DepartsAt":"2026-08-15T15:43:00Z","Cabin":"business","ArrivesAt":"2026-08-16T09:05:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi30f46S28Af0R0RX6UESwiQf","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":664,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Airbus A319","Boeing 777-200"],"FlightNumbers":"AA3120, AA732","DepartsAt":"2026-08-15T17:01:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T09:05:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi33eQOyP10CDe4jea7YvFVN1","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":889,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Airbus A319","Boeing 777-300ER"],"FlightNumbers":"AA3120, AA734","DepartsAt":"2026-08-15T17:01:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T12:50:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi31ZxfuXL7nIF5QJ9Ta0DIqO","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":889,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":116000,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Airbus A319","Boeing 777-300ER"],"FlightNumbers":"AA3120, AA734","DepartsAt":"2026-08-15T17:01:00Z","Cabin":"business","ArrivesAt":"2026-08-16T12:50:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi33V44s22Owqi8vgUubCUQ98","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":664,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":127500,"TotalTaxes":560,"TotalSegmentDistance":4526,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["CLT"],"Aircraft":["Airbus A319","Boeing 777-200"],"FlightNumbers":"AA3120, AA732","DepartsAt":"2026-08-15T17:01:00Z","Cabin":"business","ArrivesAt":"2026-08-16T09:05:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi36FtkkIYKp9UQw0SPsCdlhT","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":435,"Stops":0,"Carriers":"AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 787-9"],"FlightNumbers":"AA100","DepartsAt":"2026-08-15T18:10:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T06:25:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi2zxLrLQt5JkTPjUtihRU7nl","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":435,"Stops":0,"Carriers":"AA","RemainingSeats":0,"MileageCost":70000,"TotalTaxes":560,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 787-9"],"FlightNumbers":"AA100","DepartsAt":"2026-08-15T18:10:00Z","Cabin":"premium","ArrivesAt":"2026-08-16T06:25:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi364ztvYVb09Cua2e883u6ru","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":435,"Stops":0,"Carriers":"AA","RemainingSeats":0,"MileageCost":85000,"TotalTaxes":560,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 787-9"],"FlightNumbers":"AA100","DepartsAt":"2026-08-15T18:10:00Z","Cabin":"business","ArrivesAt":"2026-08-16T06:25:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi35gHhm4M8bFoQ3QW4YJyQhp","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":895,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA606, AA78","DepartsAt":"2026-08-15T18:30:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T14:25:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3DqTh8G4z3H8E7TMvOmAh9bjZM7","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":0,"MileageCost":40000,"TotalTaxes":48350,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 777"],"FlightNumbers":"BA112","DepartsAt":"2026-08-15T18:30:00Z","Cabin":"premium","ArrivesAt":"2026-08-16T06:40:00Z","CreatedAt":"2026-05-17T09:05:16.363002Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3DqThAQMliMpCF8umVZDR6gDpQZ","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":0,"MileageCost":57500,"TotalTaxes":73350,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 777"],"FlightNumbers":"BA112","DepartsAt":"2026-08-15T18:30:00Z","Cabin":"business","ArrivesAt":"2026-08-16T06:40:00Z","CreatedAt":"2026-05-17T09:05:16.363002Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi36Erlba3BTHD1lWrbwPfenW","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":895,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":97500,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA606, AA78","DepartsAt":"2026-08-15T18:30:00Z","Cabin":"business","ArrivesAt":"2026-08-16T14:25:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi2zxwhGc1VynCYO8dZh1R3fY","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":895,"Stops":1,"Carriers":"AA, AA","RemainingSeats":0,"MileageCost":107500,"TotalTaxes":560,"TotalSegmentDistance":6133,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["DFW"],"Aircraft":["Boeing 737-800","Boeing 777-300ER"],"FlightNumbers":"AA606, AA78","DepartsAt":"2026-08-15T18:30:00Z","Cabin":"first","ArrivesAt":"2026-08-16T14:25:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3DiWxD0WSnrrq1nLoJhUnl87Lyj","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":0,"MileageCost":40000,"TotalTaxes":48350,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 777-300ER"],"FlightNumbers":"BA174","DepartsAt":"2026-08-15T18:40:00Z","Cabin":"premium","ArrivesAt":"2026-08-16T06:50:00Z","CreatedAt":"2026-05-14T13:33:34.25262Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3DiWxALR6QEsLfHJApD1B2lr18E","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":0,"MileageCost":57500,"TotalTaxes":73350,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 777-300ER"],"FlightNumbers":"BA174","DepartsAt":"2026-08-15T18:40:00Z","Cabin":"business","ArrivesAt":"2026-08-16T06:50:00Z","CreatedAt":"2026-05-14T13:33:34.25262Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3DqThAQFeEdUbxCz7gYind0Q3hn","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":0,"MileageCost":57500,"TotalTaxes":73350,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 777-300ER"],"FlightNumbers":"BA180","DepartsAt":"2026-08-15T19:10:00Z","Cabin":"business","ArrivesAt":"2026-08-16T07:20:00Z","CreatedAt":"2026-05-17T09:05:16.363002Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi36jofnVSAf5KAy6BX6X5FYr","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":420,"Stops":0,"Carriers":"AA","RemainingSeats":0,"MileageCost":30000,"TotalTaxes":560,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 787-9"],"FlightNumbers":"AA106","DepartsAt":"2026-08-15T19:20:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T07:20:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi354Jr5WmD7YHjzeZGyi8MJV","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":420,"Stops":0,"Carriers":"AA","RemainingSeats":0,"MileageCost":70000,"TotalTaxes":560,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 787-9"],"FlightNumbers":"AA106","DepartsAt":"2026-08-15T19:20:00Z","Cabin":"premium","ArrivesAt":"2026-08-16T07:20:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3Coi34qO4SZBOs8qMY7nfdk1KHI","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":420,"Stops":0,"Carriers":"AA","RemainingSeats":0,"MileageCost":85000,"TotalTaxes":560,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 787-9"],"FlightNumbers":"AA106","DepartsAt":"2026-08-15T19:20:00Z","Cabin":"business","ArrivesAt":"2026-08-16T07:20:00Z","CreatedAt":"2026-04-24T19:15:20.122445Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3DiWxDUX5WMwwZQgfmWYTP99KaK","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":0,"MileageCost":57500,"TotalTaxes":73350,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 777-300ER"],"FlightNumbers":"BA116","DepartsAt":"2026-08-15T20:20:00Z","Cabin":"business","ArrivesAt":"2026-08-16T08:30:00Z","CreatedAt":"2026-05-14T13:33:34.25262Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3DqThDanC8guE0A20wJGqtq0Z6W","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":0,"MileageCost":40000,"TotalTaxes":48350,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 777-300ER"],"FlightNumbers":"BA114","DepartsAt":"2026-08-15T21:40:00Z","Cabin":"premium","ArrivesAt":"2026-08-16T09:50:00Z","CreatedAt":"2026-05-17T09:05:16.363002Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3DqThEOoOh1B7lP8b85tsVIrsPR","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":0,"MileageCost":57500,"TotalTaxes":73350,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 777-300ER"],"FlightNumbers":"BA114","DepartsAt":"2026-08-15T21:40:00Z","Cabin":"business","ArrivesAt":"2026-08-16T09:50:00Z","CreatedAt":"2026-05-17T09:05:16.363002Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3DiWxBKcUvaAzIz6j8bIADX6jlu","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":415,"Stops":0,"Carriers":"BA","RemainingSeats":0,"MileageCost":40000,"TotalTaxes":48350,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 787-10"],"FlightNumbers":"BA182","DepartsAt":"2026-08-15T22:40:00Z","Cabin":"premium","ArrivesAt":"2026-08-16T10:35:00Z","CreatedAt":"2026-05-14T13:33:34.25262Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"},{"ID":"3DiWxCyHL2LzcAA1pPId9Oe7WXd","RouteID":"2HmZ2Xah52QzyddIB7JAM28jKqs","AvailabilityID":"32tcOo2hM0ujj10omekYkCKEM0e","TotalDuration":415,"Stops":0,"Carriers":"BA","RemainingSeats":0,"MileageCost":57500,"TotalTaxes":73350,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 787-10"],"FlightNumbers":"BA182","DepartsAt":"2026-08-15T22:40:00Z","Cabin":"business","ArrivesAt":"2026-08-16T10:35:00Z","CreatedAt":"2026-05-14T13:33:34.25262Z","UpdatedAt":"2026-05-17T09:05:16.363002Z","Source":"american"}]},{"ID":"32tcNqMwZis7w54bsEBsQyzbwzc","RouteID":"2EQhenZqHPHOifrLTtauoXhys1G","Route":{"ID":"2EQhenZqHPHOifrLTtauoXhys1G","OriginAirport":"JFK","OriginRegion":"North America","DestinationAirport":"LHR","DestinationRegion":"Europe","Distance":3446,"Source":"virginatlantic"},"Date":"2026-08-15","ParsedDate":"2026-08-15T00:00:00Z","YAvailable":true,"WAvailable":true,"JAvailable":true,"FAvailable":false,"YAvailableRaw":true,"WAvailableRaw":true,"JAvailableRaw":true,"FAvailableRaw":false,"YMileageCost":"12000","WMileageCost":"110000","JMileageCost":"72000","FMileageCost":"0","YMileageCostRaw":12000,"WMileageCostRaw":110000,"JMileageCostRaw":72000,"FMileageCostRaw":0,"YDirectMileageCost":12000,"WDirectMileageCost":110000,"JDirectMileageCost":72000,"FDirectMileageCost":0,"YDirectMileageCostRaw":12000,"WDirectMileageCostRaw":110000,"JDirectMileageCostRaw":72000,"FDirectMileageCostRaw":0,"TaxesCurrency":"USD","YTotalTaxes":16380,"WTotalTaxes":29860,"JTotalTaxes":70310,"FTotalTaxes":0,"YTotalTaxesRaw":16380,"WTotalTaxesRaw":29860,"JTotalTaxesRaw":70310,"FTotalTaxesRaw":0,"YDirectTotalTaxes":16380,"WDirectTotalTaxes":29860,"JDirectTotalTaxes":70310,"FDirectTotalTaxes":0,"YDirectTotalTaxesRaw":16380,"WDirectTotalTaxesRaw":29860,"JDirectTotalTaxesRaw":70310,"FDirectTotalTaxesRaw":0,"YRemainingSeats":9,"WRemainingSeats":8,"JRemainingSeats":9,"FRemainingSeats":0,"YRemainingSeatsRaw":9,"WRemainingSeatsRaw":9,"JRemainingSeatsRaw":9,"FRemainingSeatsRaw":0,"YDirectRemainingSeats":9,"WDirectRemainingSeats":8,"JDirectRemainingSeats":9,"FDirectRemainingSeats":0,"YDirectRemainingSeatsRaw":9,"WDirectRemainingSeatsRaw":9,"JDirectRemainingSeatsRaw":9,"FDirectRemainingSeatsRaw":0,"YAirlines":"VS","WAirlines":"VS","JAirlines":"VS","FAirlines":"","YAirlinesRaw":"VS","WAirlinesRaw":"VS","JAirlinesRaw":"VS","FAirlinesRaw":"","YDirectAirlines":"VS","WDirectAirlines":"VS","JDirectAirlines":"VS","FDirectAirlines":"","YDirectAirlinesRaw":"VS","WDirectAirlinesRaw":"VS","JDirectAirlinesRaw":"VS","FDirectAirlinesRaw":"","YDirect":true,"WDirect":true,"JDirect":true,"FDirect":false,"YDirectRaw":true,"WDirectRaw":true,"JDirectRaw":true,"FDirectRaw":false,"Source":"virginatlantic","CreatedAt":"2025-09-19T00:51:54.944344Z","UpdatedAt":"2026-05-17T11:40:47.334458Z","AvailabilityTrips":[{"ID":"39V9sfAPYcqjFYKK2Jh38kjlMWU","RouteID":"2EQhenZqHPHOifrLTtauoXhys1G","AvailabilityID":"32tcNqMwZis7w54bsEBsQyzbwzc","TotalDuration":415,"Stops":0,"Carriers":"VS","RemainingSeats":9,"MileageCost":60000,"TotalTaxes":16380,"TaxesCurrency":"USD","TaxesCurrencySymbol":"$","TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 787-9"],"FareClasses":["A"],"FlightNumbers":"VS26","DepartsAt":"2026-08-15T08:15:00Z","Cabin":"economy","ArrivesAt":"2026-08-15T20:10:00Z","CreatedAt":"2026-02-11T00:42:58.67661Z","UpdatedAt":"2026-05-17T11:40:47.334458Z","Source":"virginatlantic"},{"ID":"39V9se1GZkKt8nsRfDSw0oEKNZM","RouteID":"2EQhenZqHPHOifrLTtauoXhys1G","AvailabilityID":"32tcNqMwZis7w54bsEBsQyzbwzc","TotalDuration":415,"Stops":0,"Carriers":"VS","RemainingSeats":9,"MileageCost":120000,"TotalTaxes":70310,"TaxesCurrency":"USD","TaxesCurrencySymbol":"$","TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 787-9"],"FareClasses":["G"],"FlightNumbers":"VS26","DepartsAt":"2026-08-15T08:15:00Z","Cabin":"business","ArrivesAt":"2026-08-15T20:10:00Z","CreatedAt":"2026-02-11T00:42:58.67661Z","UpdatedAt":"2026-05-17T11:40:47.334458Z","Source":"virginatlantic"},{"ID":"39V9sgvMUbMd5Hgn88jwJX9NX1V","RouteID":"2EQhenZqHPHOifrLTtauoXhys1G","AvailabilityID":"32tcNqMwZis7w54bsEBsQyzbwzc","TotalDuration":440,"Stops":0,"Carriers":"VS","RemainingSeats":9,"MileageCost":12000,"TotalTaxes":16380,"TaxesCurrency":"USD","TaxesCurrencySymbol":"$","TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Airbus A350-1000"],"FareClasses":["A"],"FlightNumbers":"VS4","DepartsAt":"2026-08-15T18:00:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T06:20:00Z","CreatedAt":"2026-02-11T00:42:58.67661Z","UpdatedAt":"2026-05-17T11:40:47.334458Z","Source":"virginatlantic"},{"ID":"39V9sdxTsEg3T8Auw0FdXPfcXOm","RouteID":"2EQhenZqHPHOifrLTtauoXhys1G","AvailabilityID":"32tcNqMwZis7w54bsEBsQyzbwzc","TotalDuration":440,"Stops":0,"Carriers":"VS","RemainingSeats":9,"MileageCost":84000,"TotalTaxes":70310,"TaxesCurrency":"USD","TaxesCurrencySymbol":"$","TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Airbus A350-1000"],"FareClasses":["G"],"FlightNumbers":"VS4","DepartsAt":"2026-08-15T18:00:00Z","Cabin":"business","ArrivesAt":"2026-08-16T06:20:00Z","CreatedAt":"2026-02-11T00:42:58.67661Z","UpdatedAt":"2026-05-17T11:40:47.334458Z","Source":"virginatlantic"},{"ID":"39V9sbHVPm9l0bDME6Ep2naffVX","RouteID":"2EQhenZqHPHOifrLTtauoXhys1G","AvailabilityID":"32tcNqMwZis7w54bsEBsQyzbwzc","TotalDuration":440,"Stops":0,"Carriers":"VS","RemainingSeats":8,"MileageCost":110000,"TotalTaxes":29860,"TaxesCurrency":"USD","TaxesCurrencySymbol":"$","TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Airbus A350-1000"],"FareClasses":["P"],"FlightNumbers":"VS4","DepartsAt":"2026-08-15T18:00:00Z","Cabin":"premium","ArrivesAt":"2026-08-16T06:20:00Z","CreatedAt":"2026-02-11T00:42:58.67661Z","UpdatedAt":"2026-05-17T11:40:47.334458Z","Source":"virginatlantic"},{"ID":"39V9saPOcsX2rZcYyJ5qU0Lh5w1","RouteID":"2EQhenZqHPHOifrLTtauoXhys1G","AvailabilityID":"32tcNqMwZis7w54bsEBsQyzbwzc","TotalDuration":435,"Stops":0,"Carriers":"VS","RemainingSeats":9,"MileageCost":30000,"TotalTaxes":16380,"TaxesCurrency":"USD","TaxesCurrencySymbol":"$","TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Airbus A350-1000"],"FareClasses":["A"],"FlightNumbers":"VS10","DepartsAt":"2026-08-15T18:30:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T06:45:00Z","CreatedAt":"2026-02-11T00:42:58.67661Z","UpdatedAt":"2026-05-17T11:40:47.334458Z","Source":"virginatlantic"},{"ID":"39V9sdU5p2buYeGa1ymSdiDYtXo","RouteID":"2EQhenZqHPHOifrLTtauoXhys1G","AvailabilityID":"32tcNqMwZis7w54bsEBsQyzbwzc","TotalDuration":435,"Stops":0,"Carriers":"VS","RemainingSeats":9,"MileageCost":72000,"TotalTaxes":70310,"TaxesCurrency":"USD","TaxesCurrencySymbol":"$","TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Airbus A350-1000"],"FareClasses":["G"],"FlightNumbers":"VS10","DepartsAt":"2026-08-15T18:30:00Z","Cabin":"business","ArrivesAt":"2026-08-16T06:45:00Z","CreatedAt":"2026-02-11T00:42:58.67661Z","UpdatedAt":"2026-05-17T11:40:47.334458Z","Source":"virginatlantic"},{"ID":"39V9sfO2n7bkEUYZTayWjN6PVDP","RouteID":"2EQhenZqHPHOifrLTtauoXhys1G","AvailabilityID":"32tcNqMwZis7w54bsEBsQyzbwzc","TotalDuration":435,"Stops":0,"Carriers":"VS","RemainingSeats":5,"MileageCost":120000,"TotalTaxes":29860,"TaxesCurrency":"USD","TaxesCurrencySymbol":"$","TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Airbus A350-1000"],"FareClasses":["P"],"FlightNumbers":"VS10","DepartsAt":"2026-08-15T18:30:00Z","Cabin":"premium","ArrivesAt":"2026-08-16T06:45:00Z","CreatedAt":"2026-02-11T00:42:58.67661Z","UpdatedAt":"2026-05-17T11:40:47.334458Z","Source":"virginatlantic"},{"ID":"39V9sdO58q2VcRCAx1avjqzu3HC","RouteID":"2EQhenZqHPHOifrLTtauoXhys1G","AvailabilityID":"32tcNqMwZis7w54bsEBsQyzbwzc","TotalDuration":435,"Stops":0,"Carriers":"VS","RemainingSeats":9,"MileageCost":46000,"TotalTaxes":16380,"TaxesCurrency":"USD","TaxesCurrencySymbol":"$","TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Airbus A350-1000"],"FareClasses":["A"],"FlightNumbers":"VS46","DepartsAt":"2026-08-15T19:00:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T07:15:00Z","CreatedAt":"2026-02-11T00:42:58.67661Z","UpdatedAt":"2026-05-17T11:40:47.334458Z","Source":"virginatlantic"},{"ID":"39V9sczkmUnMezvihlL5glgjieX","RouteID":"2EQhenZqHPHOifrLTtauoXhys1G","AvailabilityID":"32tcNqMwZis7w54bsEBsQyzbwzc","TotalDuration":425,"Stops":0,"Carriers":"VS","RemainingSeats":9,"MileageCost":60000,"TotalTaxes":16380,"TaxesCurrency":"USD","TaxesCurrencySymbol":"$","TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Airbus A350-1000"],"FareClasses":["A"],"FlightNumbers":"VS154","DepartsAt":"2026-08-15T20:45:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T08:50:00Z","CreatedAt":"2026-02-11T00:42:58.67661Z","UpdatedAt":"2026-05-17T11:40:47.334458Z","Source":"virginatlantic"},{"ID":"39V9sfYJWaa8ezmKx1Yx2fRxJxZ","RouteID":"2EQhenZqHPHOifrLTtauoXhys1G","AvailabilityID":"32tcNqMwZis7w54bsEBsQyzbwzc","TotalDuration":445,"Stops":0,"Carriers":"VS","RemainingSeats":9,"MileageCost":72000,"TotalTaxes":70310,"TaxesCurrency":"USD","TaxesCurrencySymbol":"$","TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Airbus A330-900neo"],"FareClasses":["G"],"FlightNumbers":"VS138","DepartsAt":"2026-08-15T23:40:00Z","Cabin":"business","ArrivesAt":"2026-08-16T12:05:00Z","CreatedAt":"2026-02-11T00:42:58.67661Z","UpdatedAt":"2026-05-17T11:40:47.334458Z","Source":"virginatlantic"},{"ID":"39V9sgt967KyUdqEfqfGZPJz0ht","RouteID":"2EQhenZqHPHOifrLTtauoXhys1G","AvailabilityID":"32tcNqMwZis7w54bsEBsQyzbwzc","TotalDuration":445,"Stops":0,"Carriers":"VS","RemainingSeats":7,"MileageCost":120000,"TotalTaxes":29860,"TaxesCurrency":"USD","TaxesCurrencySymbol":"$","TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Airbus A330-900neo"],"FareClasses":["P"],"FlightNumbers":"VS138","DepartsAt":"2026-08-15T23:40:00Z","Cabin":"premium","ArrivesAt":"2026-08-16T12:05:00Z","CreatedAt":"2026-02-11T00:42:58.67661Z","UpdatedAt":"2026-05-17T11:40:47.334458Z","Source":"virginatlantic"},{"ID":"39V9sdG1QmnsKMlE97hubQcOumb","RouteID":"2EQhenZqHPHOifrLTtauoXhys1G","AvailabilityID":"32tcNqMwZis7w54bsEBsQyzbwzc","TotalDuration":445,"Stops":0,"Carriers":"VS","RemainingSeats":9,"MileageCost":150000,"TotalTaxes":16380,"TaxesCurrency":"USD","TaxesCurrencySymbol":"$","TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Airbus A330-900neo"],"FareClasses":["A"],"FlightNumbers":"VS138","DepartsAt":"2026-08-15T23:40:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T12:05:00Z","CreatedAt":"2026-02-11T00:42:58.67661Z","UpdatedAt":"2026-05-17T11:40:47.334458Z","Source":"virginatlantic"}]},{"ID":"3CdQfcjqj4yH9sCoooZlog7phv7","RouteID":"3CdF5gV6GxS5pn5rzuRPeQ8yqmz","Route":{"ID":"3CdF5gV6GxS5pn5rzuRPeQ8yqmz","OriginAirport":"JFK","OriginRegion":"North America","DestinationAirport":"LHR","DestinationRegion":"Europe","Distance":3446,"Source":"british"},"Date":"2026-08-15","ParsedDate":"2026-08-15T00:00:00Z","YAvailable":true,"WAvailable":true,"JAvailable":true,"FAvailable":false,"YAvailableRaw":true,"WAvailableRaw":true,"JAvailableRaw":true,"FAvailableRaw":false,"YMileageCost":"33000","WMileageCost":"66000","JMileageCost":"99000","FMileageCost":"0","YMileageCostRaw":33000,"WMileageCostRaw":66000,"JMileageCostRaw":99000,"FMileageCostRaw":0,"YDirectMileageCost":33000,"WDirectMileageCost":66000,"JDirectMileageCost":99000,"FDirectMileageCost":0,"YDirectMileageCostRaw":33000,"WDirectMileageCostRaw":66000,"JDirectMileageCostRaw":99000,"FDirectMileageCostRaw":0,"TaxesCurrency":"USD","YTotalTaxes":0,"WTotalTaxes":0,"JTotalTaxes":0,"FTotalTaxes":0,"YTotalTaxesRaw":0,"WTotalTaxesRaw":0,"JTotalTaxesRaw":0,"FTotalTaxesRaw":0,"YDirectTotalTaxes":0,"WDirectTotalTaxes":0,"JDirectTotalTaxes":0,"FDirectTotalTaxes":0,"YDirectTotalTaxesRaw":0,"WDirectTotalTaxesRaw":0,"JDirectTotalTaxesRaw":0,"FDirectTotalTaxesRaw":0,"YRemainingSeats":5,"WRemainingSeats":9,"JRemainingSeats":9,"FRemainingSeats":0,"YRemainingSeatsRaw":5,"WRemainingSeatsRaw":9,"JRemainingSeatsRaw":9,"FRemainingSeatsRaw":0,"YDirectRemainingSeats":5,"WDirectRemainingSeats":9,"JDirectRemainingSeats":9,"FDirectRemainingSeats":0,"YDirectRemainingSeatsRaw":5,"WDirectRemainingSeatsRaw":9,"JDirectRemainingSeatsRaw":9,"FDirectRemainingSeatsRaw":0,"YAirlines":"BA","WAirlines":"BA","JAirlines":"BA","FAirlines":"","YAirlinesRaw":"BA","WAirlinesRaw":"BA","JAirlinesRaw":"BA","FAirlinesRaw":"","YDirectAirlines":"BA","WDirectAirlines":"BA","JDirectAirlines":"BA","FDirectAirlines":"","YDirectAirlinesRaw":"BA","WDirectAirlinesRaw":"BA","JDirectAirlinesRaw":"BA","FDirectAirlinesRaw":"","YDirect":true,"WDirect":true,"JDirect":true,"FDirect":false,"YDirectRaw":true,"WDirectRaw":true,"JDirectRaw":true,"FDirectRaw":false,"Source":"british","CreatedAt":"2026-04-20T19:22:29.314809Z","UpdatedAt":"2026-05-12T09:49:27.064025Z","AvailabilityTrips":[{"ID":"3CgmSAAnOYamwOBmNkK2kks5c0j","RouteID":"3CdF5gV6GxS5pn5rzuRPeQ8yqmz","AvailabilityID":"3CdQfcjqj4yH9sCoooZlog7phv7","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":1,"MileageCost":33000,"TotalTaxes":0,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","FareClasses":["X"],"FlightNumbers":"BA112","DepartsAt":"2026-08-15T18:30:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T06:40:00Z","CreatedAt":"2026-04-21T23:53:04.892514Z","UpdatedAt":"2026-05-12T09:49:27.064025Z","Source":"british"},{"ID":"3ClFv8UhA9ngOzbiqjdgFEJgX6Z","RouteID":"3CdF5gV6GxS5pn5rzuRPeQ8yqmz","AvailabilityID":"3CdQfcjqj4yH9sCoooZlog7phv7","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":9,"MileageCost":66000,"TotalTaxes":0,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","FareClasses":["P"],"FlightNumbers":"BA112","DepartsAt":"2026-08-15T18:30:00Z","Cabin":"premium","ArrivesAt":"2026-08-16T06:40:00Z","CreatedAt":"2026-04-23T13:54:37.176553Z","UpdatedAt":"2026-05-12T09:49:27.064025Z","Source":"british"},{"ID":"3DcRS0NoYOyzEnagsQuLpuf4XWX","RouteID":"3CdF5gV6GxS5pn5rzuRPeQ8yqmz","AvailabilityID":"3CdQfcjqj4yH9sCoooZlog7phv7","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":6,"MileageCost":99000,"TotalTaxes":0,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","FareClasses":["U"],"FlightNumbers":"BA112","DepartsAt":"2026-08-15T18:30:00Z","Cabin":"business","ArrivesAt":"2026-08-16T06:40:00Z","CreatedAt":"2026-05-12T09:49:27.064025Z","UpdatedAt":"2026-05-12T09:49:27.064025Z","Source":"british"},{"ID":"3CgmSI0Hygmn7bIichDoATzteYc","RouteID":"3CdF5gV6GxS5pn5rzuRPeQ8yqmz","AvailabilityID":"3CdQfcjqj4yH9sCoooZlog7phv7","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":5,"MileageCost":33000,"TotalTaxes":0,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","FareClasses":["X"],"FlightNumbers":"BA174","DepartsAt":"2026-08-15T18:40:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T06:50:00Z","CreatedAt":"2026-04-21T23:53:04.892514Z","UpdatedAt":"2026-05-12T09:49:27.064025Z","Source":"british"},{"ID":"3ClFv9EHdj2b9ptcOExtDnE8ZCO","RouteID":"3CdF5gV6GxS5pn5rzuRPeQ8yqmz","AvailabilityID":"3CdQfcjqj4yH9sCoooZlog7phv7","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":9,"MileageCost":66000,"TotalTaxes":0,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","FareClasses":["P"],"FlightNumbers":"BA174","DepartsAt":"2026-08-15T18:40:00Z","Cabin":"premium","ArrivesAt":"2026-08-16T06:50:00Z","CreatedAt":"2026-04-23T13:54:37.176553Z","UpdatedAt":"2026-05-12T09:49:27.064025Z","Source":"british"},{"ID":"3Ce0MnwNZmk8yA8VL9UmkciPzXz","RouteID":"3CdF5gV6GxS5pn5rzuRPeQ8yqmz","AvailabilityID":"3CdQfcjqj4yH9sCoooZlog7phv7","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":9,"MileageCost":99000,"TotalTaxes":0,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","FareClasses":["U"],"FlightNumbers":"BA174","DepartsAt":"2026-08-15T18:40:00Z","Cabin":"business","ArrivesAt":"2026-08-16T06:50:00Z","CreatedAt":"2026-04-21T00:18:02.777565Z","UpdatedAt":"2026-05-12T09:49:27.064025Z","Source":"british"},{"ID":"3CoVVFkmgvl3e1gNkk2NWebOSaa","RouteID":"3CdF5gV6GxS5pn5rzuRPeQ8yqmz","AvailabilityID":"3CdQfcjqj4yH9sCoooZlog7phv7","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":1,"MileageCost":33000,"TotalTaxes":0,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","FareClasses":["X"],"FlightNumbers":"BA180","DepartsAt":"2026-08-15T19:10:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T07:20:00Z","CreatedAt":"2026-04-24T17:32:11.761982Z","UpdatedAt":"2026-05-12T09:49:27.064025Z","Source":"british"},{"ID":"3ClFvEvWUu6b0eEkZI1BWrbxGCR","RouteID":"3CdF5gV6GxS5pn5rzuRPeQ8yqmz","AvailabilityID":"3CdQfcjqj4yH9sCoooZlog7phv7","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":2,"MileageCost":66000,"TotalTaxes":0,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","FareClasses":["P"],"FlightNumbers":"BA180","DepartsAt":"2026-08-15T19:10:00Z","Cabin":"premium","ArrivesAt":"2026-08-16T07:20:00Z","CreatedAt":"2026-04-23T13:54:37.176553Z","UpdatedAt":"2026-05-12T09:49:27.064025Z","Source":"british"},{"ID":"3Ce0Ml004oiOnGWPcBeNqouiZSf","RouteID":"3CdF5gV6GxS5pn5rzuRPeQ8yqmz","AvailabilityID":"3CdQfcjqj4yH9sCoooZlog7phv7","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":9,"MileageCost":99000,"TotalTaxes":0,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","FareClasses":["U"],"FlightNumbers":"BA180","DepartsAt":"2026-08-15T19:10:00Z","Cabin":"business","ArrivesAt":"2026-08-16T07:20:00Z","CreatedAt":"2026-04-21T00:18:02.777565Z","UpdatedAt":"2026-05-12T09:49:27.064025Z","Source":"british"},{"ID":"3Ce0MoIq5oB7wCM4oLpxzdEJghj","RouteID":"3CdF5gV6GxS5pn5rzuRPeQ8yqmz","AvailabilityID":"3CdQfcjqj4yH9sCoooZlog7phv7","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":9,"MileageCost":99000,"TotalTaxes":0,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","FareClasses":["U"],"FlightNumbers":"BA116","DepartsAt":"2026-08-15T20:20:00Z","Cabin":"business","ArrivesAt":"2026-08-16T08:30:00Z","CreatedAt":"2026-04-21T00:18:02.777565Z","UpdatedAt":"2026-05-12T09:49:27.064025Z","Source":"british"},{"ID":"3DNix8XdADCfzbyLRgWr6QvD6Rm","RouteID":"3CdF5gV6GxS5pn5rzuRPeQ8yqmz","AvailabilityID":"3CdQfcjqj4yH9sCoooZlog7phv7","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":9,"MileageCost":66000,"TotalTaxes":0,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","FareClasses":["P"],"FlightNumbers":"BA114","DepartsAt":"2026-08-15T21:40:00Z","Cabin":"premium","ArrivesAt":"2026-08-16T09:50:00Z","CreatedAt":"2026-05-07T04:46:11.258346Z","UpdatedAt":"2026-05-12T09:49:27.064025Z","Source":"british"},{"ID":"3Ce0MpqkZcxNl1w3ybWhfhDTwql","RouteID":"3CdF5gV6GxS5pn5rzuRPeQ8yqmz","AvailabilityID":"3CdQfcjqj4yH9sCoooZlog7phv7","TotalDuration":430,"Stops":0,"Carriers":"BA","RemainingSeats":9,"MileageCost":99000,"TotalTaxes":0,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","FareClasses":["U"],"FlightNumbers":"BA114","DepartsAt":"2026-08-15T21:40:00Z","Cabin":"business","ArrivesAt":"2026-08-16T09:50:00Z","CreatedAt":"2026-04-21T00:18:02.777565Z","UpdatedAt":"2026-05-12T09:49:27.064025Z","Source":"british"},{"ID":"3ClFvExr1dW7HjJqhsv78NXiOyA","RouteID":"3CdF5gV6GxS5pn5rzuRPeQ8yqmz","AvailabilityID":"3CdQfcjqj4yH9sCoooZlog7phv7","TotalDuration":415,"Stops":0,"Carriers":"BA","RemainingSeats":4,"MileageCost":66000,"TotalTaxes":0,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","FareClasses":["P"],"FlightNumbers":"BA182","DepartsAt":"2026-08-15T22:40:00Z","Cabin":"premium","ArrivesAt":"2026-08-16T10:35:00Z","CreatedAt":"2026-04-23T13:54:37.176553Z","UpdatedAt":"2026-05-12T09:49:27.064025Z","Source":"british"},{"ID":"3CoVVG0eaNCqva58MhFS0JgF7GZ","RouteID":"3CdF5gV6GxS5pn5rzuRPeQ8yqmz","AvailabilityID":"3CdQfcjqj4yH9sCoooZlog7phv7","TotalDuration":415,"Stops":0,"Carriers":"BA","RemainingSeats":5,"MileageCost":99000,"TotalTaxes":0,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","FareClasses":["U"],"FlightNumbers":"BA182","DepartsAt":"2026-08-15T22:40:00Z","Cabin":"business","ArrivesAt":"2026-08-16T10:35:00Z","CreatedAt":"2026-04-24T17:32:11.761982Z","UpdatedAt":"2026-05-12T09:49:27.064025Z","Source":"british"}]},{"ID":"31YYRX4HZKx7ABZqZrLiZBZUqy4","RouteID":"2zQJdmmVGkSBaCRy8dcc6VFcjUp","Route":{"ID":"2zQJdmmVGkSBaCRy8dcc6VFcjUp","OriginAirport":"JFK","OriginRegion":"North America","DestinationAirport":"LHR","DestinationRegion":"Europe","Distance":3446,"Source":"finnair"},"Date":"2026-08-15","ParsedDate":"2026-08-15T00:00:00Z","YAvailable":false,"WAvailable":false,"JAvailable":true,"FAvailable":false,"YAvailableRaw":false,"WAvailableRaw":false,"JAvailableRaw":true,"FAvailableRaw":false,"YMileageCost":"0","WMileageCost":"0","JMileageCost":"99000","FMileageCost":"0","YMileageCostRaw":0,"WMileageCostRaw":0,"JMileageCostRaw":99000,"FMileageCostRaw":0,"YDirectMileageCost":0,"WDirectMileageCost":0,"JDirectMileageCost":99000,"FDirectMileageCost":0,"YDirectMileageCostRaw":0,"WDirectMileageCostRaw":0,"JDirectMileageCostRaw":99000,"FDirectMileageCostRaw":0,"TaxesCurrency":"USD","YTotalTaxes":0,"WTotalTaxes":0,"JTotalTaxes":140850,"FTotalTaxes":0,"YTotalTaxesRaw":0,"WTotalTaxesRaw":0,"JTotalTaxesRaw":140850,"FTotalTaxesRaw":0,"YDirectTotalTaxes":0,"WDirectTotalTaxes":0,"JDirectTotalTaxes":140850,"FDirectTotalTaxes":0,"YDirectTotalTaxesRaw":0,"WDirectTotalTaxesRaw":0,"JDirectTotalTaxesRaw":140850,"FDirectTotalTaxesRaw":0,"YRemainingSeats":0,"WRemainingSeats":0,"JRemainingSeats":6,"FRemainingSeats":0,"YRemainingSeatsRaw":0,"WRemainingSeatsRaw":0,"JRemainingSeatsRaw":6,"FRemainingSeatsRaw":0,"YDirectRemainingSeats":0,"WDirectRemainingSeats":0,"JDirectRemainingSeats":6,"FDirectRemainingSeats":0,"YDirectRemainingSeatsRaw":0,"WDirectRemainingSeatsRaw":0,"JDirectRemainingSeatsRaw":6,"FDirectRemainingSeatsRaw":0,"YAirlines":"","WAirlines":"","JAirlines":"BA","FAirlines":"","YAirlinesRaw":"","WAirlinesRaw":"","JAirlinesRaw":"BA","FAirlinesRaw":"","YDirectAirlines":"","WDirectAirlines":"","JDirectAirlines":"BA","FDirectAirlines":"","YDirectAirlinesRaw":"","WDirectAirlinesRaw":"","JDirectAirlinesRaw":"BA","FDirectAirlinesRaw":"","YDirect":false,"WDirect":false,"JDirect":true,"FDirect":false,"YDirectRaw":false,"WDirectRaw":false,"JDirectRaw":true,"FDirectRaw":false,"Source":"finnair","CreatedAt":"2025-08-20T15:07:44.742647Z","UpdatedAt":"2026-05-17T10:16:12.6237Z","AvailabilityTrips":[{"ID":"3DqLAskKh5pR83Xsr75JfPWGOMC","RouteID":"2zQJdmmVGkSBaCRy8dcc6VFcjUp","AvailabilityID":"31YYRX4HZKx7ABZqZrLiZBZUqy4","TotalDuration":415,"Stops":0,"Carriers":"BA","RemainingSeats":6,"MileageCost":99000,"TotalTaxes":140850,"TaxesCurrency":"USD","TaxesCurrencySymbol":"$","TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Boeing 787-10"],"FareClasses":["U"],"FlightNumbers":"BA182","DepartsAt":"2026-08-15T22:40:00Z","Cabin":"business","ArrivesAt":"2026-08-16T10:35:00Z","CreatedAt":"2026-05-17T07:55:12.31609Z","UpdatedAt":"2026-05-17T10:16:12.6237Z","Source":"finnair"}]},{"ID":"34YB3UIdpEJIFp0bzxhKwpJvKmv","RouteID":"34WkUFcgLONHYJPCrpKcB6Aye5S","Route":{"ID":"34WkUFcgLONHYJPCrpKcB6Aye5S","OriginAirport":"JFK","OriginRegion":"North America","DestinationAirport":"LHR","DestinationRegion":"Europe","Distance":3446,"Source":"united"},"Date":"2026-08-15","ParsedDate":"2026-08-15T00:00:00Z","YAvailable":true,"WAvailable":false,"JAvailable":true,"FAvailable":false,"YAvailableRaw":true,"WAvailableRaw":false,"JAvailableRaw":true,"FAvailableRaw":false,"YMileageCost":"44000","WMileageCost":"0","JMileageCost":"88000","FMileageCost":"0","YMileageCostRaw":44000,"WMileageCostRaw":0,"JMileageCostRaw":88000,"FMileageCostRaw":0,"YDirectMileageCost":0,"WDirectMileageCost":0,"JDirectMileageCost":115000,"FDirectMileageCost":0,"YDirectMileageCostRaw":0,"WDirectMileageCostRaw":0,"JDirectMileageCostRaw":115000,"FDirectMileageCostRaw":0,"TaxesCurrency":"USD","YTotalTaxes":4029,"WTotalTaxes":0,"JTotalTaxes":2610,"FTotalTaxes":0,"YTotalTaxesRaw":4029,"WTotalTaxesRaw":0,"JTotalTaxesRaw":2610,"FTotalTaxesRaw":0,"YDirectTotalTaxes":0,"WDirectTotalTaxes":0,"JDirectTotalTaxes":560,"FDirectTotalTaxes":0,"YDirectTotalTaxesRaw":0,"WDirectTotalTaxesRaw":0,"JDirectTotalTaxesRaw":560,"FDirectTotalTaxesRaw":0,"YRemainingSeats":9,"WRemainingSeats":0,"JRemainingSeats":4,"FRemainingSeats":0,"YRemainingSeatsRaw":9,"WRemainingSeatsRaw":0,"JRemainingSeatsRaw":4,"FRemainingSeatsRaw":0,"YDirectRemainingSeats":0,"WDirectRemainingSeats":0,"JDirectRemainingSeats":1,"FDirectRemainingSeats":0,"YDirectRemainingSeatsRaw":0,"WDirectRemainingSeatsRaw":0,"JDirectRemainingSeatsRaw":1,"FDirectRemainingSeatsRaw":0,"YAirlines":"LX","WAirlines":"","JAirlines":"B6, LX","FAirlines":"","YAirlinesRaw":"LX","WAirlinesRaw":"","JAirlinesRaw":"B6, LX","FAirlinesRaw":"","YDirectAirlines":"","WDirectAirlines":"","JDirectAirlines":"B6","FDirectAirlines":"","YDirectAirlinesRaw":"","WDirectAirlinesRaw":"","JDirectAirlinesRaw":"B6","FDirectAirlinesRaw":"","YDirect":false,"WDirect":false,"JDirect":true,"FDirect":false,"YDirectRaw":false,"WDirectRaw":false,"JDirectRaw":true,"FDirectRaw":false,"Source":"united","CreatedAt":"2025-10-25T08:05:30.588691Z","UpdatedAt":"2026-05-17T11:40:53.830936Z","AvailabilityTrips":[{"ID":"3CTOIVrgwy1QsjVjbMhXcKmWmIH","RouteID":"34WkUFcgLONHYJPCrpKcB6Aye5S","AvailabilityID":"34YB3UIdpEJIFp0bzxhKwpJvKmv","TotalDuration":420,"Stops":0,"Carriers":"B6","RemainingSeats":1,"MileageCost":115000,"TotalTaxes":560,"TaxesCurrency":"USD","TaxesCurrencySymbol":"$","AllianceCost":82560,"TotalSegmentDistance":3446,"OriginAirport":"JFK","DestinationAirport":"LHR","Aircraft":["Airbus A321neo"],"FareClasses":["A"],"FlightNumbers":"B61107","DepartsAt":"2026-08-15T08:45:00Z","Cabin":"business","ArrivesAt":"2026-08-15T20:45:00Z","CreatedAt":"2026-04-17T06:06:53.176988Z","UpdatedAt":"2026-05-17T11:40:53.830936Z","Source":"united"},{"ID":"3DbTANkguQWvgvPo96vGeYCLlfq","RouteID":"34WkUFcgLONHYJPCrpKcB6Aye5S","AvailabilityID":"34YB3UIdpEJIFp0bzxhKwpJvKmv","TotalDuration":1085,"Stops":1,"Carriers":"LX, LX","RemainingSeats":9,"MileageCost":44000,"TotalTaxes":4029,"TaxesCurrency":"USD","TaxesCurrencySymbol":"$","AllianceCost":18776,"TotalSegmentDistance":4326,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["GVA"],"Aircraft":["Airbus A330-300","Airbus A220"],"FareClasses":["X","X"],"FlightNumbers":"LX23, LX356","DepartsAt":"2026-08-15T19:25:00Z","Cabin":"economy","ArrivesAt":"2026-08-16T18:30:00Z","CreatedAt":"2026-05-12T01:33:44.375003Z","UpdatedAt":"2026-05-17T11:40:53.830936Z","Source":"united"},{"ID":"3DbTAIoyGReK4QjItIQRTdssuGu","RouteID":"34WkUFcgLONHYJPCrpKcB6Aye5S","AvailabilityID":"34YB3UIdpEJIFp0bzxhKwpJvKmv","TotalDuration":1070,"Stops":1,"Carriers":"LX, LX","RemainingSeats":4,"MileageCost":88000,"TotalTaxes":2610,"TaxesCurrency":"USD","TaxesCurrencySymbol":"$","AllianceCost":27942,"TotalSegmentDistance":4416,"OriginAirport":"JFK","DestinationAirport":"LHR","Connections":["ZRH"],"Aircraft":["Airbus A330-300","Airbus A320neo"],"FareClasses":["I","I"],"FlightNumbers":"LX15, LX338","DepartsAt":"2026-08-15T20:30:00Z","Cabin":"business","ArrivesAt":"2026-08-16T19:20:00Z","CreatedAt":"2026-05-12T01:33:44.375003Z","UpdatedAt":"2026-05-17T11:40:53.830936Z","Source":"united"}]}],"count":5,"hasMore":true,"moreURL":"/partnerapi/search?take=5\u0026skip=5\u0026origin_airport=JFK\u0026destination_airport=LHR\u0026cursor=1779021283\u0026start_date=2026-08-15\u0026end_date=2026-08-15\u0026include_trips=true","cursor":1779021283} \ No newline at end of file diff --git a/tests/seats_aero/__init__.py b/tests/seats_aero/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/seats_aero/test_auth.py b/tests/seats_aero/test_auth.py new file mode 100644 index 0000000..bd18e5f --- /dev/null +++ b/tests/seats_aero/test_auth.py @@ -0,0 +1,111 @@ +# pyright: reportPrivateUsage=false, reportUnusedFunction=false +"""Tests for seats_aero/auth.py key resolution + persistence. + +Verifies the precedence env > file > None and the 0600 perms on saved files. +Env-var manipulation uses monkeypatch so the user's real key isn't disturbed. +""" + +from __future__ import annotations + +import json +import stat +from typing import TYPE_CHECKING + +import pytest + +from flight_cli.providers.seats_aero import auth + +if TYPE_CHECKING: + from pathlib import Path + + from pytest import MonkeyPatch + + +@pytest.fixture +def _isolated_key_path(tmp_path: Path, monkeypatch: MonkeyPatch) -> Path: + """Redirect KEY_PATH + clear env var so tests don't see the user's real key.""" + monkeypatch.setattr(auth, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(auth, "KEY_PATH", tmp_path / "seats.json") + monkeypatch.delenv(auth.API_KEY_ENV, raising=False) + return tmp_path / "seats.json" + + +def test_load_returns_none_when_unconfigured(_isolated_key_path: Path) -> None: + assert auth.load_key() is None + assert auth.is_configured() is False + + +def test_load_reads_env_var(_isolated_key_path: Path, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv(auth.API_KEY_ENV, "pro_envvalue") + assert auth.load_key() == "pro_envvalue" + assert auth.is_configured() is True + + +def test_env_var_wins_over_file(_isolated_key_path: Path, monkeypatch: MonkeyPatch) -> None: + auth.save_key("pro_filevalue") + monkeypatch.setenv(auth.API_KEY_ENV, "pro_envvalue") + assert auth.load_key() == "pro_envvalue" + + +def test_file_used_when_env_unset(_isolated_key_path: Path) -> None: + auth.save_key("pro_filevalue") + assert auth.load_key() == "pro_filevalue" + + +def test_save_key_writes_0600_perms(_isolated_key_path: Path) -> None: + auth.save_key("pro_secret") + perms = stat.S_IMODE(_isolated_key_path.stat().st_mode) + # On macOS, umask may already restrict but the explicit chmod 0o600 should win. + assert perms == 0o600, f"expected 0o600, got {oct(perms)}" + + +def test_save_key_overwrites_existing(_isolated_key_path: Path) -> None: + auth.save_key("pro_old") + auth.save_key("pro_new") + assert auth.load_key() == "pro_new" + + +def test_save_key_strips_whitespace(_isolated_key_path: Path) -> None: + auth.save_key(" pro_padded ") + # Stored value is stripped + stored = json.loads(_isolated_key_path.read_text()) + assert stored["api_key"] == "pro_padded" + + +def test_clear_key_removes_file(_isolated_key_path: Path) -> None: + auth.save_key("pro_secret") + assert auth.clear_key() is True + assert not _isolated_key_path.exists() + assert auth.clear_key() is False # idempotent + + +def test_clear_key_doesnt_touch_env(_isolated_key_path: Path, monkeypatch: MonkeyPatch) -> None: + """clear_key only deletes the file; env var still resolves.""" + monkeypatch.setenv(auth.API_KEY_ENV, "pro_envvalue") + auth.save_key("pro_filevalue") + auth.clear_key() + assert auth.load_key() == "pro_envvalue" + + +def test_get_key_raises_when_unconfigured(_isolated_key_path: Path) -> None: + with pytest.raises(auth.SeatsAuthError, match=r"No Seats\.aero API key"): + auth.get_key() + + +def test_get_key_returns_value_when_configured( + _isolated_key_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.setenv(auth.API_KEY_ENV, "pro_envvalue") + assert auth.get_key() == "pro_envvalue" + + +def test_malformed_file_returns_none(_isolated_key_path: Path) -> None: + """A corrupt file should not crash — just behave as if unconfigured.""" + _isolated_key_path.write_text("not json") + assert auth.load_key() is None + + +def test_file_with_no_api_key_returns_none(_isolated_key_path: Path) -> None: + """Valid JSON without the api_key field falls through to None.""" + _isolated_key_path.write_text(json.dumps({"other_field": "x"})) + assert auth.load_key() is None diff --git a/tests/seats_aero/test_models.py b/tests/seats_aero/test_models.py new file mode 100644 index 0000000..a0414e7 --- /dev/null +++ b/tests/seats_aero/test_models.py @@ -0,0 +1,99 @@ +# pyright: reportPrivateUsage=false +"""Golden-file parser tests for the seats.aero /partnerapi/search response. + +The fixtures at tests/fixtures/seats_aero/ are real responses captured against +the live API for JFK→LHR on 2026-08-15: + - cached_search_no_trips.json: take=5, no include_trips + - cached_search_with_trips.json: take=5, include_trips=true (79 trips on + the first availability item) + +These pin the parser against the actual upstream shape so seats.aero adding +fields (or our pydantic shapes drifting) becomes a loud test failure rather +than a silent runtime crash mid-search. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from flight_cli.providers.seats_aero.models import CachedSearchResponse + +FIXTURES = Path(__file__).parent.parent / "fixtures" / "seats_aero" + + +def _load(name: str) -> CachedSearchResponse: + return CachedSearchResponse.model_validate(json.loads((FIXTURES / name).read_text())) + + +def test_no_trips_top_level_shape() -> None: + resp = _load("cached_search_no_trips.json") + assert resp.count == 5 + assert resp.hasMore is True + assert resp.cursor is not None + assert len(resp.data) == 5 + + +def test_no_trips_per_item_shape() -> None: + resp = _load("cached_search_no_trips.json") + item = resp.data[0] + assert item.Route.OriginAirport == "JFK" + assert item.Route.DestinationAirport == "LHR" + assert item.Date == "2026-08-15" + assert item.Source in {"american", "british", "finnair", "united", "virginatlantic"} + + +def test_no_trips_availability_columns() -> None: + """When include_trips is omitted, AvailabilityTrips is empty.""" + resp = _load("cached_search_no_trips.json") + item = resp.data[0] + assert item.AvailabilityTrips == [] + # Per-cabin availability flags should be booleans (not strings/None). + assert isinstance(item.YAvailable, bool) + assert isinstance(item.JAvailable, bool) + + +def test_with_trips_populates_availability_trips() -> None: + resp = _load("cached_search_with_trips.json") + item = resp.data[0] + assert len(item.AvailabilityTrips) > 0 + + +def test_with_trips_trip_fields() -> None: + resp = _load("cached_search_with_trips.json") + trips = resp.data[0].AvailabilityTrips + t = trips[0] + # FlightNumbers is a comma-joined string in segment order. + assert "," in t.FlightNumbers or "-" not in t.FlightNumbers + # DepartsAt is ISO 8601 UTC. + assert t.DepartsAt.endswith("Z") or "+" in t.DepartsAt + # Cabin is lowercased. + assert t.Cabin in {"economy", "premium", "business", "first"} + # Mileage cost is an integer. + assert isinstance(t.MileageCost, int) + assert t.MileageCost > 0 + + +def test_extra_fields_ignored() -> None: + """seats.aero adds fields without notice (CreatedAt timestamps, *Raw + variants, ID fields). The _Loose extra='ignore' contract keeps the + parser robust against forward-evolution.""" + # The raw fixture has many more fields than we model. Confirm the + # parser doesn't choke and the modeled fields are populated. + raw_keys = set(json.loads((FIXTURES / "cached_search_with_trips.json").read_text())["data"][0]) + # AvailabilityTrips, Date, Route, Source: modeled. Many *Raw fields: ignored. + assert "YAvailableRaw" in raw_keys # in raw + resp = _load("cached_search_with_trips.json") + # No attribute error means extra fields didn't break model construction. + assert resp.data[0].Source != "" + + +@pytest.mark.parametrize( + "fixture", + ["cached_search_no_trips.json", "cached_search_with_trips.json"], +) +def test_round_trip_validates(fixture: str) -> None: + """Defensive: every available fixture must parse without error.""" + _ = _load(fixture) diff --git a/tests/seats_aero/test_provider.py b/tests/seats_aero/test_provider.py new file mode 100644 index 0000000..9e4a160 --- /dev/null +++ b/tests/seats_aero/test_provider.py @@ -0,0 +1,130 @@ +# pyright: reportPrivateUsage=false +"""Tests for seats_aero/provider.py grouping + cabin-mapping logic. + +The provider's job is to convert AvailabilityTrip lists (per-cabin, per- +program) into AwardFlight lists (per-flight-set, with multiple cabins +inside). These tests pin the grouping key and label mapping against the +captured live fixtures.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from flight_cli.providers.seats_aero.models import ( + CachedSearchResponse, + SeatsAvailabilityTrip, +) +from flight_cli.providers.seats_aero.provider import ( + _cabin_label, + _first_flight_number, + _group_trips_to_awards, + _program_label, +) + +FIXTURES = Path(__file__).parent.parent / "fixtures" / "seats_aero" + + +def _load_trips() -> list[SeatsAvailabilityTrip]: + resp = CachedSearchResponse.model_validate( + json.loads((FIXTURES / "cached_search_with_trips.json").read_text()), + ) + out: list[SeatsAvailabilityTrip] = [] + for item in resp.data: + out.extend(item.AvailabilityTrips) + return out + + +# ─────────────────────────── label helpers ───────────────────────────────── + + +def test_program_label_known() -> None: + assert _program_label("american") == "American Airlines" + assert _program_label("aeroplan") == "Air Canada Aeroplan" + assert _program_label("virginatlantic") == "Virgin Atlantic Flying Club" + + +def test_program_label_unknown_falls_back_to_title() -> None: + assert _program_label("mysteryairline") == "Mysteryairline" + + +def test_cabin_label_normalises_case() -> None: + assert _cabin_label("economy") == "Economy" + assert _cabin_label("BUSINESS") == "Business" + assert _cabin_label("First") == "First" + + +def test_cabin_label_unknown_falls_back_to_title() -> None: + assert _cabin_label("ultraplus") == "Ultraplus" + + +# ─────────────────────────── first flight number ─────────────────────────── + + +def test_first_flight_number_single() -> None: + assert _first_flight_number("AA100") == "AA100" + + +def test_first_flight_number_multi_segment() -> None: + assert _first_flight_number("AA4671, BA216") == "AA4671" + + +def test_first_flight_number_handles_whitespace() -> None: + assert _first_flight_number(" AA100 , BA216 ") == "AA100" + + +# ─────────────────────────── grouping ────────────────────────────────────── + + +def test_group_collapses_same_flight_multiple_cabins() -> None: + """Same (FlightNumbers, DepartsAt, Source) with different cabins → one + AwardFlight with multiple CabinAward entries.""" + trips = _load_trips() + awards = _group_trips_to_awards(trips, tax_currency="USD") + # Find any award with >1 cabin (the fixture has plenty). + multi = [a for a in awards if len(a.cabins) > 1] + assert len(multi) > 0, "expected at least one multi-cabin grouping" + a = multi[0] + cabins = [c.cabin for c in a.cabins] + # No duplicates within a single award + assert len(cabins) == len(set(cabins)) + + +def test_group_distinguishes_different_flight_sets() -> None: + """Same first flight number on the same date but different connecting + segments → distinct AwardFlights. Pin this against the captured fixture + which has AA2643/AA730 and AA2643/AA732 at the same DepartsAt.""" + trips = _load_trips() + awards = _group_trips_to_awards(trips, tax_currency="USD") + # Two awards with flight_number=AA2643 should exist (different second-leg + # flight numbers in their flight_set keys). + aa2643 = [a for a in awards if a.flight_number == "AA2643"] + assert len(aa2643) >= 2 + + +def test_group_assigns_correct_provider_and_program_labels() -> None: + trips = _load_trips() + awards = _group_trips_to_awards(trips, tax_currency="USD") + # All awards from this provider tag as Seats.aero + assert all(a.provider == "Seats.aero" for a in awards) + # Programs cover at least the known set seen in the fixture + programs = {a.program for a in awards} + assert "American Airlines" in programs + + +def test_group_converts_tax_cents_to_usd() -> None: + """seats.aero returns TotalTaxes in cents; CabinAward.tax_usd should be + the dollar value.""" + trips = _load_trips() + awards = _group_trips_to_awards(trips, tax_currency="USD") + # Find an award with a non-zero tax; verify it's a sensible $ amount + nonzero = [a for a in awards if any(c.tax_usd > 0 for c in a.cabins)] + assert nonzero, "expected at least one award with tax > 0" + sample = next(c.tax_usd for c in nonzero[0].cabins if c.tax_usd > 0) + # Sanity: post-conversion taxes should be O($1)-O($1000), not O($10k+) + # (which would indicate we forgot the /100 and are treating cents as dollars). + assert 0 < sample < 5000 + + +def test_group_empty_input_returns_empty() -> None: + assert _group_trips_to_awards([], tax_currency="USD") == [] diff --git a/tests/test_config.py b/tests/test_config.py index 438c8a4..e8b1683 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -115,3 +115,38 @@ def test_merge_provider_options_adds_new_provider() -> None: "pp": {"airlines": ["A"]}, "seats": {"api_key": "k"}, } + + +# ─────────────────────────── canonical / aliases ────────────────────────── + + +def test_canonical_provider_pp_aliases() -> None: + assert _config.canonical_provider("pp") == "pp" + assert _config.canonical_provider("PP") == "pp" + assert _config.canonical_provider("pointspath") == "pp" + assert _config.canonical_provider("Points-Path") == "pp" + + +def test_canonical_provider_seats_aliases() -> None: + assert _config.canonical_provider("seats-aero") == "seats-aero" + assert _config.canonical_provider("sa") == "seats-aero" + assert _config.canonical_provider("seatsaero") == "seats-aero" + assert _config.canonical_provider("seats.aero") == "seats-aero" + assert _config.canonical_provider("SA") == "seats-aero" + + +def test_canonical_provider_unknown_passes_through_lowercased() -> None: + """Unknown names lowercase but otherwise pass through, so callers can + surface a clear error naming exactly what the user typed.""" + assert _config.canonical_provider("MysteryProvider") == "mysteryprovider" + + +def test_parse_provider_opt_normalizes_alias(_config_dir: Path) -> None: + parsed = _config.parse_provider_opt_overrides(["sa.sources=american,united"]) + assert parsed == {"seats-aero": {"sources": ["american", "united"]}} + + +def test_known_canonical_providers_lists_both() -> None: + canonicals = _config.known_canonical_providers() + assert "pp" in canonicals + assert "seats-aero" in canonicals diff --git a/tests/test_provider_selection.py b/tests/test_provider_selection.py index 4ffde46..cb8d5ff 100644 --- a/tests/test_provider_selection.py +++ b/tests/test_provider_selection.py @@ -223,3 +223,82 @@ def test_invalid_provider_opt_format(_config_dir: Path) -> None: awards_only=False, provider_opt=("pp.airlines",), # missing = ) + + +# ─────────────────────────── alias tests (work-2eoa) ─────────────────────── + + +def test_providers_filter_normalizes_aliases(_config_dir: Path) -> None: + """`--providers sa` and `--providers seats-aero` produce the same filter.""" + sa_sel = _resolve_providers( + providers="sa", + cash_only=False, + awards_only=False, + provider_opt=(), + ) + full_sel = _resolve_providers( + providers="seats-aero", + cash_only=False, + awards_only=False, + provider_opt=(), + ) + assert sa_sel.provider_filter == full_sel.provider_filter == ("seats-aero",) + + +def test_providers_pointspath_alias(_config_dir: Path) -> None: + sel = _resolve_providers( + providers="pointspath", + cash_only=False, + awards_only=False, + provider_opt=(), + ) + assert sel.provider_filter == ("pp",) + + +def test_providers_csv_mixes_aliases(_config_dir: Path) -> None: + sel = _resolve_providers( + providers="sa,pointspath", + cash_only=False, + awards_only=False, + provider_opt=(), + ) + assert sel.provider_filter == ("seats-aero", "pp") + + +def test_provider_opt_normalizes_alias_keys(_config_dir: Path) -> None: + """`--provider-opt sa.sources=american` lands at `seats-aero` canonical.""" + sel = _resolve_providers( + providers=None, + cash_only=False, + awards_only=False, + provider_opt=("sa.sources=american,united",), + ) + assert sel.provider_opts == {"seats-aero": {"sources": ["american", "united"]}} + + +def test_config_toml_aliased_section_resolves(_config_dir: Path) -> None: + """`[providers.sa]` and `[providers.seats-aero]` both populate the + canonical key in the resolver's provider_opts.""" + (_config_dir / "config.toml").write_text( + """\ +[providers.sa] +sources = ["american"] +""" + ) + sel = _resolve_providers( + providers=None, + cash_only=False, + awards_only=False, + provider_opt=(), + ) + assert sel.provider_opts == {"seats-aero": {"sources": ["american"]}} + + +def test_seats_sources_helper_reads_canonical_key(_config_dir: Path) -> None: + sel = _resolve_providers( + providers=None, + cash_only=False, + awards_only=False, + provider_opt=("sa.sources=american,united",), + ) + assert sel.seats_sources() == ("american", "united") diff --git a/tests/test_registry.py b/tests/test_registry.py index 8bf6f0a..9e6d83f 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -107,22 +107,40 @@ async def go() -> list[AwardFlight]: assert len(out) == count -# ─────────────────────────── provider filter (work-4byx) ───────────────── +# ─────────────────────────── provider filter (work-4byx + work-2eoa) ───── -def test_matches_is_case_insensitive() -> None: - assert _matches(("pp",), "PP") is True +def test_matches_filter_case_insensitive() -> None: + """Filter entries are case-normalized through the alias map.""" assert _matches(("PP",), "pp") is True assert _matches(("Pp",), "pp") is True -def test_matches_trims_whitespace() -> None: +def test_matches_filter_trims_whitespace() -> None: assert _matches((" pp ",), "pp") is True def test_matches_returns_false_for_unknown_provider() -> None: - assert _matches(("pp",), "seats") is False + """An unknown filter name doesn't match any canonical.""" + assert _matches(("pp",), "seats-aero") is False def test_matches_empty_filter_returns_false() -> None: assert _matches((), "pp") is False + + +def test_matches_resolves_aliases_to_canonical() -> None: + """The point of the alias map: filter spelled in any user-friendly form + matches the canonical name the caller passes.""" + assert _matches(("sa",), "seats-aero") is True + assert _matches(("seats.aero",), "seats-aero") is True + assert _matches(("seatsaero",), "seats-aero") is True + assert _matches(("pointspath",), "pp") is True + assert _matches(("Points-Path",), "pp") is True + + +def test_matches_multi_filter_aliases() -> None: + """A CSV of aliases collapses correctly.""" + f = ("sa", "pointspath") + assert _matches(f, "seats-aero") is True + assert _matches(f, "pp") is True