# EP Reisen Mobile App — React Native Plan **Copy this file into the React Native repository** (suggested: `docs/plan.md`). It is written to be self-contained: you do not need access to the backend codebase to work from it. Where it says "the contract", it means the API guide and fixtures shipped by the backend team — see §3. **Companion (backend side, not needed here):** `docs/mobile-app/backend-plan.md` in the `myep` Symfony repository. --- ## 1. What this app is EP Reisen sells group travel — primarily ski and coach trips. A booking is not "one person buys one thing": it is **one applicant booking a set of rooms for a group of participants**, where each participant independently selects ski passes, equipment rental, board, courses, transport, insurance and more, with prices and eligibility varying per person (notably by age). The existing web product is a server-rendered Symfony application. This app covers its **public** surface. The admin area is explicitly out of scope and will remain web-only. The app is being built for App Store presence and a native-feeling booking flow. Push notifications are **not** currently a requirement — do not build infrastructure for them without asking. ### The two systems behind the API You will see this distinction leak into behaviour, so it is worth knowing: - **BusProNet (BPN)** — an external, third-party XML system that is the system of record for individual travel bookings. It is slow, occasionally unavailable, and its rules are not ours to change. Endpoints backed by it can fail in ways local data cannot. - **Local database** — system of record for group/accommodation bookings, user accounts, newsletter consent, and booking drafts. Fast and reliable. Increments are deliberately ordered local-data-first so you are not fighting BPN latency while also debugging your own foundations. --- ## 2. The one architectural rule > **Business rules live on the server. The app renders what it is told.** This is not a stylistic preference; it is the reason the project is viable. The backend already contains, in one place, 27 condition classes and 25 field handlers that decide — per participant, per field — whether a field is visible, editable, required, or shown as read-only text, and which choices it offers. Reimplementing any of that in TypeScript creates two sources of truth that will silently diverge. **Concretely, never write code in this repo that:** - decides whether a field is shown, e.g. inferring that a licence-plate input appears because transport is by car; - computes or re-computes a price, total, discount or voucher deduction from line items; - decides whether a participant is eligible for a service, especially by age; - validates a business constraint (as opposed to a formatting constraint like "this looks like an email"). **If the app appears to need one of those, the schema is missing information. That is a backend bug.** Report it; do not patch around it. This single discipline is what the whole architecture buys, and it is lost the first time it is violated. Client-side validation is limited to immediate input affordances — keyboard type, max length, obviously-malformed email — purely for responsiveness. The server's verdict is always authoritative and always re-checked on submit. --- ## 3. The contract The backend ships two artifacts. Together they are your ground truth. 1. **The API guide** — a self-contained markdown document describing every endpoint: method, path, auth scope, request shape, response shape, error shapes. The backend team updates it in the same commit as the endpoint. 2. **Fixtures** — real JSON responses, written out by the backend's own tests. Because they are test *output*, they cannot silently go stale. ### How to work with them - **Build against fixtures first**, then point at a live backend. This means app work never blocks on a deployed endpoint. - **Generate your TypeScript types from the contract** rather than hand-writing them, so a schema change surfaces as a compile error instead of a runtime surprise. - **When the guide is ambiguous, ask — do not guess.** A guess that happens to work becomes an undocumented dependency on an accident. ### Error shapes to expect The existing API does not have one universal error envelope. Handle all of these: - `{"message": "..."}` — typically with 404 or 400. - `{"error": "...", "type": "api_error"}` — upstream (BPN) failure. **Some endpoints return this with HTTP 200.** Check for the presence of an `error` key, not only the status code. - `{"success": false, "message": "..."}` — some write endpoints. - **400 with a constraint-violation body** — validation failures. These are the ones you map back onto form fields; see §5. `401` means the token is missing or invalid (refresh, then retry once). `403` means insufficient scope — a bug, not a user-recoverable state. --- ## 4. Authentication OAuth2 **authorization_code with PKCE**, against the backend's `/token` endpoint. The password and implicit grants are disabled server-side and are not available to you. Requirements: - Use the system browser / `ASWebAuthenticationSession` / Custom Tabs — **not** a `WebView`. App Store review and OAuth best practice both require this. - Store tokens in the platform secure store (Keychain / Keystore), never in `AsyncStorage`. - **Access tokens are short-lived** — 10 minutes today, likely 1 hour by the time you build. Confirm with the backend team in Increment 0. Either way the refresh-token grant is enabled and you must use it. - **Refresh-token rotation is ON.** Each refresh returns a *new* refresh token and revokes the one you just used. This has two consequences you must design for, not discover: - **Single-flight refresh is mandatory.** If two requests 401 at the same time and both try to refresh, one wins and the other presents an already-revoked token — logging the user out mid-booking. Put a mutex around refresh and queue every other 401 behind it, then replay them with the new token. This is the most common OAuth bug in mobile apps and the booking flow's per-field schema refreshes will find it immediately. - **Persist the rotated token atomically before using it.** A crash between receiving a new refresh token and storing it logs the user out permanently. - The refresh TTL is a sliding window (currently ~1 month, likely 3), so an active user should never be forced to re-authenticate. If yours are, the refresh path is broken — treat it as a bug, not as expected behaviour. - The redirect URI must be registered server-side against the exact host the app calls. This bites during local development — see §8. **You will never handle credentials for the upstream BPN system.** The server resolves those internally. If any task seems to require the app to send a BPN password, stop and ask; the answer is no. --- ## 5. Server-driven UI The dynamic parts of the booking flow are rendered from a schema the server computes. You build **one generic renderer**, not per-screen field logic. ### Expected schema shape Confirm against the fixtures — they are authoritative — but the agreed shape is: ```json { "fields": [ { "name": "skiPass", "type": "choice", "label": "Skipass", "required": true, "value": 881, "choices": [ {"id": 881, "label": "6 Tage", "price": 249.0} ] }, {"name": "licensePlate", "type": "text", "hidden": true}, {"name": "pickup", "type": "choice", "readonly": true, "value": 12, "choices": []}, {"name": "firstName", "type": "text", "staticText": true, "value": "Anna"} ], "dependsOn": { "licensePlate": ["transportationOutbound"], "pickup": ["transportationOutbound"] } } ``` ### Field states — all four are distinct | State | Meaning | Render as | |---|---|---| | `hidden` | Not applicable at all | Nothing | | `staticText` | Value matters, user may not change it | Read-only text, no input affordance | | `readonly` | Input shown but not editable | Disabled-looking input | | `disabled` | Temporarily not interactive | Greyed input | `hidden` and `staticText` are genuinely different and the backend treats them differently — `staticText` is used, for example, when personal data is locked because changing it would create a duplicate customer record upstream. Collapsing the two will produce wrong screens. ### `dependsOn` Maps a field to the fields whose changes invalidate it. When the user changes field *X*, any field listing *X* in its `dependsOn` may now have different visibility, state, or choices. **Re-fetch the schema from the server** — do not attempt to recompute locally. This is the mechanism that keeps rule evaluation server-side. Debounce these refreshes and keep the UI responsive during them, but never predict the result optimistically for anything that affects price or eligibility. ### Field types you must support `text`, `email`, `tel`, `date`, `choice` (single), `multichoice`, `checkbox`, `address` (composite), plus a `static` presentation mode. Confirm the final list against the contract before building. ### Mapping validation errors back The server returns violations keyed by a property path (e.g. `participants[2].dateOfBirth`). Your renderer must resolve those onto the right field on the right participant. Design for this from the start — retrofitting it is painful. --- ## 6. Domain glossary Enough to read the API without guessing. - **Travel** — a dated departure of a product, at a specific hotel. Identified by a numeric `dateId` + `hotelId`, or by string codes. Carries the available rooms, services, pickups, insurances. - **Applicant** — participant index 0. The person who books and is billed. Subject to extra rules the others are not; their personal data is frequently locked (`staticText`). - **Participant** — one traveller. Most selections live per participant, not per booking. - **Room selection** — how many of which room type. This determines the participant count, which is why step 1 precedes step 2. - **Service** — anything bookable alongside: ski pass, equipment rental, board, courses, transport, parking, additional services. Some are mandatory, some auto-booked with opt-out, some age-restricted. - **Booking status** — `F` firm booking, `O` option (held), `A` inquiry. Determined by server rules, not user choice. - **Payment method** — `transfer` or `debit`. Debit requires bank account details. - **Voucher** — purchase vouchers (a balance), goodwill vouchers, and promo vouchers (fixed or percentage, per-participant or per-booking). **Always** server-validated; never compute a discount locally. - **Baby** — participants aged 0–2 are a real special case in eligibility and pricing. - **Mutability** — for existing bookings, BPN decides per field whether a change is still permitted. Surfaces as `staticText`/`readonly`. Never override it. --- ## 7. Increments Each increment is a working loop with the backend session; see §9. Do not start an increment before its backend endpoints and fixtures exist. ### Increment 0 — Foundations - RN app skeleton, navigation, theming, error boundary. - OAuth2 authorization_code + PKCE, secure token storage, silent refresh with request queuing. - API client: auth injection, 401-refresh-retry, the error shapes in §3, and a fixture mock mode toggled by env. - **The generic SDUI renderer** — consumes `{fields, dependsOn}`, renders every field type, emits changes, maps server violations onto fields. - Type generation from the contract wired into the build. - **Done when:** Increment 1 renders entirely through the generic renderer. ### Increment 1 — Accommodation & group offers (pilot) Local data, fast, low risk. Deliberately first so the foundations are exercised end-to-end before anything hard. - Offer list for the signed-in user, offer detail, accept an offer. - **Validates:** auth, error handling, navigation, the fixture pipeline. ### Increment 2 — Account, personal data, newsletter, registration - Sign-up, password reset, profile view/edit, newsletter preferences. - **First real use of SDUI** on a small form, before betting the booking flow on it. Expect to find gaps in the renderer here — that is the point of doing it now. ### Increment 3 — Booking read - "My travels": booking list, booking detail with participants, services and price breakdown, document/invoice download. - First BPN-backed screens. Expect slower responses and genuine upstream failures — build proper loading and error states here, and reuse them everywhere after. ### Increment 4 — *(backend only)* An internal backend refactor that produces the participant field schema. No app work. Good moment to pay down debt or firm up the renderer. ### Increment 5 — Booking edit - Edit an existing booking through a server-persisted draft: load draft, edit participants via SDUI, review changes, submit. - Honour mutability strictly — many fields will be `staticText`, and that is correct. - Drafts are server-side, so edits survive app restarts. Design the UX around that. ### Increment 6 — Booking create The full four-step flow, last, once everything above is proven. 1. **Rooms** — select room types and quantities. Determines participant count. 2. **Participants** — one card per participant, each opening an SDUI form. Groups can be large (50+); the web version lazy-loads per participant and you should too. 3. **Payment** — method, and bank details when debit. 4. **Confirmation** — full summary, terms, submit. Notes: - State lives in a **server-side draft keyed by UUID**, not on the device. The app holds the draft id and renders server state. - Price refreshes are server calls. Never compute totals locally, not even for a "preview". - Submission goes to BPN and can fail or partially succeed. Treat the submit step as genuinely fallible and make retry safe. ### Increment 7 — Discovery *(not yet scoped)* Browsing travels and hotels currently lives in a separate CMS website, not in the backend this app talks to. If the app must be a standalone entry point rather than something users deep-link into, that is a second integration project. Do not assume it is in scope. --- ## 8. Local development - The backend runs in DDEV. A **simulator** can reach it via the host; a **physical device** cannot resolve `*.ddev.site`. The backend team will expose a shareable URL (`ddev share` / ngrok). - OAuth2 redirect URIs are registered per host, so switching between simulator and device URLs needs a backend-side change. Agree the setup in Increment 0 rather than discovering it mid-increment. - Keep fixture-mock mode working permanently, not just as scaffolding — it is how you stay unblocked and how you write deterministic tests. --- ## 9. Working agreement with the backend session The backend lives in a separate repository and a separate sandboxed session. Neither side can see the other's code. The contract is the only coupling point, and keeping it that way is a shared discipline. **Per-increment loop:** 1. Backend ships: endpoints + tests + fixtures + updated guide. 2. Handoff brief: what is live, which fixtures to mock, what changed in the guide. 3. App builds against fixtures, then against a live backend. 4. Joint end-to-end verification. The two sessions do not work the same increment concurrently — they overlap by one stage, so the backend starts *N+1* while the app builds *N*. **Escalate to the backend rather than solving locally when:** - a rule seems missing from the schema; - a response shape is undocumented or contradicts the guide; - you need a field the API does not expose; - you are tempted to hardcode a business constant. Each of those is a contract gap. Fixing it in the app is the failure mode this entire architecture exists to prevent. **Suggested `AGENTS.md` for this repo**, so future sessions inherit the rule: > Business rules live server-side. This app renders a server-provided schema and never > decides field visibility, eligibility, or pricing. If a rule appears to be missing, > that is a backend bug — report it, do not implement it here. Never recompute prices > from line items or infer field visibility from other field values. --- ## 10. Verification - **Unit:** the SDUI renderer against every field type and state combination, including `hidden` vs `staticText`, and violation-path mapping onto nested participant fields. - **Auth:** an explicit test that fires several concurrent requests against an expired access token and asserts *exactly one* refresh call is made, all requests succeed, and the rotated refresh token is persisted. Rotation makes this a correctness test, not a performance one. - **Integration:** screens against committed fixtures — deterministic, no backend needed. - **Contract:** types regenerate cleanly and compile against current fixtures. A failure here is an early warning of drift; treat it as urgent. - **End-to-end:** at the close of Increment 6, complete one booking through the app against a real backend. The backend team verifies the resulting upstream payload is **identical** to the same booking made through the web flow. That equivalence is the project's actual acceptance criterion — everything else is a proxy for it.