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:
GET /connections/pms-cutover/listing-mappings— see how new PMS listings map onto existing listings.POST /connections/pms-cutover/preview— dry-run the cutover and review impact counts and blockers.POST /connections/pms-cutover/apply— execute the cutover in one transaction, then read the result fromGET /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
accountingStartAtset (often1900-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
accountingEndAtand the targetaccountingStartAtto 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:
| Status | Meaning | Apply behavior |
|---|---|---|
alreadyShared | Ref already points at a source listing | Nothing to do |
matched | Exactly one source listing title matches | Merged into the source listing |
suggested | PMS name or shared reservations point at one source listing | Blocks apply until confirmed or overridden |
ambiguous | Several source listings match | Blocks apply until resolved |
unmapped | No source listing matches | Kept 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, andlockedCountblockers.lockedReservationslists up to 100 blockers with theirconfirmationCodeandreasons:booksClosedorstatementAttachment.lockedCountis always the full count.listingContinuity— match counts per status, how many target reservations, transaction lines, and payment lines would move, andlockedCountfor 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.lockedCountmeans 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 beforebooksClosedAt. Contact support; do not reopen books to clear it.
impactedReservations.unmatchedCountmeans old-PMS reservations with an amount have no matching reservation on the new PMS. Apply rejects them withreason: "reservationUnmatched", because retiring them would remove their revenue and detach their payments.unmatchedReservationslists up to 100, each with asuggestedReservation: the only new-PMS reservation on the same listing and dates. Send areservationMappingsentry for each: the suggested id, another new-PMS reservation, ornullto 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.suggestedCountmeans target refs have asuggestedsource listing. Apply rejects it withreason: "listingMappingRequired". Check each suggestion and send alistingMappingsentry: the suggestedsourceListingId, another source listing, ornull.listingContinuity.ambiguousCountmeans a target ref matches multiple source listings. Apply rejects it withreason: "listingMappingAmbiguous". Send an explicitlistingMappingsentry.listingContinuity.lockedCountmeans a listing merge would rewrite statement-attached or books-closed journal entries. Resolve the lock or map the ref tonullto 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
sourceListingIdmerges the target ref into that source listing. nullkeeps 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
targetReservationIdmoves every line of the source reservation to that new-PMS reservation. nullretires 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/applyApply re-runs the full preview validation immediately before writing, then in one transaction:
- sets the source
accountingEndAtand targetaccountingStartAttocutoverAt; - 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) andREFRESH_TRANSACTION_JOURNALeffects 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— ContractPOST /connections/pms-cutover/preview— ContractPOST /connections/pms-cutover/apply— ContractGET /connections/pms-cutover/apply/{operationId}— Contract
