MOB · GoldenPass — Capability walkthrough
Test cases
One card per requirement. API-backed cases run the live mock and show the real request/response; flip one env var and the same call hits the real system. Data-driven cases show the example record + a backoffice link. 49 cases — 34 MUST · 14 SHOULD · 1 NICE.
Verkauf & Sortiment
Verkauf des kompletten Sortiments via NOVA
Offer search returns point-to-point, supersaver (train-bound, with quota) and day-pass, priced in cents.
Contract: NovaSalesPort.offers(req)
Go live: INTEGRATION_NOVA=b2p + NOVA_*
Run mock · raw request/response
POST /api/offers{
"from": "Montreux",
"to": "Zweisimmen",
"date": "2026-07-01",
"passengers": [
{
"type": "adult",
"count": 1
}
],
"travelClass": "second"
}Verkauf von Sitzplatzreservationen
Get a seat map, then hold a seat with a TTL — a second hold on the same seat is rejected.
Contract: SeatInventoryPort.getSeatMap / hold
Go live: (Payload-backed; real adapter = NOVA seat svc)
Run mock · raw request/response
GET /api/seat-map?schedule=demo&date=2026-07-01&class=secondHaustarif mit KoServ QR-Code
Issue a UIC-918.3 (KoServ-scannable) e-ticket — returns the Aztec barcode + encoded payload.
Contract: TicketIssuancePort.issue(input)
Go live: UIC_RICS + UIC_KEY_ID + UIC_SIGN_SECRET
Run mock · raw request/response
POST /api/tickets/preview{
"bookingNumber": "MOB-DEMO123",
"passengerName": "John Doe",
"from": "Montreux",
"to": "Zweisimmen",
"travelDate": "2026-07-01",
"travelClass": "second",
"priceCents": 5000
}Hybride Artikel mit Constraints (Train du Fromage)
Composite article (transport + gastro) with constraints: max party size, operating days, advance days.
Contract: products.articleType="hybrid" + components[] + constraints[]
Im Preis inbegriffen
- Round-trip journey
- 7-course cheese & wine menu · max. 30
Contract data (JSON)
{
"articleType": "hybrid",
"components": [
{
"kind": "transport",
"label": "Round-trip journey"
},
{
"kind": "gastro",
"label": "7-course cheese & wine menu",
"capacityPerSlot": 30
}
],
"constraints": {
"maxPartySize": 9,
"dateLocked": false,
"minAdvanceDays": 7,
"operatingDays": [
"tue",
"fri",
"sat",
"sun"
]
},
"validity": {
"season": "summer"
}
}Dynamisches Upselling je Verbindung
Rules trigger by line/route/class and surface gastro offers with a discount in the funnel.
Contract: upsell-rules
What you’re seeing: An upsell rule: when a booking matches the trigger (route/class), these offers appear in the funnel at the given discount.
| Product | Label | Discount Pct |
|---|---|---|
| dining-deluxe | Chef's 3-course | 15 |
| wine-pairing | Wine pairing | 10 |
Contract data (JSON)
{
"name": "Gastro on scenic routes",
"active": true,
"trigger": {
"route": "montreux-zweisimmen",
"class": "any"
},
"offers": [
{
"product": "dining-deluxe",
"label": "Chef's 3-course",
"discountPct": 15
},
{
"product": "wine-pairing",
"label": "Wine pairing",
"discountPct": 10
}
]
}Optimales / intelligentes Ticket
The cheapest valid offer is flagged BEST PRICE with the saving vs point-to-point (tick Half-Fare).
Contract: optimize(offers) over NovaSalesPort
Go live: automatic once NOVA is real
Run mock · raw request/response
POST /api/offers{
"from": "Montreux",
"to": "Zweisimmen",
"date": "2026-07-01",
"passengers": [
{
"type": "adult",
"count": 1
}
],
"travelClass": "second",
"reductions": [
"half_fare"
]
}Regionalzüge ohne Reservation
Schedules flagged reservationRequired=false skip the seat-selection step (free seating).
Contract: schedules.reservationRequired=false
What you’re seeing: A schedule with reservationRequired = false → the booking flow skips the seat step (free seating on regional trains).
Contract data (JSON)
{
"route": "regional-pleiades",
"departureTime": "09:15",
"arrivalTime": "11:45",
"bookable": true,
"reservationRequired": false,
"notes": "Regional — free seating"
}Artikel-Gültigkeit (zeitlich / saisonal)
Products carry a validity window + season; shown as a badge and enforced at purchase.
Contract: products.validity { validFrom, validUntil, season }
Contract data (JSON)
{
"validity": {
"season": "summer"
}
}Customer Experience (UX)
Interaktiver Sitzplan (SBB-Standard)
Coach plan with window/aisle/table/quiet seats + live availability (x/y SVG coords).
Contract: SeatInventoryPort.getSeatMap + seat-maps
Run mock · raw request/response
GET /api/seat-map?schedule=demo&date=2026-07-01&class=firstSehr schnelle Navigation
Each purchase step responds in a few hundred ms (server components / route handlers).
Contract: Next.js server components
Each purchase step responds in a few hundred ms (server components / route handlers).
Open planner →Strecke auf interaktiver Karte
The planner draws the route line from route waypoints + station pins on a Leaflet map.
Contract: routes.waypoints + /fahrplan
The planner draws the route line from route waypoints + station pins on a Leaflet map.
Open planner (Montreux→Zweisimmen) →Responsives Rendering
Layouts reflow mobile ↔ desktop (toggle mobile devtools on the planner).
Contract: Tailwind responsive layouts
Layouts reflow mobile ↔ desktop (toggle mobile devtools on the planner).
Open planner →Saisonales Content-Management (global)
One switch (auto from date ranges, or forced) flips seasonal imagery/copy + banner site-wide.
Contract: seasons global
What you’re seeing: The Seasons global: activeSeason resolves from the date ranges (or is forced); the matching banner + season-variant images then drive the whole site.
| Season | From | To |
|---|---|---|
| summer | 2026-06-21 | 2026-09-22 |
| winter | 2026-12-21 | 2027-03-20 |
| Season | Headline |
|---|---|
| summer | Sommerabenteuer warten |
Contract data (JSON)
{
"activeSeason": "auto",
"ranges": [
{
"season": "summer",
"from": "2026-06-21",
"to": "2026-09-22"
},
{
"season": "winter",
"from": "2026-12-21",
"to": "2027-03-20"
}
],
"banners": [
{
"season": "summer",
"headline": "Sommerabenteuer warten"
}
]
}Bilder je Saisonvariante
Media tagged per season; the active season selects the matching variant.
Contract: media.seasonVariant
What you’re seeing: Each media asset can carry a seasonVariant; the active season picks the matching image automatically.
Contract data (JSON)
{
"alt": "GPX viaduct",
"seasonVariant": "summer",
"filename": "gpx-summer.jpg",
"width": 1920,
"height": 1080
}Live-Informationen (SBB / SKI)
Departures board (delay/platform) + disruptions; plus the editor-managed orange ticker.
Contract: LivePort.departures / disruptions
Go live: OPENTRANSPORT_TOKEN
Run mock · raw request/response
GET /api/live?stop=MontreuxAccounts & Payments
Kauf als Gast oder via SwissPass Login
Checkout as guest or log in with SwissPass; customers stored with accountType.
Contract: AuthPort + customers (auth)
SwissPass Login (OIDC / PKCE)
Auth-Code + PKCE round-trip → profile {sub,email,…}; callback upserts a customer.
Contract: AuthPort.getAuthUrl / exchangeCode
Go live: INTEGRATION_AUTH=swisspass + SWISSPASS_*
Referenzierung des Tickets auf SwissPass
A SwissPass purchase records a swissPassRef on the booking.
Contract: EbAtSpPort + bookings.swissPassRef
What you’re seeing: A booking made by a SwissPass user stores the SwissPass reference, so the ticket can be tied to their card.
Contract data (JSON)
{
"bookingNumber": "MOB-00012345",
"customerEmail": "user@example.com",
"channel": "web",
"swissPassRef": "SP-REF-9f2a3b",
"status": "paid"
}B2B-Login mit Rechnungszahlung
Corporate account books on invoice up to a credit limit; group discount.
Contract: b2b-accounts + customers + InvoicePort
What you’re seeing: A B2B account: members book on account up to the credit limit and settle by invoice on the payment terms, with a group discount.
Contract data (JSON)
{
"companyName": "Acme Tours AG",
"vatId": "CHE-123.456.789",
"creditLimitCents": 500000,
"paymentTermsDays": 45,
"groupDiscountPct": 12,
"status": "active"
}Zustellung als E-Ticket (Mail / Download)
On payment success: PDF e-ticket + confirmation email (Resend).
Contract: TicketIssuancePort + Resend
Run mock · raw request/response
POST /api/tickets/preview{
"bookingNumber": "MOB-DEMO123",
"passengerName": "Jane Doe",
"from": "Montreux",
"to": "Gstaad",
"travelDate": "2026-07-01",
"travelClass": "first",
"priceCents": 7800
}Standard-Zahlungsmittel
Payment means: card / TWINT / Apple Pay / MatchPay / voucher / B2B invoice.
Contract: PaymentPort.means
Go live: STRIPE_SECRET_KEY (card live on prod)
Contract data (JSON)
[
{
"code": "card",
"label": "Credit / debit card",
"provider": "stripe"
},
{
"code": "twint",
"label": "TWINT",
"provider": "stripe"
},
{
"code": "apple_pay",
"label": "Apple Pay",
"provider": "stripe"
},
{
"code": "matchpay",
"label": "MatchPay (SBB)",
"provider": "payment_ov"
},
{
"code": "voucher",
"label": "Gift card / Voucher",
"provider": "eguma"
},
{
"code": "invoice",
"label": "B2B invoice",
"provider": "cembrapay",
"b2bOnly": true
}
]MatchPay (SBB)
MatchPay settles server-side (no client secret) and reconciles on its channel.
Contract: PaymentPort (means=matchpay, provider=payment_ov)
Go live: PAYMENT_OV_MERCHANT_ID
Contract data (JSON)
[
{
"code": "card",
"label": "Credit / debit card",
"provider": "stripe"
},
{
"code": "matchpay",
"label": "MatchPay (SBB)",
"provider": "payment_ov"
},
{
"code": "invoice",
"label": "B2B invoice",
"provider": "cembrapay",
"b2bOnly": true
}
]B2B-Rechnung (CembraPay)
Create a B2B invoice (subtotal + VAT 8.1% + due date) + PDF from line items.
Contract: InvoicePort.createInvoice
Go live: CEMBRAPAY_MERCHANT_ID
| Position | Menge | Total |
|---|---|---|
| GPX 4068 · 3 pax · Montreux → Zweisimmen · 2026-07-01 | 3 | CHF 135.00 |
| Vegan meal add-ons | 3 | CHF 75.00 |
Contract data (JSON)
{
"invoiceNumber": "INV-7K4MN9PQ",
"companyName": "Acme Tours AG",
"lineItems": [
{
"description": "GPX 4068 · 3 pax · Montreux → Zweisimmen · 2026-07-01",
"quantity": 3,
"unitPriceCents": 4500,
"lineTotalCents": 13500
},
{
"description": "Vegan meal add-ons",
"quantity": 3,
"unitPriceCents": 2500,
"lineTotalCents": 7500
}
],
"subtotalCents": 21000,
"vatCents": 1701,
"totalCents": 22701,
"status": "issued",
"dueAt": "2026-07-29"
}E-Guma Geschenkkarten
Check a gift-card balance, then redeem against a booking; remaining balance decremented.
Contract: VoucherPort.balance / redeem
Go live: INTEGRATION_VOUCHER=eguma + EGUMA_API_KEY
Redeemable at checkout; balance decremented per use (partial redemptions supported).
Run mock · raw request/response
GET /api/voucher/balance?code=EGUMA001NGW-Gutscheine
Same VoucherPort, type=ngw (öV gift card). Demo code NGW002.
Contract: VoucherPort (type=ngw)
Redeemable at checkout; balance decremented per use (partial redemptions supported).
Run mock · raw request/response
GET /api/voucher/balance?code=NGW002Ticket in der SwissPass App (EB@SP)
On a paid SwissPass booking, EB@SP delivery is attempted + tracked (none→pending→delivered).
Contract: EbAtSpPort.deliver + bookings.ebAtSp
What you’re seeing: The booking tracks EB@SP delivery status as the ticket is loaded onto the customer’s SwissPass app.
Contract data (JSON)
{
"bookingNumber": "MOB-00012345",
"swissPassSub": "spy333344",
"ebAtSp": "delivered",
"deliveredAt": "2026-07-01T08:12:00Z"
}Self-Service & Customer Care
Self-Service Storno/Rückerstattung (regelbasiert)
Tiered refund rules by days-before with fee + refund percentage.
Contract: refund-rules
What you’re seeing: A refund-rule: the tier matching how many days before travel sets the fee and the refunded percentage.
| Days Before From | Refund Pct | Fee Value | Days Before To | Fee Type |
|---|---|---|---|---|
| 14 | 100 | 0 | — | — |
| 7 | 90 | 10 | 13 | percent |
| 0 | 0 | 50 | 2 | percent |
Contract data (JSON)
{
"name": "Standard cancellation policy",
"active": true,
"tiers": [
{
"daysBeforeFrom": 14,
"refundPct": 100,
"feeValue": 0
},
{
"daysBeforeFrom": 7,
"daysBeforeTo": 13,
"feeType": "percent",
"feeValue": 10,
"refundPct": 90
},
{
"daysBeforeFrom": 0,
"daysBeforeTo": 2,
"feeType": "percent",
"feeValue": 50,
"refundPct": 0
}
]
}Self-Service-Storno im Kundenprofil
Customer cancels → refund record with computed fee/refund, status requested→completed.
Contract: refunds (self_service) + PaymentPort.refund
What you’re seeing: A self-service refund: the rule computed the fee and refunded amount; the record moves requested → completed.
Contract data (JSON)
{
"booking": "MOB-00012345",
"reason": "request",
"channel": "self_service",
"policyApplied": "7-13 days",
"feeCents": 2000,
"refundedCents": 18000,
"status": "completed"
}Massenstornierung (Zugausfall)
One cancellation event finds affected bookings and issues bulk refunds.
Contract: cancellation-events → refunds
What you’re seeing: A Zugausfall event: processing it finds all affected bookings and auto-issues refunds in bulk.
Contract data (JSON)
{
"title": "Zugausfall GPX 4068 — 2026-06-15",
"reason": "infrastructure",
"autoRefund": true,
"affectedBookings": 47,
"status": "closed"
}Backoffice-Rückerstattungen
Agent approves → status requested→approved→processing→completed with processor ref.
Contract: refunds (backoffice)
What you’re seeing: An agent-initiated refund: status advances requested → approved → processing → completed, with a payment-processor reference.
Contract data (JSON)
{
"booking": "MOB-00067890",
"reason": "goodwill",
"channel": "backoffice",
"status": "processing",
"refundedCents": 9000,
"processorRef": "ch_3Pxy…"
}Backoffice & CMS
Kontingentverwaltung + Verkaufsfenster
Capacity per date/train/class with per-channel allocations + a sales window.
Contract: quotas
What you’re seeing: A quota: total capacity for a date/train/class, split into per-channel allocations, with a sales window. Overselling is blocked.
| Channel | Allocated | Sold |
|---|---|---|
| web | 60 | 38 |
| nova | 30 | 9 |
| retained | 10 | 0 |
Contract data (JSON)
{
"displayName": "GPX 4068 · 2026-06-15 · second",
"date": "2026-06-15",
"class": "second",
"totalCapacity": 100,
"sold": 47,
"allocations": [
{
"channel": "web",
"allocated": 60,
"sold": 38
},
{
"channel": "nova",
"allocated": 30,
"sold": 9
},
{
"channel": "retained",
"allocated": 10,
"sold": 0
}
],
"salesWindowUntil": "2026-06-15T23:59:59Z"
}Headless-CMS-Blöcke
Pages built from editable blocks (hero, content, media, FAQ, CTA, map…), shown in our design.
Contract: pages.layout blocks
Pages built from editable blocks (hero, content, media, FAQ, CTA, map…), shown in our design.
Open a migrated page →Warenkorb mit Ablaufzeit
Cart + seat hold carry an expiry (mirrors the 30-min NOVA hold); expiry releases the seat.
Contract: carts.expiresAt + reservations.holdExpiresAt
What you’re seeing: A cart with a TTL: a visible countdown runs; on expiry the held seats are released back to inventory.
| Title Snapshot | Quantity | Unit Price (CHF) |
|---|---|---|
| GPX 4068 Montreux → Zweisimmen | 1 | CHF 45.00 |
Contract data (JSON)
{
"sessionId": "sess_abc123",
"status": "active",
"items": [
{
"titleSnapshot": "GPX 4068 Montreux → Zweisimmen",
"quantity": 1,
"unitPriceCents": 4500
}
],
"totalCents": 7000,
"expiresAt": "2026-06-14T16:45:00Z"
}Promo-Codes nach Produkt
Generate a batch of codes scoped to a product.
Contract: promo-codes.scope.products
Run mock · raw request/response
POST /api/promos{
"count": 5,
"discountType": "percent",
"discountValue": 25,
"prefix": "FROMAGE"
}Promo-Codes nach Bahnhof
Codes scoped to an origin/destination pair.
Contract: promo-codes.scope.fromStation/toStation
Run mock · raw request/response
POST /api/promos{
"count": 5,
"discountType": "amount",
"discountValue": 1000,
"prefix": "MOZ"
}Promo-Codes nach Reisedatum
Codes scoped to a travel-date range.
Contract: promo-codes.scope.travelDateFrom/To
Run mock · raw request/response
POST /api/promos{
"count": 5,
"discountType": "percent",
"discountValue": 15,
"prefix": "JULY",
"travelDateFrom": "2026-07-01",
"travelDateTo": "2026-07-31"
}Vorschau der Ticket-Layouts
Render the ticket layout + Aztec without issuing a real ticket.
Contract: TicketIssuancePort.issue (preview)
Run mock · raw request/response
POST /api/tickets/preview{
"bookingNumber": "MOB-PREVIEW",
"passengerName": "Preview",
"from": "Montreux",
"to": "Zweisimmen",
"travelDate": "2026-07-01",
"travelClass": "second",
"priceCents": 4500
}Promo-Code-API (Automatisierung)
Generate codes via API (e.g. birthday automation) — returns a batchId + N codes.
Contract: POST /api/promos
Run mock · raw request/response
POST /api/promos{
"count": 10,
"discountType": "percent",
"discountValue": 15,
"prefix": "BDAY",
"travelDateFrom": "2026-07-01",
"travelDateTo": "2026-07-31"
}Finanzen & Daten
Automatische Reconciliation NOVA (Multi-Channel)
Per-channel gross / commission / refunds / net (nova 30%, matchpay 1.5%, haustarif 2.9%).
Contract: ReconciliationPort.run
Run mock · raw request/response
GET /api/reconciliationReconciliation MatchPay (SBB)
The settlement table includes a MatchPay channel row.
Contract: ReconciliationPort.run (matchpay)
Run mock · raw request/response
GET /api/reconciliationFinanzdaten & Reportings
Revenue by channel/period with totals (reconciliations collection + dashboard).
Contract: reconciliations
CRM-Übergabe nach Kauf/SAV
On purchase/SAV: upsert CRM contact + deal with an audit trail.
Contract: hubspot.ts (gated)
Go live: HUBSPOT_TOKEN
What you’re seeing: After a purchase or SAV action, the customer is upserted into the CRM as a contact + deal; the booking keeps an audit-trail event.
Contract data (JSON)
{
"event": "purchase",
"bookingRef": "MOB-00012345",
"crmContact": "upserted",
"crmDeal": "created · closed-won",
"amountCents": 9600
}Google Analytics
GA4 ecommerce events fire once a measurement id is set.
Contract: site-settings.analytics.ga4MeasurementId
What you’re seeing: Set a GA4 measurement id in Site Settings → GA4 ecommerce events (view_item, add_to_cart, purchase) start firing.
Contract data (JSON)
{
"analytics": {
"ga4MeasurementId": "G-XXXXXXX",
"enableEcommerce": true,
"requireConsent": true
}
}Architektur & Sicherheit
White-Label-Architektur
Documented target — tenant discriminator + theming tokens (per-env build args today).
Contract: documented (plan §5.5)
Documented capability: The design-token system + per-environment build args are the basis; a tenant discriminator + theme resolver make it multi-tenant.
Gesicherte API (WAF / DDoS / BFF)
BFF = Next route handlers (only public surface); WAF/DDoS/cert = Cloud Armor + Cloud Run.
Contract: Next route handlers + Cloud Armor (infra)
| Integration | Adapter | Mode | Keys to go live |
|---|
Run mock · raw request/response
GET /api/integrations/statusVollständiges technisches Monitoring
Per-integration mode (mock/real) + readiness + required env keys; plus uptime checks.
Contract: GET /api/integrations/status
| Integration | Adapter | Mode | Keys to go live |
|---|
Run mock · raw request/response
GET /api/integrations/statusAPI für Drittsysteme (Chatbot / agentisch)
Payload API keys + the AI Concierge (search / offer / draft booking tools).
Contract: Payload API keys + AI Concierge
Payload API keys + the AI Concierge (search / offer / draft booking tools).
Open site (concierge) →Fraud Detection via Log-Analyse
Score a context → severity + action + reasons (velocity, value, email, refund abuse, missing IP).
Contract: FraudPort.evaluate
Run mock · raw request/response
POST /api/fraud/evaluate{
"recentAttempts": 5,
"amountCents": 200000,
"email": "temp@mailinator.com",
"isRefund": true
}Aktive Fraud-Prevention
Fraud rules + blocklist (email/ip/card/device) consulted at checkout → block/challenge.
Contract: fraud-rules + blocklist
What you’re seeing: A blocklist entry: checkout consults the blocklist + rules and blocks/challenges matching patterns (e.g. SAV abuse).
Contract data (JSON)
{
"value": "temp@mailinator.com",
"kind": "email",
"reason": "SAV abuse",
"active": true,
"expiresAt": null
}