VRPlatformVRPlatform
Integrate & Migrate Data

PMS Migration

Plan a clean accounting cutover between property management systems

When a team replaces its property management system, accounting must switch from the old PMS to the new one on a single cutover date — without double counting reservations, breaking published owner statements, or splitting listing history across duplicate listings.

The PMS cutover endpoints handle this as one reviewed operation:

  1. GET /connections/pms-cutover/listing-mappings — see how new PMS listings map onto existing listings.
  2. POST /connections/pms-cutover/preview — dry-run the cutover and review impact counts and blockers.
  3. POST /connections/pms-cutover/apply — execute the cutover in one transaction, then read the result from GET /connections/pms-cutover/apply/{operationId}.

The VRPlatform app has no PMS migration screen. Run the cutover through these endpoints directly or through the VRPlatform MCP.

Concepts

Accounting windows

Every PMS connection can carry an accounting window (accountingStartAt / accountingEndAt). Only reservations whose accounting date falls inside the window post to the general ledger.

  • The current PMS has accountingStartAt set (often 1900-01-01) and no end.
  • The replacement PMS is connected in parallel without window dates. It syncs reservations and listings, but stays out of live accounting.
  • Listing, ownership, and recurring-fee changes do not queue journal refreshes for replacement PMS reservations before the cutover unless those reservations already have journal entries that require review.
  • The same changes skip reservations from inactive PMS connections when they have no journal entries. If entries exist, refresh keeps the reservation in scope and produces no replacement postings so the stored journal can clear.
  • The cutover sets the source accountingEndAt and the target accountingStartAt to the same cutover date in one transaction.

The accounting date per reservation follows the team's revenue recognition setting:

  • checkIn — revenue posts on the check-in date (the default);
  • checkOut — revenue posts on the check-out date;
  • proRata — revenue is spread across the nights of the stay;
  • bookedAt — revenue posts on the booking date; retired for new configuration, but stored values keep working.

A cancelled reservation posts on its cancellation date, or its booking date when no cancellation date exists. A stay cancelled before the cutover therefore stays with the old PMS, even when its check-in is later.

Listing continuity

The replacement PMS imports its own listing refs. Without intervention, each ref would create a duplicate listing — splitting ownership periods, statements, and journal history across two buckets for the same property.

The cutover matches target PMS listing refs to existing source listings by exact normalized listing title, using the address only to break ties between identical titles. When no title matches, it suggests a source listing from weaker evidence:

  • the PMS listing name is the same on both sides, or
  • target reservations share a booking code with source reservations on one source listing.

Every target ref gets one of these statuses:

StatusMeaningApply behavior
alreadySharedRef already points at a source listingNothing to do
matchedExactly one source listing title matchesMerged into the source listing
suggestedPMS name or shared reservations point at one source listingBlocks apply until confirmed or overridden
ambiguousSeveral source listings matchBlocks apply until resolved
unmappedNo source listing matchesKept separate as a new listing

unmapped refs are normal — teams onboard new properties on the replacement PMS. They do not block the migration.

1. Review listing mappings

GET /connections/pms-cutover/listing-mappings
  ?sourceConnectionId=...&targetConnectionId=...

Returns one row per target PMS listing ref plus the source listing pool for building a mapping UI:

{
  "sourceListings": [
    { "id": "listing-a", "name": "Twin Cabin", "address": "12 Forest Rd" },
    { "id": "listing-b", "name": "Twin Cabin", "address": "14 Forest Rd" }
  ],
  "targetListings": [
    {
      "targetListingConnectionId": "ref-1",
      "uniqueRef": "hostaway-123",
      "name": "Twin Cabin",
      "address": null,
      "listingId": "target-only-listing",
      "status": "ambiguous",
      "suggestedListing": null
    }
  ]
}

For matched, suggestedListing is the source listing apply merges into when no explicit mapping is sent. For suggested, it is the proposal to confirm in listingMappings.

2. Preview the cutover

POST /connections/pms-cutover/preview
{
  "sourceConnectionId": "source-pms-uuid",
  "targetConnectionId": "target-pms-uuid",
  "cutoverAt": "2026-05-01"
}

Preview is read-only and reports:

  • impactedReservations — source/target reservations in the cutover window, how many journals will refresh, and lockedCount blockers. lockedReservations lists up to 100 blockers with their confirmationCode and reasons: booksClosed or statementAttachment. lockedCount is always the full count.
  • listingContinuity — match counts per status, how many target reservations, transaction lines, and payment lines would move, and lockedCount for listing moves that would touch locked journal history.

The preview request returns an operation ID. Poll GET /operations/{id} before reading the result. The result endpoint returns an expected 409 CONFLICT while the preview is queued or running. After failure, it returns 409 CONFLICT with the failure's message, reason, and context. GET /operations/{id} only reports operationFailed; read the result endpoint for the cause. Unknown failures remain the generic Operation failed response.

Resolving blockers

