Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion docs/format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
| `<operator>/stations.json` | `[{ id, name }]`; `id` is the URL slug used below |
| `<operator>/<pass>/all.json` | every bookable train for the pass (compact) |
| `<operator>/<pass>/from/<id>.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/<origin>.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.
38 changes: 38 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,6 +11,7 @@ import { INTERRAIL_BY_OPERATOR } from "./operators/interrail";
* <operator>/stations.json [{ id, name }] — id is the URL slug
* <operator>/<pass>/all.json every bookable train, compact snapshot
* <operator>/<pass>/from/<id>.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/<origin>.json` and filter.
*/
Expand Down Expand Up @@ -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<string, Train[]>();
for (const t of ok) {
Expand Down
2 changes: 2 additions & 0 deletions src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PassId, PassInfo> = {
Expand Down
95 changes: 95 additions & 0 deletions src/operators/interrail-fees.ts
Original file line number Diff line number Diff line change
@@ -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<FeeCountry, string[]> = {
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<Exclude<FeeCountry, "ES">, 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<string, ReservationFee> = {
"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,
};
}
41 changes: 41 additions & 0 deletions tests/fees.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading