Feature Spec: Boarding Points (Backoffice) β
Goal: Design a smart boarding point system that supports the 3-tier pickup model used by DACH bus tour operators β without the 50-entry flat-list problem of competitor tools.
Domain Concepts β
The 3-Tier Pickup Model β
DACH bus tour operators use a consistent 3-tier model for passenger pickups:
| Tier | Name | Example | Surcharge | Typical Count |
|---|---|---|---|---|
| 1. Origin | Startpunkt | "Busbahnhof Musterstadt" | Free (always) | 1 per tour |
| 2. Extra Stops | Zustiegsstellen | "Dorfplatz Nachbardorf", "Bahnhof Kleinstadt" | Fixed surcharge (e.g., +β¬5) | 5β20 per tour |
| 3. Door Pickup | HaustΓΌrabholung | Passenger's own address (freeform) | Premium surcharge (e.g., +β¬15) | Dynamic β depends on bookings |
The Competitor Problem β
Competitor tools (Kuschick, Turista) present boarding points as a flat dropdown. An operator serving a 10 km radius ends up with 50+ entries: every village appears twice (central stop + door pickup). Passengers see a wall of text. Operators maintain a nightmare of duplicate entries.
Root cause: Competitors conflate two dimensions into one list β geography (which village) and pickup mode (stop vs. door). The fix belongs in the UI layer, not the data model.
Zones as a Grouping Concept β
Extra stops and door pickups cluster geographically into zones (e.g., "Nachbardorf", "Kleinstadt-SΓΌd"). A zone might contain multiple centralized stop options. If door pickup is enabled on any stop in that zone, the passenger sees it as a contextual upsell. The zone_label attribute on stops provides implicit grouping for the UI.
Data Model β
Boarding point data lives in a single operator-level library. Each entry represents a physical pickup location that optionally also offers door pickup in its surrounding area.
Boarding Point Library β
Operators maintain a flat library of pickup stops. Each belongs to the operator β not to a template or departure. Stops are reusable across templates via assignment.
| Field | Description |
|---|---|
name | Display name (e.g., "Dorfplatz Nachbardorf") |
address | Full address string |
geo_coordinates | Lat/lng for map display, routing, and door pickup radius validation |
zone_label | Grouping label (e.g., "Nachbardorf"). Provides implicit UI grouping β not a FK to a separate entity. |
surcharge | Stop pickup surcharge in cents. β¬0 for stops that typically serve as the origin. |
door_pickup_available | Whether this location offers door pickup in its surrounding area |
door_pickup_surcharge | Door pickup premium in cents (e.g., 1500 = β¬15). Null if door_pickup_available = false. |
door_pickup_radius_km | Max distance from the stop's coordinates for valid door pickup addresses. Null if door_pickup_available = false. |
passenger_instructions | Traveler-facing guidance (e.g., "South exit, look for BusFlow sign") |
is_archived | Soft-delete flag (disappears from pickers, preserved on existing assignments). |
NOTE
No type enum. Whether a stop serves as the origin (free, main departure) or an extra stop (surcharge) is a per-template decision, not a property of the library item itself. "Busbahnhof Musterstadt" can be the origin on Nordsee tours and an extra stop on Alpen tours that depart from MΓΌnchen. The is_origin toggle lives on the template assignment.
NOTE
Door pickup is a property, not a separate entity. A door pickup zone is always anchored to a stop's geographic area. "Door pickup in Nachbardorf" means picking up passengers near the Nachbardorf stop. The stop's geo_coordinates serve as the zone centroid. Splitting stops and zones into separate tables would create a polymorphic FK on passengers, string-based cross-table coupling, and two parallel assignment tables β complexity that yields no V0.1 benefit.
Template Assignment β
Which library items are assigned to which template, with per-template configuration.
| Field | Description |
|---|---|
boarding_point_id | FK to boarding_point_library |
is_origin | Marks this stop as the departure origin for this template. Origin surcharge is always β¬0. Max one per template (CHECK constraint). |
surcharge_override | Per-template surcharge override. Null = use library default. Ignored if is_origin = true. |
door_pickup_override | Override door pickup availability for this template. Null = inherit from library. |
door_pickup_surcharge_override | Override door pickup surcharge. Null = inherit from library. |
display_order | Display sequence in the booking widget and template config. NOT the operational pickup route β that depends on actual bookings and is determined at dispatch time. |
enabled | Quick toggle to disable a stop for this template without removing the assignment. |
This gives operators fine-grained control: a budget tour can use Nachbardorf stops without offering door pickup, while the premium tour enables it.
Commerce Reference: What does the passenger select? β
When a passenger picks a stop, Commerce stores a boarding_point_id referencing boarding_point_library. When they pick door pickup, Commerce stores the same boarding_point_id (the stop whose area they're in) plus is_door_pickup = true and the geocoded door_pickup_address.
is_door_pickup = falseβ passenger boards at the physical stopis_door_pickup = trueβ passenger boards at their address within the stop's radius
NOTE
Single FK, no polymorphism. passengers.boarding_point_id always references boarding_point_library. The is_door_pickup boolean indicates the pickup mode, not which table the FK points to. Hasura generates a clean relationship.
Resolution Cascade β
Effective Surcharge = is_origin ? 0 : (surcharge_override ?? library.surcharge)
Effective Door Pickup = door_pickup_override ?? library.door_pickup_available
Effective Door Pickup β¬ = door_pickup_surcharge_override ?? library.door_pickup_surchargeDeparture-level overrides follow the same "inherit with optional override" pattern as ancillaries. Departures inherit all template assignments at V0.1. A departure_boarding_point_overrides table arrives at [v0.2] for per-departure stop disabling.
IMPORTANT
Pickup times and boarding order are future scope. Both are operational concerns that depend on the finalized passenger list and actual door pickup bookings β not product configuration. Defining a boarding order at the library level implies a static route that ignores real-world booking patterns. Both require their own design at [v0.2] (likely a dispatch-side table or computation). Tracked as a drilldown target.
Door Pickup Mechanics β
- When a passenger selects door pickup during checkout, they enter their freeform address. The booking widget geocodes it via Google Places API and validates it falls within the stop's
door_pickup_radius_kmfrom the stop'sgeo_coordinates. - Surcharge model: A door pickup surcharge is standalone β the passenger pays the door pickup surcharge only, not the stop surcharge on top. Each pickup mode (stop or door) carries its own independent surcharge.
- Surcharge as Ancillary: When a passenger selects a boarding point with a surcharge (stop or door pickup), Commerce auto-creates a
commerce.ancillariesrow withtype = 'BOARDING_SURCHARGE'. This reuses the existing financial pipeline. - The actual pickup address is stored as
door_pickup_address JSONB({ formatted_address, lat, lng }) on thePassengerentity (Commerce), not on the library item. - Capacity Limit:
max_door_pickups(smart default: 5) lives on the template (alongsidedeposit_config,cancellation_policy). It travels viaTripPublishedto Commerce, which enforces it at checkout time. - At dispatch time (
[v0.2]), the routing engine uses all door pickup addresses to calculate the optimal morning pickup sequence.
Checkout Session Integration β
The checkout_sessions.selected_options JSONB carries door pickup data: boarding_point_id (always referencing boarding_point_library), is_door_pickup, and door_pickup_address. Commerce uses these during the CheckoutSession β Booking conversion to populate passengers.door_pickup_address and auto-create the BOARDING_SURCHARGE ancillary.
Archive Behavior β
Library items support an archived state (via is_archived).
- Same as Ancillaries: Archived items stay on existing template assignments and historical departures.
- They immediately disappear from the picker for any new assignments or bookings.
Commerce Projection β
When a TourDeparture publishes (via the TripPublished event), the Backoffice handler resolves the template assignment cascade (library defaults β template overrides β is_origin surcharge zeroing) and emits the result as a boarding_points: TripPublishedBoardingPoint[] array in the payload.
Commerce stores this array as-is into tour_offerings.available_boarding_points JSONB. The booking widget reads directly from this projection β no cross-schema query to Backoffice and no further resolution at read time.
NOTE
The TripPublishedBoardingPoint interface is defined in schema-operations.md Β§TripPublished Consumer Handler. The available_boarding_points column is defined in schema-commerce.md Β§tour_offerings.
Workspace UX: Operator Side β
Library View (Tour Overview β Tab 3: Boarding Points) β
The library displays zone-grouped cards rather than a flat table.
- Origin is not visually distinct at the library level (any stop can be an origin per template). Instead, stops with
surcharge = 0appear with a "Free" badge. - Each zone renders as a collapsible card showing its stops (grouped by
zone_label). - Stops without a
zone_labelappear in an "Ungrouped" section. - Adding a stop prompts for zone assignment (new or existing
zone_label).
Actions:
- "Add Stop" β Name, address, geo (auto-geocoded from address), zone label, surcharge, passenger instructions.
- "Enable Door Pickup" (per stop card) β Toggles
door_pickup_available, sets radius and surcharge. The stop's coordinates serve as the zone centroid. - "Disable Door Pickup" β Toggles
door_pickup_available = false.
Smart creation UX ideas ([v0.2]):
- Map-based editor: drop pins on a map, draw zone radius circles.
- Bulk import from a CSV (common operator request during migration).
Template Detail β Tab 4: Boarding Points β
- Pick stops from the library to assign to this template. Multi-select picker β the operator selects multiple stops at once, not one by one.
- Mark one stop as origin for this template (
is_origintoggle). - Enable/disable door pickup per stop for this template.
- Override surcharges per stop if needed.
- Set
max_door_pickupsfor this template (smart default: 5).
Departure Detail β Tab 3: Boarding Points & Logistics β
- Inherited from the template's assigned stops.
- Per-departure overrides (
[v0.2]): disable specific stops for this date (e.g., "road works on Dorfplatz"). - Pickup Sequence Preview: Ordered list showing the morning route. At
[v0.2], this visualizes on a map with door pickup addresses from actual bookings.
Commerce UX: Passenger Side (Booking Widget) β
IMPORTANT
This section describes the booking widget experience (apps/booking-widget). It is documented here because the data model decisions directly shape it.
The Selection Model: Search-First with Zone Fallback β
The booking widget solves the 50-item flat list by separating geography from pickup mode.
Primary input: Search. The passenger types their address, town, or zip code. The system geocodes it and shows available options near that location, grouped by mode:
βββββββββββββββββββββββββββββββββββββββββββββββ
β π Busbahnhof Musterstadt β
β Main departure β Free β
βββββββββββββββββββββββββββββββββββββββββββββββ€
β π Where are you? Enter address or town β
β βββββββββββββββββββββββββββββββββββββββ β
β β Nachbar... β β
β βββββββββββββββββββββββββββββββββββββββ β
β β
β Results for "Nachbardorf": β
β β
β π Dorfplatz Nachbardorf (3 min walk) +β¬5 β
β π Bahnhof Nachbardorf (8 min walk) +β¬5 β
β π Door pickup at your address +β¬15 β
β β
βββββββββββββββββββββββββββββββββββββββββββββββIf the passenger enters an address outside all stop areas: "We don't serve your area directly. Nearest option: Busbahnhof Musterstadt (Free)."
Fallback: Browse. A "Browse all pickup locations" link expands to a zone-grouped list. Passengers who want to browse see ~10 zone cards (not 50 flat entries). Each zone card expands to show its stops + door pickup option.
If door pickup is selected: A second input asks for the exact address. The system geocodes and validates it against the stop's door_pickup_radius_km before proceeding.
NOTE
Geocoding dependency. The search-first UX requires a Places API integration (Google Places or Mapbox) for autocomplete and distance calculations. Geocoding is already required for door pickup address validation (checking if an address falls within a stop's radius). The search UX is an incremental addition on top of that baseline dependency.
Why This Works β
| Problem | How search-first solves it |
|---|---|
| 50-item flat list | The passenger never browses. They type and the system filters. |
| Geography + mode conflated | Search resolves geography. Mode options appear after. |
| Door pickup invisible | "Door pickup at your address" appears contextually when the entered location falls within a stop's radius. |
| Scales to 100+ stops | Search scales linearly. Adding stops does not degrade UX. |
Logistics Integration ([v0.2]) β
When the Dispatch module ships, boarding points affect ServiceLeg creation:
- Each stop assigned to a template maps to a
PICKUP-typed ServiceLeg. - Door pickup bookings generate dynamic routing: the system optimizes the pickup sequence across multiple freeform addresses within the stop's radius.
Phasing β
| Feature | Horizon |
|---|---|
| Boarding Point Library (flat, operator-level) | [v0.1] |
Template β library assignment with is_origin per template | [v0.1] |
| Door pickup toggle + surcharge + radius per stop | [v0.1] |
| Departure β inherited stops (no per-departure overrides) | [v0.1] |
| Surcharge per stop with template overrides | [v0.1] |
| Booking Widget: search-first selection UX | [v0.1] |
| Booking Widget: freeform address geocoding validation | [v0.1] |
Commerce projection via TripPublished | [v0.1] |
| Departure-specific pickup times + boarding order (drilldown TBD) | [v0.2] |
| Per-departure stop overrides (disable/enable) | [v0.2] |
| Map-based editor for stop creation | [v0.2] |
| Bulk CSV import of boarding points | [v0.2] |
| Network grouping layer (bulk assignment) | [v0.2] |
| Dispatch: PICKUP ServiceLeg creation | [v0.2] |
| Dispatch: dynamic route optimization | [v0.2]β[future] |
Onboarding Strategy β
NOTE
No onboarding seeds. Unlike ancillaries, boarding points are location-specific β no universal defaults exist. The Concierge Onboarding wizard prompts: "Where is your main departure point?" and creates the operator's first library item from that address.
Future ([v0.2]): Based on the first departure point, the system could suggest nearby extra stops and enable door pickup automatically.