Busflow Docs

Internal documentation portal

Skip to content

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:

TierNameExampleSurchargeTypical Count
1. OriginStartpunkt"Busbahnhof Musterstadt"Free (always)1 per tour
2. Extra StopsZustiegsstellen"Dorfplatz Nachbardorf", "Bahnhof Kleinstadt"Fixed surcharge (e.g., +€5)5–20 per tour
3. Door PickupHaustΓΌrabholungPassenger'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.

FieldDescription
nameDisplay name (e.g., "Dorfplatz Nachbardorf")
addressFull address string
geo_coordinatesLat/lng for map display, routing, and door pickup radius validation
zone_labelGrouping label (e.g., "Nachbardorf"). Provides implicit UI grouping β€” not a FK to a separate entity.
surchargeStop pickup surcharge in cents. €0 for stops that typically serve as the origin.
door_pickup_availableWhether this location offers door pickup in its surrounding area
door_pickup_surchargeDoor pickup premium in cents (e.g., 1500 = €15). Null if door_pickup_available = false.
door_pickup_radius_kmMax distance from the stop's coordinates for valid door pickup addresses. Null if door_pickup_available = false.
passenger_instructionsTraveler-facing guidance (e.g., "South exit, look for BusFlow sign")
is_archivedSoft-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.

FieldDescription
boarding_point_idFK to boarding_point_library
is_originMarks this stop as the departure origin for this template. Origin surcharge is always €0. Max one per template (CHECK constraint).
surcharge_overridePer-template surcharge override. Null = use library default. Ignored if is_origin = true.
door_pickup_overrideOverride door pickup availability for this template. Null = inherit from library.
door_pickup_surcharge_overrideOverride door pickup surcharge. Null = inherit from library.
display_orderDisplay sequence in the booking widget and template config. NOT the operational pickup route β€” that depends on actual bookings and is determined at dispatch time.
enabledQuick 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 stop
  • is_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_surcharge

Departure-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_km from the stop's geo_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.ancillaries row with type = 'BOARDING_SURCHARGE'. This reuses the existing financial pipeline.
  • The actual pickup address is stored as door_pickup_address JSONB ({ formatted_address, lat, lng }) on the Passenger entity (Commerce), not on the library item.
  • Capacity Limit: max_door_pickups (smart default: 5) lives on the template (alongside deposit_config, cancellation_policy). It travels via TripPublished to 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 = 0 appear with a "Free" badge.
  • Each zone renders as a collapsible card showing its stops (grouped by zone_label).
  • Stops without a zone_label appear 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_origin toggle).
  • Enable/disable door pickup per stop for this template.
  • Override surcharges per stop if needed.
  • Set max_door_pickups for 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 ​

ProblemHow search-first solves it
50-item flat listThe passenger never browses. They type and the system filters.
Geography + mode conflatedSearch 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+ stopsSearch 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 ​

FeatureHorizon
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.

Internal documentation β€” Busflow