Busflow Docs

Internal documentation portal

Skip to content
Reviewed 20 May 2026

ADR-037: CRM Identity Model โ€” PersonProfile and Booker Concept โ€‹

Status: โœ… Approved โ€” 2026-05-20 Impacts: domain-backoffice.md, schema-backoffice.md, domain-commerce.md, schema-commerce.md, domain-communications.md, domain-customer-intelligence.md, adr-021, event-contracts-commerce.md, invoice-service-protocol.md, booking-lifecycle-protocol.md, cancellation-protocol.md

Context โ€‹

The domain model uses four different names for the same real-world person across four bounded contexts:

ContextEntityMeaning
BackofficePersonProfileCRM person record
CommercePassengerTraveler on a booking
CommunicationsContactMessaging identity
Customer IntelligenceCustomerProfileBehavioral analytics root

Three structural problems triggered this ADR:

  1. PassengerProfile bakes in the wrong assumption. The entity functions as a universal person record (de-duplication by email/phone, dietary needs, CRM history), but its name implies every person is a traveler. A company secretary booking 50 employee tours, or a grandmother booking for her grandchildren, receives a "PassengerProfile" despite never boarding a bus.

  2. No explicit Booker identity. The person who pays (booker) hides behind passengers.is_primary_contact = true. This forces the booker into a Passenger row even when they don't travel. Customer Intelligence cannot distinguish "this person booked 8 times" (commercial value) from "this person rode 8 times" (travel behavior). The booker drives revenue โ€” CI needs to identify them directly.

  3. CI identity anchor is unspecified. ADR-021 says CI "reads PassengerProfile via PersonProfileUpdated events" but never declares the FK column connecting CustomerProfile to the identity graph.

Decision โ€‹

1. Rename PassengerProfile โ†’ PersonProfile โ€‹

The Backoffice CRM record becomes PersonProfile โ€” a role-neutral anchor for any person the operator has a relationship with (booker, traveler, or both).

  • Traveler-specific fields (dietary_needs, date_of_birth) remain on PersonProfile as nullable attributes. They are populated only when the person also travels. Even for travelers, dietary_needs are optional (not every tour type cares).
  • Profile creation triggers when a person first appears as either a booker or a passenger on any booking.
  • De-duplication matches by email or phone number. Profiles without contact identifiers (e.g., children) cannot de-duplicate โ€” manual merge is the operator's responsibility via the CRM interface.
  • Physical table rename (passenger_profiles โ†’ person_profiles) and column renames (passenger_profile_id โ†’ person_profile_id) are complete. Hasura metadata, SQL schema, and documentation are aligned.

2. Add booker_profile_id on Booking [planned โ€” Phase 1] โ€‹

A new soft FK on commerce.bookings identifies the person with billing authority:

booker_profile_id UUID NULLABLE
  Soft FK โ†’ backoffice.person_profiles
  CHECK (booker_profile_id IS NOT NULL OR status = 'DRAFT' OR reseller_id IS NOT NULL)
  • B2C checkout (Phase 1): The form submitter is assumed to also be the primary contact passenger. The submitCheckout handler resolves booker_profile_id from that passenger's PersonProfile. Capturing a non-traveler booker (the grandmother case) requires a checkout UI change โ€” deferred to Phase 1.x. The data model already supports it via the booker/passenger separation.
  • Dispatcher-created bookings: The dispatcher sets booker_profile_id directly. The booker need not appear as a Passenger row.
  • B2B bookings (Reseller): booker_profile_id is optional when reseller_id is present. The reseller_id captures the primary commercial relationship; the booker provides optional CRM depth on the agency contact. CI limitation: B2B bookings without booker_profile_id do not attribute lifetime-value or booking-count metrics to individual end-customers. Aggregate revenue is attributed at the Reseller level. A future enhancement may capture the end-customer's identity at quote conversion.
  • Migration backfill: UPDATE bookings SET booker_profile_id = (SELECT person_profile_id FROM passengers WHERE booking_id = bookings.id AND is_primary_contact = true LIMIT 1).

3. Redefine is_primary_contact on Passenger โ€‹

is_primary_contact no longer indicates billing authority. It now means: "the on-trip communication recipient and driver's on-site contact." Billing authority lives exclusively on bookings.booker_profile_id.

4. Communication Routing Split โ€‹

Message typeRecipientResolution
Transactional (confirmations, invoices, payment reminders, cancellation refunds)Bookerbookings.booker_profile_id โ†’ person_profiles
Operational (pre-departure reminders, boarding details)Primary contact passengerpassengers WHERE is_primary_contact = true

Communications auto-creates or matches a Contact for the booker's PersonProfile via email/phone de-duplication.

5. CI Identity Anchor โ€‹

  • CustomerProfile carries person_profile_id (soft FK to Backoffice PersonProfile). CI creates a CustomerProfile only when a PersonProfile exists.
  • BookingConfirmed events carry booker_profile_id. CI attributes lifetime_revenue, booking_count, and average_booking_lead_time to the booker's CustomerProfile. Travelers receive travel-specific metrics only.
  • Other lifecycle events (BookingCancelled, BookingRefunded, BookingFullyPaid, BookingCompleted, BookingNoShow) do not carry booker_profile_id in their payloads. CI resolves the booker by joining booking_id โ†’ bookings.booker_profile_id at consumption time.
  • CI event consumers discard communication events from Contact records without a person_profile_id link.