Run preview, fix each blocker below, and preview again until nothing blocks. Apply returns the same blockers as 409 CONFLICT with the reason shown.

  • impactedReservations.lockedCount means reservations in the window have active journal entries that the cutover cannot rewrite:
    • statementAttachment — an entry is attached to an owner statement. Unpublish the statement before cutover.
    • booksClosed — an entry is dated before booksClosedAt. Contact support; do not reopen books to clear it.
  • impactedReservations.unmatchedCount means old-PMS reservations with an amount have no matching reservation on the new PMS. Apply rejects them with reason: "reservationUnmatched", because retiring them would remove their revenue and detach their payments. unmatchedReservations lists up to 100, each with a suggestedReservation: the only new-PMS reservation on the same listing and dates. Send a reservationMappings entry for each: the suggested id, another new-PMS reservation, or null to retire it without one. A stay missing on the new PMS, typically a direct booking, can also be created there before you preview again.
  • listingContinuity.suggestedCount means target refs have a suggested source listing. Apply rejects it with reason: "listingMappingRequired". Check each suggestion and send a listingMappings entry: the suggested sourceListingId, another source listing, or null.
  • listingContinuity.ambiguousCount means a target ref matches multiple source listings. Apply rejects it with reason: "listingMappingAmbiguous". Send an explicit listingMappings entry.
  • listingContinuity.lockedCount means a listing merge would rewrite statement-attached or books-closed journal entries. Resolve the lock or map the ref to null to keep it separate.

To resolve ambiguity (or override a suggestion), pass listingMappings on preview and apply:

{
  "sourceConnectionId": "source-pms-uuid",
  "targetConnectionId": "target-pms-uuid",
  "cutoverAt": "2026-05-01",
  "listingMappings": [
    { "targetListingConnectionId": "ref-1", "sourceListingId": "listing-a" },
    { "targetListingConnectionId": "ref-2", "sourceListingId": null }
  ]
}
  • A sourceListingId merges the target ref into that source listing.
  • null keeps the ref separate as a new listing.
  • Unknown target refs, ids that are not source listings, and duplicate target refs are rejected.

Pass reservationMappings the same way:

{
  "reservationMappings": [
    {
      "sourceReservationId": "old-pms-reservation",
      "targetReservationId": "new-pms-reservation"
    },
    { "sourceReservationId": "cancelled-stay", "targetReservationId": null }
  ]
}
  • A targetReservationId moves every line of the source reservation to that new-PMS reservation.
  • null retires the source reservation and detaches its lines.
  • Both reservations must be in the cutover window. Duplicate source reservations are rejected.

3. Apply the cutover

POST /connections/pms-cutover/apply

Apply re-runs the full preview validation immediately before writing, then in one transaction:

  • sets the source accountingEndAt and target accountingStartAt to cutoverAt;
  • sets source reservations whose accounting date is on or after the cutover date to inactive;
  • moves matched target listing refs, target reservations, and listing-linked transaction/payment lines onto the matched source listings;
  • re-links deposit and payment lines from retired source reservations to the target reservation from reservationMappings, or else the one matched by reservation matcher fields, then by source reservation identifiers; lines without a target match are detached;
  • queues REFRESH_RESERVATION_JOURNAL (strict lock policy) and REFRESH_TRANSACTION_JOURNAL effects for everything that changed.

Apply returns an operation ID. Poll GET /operations/{id}, then read GET /connections/pms-cutover/apply/{operationId}. The result is the preview payload plus applied: true, queuedReservationRefreshCount, and duplicateListingCleanup. A rejected cutover returns 409 CONFLICT with the failure's reason, context, and issues. lockedReservationIds holds up to 100 IDs; lockedReservationCount is the full count. For example:

{
  "code": "CONFLICT",
  "reason": "reservationLocked",
  "message": "PMS cutover has locked impacted reservations",
  "issues": [],
  "context": {
    "lockedReservationCount": 1,
    "lockedReservationIds": ["reservation-uuid"]
  }
}

Example

# 1. What needs mapping?
curl --get 'https://api.vrplatform.app/connections/pms-cutover/listing-mappings' \
  --data-urlencode 'sourceConnectionId=SRC' \
  --data-urlencode 'targetConnectionId=TGT' \
  -H 'x-api-key: your-api-key' \
  -H 'x-team-id: your-team-id'

# 2. Preview
curl 'https://api.vrplatform.app/connections/pms-cutover/preview' \
  -X POST \
  -H 'content-type: application/json' \
  -H 'x-api-key: your-api-key' \
  -H 'x-team-id: your-team-id' \
  --data '{
    "sourceConnectionId": "SRC",
    "targetConnectionId": "TGT",
    "cutoverAt": "2026-05-01"
  }'

# 3. Apply with explicit mappings
curl 'https://api.vrplatform.app/connections/pms-cutover/apply' \
  -X POST \
  -H 'content-type: application/json' \
  -H 'x-api-key: your-api-key' \
  -H 'x-team-id: your-team-id' \
  --data '{
    "sourceConnectionId": "SRC",
    "targetConnectionId": "TGT",
    "cutoverAt": "2026-05-01",
    "listingMappings": [
      { "targetListingConnectionId": "ref-1", "sourceListingId": "listing-a" }
    ]
  }'

Rules

  • Source and target must be different active PMS connections in the same team.
  • The source must have accountingStartAt; the target must have no window.
  • The cutover date must be after the source accounting start and not before the team's booksClosedAt.
  • A third PMS connection whose window overlaps the post-cutover range is rejected.
  • Apply rejects locked impacted reservations, source reservations without a target match, unresolved ambiguous listing refs, and listing moves that would touch locked journal history.

API Reference

  • GET /connections/pms-cutover/listing-mappings — Contract
  • POST /connections/pms-cutover/preview — Contract
  • POST /connections/pms-cutover/apply — Contract
  • GET /connections/pms-cutover/apply/{operationId} — Contract

On this page