v4/Architecture

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:

  1. Find all existing accounts that match the incoming identifiers
  2. Pick the best one when there are multiple matches
  3. Enrich the winning account with any new contact info (without overwriting)
  4. Create a new account only when no match exists

Identity input categories

Every incoming booking falls into one of four input categories:

CategoryCondition
ABoth phone and email provided
BPhone only
CEmail only
DNeither — 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:

ScoreCondition
2Has both phone and email
1Has phone only, or email only
0Has 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

CasePhone matchesEmail matchesResolution
A11 user0Use phone match → add email if missing
A21 user1 (same user)Return the user as-is
A31 user1 (different user)Phone wins. Log conflict. No merge across accounts.
A401 userUse email match → add phone if missing
A500Create new account (phone as primary)
A602+ usersPick best email match → add phone if missing
A72+ users0Pick best phone match → add email if missing
A82+ users2+ 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

CasePhone matchesResolution
B11 userReturn the user
B20Create new account (phone as primary contact)
B32+ usersPick best (most complete, then newest)

Case C — Email only

CaseEmail matchesResolution
C11 userReturn the user
C20Create new account (email as primary contact)
C32+ usersPick 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:

FieldValue
display_nameGuest's name from the PMS booking
contactInfo.emailEmail address (if provided)
contactInfo.phone{ country_code, national_number } (if provided)
primaryContactPHONE if phone is available, otherwise EMAIL
userTypeGUEST
roleUSER
company_idCompany identifier from the booking event