6. Authorization Update โ€‹

Self-service cancelBooking authorizes on the booker (billing authority), not on is_primary_contact. A child listed as primary contact should not cancel a booking their grandmother paid for.

7. Border Document Authority and Pre-Fill โ€‹

Border manifest fields (document_number, nationality) exist only on Commerce Passenger โ€” not on PersonProfile. These are point-in-time travel credentials, not identity attributes:

  • Passport numbers expire and renew between bookings. The border manifest requires the document the traveler carries on this departure, not the one from a previous trip.
  • PersonProfile deliberately omits these fields to avoid a stale-data trap where the CRM claims "passport X123" while the traveler renewed to "Y456."
  • Conditional requirement. document_number and nationality are required only for cross-border tours. They are nullable in the schema โ€” domestic tours (e.g., Nordsee day trips) do not need border manifest data. The operator bears responsibility for collecting the right fields for their specific routes.

DOB sync direction: date_of_birth is the one shared field โ€” it exists on both PersonProfile (CRM, demographic pricing) and Passenger (border manifests). On BookingConfirmed, the PersonProfile updater writes back the Passenger's DOB to PersonProfile.date_of_birth โ€” the most recent booking input wins. If a Passenger row omits DOB (domestic tour), the system does not overwrite an existing PersonProfile DOB. Rationale: the traveler actively re-entered it at booking time and may have corrected a typo.

Pre-fill strategy (returning travelers): The checkout and dispatcher UIs query the traveler's most recent Passenger row (passengers WHERE person_profile_id = :id ORDER BY created_at DESC LIMIT 1) and offer the previous document_number, nationality, and date_of_birth as pre-filled defaults. The traveler confirms or updates before submission. This is a UI-layer read-only suggestion โ€” the Passenger row remains the authoritative, booking-scoped record. Pre-fill requires a linked person_profile_id โ€” children and other passengers without contact identifiers may lack a stable profile link (see ยง2 de-duplication limits) and require manual data entry.

GDPR note: document_number, nationality, and date_of_birth are included in the scrub_passengers tombstone set alongside first_name, last_name, email, and phone. A passport number is a government-issued personal identifier โ€” it must be redacted on the same schedule as other PII.

Consequences โ€‹

Positive โ€‹

  • Identity-role separation. "Who is this person?" (PersonProfile) and "what role do they play on this booking?" (booker vs. traveler) are distinct questions with distinct answers.
  • CI revenue attribution. CI can attribute lifetime value to the person who pays, not the person who rides.
  • CRM accuracy. Booker-only records (e.g., company secretaries) no longer carry empty traveler fields with an incorrect "Passenger" label.

Negative โ€‹

  • ~28 documentation files require updates. One-time cost, no code changes.
  • Physical renames complete. passenger_profiles โ†’ person_profiles (table), passenger_profile_id โ†’ person_profile_id (columns on commerce.passengers and communications.contacts), trip_id โ†’ service_leg_id (on communications.conversations). Hasura metadata, SQL schema, index migrations, and all documentation are aligned.
  • GDPR cascade required. bookings.booker_profile_id follows the same GDPR cascade as passengers.person_profile_id โ€” soft-nullified on PersonProfile erasure. invoices.recipient_snapshot retains the frozen booker PII per GoBD ยง 147 AO (10-year retention) โ€” the snapshot is NOT redacted on PersonProfile erasure.

Neutral โ€‹

  • CI entity names unchanged. CustomerProfile, CustomerActivityLog, CustomerSegment stay โ€” "customer" is CI's own ubiquitous language, industry-standard for analytics contexts.
  • Passenger entity name stays. It correctly means "a traveler on a booking."

Rejected Alternatives โ€‹

Option B โ€” Separate BookerProfile alongside PassengerProfile โ€‹

Create a new BookerProfile entity for the payer, keep PassengerProfile for travelers. Rejected because:

  1. De-duplication splits. A person who is both booker and traveler exists in two tables. Merge logic becomes a cross-entity join problem.
  2. Contact resolution ambiguity. Communications would need to link to both BookerProfile and PassengerProfile โ€” polymorphic FK or union query.
  3. The CRM already functions as a universal record. The existing de-duplication logic (email/phone match) treats the profile as person-centric, not role-centric. Renaming aligns the name with the existing behavior.

Cross-References โ€‹

DocumentSectionChange
domain-backoffice.mdEntity dictionaryPassengerProfile โ†’ PersonProfile
schema-backoffice.mdperson_profilesPhysical rename complete
domain-commerce.mdBooking entityAdd booker_profile_id
schema-commerce.mdbookings tableAdd booker_profile_id column
domain-model.mdSoft FK mapNew row: Booking.booker_profile_id โ†’ PersonProfile
invoice-service-protocol.mdยง 14 UStG mappingRecipient source โ†’ booker
booking-lifecycle-protocol.mdcancelBooking guardAuth on booker
event-contracts-commerce.mdPayloadspassenger_email โ†’ booker_email
adr-021Identity anchorperson_profile_id specification

Internal documentation โ€” Busflow