Hostaway Field Mapping Contracts
Summary
This document defines the complete field mapping specifications between TVL's internal data models and Hostaway's API schemas. Field mappings enable bi-directional data synchronization, ensuring that property listings, bookings, availability, and pricing data are correctly transformed when flowing between TVL and Hostaway.
The document includes:
- Field-level mappings for all entity types
- Transformation functions for data conversion
- Validation rules for data integrity
- Concrete examples with before/after data
Table of Contents
- Mapping Architecture
- Property & Unit Mappings
- Booking Mappings
- Availability Mappings
- Pricing Mappings
- Transformation Functions
- Validation Rules
- Examples
- Validation & Alternatives
Mapping Architecture
Mapping Strategy
TVL uses a configuration-driven mapping approach where field mappings are stored in the database and can be customized per channel or listing. This allows flexibility without code changes.
interface FieldMapping {
  id: string;
  channelId: string;           // Hostaway channel ID
  entityType: EntityType;      // 'property', 'booking', 'availability', 'pricing'
  direction: Direction;        // 'inbound', 'outbound', 'bidirectional'
  sourceField: string;         // TVL field path (e.g., 'property.name')
  targetField: string;         // Hostaway field path (e.g., 'listing.name')
  transformation?: string;     // Optional transform function
  defaultValue?: any;          // Fallback if source is null
  isRequired: boolean;         // Required by target system
  validationRule?: string;     // Custom validation
}
enum EntityType {
  PROPERTY = 'property',
  UNIT = 'unit',
  BOOKING = 'booking',
  AVAILABILITY = 'availability',
  PRICING = 'pricing'
}
enum Direction {
  INBOUND = 'inbound',         // Hostaway → TVL
  OUTBOUND = 'outbound',       // TVL → Hostaway
  BIDIRECTIONAL = 'bidirectional'
}
Mapper Service
class FieldMapperService {
  async mapToHostaway<T>(
    entityType: EntityType,
    tvlData: any,
    channelId: string
  ): Promise<T> {
    // Fetch mappings for channel and entity type
    const mappings = await this.getMappings(channelId, entityType, 'outbound');
    const hostawayData: any = {};
    for (const mapping of mappings) {
      const sourceValue = this.getNestedValue(tvlData, mapping.sourceField);
      // Apply transformation if specified
      let targetValue = mapping.transformation
        ? await this.transform(sourceValue, mapping.transformation)
        : sourceValue;
      // Use default if null and default specified
      if (targetValue == null && mapping.defaultValue != null) {
        targetValue = mapping.defaultValue;
      }
      // Validate required fields
      if (mapping.isRequired && targetValue == null) {
        throw new MappingError(
          `Required field missing: ${mapping.targetField}`,
          { mapping, tvlData }
        );
      }
      // Set value in target object
      this.setNestedValue(hostawayData, mapping.targetField, targetValue);
    }
    return hostawayData as T;
  }
  async mapFromHostaway<T>(
    entityType: EntityType,
    hostawayData: any,
    channelId: string
  ): Promise<T> {
    const mappings = await this.getMappings(channelId, entityType, 'inbound');
    const tvlData: any = {};
    for (const mapping of mappings) {
      const sourceValue = this.getNestedValue(hostawayData, mapping.targetField);
      let targetValue = mapping.transformation
        ? await this.transform(sourceValue, mapping.transformation)
        : sourceValue;
      if (targetValue == null && mapping.defaultValue != null) {
        targetValue = mapping.defaultValue;
      }
      this.setNestedValue(tvlData, mapping.sourceField, targetValue);
    }
    return tvlData as T;
  }
  private getNestedValue(obj: any, path: string): any {
    return path.split('.').reduce((acc, part) => acc?.[part], obj);
  }
  private setNestedValue(obj: any, path: string, value: any): void {
    const parts = path.split('.');
    const last = parts.pop()!;
    const target = parts.reduce((acc, part) => {
      if (!acc[part]) acc[part] = {};
      return acc[part];
    }, obj);
    target[last] = value;
  }
  private async transform(value: any, transformName: string): Promise<any> {
    const transformFn = transformations[transformName];
    if (!transformFn) {
      throw new Error(`Unknown transformation: ${transformName}`);
    }
    return transformFn(value);
  }
}
Property & Unit Mappings
TVL Property/Unit Schema
interface Property {
  id: string;
  organizationId: string;
  name: string;
  description: string;
  propertyType: PropertyType;
  address: {
    street: string;
    city: string;
    state: string;
    postalCode: string;
    country: string;
    latitude?: number;
    longitude?: number;
  };
  amenities: string[];         // ['wifi', 'parking', 'pool', ...]
  photos: {
    url: string;
    caption?: string;
    order: number;
  }[];
}
interface Unit {
  id: string;
  propertyId: string;
  name: string;
  unitType: UnitType;          // 'entire_place', 'private_room', 'shared_room'
  maxGuests: number;
  bedrooms: number;
  beds: number;
  bathrooms: number;
  bathroomType: 'private' | 'shared';
  squareFeet?: number;
  checkInTime: string;         // '15:00'
  checkOutTime: string;        // '11:00'
  minStay: number;             // Minimum nights
  maxStay?: number;            // Maximum nights
}
enum PropertyType {
  HOUSE = 'house',
  APARTMENT = 'apartment',
  CONDO = 'condo',
  VILLA = 'villa',
  CABIN = 'cabin',
  COTTAGE = 'cottage'
}
enum UnitType {
  ENTIRE_PLACE = 'entire_place',
  PRIVATE_ROOM = 'private_room',
  SHARED_ROOM = 'shared_room'
}
Hostaway Listing Schema
{
  "id": "hostaway_lst_123",
  "name": "Beautiful Beachfront Villa",
  "address": {
    "street": "123 Ocean Drive",
    "city": "Miami Beach",
    "state": "FL",
    "zipcode": "33139",
    "country": "US",
    "lat": 25.7617,
    "lng": -80.1918
  },
  "propertyTypeId": 1,
  "roomTypeId": 1,
  "accommodates": 8,
  "bedrooms": 4,
  "beds": 5,
  "bathrooms": 3.5,
  "timezone": "America/New_York",
  "checkInTime": "15:00",
  "checkOutTime": "11:00",
  "currency": "USD",
  "amenities": [1, 5, 12, 23],
  "pictures": [
    {
      "url": "https://cdn.example.com/photo1.jpg",
      "caption": "Living room view",
      "sortOrder": 1
    }
  ],
  "status": "active"
}
Field Mappings (Outbound: TVL → Hostaway)
| TVL Field | Hostaway Field | Transform | Required | Default | Validation | 
|---|---|---|---|---|---|
| property.name | name | - | Yes | - | Max 100 chars | 
| property.description | description | - | No | - | Max 5000 chars | 
| property.propertyType | propertyTypeId | mapPropertyType | Yes | 1 | Valid type ID | 
| unit.unitType | roomTypeId | mapRoomType | Yes | 1 | Valid room ID | 
| property.address.street | address.street | - | Yes | - | Max 255 chars | 
| property.address.city | address.city | - | Yes | - | - | 
| property.address.state | address.state | - | Yes | - | 2-letter code | 
| property.address.postalCode | address.zipcode | - | Yes | - | - | 
| property.address.country | address.country | - | Yes | - | 2-letter ISO | 
| property.address.latitude | address.lat | - | No | - | -90 to 90 | 
| property.address.longitude | address.lng | - | No | - | -180 to 180 | 
| unit.maxGuests | accommodates | roundUp | Yes | - | Min 1 | 
| unit.bedrooms | bedrooms | roundUp | Yes | 1 | Min 0 | 
| unit.beds | beds | roundUp | Yes | 1 | Min 1 | 
| unit.bathrooms | bathrooms | - | Yes | 1 | Min 0 | 
| unit.checkInTime | checkInTime | formatTime | No | '15:00' | HH:MM | 
| unit.checkOutTime | checkOutTime | formatTime | No | '11:00' | HH:MM | 
| property.amenities | amenities | mapAmenities | No | [] | Array of IDs | 
| property.photos | pictures | mapPhotos | No | [] | Max 50 | 
Field Mappings (Inbound: Hostaway → TVL)
| Hostaway Field | TVL Field | Transform | Required | Default | 
|---|---|---|---|---|
| id | listing.externalId | - | Yes | - | 
| name | property.name | sanitize | Yes | - | 
| address.street | property.address.street | - | Yes | - | 
| address.city | property.address.city | - | Yes | - | 
| address.state | property.address.state | - | Yes | - | 
| address.zipcode | property.address.postalCode | - | Yes | - | 
| address.country | property.address.country | - | Yes | - | 
| address.lat | property.address.latitude | - | No | null | 
| address.lng | property.address.longitude | - | No | null | 
| propertyTypeId | property.propertyType | mapPropertyTypeFromId | Yes | 'house' | 
| roomTypeId | unit.unitType | mapRoomTypeFromId | Yes | 'entire_place' | 
| accommodates | unit.maxGuests | - | Yes | - | 
| bedrooms | unit.bedrooms | - | Yes | 1 | 
| beds | unit.beds | - | Yes | 1 | 
| bathrooms | unit.bathrooms | - | Yes | 1 | 
| amenities | property.amenities | mapAmenitiesFromIds | No | [] | 
| pictures | property.photos | mapPhotosFromHostaway | No | [] | 
Booking Mappings
TVL Booking Schema
interface Booking {
  id: string;
  unitId: string;
  externalId?: string;         // Hostaway reservation ID
  externalSource?: string;     // 'hostaway'
  channelName?: string;        // 'Airbnb', 'VRBO'
  confirmationCode: string;
  status: BookingStatus;
  checkInDate: string;         // YYYY-MM-DD
  checkOutDate: string;        // YYYY-MM-DD
  numberOfNights: number;
  guest: {
    name: string;
    email: string;
    phone?: string;
  };
  guests: {
    adults: number;
    children: number;
    infants: number;
  };
  pricing: {
    baseAmount: number;        // Cents
    cleaningFee: number;       // Cents
    taxAmount: number;         // Cents
    totalAmount: number;       // Cents
    currency: string;          // 'USD'
  };
  isPaid: boolean;
  createdAt: string;
}
enum BookingStatus {
  PENDING = 'pending',
  CONFIRMED = 'confirmed',
  CANCELLED = 'cancelled',
  COMPLETED = 'completed'
}
Hostaway Reservation Schema
{
  "id": "hostaway_res_123",
  "listingId": "hostaway_lst_456",
  "channelId": "airbnb",
  "channelName": "Airbnb",
  "confirmationCode": "ABC123XYZ",
  "status": "confirmed",
  "arrivalDate": "2025-11-15",
  "departureDate": "2025-11-20",
  "nights": 5,
  "guestName": "John Doe",
  "guestEmail": "john@example.com",
  "guestPhone": "+1-555-0123",
  "numberOfGuests": 4,
  "adults": 3,
  "children": 1,
  "infants": 0,
  "totalPrice": 1500.00,
  "cleaningFee": 150.00,
  "taxAmount": 120.00,
  "currency": "USD",
  "isPaid": false,
  "isManuallyChecked": false,
  "insertedOn": "2025-10-24T10:00:00Z"
}
Field Mappings (Inbound: Hostaway → TVL)
| Hostaway Field | TVL Field | Transform | Required | Default | Notes | 
|---|---|---|---|---|---|
| id | externalId | - | Yes | - | Idempotency key | 
| listingId | unitId | mapListingToUnit | Yes | - | Via listing lookup | 
| channelName | channelName | - | No | 'Unknown' | Display name | 
| confirmationCode | confirmationCode | - | Yes | - | - | 
| status | status | mapBookingStatus | Yes | 'confirmed' | - | 
| arrivalDate | checkInDate | - | Yes | - | YYYY-MM-DD | 
| departureDate | checkOutDate | - | Yes | - | YYYY-MM-DD | 
| nights | numberOfNights | - | Yes | - | Calculated field | 
| guestName | guest.name | sanitize | Yes | - | - | 
| guestEmail | guest.email | toLowerCase | Yes | - | - | 
| guestPhone | guest.phone | formatPhone | No | null | - | 
| adults | guests.adults | - | Yes | 1 | Min 1 | 
| children | guests.children | - | Yes | 0 | Min 0 | 
| infants | guests.infants | - | Yes | 0 | Min 0 | 
| totalPrice | pricing.totalAmount | dollarsToCents | Yes | - | USD cents | 
| cleaningFee | pricing.cleaningFee | dollarsToCents | No | 0 | USD cents | 
| taxAmount | pricing.taxAmount | dollarsToCents | No | 0 | USD cents | 
| currency | pricing.currency | - | Yes | 'USD' | ISO code | 
| isPaid | isPaid | - | No | false | - | 
| insertedOn | createdAt | - | Yes | - | ISO timestamp | 
Field Mappings (Outbound: TVL → Hostaway)
Bookings typically flow inbound only (Hostaway → TVL), as guests book on OTAs and Hostaway notifies TVL. However, if TVL creates a booking (direct booking), it may need to push to Hostaway:
| TVL Field | Hostaway Field | Transform | Required | Notes | 
|---|---|---|---|---|
| unitId | listingId | mapUnitToListing | Yes | Via listing lookup | 
| confirmationCode | confirmationCode | - | Yes | - | 
| status | status | mapBookingStatusToHostaway | Yes | - | 
| checkInDate | arrivalDate | - | Yes | YYYY-MM-DD | 
| checkOutDate | departureDate | - | Yes | YYYY-MM-DD | 
| guest.name | guestName | - | Yes | - | 
| guest.email | guestEmail | - | Yes | - | 
| guest.phone | guestPhone | - | No | - | 
| guests.adults | adults | - | Yes | Min 1 | 
| guests.children | children | - | No | Default 0 | 
| pricing.totalAmount | totalPrice | centsToDollars | Yes | USD dollars | 
| pricing.cleaningFee | cleaningFee | centsToDollars | No | USD dollars | 
Availability Mappings
TVL Availability Schema
interface AvailabilityBlock {
  id: string;
  unitId: string;
  startDate: string;           // YYYY-MM-DD
  endDate: string;             // YYYY-MM-DD
  status: BlockStatus;
  reason?: string;             // 'booking', 'maintenance', 'owner_block'
  minStay?: number;
  maxStay?: number;
}
enum BlockStatus {
  AVAILABLE = 'available',
  BLOCKED = 'blocked',
  BOOKED = 'booked'
}
Hostaway Calendar Schema
{
  "listingId": "hostaway_lst_456",
  "calendar": [
    {
      "date": "2025-11-15",
      "status": "unavailable",
      "reason": "booked",
      "minStay": 2,
      "maxStay": null
    },
    {
      "date": "2025-11-16",
      "status": "available",
      "minStay": 3,
      "maxStay": 14
    }
  ]
}
Field Mappings (Outbound: TVL → Hostaway)
| TVL Field | Hostaway Field | Transform | Required | Default | Notes | 
|---|---|---|---|---|---|
| unitId | listingId | mapUnitToListing | Yes | - | Via listing lookup | 
| startDate | date | - | Yes | - | Each date in range | 
| status | status | mapAvailabilityStatus | Yes | - | 'available' or 'unavailable' | 
| reason | reason | - | No | null | Display only | 
| minStay | minStay | - | No | null | Minimum nights | 
| maxStay | maxStay | - | No | null | Maximum nights | 
Availability Status Mapping:
function mapAvailabilityStatus(tvlStatus: BlockStatus): string {
  switch (tvlStatus) {
    case BlockStatus.AVAILABLE:
      return 'available';
    case BlockStatus.BLOCKED:
    case BlockStatus.BOOKED:
      return 'unavailable';
    default:
      return 'unavailable';
  }
}
Field Mappings (Inbound: Hostaway → TVL)
| Hostaway Field | TVL Field | Transform | Required | Notes | 
|---|---|---|---|---|
| listingId | unitId | mapListingToUnit | Yes | Via listing lookup | 
| date | startDate&endDate | singleDate | Yes | Same date | 
| status | status | mapAvailabilityStatusFromHostaway | Yes | - | 
| reason | reason | - | No | - | 
Pricing Mappings
TVL Pricing Schema
interface PriceRule {
  id: string;
  unitId: string;
  startDate: string;           // YYYY-MM-DD
  endDate: string;             // YYYY-MM-DD
  basePrice: number;           // Cents per night
  cleaningFee: number;         // Cents
  currency: string;            // 'USD'
  minStay?: number;
  extraGuestFee?: number;      // Cents per guest over base occupancy
}
Hostaway Pricing Schema
{
  "listingId": "hostaway_lst_456",
  "rates": [
    {
      "date": "2025-11-15",
      "price": 250.00,
      "currency": "USD",
      "minStay": 3
    },
    {
      "date": "2025-11-16",
      "price": 275.00,
      "currency": "USD"
    }
  ]
}
Field Mappings (Outbound: TVL → Hostaway)
| TVL Field | Hostaway Field | Transform | Required | Default | Notes | 
|---|---|---|---|---|---|
| unitId | listingId | mapUnitToListing | Yes | - | Via listing lookup | 
| startDate | date | - | Yes | - | Each date in range | 
| basePrice | price | centsToDollars | Yes | - | USD dollars | 
| currency | currency | - | Yes | 'USD' | ISO code | 
| minStay | minStay | - | No | null | Minimum nights | 
Note: Hostaway does not support cleaningFee or extraGuestFee in daily rates. These must be configured at the listing level.
Field Mappings (Inbound: Hostaway → TVL)
Pricing typically flows outbound only (TVL → Hostaway), as TVL is the source of truth for pricing. However, if Hostaway prices are updated externally, they can be imported:
| Hostaway Field | TVL Field | Transform | Required | Notes | 
|---|---|---|---|---|
| listingId | unitId | mapListingToUnit | Yes | Via listing lookup | 
| date | startDate&endDate | singleDate | Yes | Same date | 
| price | basePrice | dollarsToCents | Yes | USD cents | 
| currency | currency | - | Yes | ISO code | 
Transformation Functions
String Transformations
const transformations = {
  // Sanitize HTML and trim whitespace
  sanitize: (value: string): string => {
    if (!value) return value;
    return value
      .replace(/<[^>]*>/g, '')  // Strip HTML tags
      .trim();
  },
  // Convert to lowercase
  toLowerCase: (value: string): string => {
    return value?.toLowerCase() || value;
  },
  // Format phone number
  formatPhone: (value: string): string => {
    if (!value) return value;
    // Remove non-digits
    const digits = value.replace(/\D/g, '');
    // Format as +1-XXX-XXX-XXXX (US format)
    if (digits.length === 10) {
      return `+1-${digits.slice(0,3)}-${digits.slice(3,6)}-${digits.slice(6)}`;
    }
    return value; // Return as-is if not 10 digits
  },
  // Format time to HH:MM
  formatTime: (value: string): string => {
    if (!value) return value;
    const match = value.match(/^(\d{1,2}):(\d{2})$/);
    if (!match) return value;
    const [, hours, minutes] = match;
    return `${hours.padStart(2, '0')}:${minutes}`;
  }
};
Numeric Transformations
const transformations = {
  // Round up to nearest integer
  roundUp: (value: number): number => {
    return Math.ceil(value);
  },
  // Convert dollars to cents
  dollarsToCents: (value: number): number => {
    return Math.round(value * 100);
  },
  // Convert cents to dollars
  centsToDollars: (value: number): number => {
    return value / 100;
  },
  // Clamp value between min and max
  clamp: (value: number, min: number, max: number): number => {
    return Math.max(min, Math.min(max, value));
  }
};
Enum Mappings
const transformations = {
  // Property Type: TVL → Hostaway
  mapPropertyType: (tvlType: PropertyType): number => {
    const mapping: Record<PropertyType, number> = {
      [PropertyType.HOUSE]: 1,
      [PropertyType.APARTMENT]: 2,
      [PropertyType.CONDO]: 3,
      [PropertyType.VILLA]: 4,
      [PropertyType.CABIN]: 5,
      [PropertyType.COTTAGE]: 6
    };
    return mapping[tvlType] || 1; // Default to house
  },
  // Property Type: Hostaway → TVL
  mapPropertyTypeFromId: (hostawayId: number): PropertyType => {
    const mapping: Record<number, PropertyType> = {
      1: PropertyType.HOUSE,
      2: PropertyType.APARTMENT,
      3: PropertyType.CONDO,
      4: PropertyType.VILLA,
      5: PropertyType.CABIN,
      6: PropertyType.COTTAGE
    };
    return mapping[hostawayId] || PropertyType.HOUSE;
  },
  // Room Type: TVL → Hostaway
  mapRoomType: (tvlType: UnitType): number => {
    const mapping: Record<UnitType, number> = {
      [UnitType.ENTIRE_PLACE]: 1,
      [UnitType.PRIVATE_ROOM]: 2,
      [UnitType.SHARED_ROOM]: 3
    };
    return mapping[tvlType] || 1;
  },
  // Room Type: Hostaway → TVL
  mapRoomTypeFromId: (hostawayId: number): UnitType => {
    const mapping: Record<number, UnitType> = {
      1: UnitType.ENTIRE_PLACE,
      2: UnitType.PRIVATE_ROOM,
      3: UnitType.SHARED_ROOM
    };
    return mapping[hostawayId] || UnitType.ENTIRE_PLACE;
  },
  // Booking Status: Hostaway → TVL
  mapBookingStatus: (hostawayStatus: string): BookingStatus => {
    const mapping: Record<string, BookingStatus> = {
      'confirmed': BookingStatus.CONFIRMED,
      'pending': BookingStatus.PENDING,
      'cancelled': BookingStatus.CANCELLED,
      'completed': BookingStatus.COMPLETED
    };
    return mapping[hostawayStatus] || BookingStatus.CONFIRMED;
  },
  // Booking Status: TVL → Hostaway
  mapBookingStatusToHostaway: (tvlStatus: BookingStatus): string => {
    const mapping: Record<BookingStatus, string> = {
      [BookingStatus.CONFIRMED]: 'confirmed',
      [BookingStatus.PENDING]: 'pending',
      [BookingStatus.CANCELLED]: 'cancelled',
      [BookingStatus.COMPLETED]: 'completed'
    };
    return mapping[tvlStatus] || 'confirmed';
  }
};
Array Transformations
const transformations = {
  // Map amenity names to Hostaway IDs
  mapAmenities: async (tvlAmenities: string[]): Promise<number[]> => {
    const mapping = await getAmenityMapping(); // Fetch from DB
    return tvlAmenities
      .map(name => mapping[name])
      .filter(id => id != null);
  },
  // Map Hostaway amenity IDs to TVL names
  mapAmenitiesFromIds: async (hostawayIds: number[]): Promise<string[]> => {
    const mapping = await getAmenityMapping();
    const reverseMapping = Object.fromEntries(
      Object.entries(mapping).map(([k, v]) => [v, k])
    );
    return hostawayIds
      .map(id => reverseMapping[id])
      .filter(name => name != null);
  },
  // Map photos
  mapPhotos: (tvlPhotos: Property['photos']): any[] => {
    return tvlPhotos.map(photo => ({
      url: photo.url,
      caption: photo.caption || '',
      sortOrder: photo.order
    }));
  },
  // Map photos from Hostaway
  mapPhotosFromHostaway: (hostawayPictures: any[]): Property['photos'] => {
    return hostawayPictures.map(pic => ({
      url: pic.url,
      caption: pic.caption,
      order: pic.sortOrder
    }));
  }
};
Lookup Transformations
const transformations = {
  // Map Hostaway listing ID to TVL unit ID
  mapListingToUnit: async (listingId: string): Promise<string> => {
    const listing = await db.query(
      'SELECT unit_id FROM listings WHERE external_id = $1 AND channel_type = $2',
      [listingId, 'hostaway']
    );
    if (!listing) {
      throw new MappingError(`Listing not found: ${listingId}`);
    }
    return listing.unit_id;
  },
  // Map TVL unit ID to Hostaway listing ID
  mapUnitToListing: async (unitId: string): Promise<string> => {
    const listing = await db.query(
      'SELECT external_id FROM listings WHERE unit_id = $1 AND channel_type = $2 AND status = $3',
      [unitId, 'hostaway', 'active']
    );
    if (!listing) {
      throw new MappingError(`Active listing not found for unit: ${unitId}`);
    }
    return listing.external_id;
  }
};
Validation Rules
Field Validators
interface ValidationRule {
  type: ValidationType;
  params?: any;
  message?: string;
}
enum ValidationType {
  REQUIRED = 'required',
  MIN_LENGTH = 'min_length',
  MAX_LENGTH = 'max_length',
  MIN_VALUE = 'min_value',
  MAX_VALUE = 'max_value',
  PATTERN = 'pattern',
  ENUM = 'enum',
  CUSTOM = 'custom'
}
class FieldValidator {
  validate(value: any, rules: ValidationRule[]): ValidationResult {
    const errors: string[] = [];
    for (const rule of rules) {
      const error = this.validateRule(value, rule);
      if (error) {
        errors.push(error);
      }
    }
    return {
      isValid: errors.length === 0,
      errors
    };
  }
  private validateRule(value: any, rule: ValidationRule): string | null {
    switch (rule.type) {
      case ValidationType.REQUIRED:
        if (value == null || value === '') {
          return rule.message || 'Field is required';
        }
        break;
      case ValidationType.MIN_LENGTH:
        if (typeof value === 'string' && value.length < rule.params) {
          return rule.message || `Minimum length is ${rule.params}`;
        }
        break;
      case ValidationType.MAX_LENGTH:
        if (typeof value === 'string' && value.length > rule.params) {
          return rule.message || `Maximum length is ${rule.params}`;
        }
        break;
      case ValidationType.MIN_VALUE:
        if (typeof value === 'number' && value < rule.params) {
          return rule.message || `Minimum value is ${rule.params}`;
        }
        break;
      case ValidationType.MAX_VALUE:
        if (typeof value === 'number' && value > rule.params) {
          return rule.message || `Maximum value is ${rule.params}`;
        }
        break;
      case ValidationType.PATTERN:
        if (typeof value === 'string' && !new RegExp(rule.params).test(value)) {
          return rule.message || `Value does not match pattern`;
        }
        break;
      case ValidationType.ENUM:
        if (!rule.params.includes(value)) {
          return rule.message || `Value must be one of: ${rule.params.join(', ')}`;
        }
        break;
    }
    return null;
  }
}
Common Validation Rules
// Property name
const propertyNameRules: ValidationRule[] = [
  { type: ValidationType.REQUIRED },
  { type: ValidationType.MIN_LENGTH, params: 5, message: 'Name too short' },
  { type: ValidationType.MAX_LENGTH, params: 100, message: 'Name too long' }
];
// Email
const emailRules: ValidationRule[] = [
  { type: ValidationType.REQUIRED },
  { type: ValidationType.PATTERN, params: '^[^@]+@[^@]+\\.[^@]+$', message: 'Invalid email' }
];
// Price (cents)
const priceRules: ValidationRule[] = [
  { type: ValidationType.REQUIRED },
  { type: ValidationType.MIN_VALUE, params: 0, message: 'Price cannot be negative' }
];
// Max guests
const maxGuestsRules: ValidationRule[] = [
  { type: ValidationType.REQUIRED },
  { type: ValidationType.MIN_VALUE, params: 1, message: 'At least 1 guest required' },
  { type: ValidationType.MAX_VALUE, params: 50, message: 'Maximum 50 guests' }
];
// Date (YYYY-MM-DD)
const dateRules: ValidationRule[] = [
  { type: ValidationType.REQUIRED },
  { type: ValidationType.PATTERN, params: '^\\d{4}-\\d{2}-\\d{2}$', message: 'Invalid date format' }
];
Examples
Example 1: Property Mapping (Outbound)
TVL Data:
const tvlProperty: Property = {
  id: 'prop_123',
  organizationId: 'org_456',
  name: 'Stunning Oceanfront Villa',
  description: 'Luxury 4BR villa with private beach access...',
  propertyType: PropertyType.VILLA,
  address: {
    street: '456 Seaside Lane',
    city: 'Malibu',
    state: 'CA',
    postalCode: '90265',
    country: 'US',
    latitude: 34.0259,
    longitude: -118.7798
  },
  amenities: ['wifi', 'parking', 'pool', 'beach_access'],
  photos: [
    { url: 'https://cdn.example.com/photo1.jpg', caption: 'Ocean view', order: 1 },
    { url: 'https://cdn.example.com/photo2.jpg', caption: 'Kitchen', order: 2 }
  ]
};
const tvlUnit: Unit = {
  id: 'unit_789',
  propertyId: 'prop_123',
  name: 'Main Villa',
  unitType: UnitType.ENTIRE_PLACE,
  maxGuests: 8,
  bedrooms: 4,
  beds: 5,
  bathrooms: 3.5,
  bathroomType: 'private',
  checkInTime: '15:00',
  checkOutTime: '11:00',
  minStay: 3,
  maxStay: 14
};
Mapped Hostaway Data:
{
  "name": "Stunning Oceanfront Villa",
  "description": "Luxury 4BR villa with private beach access...",
  "propertyTypeId": 4,
  "roomTypeId": 1,
  "address": {
    "street": "456 Seaside Lane",
    "city": "Malibu",
    "state": "CA",
    "zipcode": "90265",
    "country": "US",
    "lat": 34.0259,
    "lng": -118.7798
  },
  "accommodates": 8,
  "bedrooms": 4,
  "beds": 5,
  "bathrooms": 3.5,
  "checkInTime": "15:00",
  "checkOutTime": "11:00",
  "currency": "USD",
  "amenities": [1, 5, 12, 23],
  "pictures": [
    {
      "url": "https://cdn.example.com/photo1.jpg",
      "caption": "Ocean view",
      "sortOrder": 1
    },
    {
      "url": "https://cdn.example.com/photo2.jpg",
      "caption": "Kitchen",
      "sortOrder": 2
    }
  ]
}
Example 2: Booking Mapping (Inbound)
Hostaway Webhook Data:
{
  "event": "reservation.created",
  "data": {
    "id": "hostaway_res_999",
    "listingId": "hostaway_lst_789",
    "channelId": "airbnb",
    "channelName": "Airbnb",
    "confirmationCode": "HMABCDE12345",
    "status": "confirmed",
    "arrivalDate": "2025-12-01",
    "departureDate": "2025-12-08",
    "nights": 7,
    "guestName": "Jane Smith",
    "guestEmail": "jane.smith@email.com",
    "guestPhone": "+1-555-0199",
    "numberOfGuests": 6,
    "adults": 4,
    "children": 2,
    "infants": 0,
    "totalPrice": 3500.00,
    "cleaningFee": 250.00,
    "taxAmount": 280.00,
    "currency": "USD",
    "isPaid": false,
    "insertedOn": "2025-11-20T14:30:00Z"
  }
}
Mapped TVL Booking:
const tvlBooking: Booking = {
  id: 'bkg_auto_generated',
  unitId: 'unit_789',                    // Looked up via listingId
  externalId: 'hostaway_res_999',
  externalSource: 'hostaway',
  channelName: 'Airbnb',
  confirmationCode: 'HMABCDE12345',
  status: BookingStatus.CONFIRMED,
  checkInDate: '2025-12-01',
  checkOutDate: '2025-12-08',
  numberOfNights: 7,
  guest: {
    name: 'Jane Smith',
    email: 'jane.smith@email.com',
    phone: '+1-555-0199'
  },
  guests: {
    adults: 4,
    children: 2,
    infants: 0
  },
  pricing: {
    baseAmount: 350000,                  // $3500 * 100
    cleaningFee: 25000,                  // $250 * 100
    taxAmount: 28000,                    // $280 * 100
    totalAmount: 403000,                 // $4030 * 100
    currency: 'USD'
  },
  isPaid: false,
  createdAt: '2025-11-20T14:30:00Z'
};
Example 3: Availability Mapping (Outbound)
TVL Availability Blocks:
const tvlBlocks: AvailabilityBlock[] = [
  {
    id: 'block_1',
    unitId: 'unit_789',
    startDate: '2025-12-01',
    endDate: '2025-12-08',
    status: BlockStatus.BOOKED,
    reason: 'booking',
    minStay: 3
  },
  {
    id: 'block_2',
    unitId: 'unit_789',
    startDate: '2025-12-15',
    endDate: '2025-12-20',
    status: BlockStatus.BLOCKED,
    reason: 'maintenance',
    minStay: null
  }
];
Mapped Hostaway Calendar:
{
  "listingId": "hostaway_lst_789",
  "calendar": [
    { "date": "2025-12-01", "status": "unavailable", "reason": "booked", "minStay": 3 },
    { "date": "2025-12-02", "status": "unavailable", "reason": "booked", "minStay": 3 },
    { "date": "2025-12-03", "status": "unavailable", "reason": "booked", "minStay": 3 },
    { "date": "2025-12-04", "status": "unavailable", "reason": "booked", "minStay": 3 },
    { "date": "2025-12-05", "status": "unavailable", "reason": "booked", "minStay": 3 },
    { "date": "2025-12-06", "status": "unavailable", "reason": "booked", "minStay": 3 },
    { "date": "2025-12-07", "status": "unavailable", "reason": "booked", "minStay": 3 },
    { "date": "2025-12-15", "status": "unavailable", "reason": "maintenance" },
    { "date": "2025-12-16", "status": "unavailable", "reason": "maintenance" },
    { "date": "2025-12-17", "status": "unavailable", "reason": "maintenance" },
    { "date": "2025-12-18", "status": "unavailable", "reason": "maintenance" },
    { "date": "2025-12-19", "status": "unavailable", "reason": "maintenance" }
  ]
}
Validation & Alternatives
Architectural Decisions
Decision: Database-Driven Field Mappings
Rationale:
- Allows per-tenant customization without code changes
- Supports A/B testing of different mapping strategies
- Enables UI for property managers to adjust mappings
Alternatives Considered:
- Hardcoded mappings in code
- Pro: Simpler, faster
- Con: No flexibility, requires deploy for changes
 
- Config files (JSON/YAML)
- Pro: Version controlled, code review
- Con: Still requires deploy, no per-tenant customization
 
Decision: Eager Validation vs. Lazy Validation
Chosen: Eager validation (validate before API call)
Rationale:
- Fail fast, avoid wasted API calls
- Better error messages for property managers
- Reduces external API load
Alternative: Lazy validation (let Hostaway API reject)
- Pro: Simpler implementation
- Con: Wastes API quota, poor UX
Decision: Transformation Functions as Named Strings
Rationale:
- Stored in database as function names
- Allows dynamic application without code generation
- Supports custom transformations per tenant
Alternative: Lambda/code stored in DB
- Pro: Maximum flexibility
- Con: Security risk, hard to test
Manual Additions
(Reserved for human notes and decisions)