User Resolution Architecture
When a PMS booking event arrives, Mosler must decide which guest account to attach the booking to — or create a new one. This document describes the complete resolution algorithm, covering all 14 identity cases.
Why this is non-trivial
PMS systems vary widely in what guest data they send. Some send only an email, some only a phone number, some both. A guest may already exist in Mosler's database from a previous stay — but their record might be incomplete. And the same guest may have accidentally created two accounts over time.
The resolver must:
- Find all existing accounts that match the incoming identifiers
- Pick the best one when there are multiple matches
- Enrich the winning account with any new contact info (without overwriting)
- Create a new account only when no match exists
Identity input categories
Every incoming booking falls into one of four input categories:
| Category | Condition |
|---|---|
| A | Both phone and email provided |
| B | Phone only |
| C | Email only |
| D | Neither — anonymous guest |
Resolution algorithm
The worker runs two database queries in parallel — one for phone matches, one for email matches — then applies the case logic below.
Disambiguation rule — pickBestUser
When multiple accounts match the same identifier, the resolver scores each one:
| Score | Condition |
|---|---|
| 2 | Has both phone and email |
| 1 | Has phone only, or email only |
| 0 | Has neither |
Ties are broken by creation time — the most recently created account wins (MongoDB ObjectId encodes timestamp, so the largest ObjectId = newest).
Merge rule
Contact info is additive only. If a winning account is missing a phone number, the incoming phone is added to it. If it already has a phone, it is left unchanged. The same rule applies to email. Existing contact details are never overwritten.
Case A — Both phone and email provided
| Case | Phone matches | Email matches | Resolution |
|---|---|---|---|
| A1 | 1 user | 0 | Use phone match → add email if missing |
| A2 | 1 user | 1 (same user) | Return the user as-is |
| A3 | 1 user | 1 (different user) | Phone wins. Log conflict. No merge across accounts. |
| A4 | 0 | 1 user | Use email match → add phone if missing |
| A5 | 0 | 0 | Create new account (phone as primary) |
| A6 | 0 | 2+ users | Pick best email match → add phone if missing |
| A7 | 2+ users | 0 | Pick best phone match → add email if missing |
| A8 | 2+ users | 2+ users (disjoint) | Phone wins. Pick best phone match → add email if missing |
Why phone wins in conflicts (A3, A8)? Phone numbers are harder to share and more strongly tied to a physical person. An email address can be a shared or work address. When a booking sends a phone that resolves to user A and an email that resolves to user B, phone is treated as the authoritative identifier.
Case B — Phone only
| Case | Phone matches | Resolution |
|---|---|---|
| B1 | 1 user | Return the user |
| B2 | 0 | Create new account (phone as primary contact) |
| B3 | 2+ users | Pick best (most complete, then newest) |
Case C — Email only
| Case | Email matches | Resolution |
|---|---|---|
| C1 | 1 user | Return the user |
| C2 | 0 | Create new account (email as primary contact) |
| C3 | 2+ users | Pick best (most complete, then newest) |
Case D — No identifiers
An anonymous guest account is created with no contact info. Bookings can still be looked up by referenceId and device access is provisioned normally — it simply isn't associated with a contactable guest profile.
Decision flow diagram
Incoming booking: { phone?, email? }
│
├── Neither ─────────────────────────────────────► D: Create anonymous
│
│ Parallel DB fetch:
│ phoneMatches = find({ phone }) -- all matches
│ emailMatches = find({ email }) -- all matches
│
├── Phone only ──────────────────────────────────► B cases
│ 0 matches → B2: Create (phone primary)
│ 1 match → B1: Return
│ 2+ matches → B3: pickBestUser
│
├── Email only ──────────────────────────────────► C cases
│ 0 matches → C2: Create (email primary)
│ 1 match → C1: Return
│ 2+ matches → C3: pickBestUser
│
└── Both ────────────────────────────────────────► A cases
overlap? (same user in both sets)
Yes → A2: Return
No →
phoneMatches > 0
emailMatches > 0 → A3/A8: LOG conflict, phone wins
emailMatches = 0 → A1/A7: phone match, +email
phoneMatches = 0
emailMatches > 0 → A4/A6: email match, +phone
emailMatches = 0 → A5: Create (phone primary)
Duplicate account warnings
When 2+ accounts share the same phone or email, a warning is logged:
[HandlePmsBookingEventUseCase] Multiple users share the same phone number — picking best
{ count: 2, phone: "91:9991234567" }
These warnings appear in the worker logs and surface in Grafana. They indicate data quality issues that should be cleaned up in your PMS (and optionally merged in Mosler's admin panel).
Phone parsing
Phone numbers are normalised before lookup. The raw string from the PMS (e.g. +91 999-123-4567, 09991234567, +919991234567) is parsed into:
{
"country_code": 91,
"national_number": 9991234567
}
Both fields are indexed in MongoDB and matched exactly. Formatting differences in the raw string are irrelevant — two numbers are considered the same if their country_code and national_number match.
If a phone string cannot be parsed (unrecognised format, missing country code), the phone is treated as absent and the resolver falls through to email-only or anonymous resolution.
New account structure
When a guest account is created, it is initialised with:
| Field | Value |
|---|---|
display_name | Guest's name from the PMS booking |
contactInfo.email | Email address (if provided) |
contactInfo.phone | { country_code, national_number } (if provided) |
primaryContact | PHONE if phone is available, otherwise EMAIL |
userType | GUEST |
role | USER |
company_id | Company identifier from the booking event |