diff --git a/docs/format.md b/docs/format.md index fcfebbc..80fa837 100644 --- a/docs/format.md +++ b/docs/format.md @@ -74,9 +74,27 @@ All static files under `/v1/`: | Path | Content | |------|---------| -| `index.json` | freshness, dates covered, and each pass with its `seatKnown`, `bookingUrl`, `note`, `trainCount` | +| `index.json` | freshness, dates covered, and each pass with its `seatKnown`, `bookingUrl`, `note`, `fees`, `trainCount` | | `/stations.json` | `[{ id, name }]`; `id` is the URL slug used below | | `//all.json` | every bookable train for the pass (compact) | | `//from/.json` | the bookable trains leaving one station (compact) | +| `sncf/interrail/fees.json` | what an Interrail holder pays to reserve each SNCF train (see below) | A search by origin, destination and date fetches `from/.json` and filters it. + +## Reservation fees + +`sncf/interrail/fees.json` holds the pass-holder reservation fees for SNCF trains, in +euros, 2nd class, hand-entered from +[Interrail's fee pages](https://www.interrail.com/en/book-reservations/reservation-fees/domestic-train-reservation-fees) +(`checked` gives the date). The pass entry in `index.json` points to it with `fees`. A +train's fee is `{ min, max }`: + +1. If a stop is abroad (a whole word of `foreignStops`), the fee is `byCountry` for that + country. Spain has two: `fromParis` when a stop is Paris, `other` otherwise. +2. Otherwise the train's axe (`category`) gives `byAxe`. A stretch of an international + train inside France is domestic. +3. No match (SNCF coaches) means no published fee. + +`sncfInterrailFee` in `src/operators/interrail-fees.ts` does exactly this. The fees are +indicative: show them as such and link the source. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a5bde78..66e09e4 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -82,6 +82,40 @@ paths: application/json: schema: { $ref: "#/components/schemas/CompactSnapshot" } "404": { $ref: "#/components/responses/NotPublished" } + /sncf/interrail/fees.json: + get: + operationId: getInterrailFees + summary: What an Interrail holder pays to reserve each SNCF train + description: | + Indicative pass-holder fees in euros, 2nd class, from Interrail's fee pages. A stop + abroad sets the fee for its country (`byCountry`, matched on whole words of + `foreignStops`); otherwise the train's axe does (`byAxe`). See docs/format.md. + responses: + "200": + description: Fee table. + content: + application/json: + schema: + type: object + required: [currency, class, checked, source, byCountry, byAxe, foreignStops] + properties: + currency: { type: string, const: EUR } + class: { type: integer, const: 2 } + checked: { $ref: "#/components/schemas/Date" } + source: { type: string, format: uri } + note: { type: string } + byCountry: { type: object } + byAxe: + type: object + additionalProperties: + type: object + required: [min, max] + properties: + min: { type: number } + max: { type: number } + foreignStops: + type: object + additionalProperties: { type: array, items: { type: string } } /archive/max/{year}/{date}.json.gz: servers: - url: https://raw.githubusercontent.com/offware-apps/train-api/data @@ -203,6 +237,10 @@ components: note: type: string description: Pass rules the data can't check. Show it to your users. + fees: + type: string + description: Path of the operator's reservation fee table for this pass, when there is one. + examples: [v1/sncf/interrail/fees.json] trainCount: type: integer minimum: 0 diff --git a/src/api.ts b/src/api.ts index 8c61cfe..8f86c97 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,7 @@ import { encodeCompact } from "./compact"; import { PASSES, PASS_INFO, type PassId, type PassInfo, type Train } from "./format"; import { INTERRAIL_BY_OPERATOR } from "./operators/interrail"; +import { sncfFeeTable } from "./operators/interrail-fees"; /** * The static API: plain JSON files, so it can be hosted for free and never needs a @@ -10,6 +11,7 @@ import { INTERRAIL_BY_OPERATOR } from "./operators/interrail"; * /stations.json [{ id, name }] — id is the URL slug * //all.json every bookable train, compact snapshot * //from/.json the trains leaving one station, compact snapshot + * sncf/interrail/fees.json what a pass holder pays to reserve each SNCF train * * Search by origin, destination and date = fetch `from/.json` and filter. */ @@ -71,7 +73,9 @@ export function buildOperatorApi( const ok = bookableFor(trains, pass); if (ok.length === 0) continue; const own = pass === "interrail" ? INTERRAIL_BY_OPERATOR[operator] : undefined; - passes.push({ ...PASS_INFO[pass], ...own, trainCount: ok.length }); + const fees = operator === "sncf" && pass === "interrail" ? `v1/${operator}/${pass}/fees.json` : undefined; + passes.push({ ...PASS_INFO[pass], ...own, ...(fees ? { fees } : {}), trainCount: ok.length }); + if (fees) files.push({ path: fees, body: sncfFeeTable() }); files.push({ path: `v1/${operator}/${pass}/all.json`, body: encodeCompact(ok) }); const byOrigin = new Map(); for (const t of ok) { diff --git a/src/format.ts b/src/format.ts index a658e46..29f00a6 100644 --- a/src/format.ts +++ b/src/format.ts @@ -45,6 +45,8 @@ export interface PassInfo { bookingUrl: string; /** Pass rules the data can't check, for clients to show. */ note?: string; + /** Path of this operator's reservation fee table for the pass, when there is one. */ + fees?: string; } export const PASS_INFO: Record = { diff --git a/src/operators/interrail-fees.ts b/src/operators/interrail-fees.ts new file mode 100644 index 0000000..025c0cd --- /dev/null +++ b/src/operators/interrail-fees.ts @@ -0,0 +1,95 @@ +/** + * What an Interrail pass holder pays to reserve a seat on SNCF trains, in euros, 2nd + * class. Hand-entered from Interrail's own fee pages (they are indicative and change; + * clients should say so and link them). Checked on 2026-10-12. + * + * A train's fee depends on where it goes: a stop abroad sets the international fee for + * that country, otherwise the train family (the SNCF "axe") sets the domestic one. + */ + +export const INTERRAIL_FEES_URL = + "https://www.interrail.com/en/book-reservations/reservation-fees/domestic-train-reservation-fees"; +export const INTERRAIL_FEES_CHECKED = "2026-10-12"; + +/** A pass-holder reservation, 2nd class, in euros. `min === max` for a fixed fee. */ +export interface ReservationFee { + min: number; + max: number; +} + +export type FeeCountry = "BE" | "CH" | "DE" | "ES" | "IT" | "LU"; + +/** Whole words of SNCF stop names abroad, by country (accents and case ignored). */ +export const FOREIGN_STOPS: Record = { + BE: ["bruxelles", "brussel", "liege", "anvers", "antwerpen"], + CH: ["geneve", "lausanne", "zurich", "basel", "bale", "bern", "vallorbe", "neuchatel"], + DE: [ + "mannheim", "saarbrucken", "saarbruecken", "kaiserslautern", "frankfurt", "karlsruhe", + "stuttgart", "augsburg", "munchen", "baden baden", "ulm", "freiburg", "koln", "dortmund", + ], + ES: ["barcelona", "girona", "figueres", "madrid"], + IT: ["torino", "milano", "oulx", "oux"], + LU: ["luxembourg"], +}; + +/** International pass-holder fees (international train reservation fees page). */ +const BY_COUNTRY: Record, ReservationFee> = { + BE: { min: 20, max: 35 }, // TGV to Brussels + CH: { min: 25, max: 25 }, // TGV Lyria + DE: { min: 19, max: 19 }, // ICE & TGV France–Germany + IT: { min: 31, max: 31 }, // TGV France–Italy + LU: { min: 10, max: 20 }, // TGV to Luxembourg +}; +/** TGV France–Spain: from Paris, or from Lyon, Montpellier or Perpignan. */ +const SPAIN_FROM_PARIS: ReservationFee = { min: 35, max: 35 }; +const SPAIN_OTHER: ReservationFee = { min: 25, max: 25 }; + +/** Domestic fees by SNCF axe (domestic and night train reservation fees pages). */ +const BY_AXE: Record = { + "SUD EST": { min: 10, max: 20 }, // TGV INOUI + EST: { min: 10, max: 20 }, + ATLANTIQUE: { min: 10, max: 20 }, + NORD: { min: 10, max: 20 }, + INTERNATIONAL: { min: 10, max: 20 }, // a stretch inside France + "IC SRO": { min: 10, max: 20 }, // Intercités, reservation compulsory + "IC ARO": { min: 10, max: 20 }, + "IC NUIT": { min: 10, max: 25 }, // seat €10, couchette €19–25 +}; + +function fold(s: string): string { + return ` ${s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, " ")} `; +} + +/** The country of a stop abroad, or undefined for a stop in France. */ +export function foreignCountry(stop: string): FeeCountry | undefined { + const f = fold(stop); + for (const [country, names] of Object.entries(FOREIGN_STOPS) as [FeeCountry, string[]][]) { + if (names.some((n) => f.includes(` ${n} `))) return country; + } + return undefined; +} + +/** + * The pass-holder fee for one SNCF train between two stops, or undefined when no fee + * page covers it (SNCF coaches, an unknown axe). + */ +export function sncfInterrailFee(category: string | undefined, origin: string, destination: string): ReservationFee | undefined { + const abroad = foreignCountry(origin) ?? foreignCountry(destination); + if (abroad === "ES") return [origin, destination].some((s) => fold(s).includes(" paris ")) ? SPAIN_FROM_PARIS : SPAIN_OTHER; + if (abroad) return BY_COUNTRY[abroad]; + return category ? BY_AXE[category] : undefined; +} + +/** The fee table as published in v1/sncf/interrail/fees.json. */ +export function sncfFeeTable(): unknown { + return { + currency: "EUR", + class: 2, + checked: INTERRAIL_FEES_CHECKED, + source: INTERRAIL_FEES_URL, + note: "Indicative pass-holder reservation fees, 2nd class; 1st class can cost more on international trains. A stop abroad sets the fee for that country, otherwise the axe does.", + byCountry: { ...BY_COUNTRY, ES: { fromParis: SPAIN_FROM_PARIS, other: SPAIN_OTHER } }, + byAxe: BY_AXE, + foreignStops: FOREIGN_STOPS, + }; +} diff --git a/tests/fees.test.ts b/tests/fees.test.ts new file mode 100644 index 0000000..70b99a5 --- /dev/null +++ b/tests/fees.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { buildOperatorApi } from "../src/api"; +import { mapSncfFeed } from "../src/operators/sncf"; +import { foreignCountry, sncfInterrailFee } from "../src/operators/interrail-fees"; + +const feed = JSON.parse(readFileSync(new URL("./fixtures/sncf-tgvmax.sample.json", import.meta.url), "utf-8")) as unknown[]; + +describe("Interrail reservation fees on SNCF trains", () => { + it("tells a stop abroad from a French one", () => { + expect(foreignCountry("MANNHEIM HBF")).toBe("DE"); + expect(foreignCountry("SAARBRUECKEN/SARREBRUCK")).toBe("DE"); + expect(foreignCountry("GENEVE CORNAVIN")).toBe("CH"); + expect(foreignCountry("OUX CESANA CLAV SESTRIERE")).toBe("IT"); + expect(foreignCountry("MODANE")).toBeUndefined(); + expect(foreignCountry("BERNAY")).toBeUndefined(); + expect(foreignCountry("PARIS (intramuros)")).toBeUndefined(); + }); + + it("charges the domestic fee by axe and the international fee by the country abroad", () => { + expect(sncfInterrailFee("SUD EST", "PARIS (intramuros)", "MARSEILLE ST CHARLES")).toEqual({ min: 10, max: 20 }); + expect(sncfInterrailFee("IC NUIT", "PARIS (intramuros)", "BRIANCON")).toEqual({ min: 10, max: 25 }); + expect(sncfInterrailFee("INTERNATIONAL", "PARIS (intramuros)", "STUTTGART HBF")).toEqual({ min: 19, max: 19 }); + expect(sncfInterrailFee("INTERNATIONAL", "PARIS (intramuros)", "ZURICH HB")).toEqual({ min: 25, max: 25 }); + expect(sncfInterrailFee("INTERNATIONAL", "LYON (intramuros)", "MILANO PORTA GARIBALDI")).toEqual({ min: 31, max: 31 }); + // A stretch of an international train inside France is a domestic trip. + expect(sncfInterrailFee("INTERNATIONAL", "PARIS (intramuros)", "DIJON VILLE")).toEqual({ min: 10, max: 20 }); + expect(sncfInterrailFee("SUD EST", "PARIS (intramuros)", "BARCELONA SANTS")).toEqual({ min: 35, max: 35 }); + expect(sncfInterrailFee("SUD EST", "MONTPELLIER ST ROCH", "BARCELONA SANTS")).toEqual({ min: 25, max: 25 }); + expect(sncfInterrailFee("AUTOCAR SNCF", "PARIS (intramuros)", "ROUEN RIVE DROITE")).toBeUndefined(); + }); + + it("publishes the fee table next to the Interrail snapshot and points to it from the index", () => { + const { files, entry } = buildOperatorApi("sncf", "test", mapSncfFeed(feed)); + const table = files.find((f) => f.path === "v1/sncf/interrail/fees.json")?.body as { currency: string; source: string }; + expect(table.currency).toBe("EUR"); + expect(table.source).toContain("interrail.com"); + expect(entry.passes.find((p) => p.id === "interrail")?.fees).toBe("v1/sncf/interrail/fees.json"); + expect(entry.passes.find((p) => p.id === "max-jeune")?.fees).toBeUndefined(); + }); +});