A reservation cancellation is usually implemented as one action.
A replacement booking is implemented as another.
That separation creates the most dangerous moment in the workflow: the original reservation can be released before the replacement is safely secured.
If the replacement subsequently fails, the customer may lose the room, face a changed rate, receive duplicate charges, or become trapped between two systems that each report a different version of the transaction.
A safer architecture treats cancellation and replacement as one controlled transition.
The governing principle is straightforward:
Never release the original reservation until a valid replacement has been secured, and never declare the transition complete until the release, replacement, and financial state have all been verified.
I use the term Replacement Before Release to describe this implementation pattern.
The architecture was originally developed inside a hospitality technology platform and has since been generalized into a provider-neutral reference implementation.
It is designed for authorized reservation replacements, not for circumventing distribution agreements or modifying bookings without customer and provider authority.
The problem with “cancel, then book”
The simplest implementation looks like this:
Cancel original reservation
|
v
Create replacement reservation
This sequence places the irreversible step first.
After the cancellation occurs, any of the following can still fail:
- replacement inventory disappears;
- the replacement rate changes;
- the provider rejects the new booking;
- payment authorization fails;
- the cancellation produces a fee;
- a refund remains unresolved;
- the original virtual card has already been charged;
- the customer is charged for both reservations;
- one provider confirms while another times out;
- the replacement exists but cannot be activated;
- the system cannot determine which reservation is authoritative.
A traditional database rollback cannot reliably reverse these events.
Once an external reservation provider releases inventory, the original room may immediately return to market. Once a payment processor captures funds, the transaction may require a separate refund. Once an OTA or channel manager records a cancellation, recreating the exact original state may be impossible.
The system must therefore reduce risk before the destructive action occurs.
The safer sequence
The safer workflow is:
Evaluate eligibility
|
v
Create replacement hold
|
v
Verify replacement
|
v
Request release of original
|
v
Verify release and financial proof
|
v
Promote replacement
|
v
Complete transition
The replacement is secured first.
The original is released only after the system confirms that the replacement exists and satisfies the required stay conditions.
The transition is completed only after the system verifies both sides:
- the original reservation was successfully released;
- the replacement reservation was successfully promoted;
- no unacceptable financial condition exists.
This is not merely a sequence of API calls. It is a distributed transaction with external side effects, incomplete rollback, and multiple systems of record.
Model the transition explicitly
The workflow should be represented as a state machine rather than inferred from loosely related flags and API responses.
A simplified state model might look like this:
export type TransitionState =
| "ELIGIBILITY_CHECK"
| "REPLACEMENT_HOLD_PENDING"
| "REPLACEMENT_HOLD_CREATED"
| "REPLACEMENT_VERIFIED"
| "ORIGINAL_RELEASE_PENDING"
| "ORIGINAL_RELEASE_REQUESTED"
| "RELEASE_PROOF_VERIFIED"
| "REPLACEMENT_PROMOTION_PENDING"
| "REPLACEMENT_PROMOTED"
| "TRANSITION_COMPLETE"
| "MANUAL_REVIEW_REQUIRED"
| "TRANSITION_FAILED";
Each state transition should have explicit prerequisites.
For example:
export function canCompleteTransition(
context: TransitionContext
): boolean {
return (
context.replacementVerified === true &&
context.releaseProofVerified === true &&
context.replacementPromoted === true &&
context.financialSafetyVerified === true
);
}
A terminal state must not be assigned because the workflow “mostly succeeded.”
Completion must be supported by evidence.
A successful HTTP response is not proof
A provider may return a successful HTTP status even when the underlying business event is incomplete.
A cancellation endpoint may return 200 OK when:
- the request was accepted but not completed;
- the cancellation is pending;
- a fee was applied;
- the refund remains unresolved;
- the returned identifier represents the request rather than the completed cancellation;
- the provider accepted the action but another downstream system has not updated.
The orchestrator should therefore normalize raw provider responses into a structured proof object.
export interface VerifiedReleaseProof {
confirmationId: string;
confirmationSource: string;
machineVerified: true;
cancellationFeeCharged: false;
refundStatus:
| "confirmed"
| "not_required";
noDuplicateCustomerChargeConfirmed: true;
virtualPaymentMethodCharged: false;
providerPayload?: unknown;
}
The transition should not proceed unless every required condition is explicitly supported.
Production-derived release validation
The following validator is closely adapted from the actual orchestration logic, with provider and product-specific names removed.
export interface ProviderReleaseResponse {
providerCancellationReference?: string;
channelCancellationReference?: string;
confirmationId?: string;
dispatchMethod?: string;
machineVerified?: boolean;
refundStatus?: string;
cancellationFeeCharged?: boolean;
noDuplicateCustomerChargeConfirmed?: boolean;
virtualPaymentMethodCharged?: boolean;
providerResponse?: Record<string, unknown>;
}
export function buildVerifiedReleaseProof(
dispatch: ProviderReleaseResponse
): VerifiedReleaseProof | null {
const confirmationId =
dispatch.providerCancellationReference ??
dispatch.channelCancellationReference ??
dispatch.confirmationId;
if (!confirmationId) {
return null;
}
const payload =
dispatch.providerResponse ?? {};
const machineVerified =
dispatch.machineVerified === true ||
payload.machineVerified === true ||
payload.cancelled === true ||
payload.released === true;
const refundStatus =
dispatch.refundStatus ??
payload.refundStatus;
const cancellationFeeCharged =
dispatch.cancellationFeeCharged ??
payload.cancellationFeeCharged;
const noDuplicateCharge =
dispatch.noDuplicateCustomerChargeConfirmed ??
payload.noDuplicateCustomerChargeConfirmed;
const virtualPaymentMethodCharged =
dispatch.virtualPaymentMethodCharged ??
payload.virtualPaymentMethodCharged;
if (!machineVerified) {
return null;
}
if (cancellationFeeCharged !== false) {
return null;
}
if (
refundStatus !== "confirmed" &&
refundStatus !== "not_required"
) {
return null;
}
if (noDuplicateCharge !== true) {
return null;
}
if (virtualPaymentMethodCharged === true) {
return null;
}
return {
confirmationId,
confirmationSource:
dispatch.dispatchMethod ?? "provider",
machineVerified: true,
cancellationFeeCharged: false,
refundStatus,
noDuplicateCustomerChargeConfirmed: true,
virtualPaymentMethodCharged: false,
providerPayload: payload
};
}
The critical behavior is not what the function accepts.
It is what the function refuses to accept.
Missing information does not become an optimistic default.
Unknown cancellation-fee status is not treated as “probably no fee.”
Unresolved refund status is not treated as success.
Unconfirmed duplicate-charge prevention is not treated as safe.
The validator returns null and the workflow stops.
Fail closed
Fail-closed behavior is central to this architecture.
A fail-open system might interpret incomplete proof as permission to continue.
A fail-closed system interprets incomplete proof as a reason to stop.
A generic response helper might look like this:
export function blockedResult(
code: string,
message: string,
context: Record<string, unknown> = {}
): TransitionResult {
return {
ok: false,
state: "MANUAL_REVIEW_REQUIRED",
code,
message,
context
};
}
Examples of fail-closed conditions include:
- no replacement reference returned;
- unsupported release route;
- missing cancellation confirmation;
- cancellation not machine verified;
- cancellation fee status unknown;
- refund still pending;
- duplicate-charge prevention unverified;
- virtual card may already have been charged;
- replacement promotion fails;
- provider responses conflict.
The architecture does not attempt to hide these conditions behind a generic internal-server error.
Each becomes a specific operational state.
Separate the engine from providers
The core transaction logic should not depend directly on one PMS, OTA, channel manager, payment processor, or reservation platform.
External systems should be represented through adapters.
export interface ReservationAdapter {
createReplacementHold(
request: ReplacementRequest
): Promise<ReplacementProof>;
promoteReplacement(
reference: string
): Promise<PromotionProof>;
}
export interface ReleaseAdapter {
releaseOriginal(
request: ReleaseRequest
): Promise<ProviderReleaseResponse>;
}
export interface FinancialVerifier {
verifyFinancialSafety(
request: FinancialVerificationRequest
): Promise<FinancialVerificationResult>;
}
This separation provides several advantages:
- provider-specific payloads remain outside the core engine;
- inconsistent provider responses can be normalized;
- mock providers can be used during testing;
- the orchestration sequence remains readable;
- integrations can change without redefining the state machine;
- the same core logic can support different reservation systems.
The orchestrator should consume normalized domain outcomes rather than raw API responses.
A simplified orchestrator
A provider-neutral orchestrator might look like this:
export class ReservationTransitionOrchestrator {
constructor(
private readonly reservationAdapter:
ReservationAdapter,
private readonly releaseAdapter:
ReleaseAdapter,
private readonly financialVerifier:
FinancialVerifier
) {}
async execute(
request: TransitionRequest
): Promise<TransitionResult> {
const replacement =
await this.reservationAdapter
.createReplacementHold({
originalReservationId:
request.originalReservationId,
requestedStay:
request.requestedStay
});
if (
!replacement.reference ||
replacement.verified !== true
) {
return blockedResult(
"replacement_unverified",
"Replacement reservation could not be verified."
);
}
const financialCheck =
await this.financialVerifier
.verifyFinancialSafety({
originalReservationId:
request.originalReservationId,
replacementReference:
replacement.reference
});
if (!financialCheck.safeToProceed) {
return blockedResult(
"financial_safety_unverified",
financialCheck.reason ??
"Financial safety could not be verified."
);
}
const releaseResponse =
await this.releaseAdapter
.releaseOriginal({
originalReservationId:
request.originalReservationId,
authorization:
request.authorization
});
const releaseProof =
buildVerifiedReleaseProof(
releaseResponse
);
if (!releaseProof) {
return blockedResult(
"release_proof_incomplete",
"Original reservation release could not be verified."
);
}
const promotion =
await this.reservationAdapter
.promoteReplacement(
replacement.reference
);
if (promotion.confirmed !== true) {
return blockedResult(
"replacement_promotion_failed",
"Replacement could not be promoted after release.",
{
releaseConfirmationId:
releaseProof.confirmationId
}
);
}
return {
ok: true,
state: "TRANSITION_COMPLETE",
replacementReference:
replacement.reference,
releaseConfirmationId:
releaseProof.confirmationId
};
}
}
The most difficult condition is not replacement creation failure.
The most difficult condition is:
The original reservation has been released, but the replacement cannot be promoted.
That state must be treated as a first-class exception.
It cannot be represented as a generic error because the workflow has already crossed an external point of no return.
Exceptions are part of the product
A robust transaction engine does not merely define the happy path.
It defines the failure topology.
export type TransitionException =
| "REPLACEMENT_CREATION_FAILED"
| "REPLACEMENT_REFERENCE_MISSING"
| "REPLACEMENT_MISMATCH"
| "RELEASE_ROUTE_UNSUPPORTED"
| "RELEASE_UNCONFIRMABLE"
| "CANCELLATION_FEE_DETECTED"
| "REFUND_UNRESOLVED"
| "DUPLICATE_CHARGE_RISK"
| "VIRTUAL_PAYMENT_ALREADY_CHARGED"
| "REPLACEMENT_PROMOTION_FAILED"
| "PROVIDER_TIMEOUT"
| "STATE_CONFLICT";
Each exception should define:
- whether retry is safe;
- whether manual review is required;
- which provider is authoritative;
- which evidence must be retained;
- whether customer communication is appropriate;
- which compensating action may be attempted;
- whether the original or replacement reservation currently controls inventory.
A workflow that handles only the successful sequence is not a transaction engine.
It is an API script.
Idempotency is mandatory
Reservation transitions are vulnerable to retries.
Users refresh pages. Queue workers restart. Networks time out. Webhooks are delivered more than once. An operator may repeat an action because the first response did not appear.
Every transition therefore needs a stable identifier.
export interface TransitionRequest {
transitionId: string;
originalReservationId: string;
requestedStay: RequestedStay;
authorization: AuthorizationProof;
}
Before any external action occurs, the system should determine whether the transition has already been processed.
const existing =
await repository.findByTransitionId(
request.transitionId
);
if (
existing?.state ===
"TRANSITION_COMPLETE"
) {
return existing.result;
}
Where supported, the same idempotency key should be passed to external providers.
Without idempotency, a retry can:
- create multiple replacement holds;
- submit repeated cancellation requests;
- promote the same reservation more than once;
- trigger duplicate financial activity;
- create contradictory audit records.
Preserve an immutable timeline
Every meaningful event should be recorded.
export interface TransitionEvent {
transitionId: string;
state: TransitionState;
eventType: string;
occurredAt: string;
provider?: string;
referenceId?: string;
evidence?: unknown;
}
The timeline should answer:
- who authorized the transition;
- which route was selected;
- when the replacement was created;
- how the replacement was verified;
- when the release was requested;
- what evidence confirmed the release;
- which financial checks were performed;
- whether a retry occurred;
- why the workflow completed or stopped.
The event history should not be rewritten to make the transaction appear cleaner than it was.
The purpose of the record is not only debugging.
It is reconstructability.
Authorization is distinct from technical capability
A provider API may technically allow a reservation to be cancelled.
That does not establish that the party using the API has contractual or legal authority to perform the cancellation.
The transition request should therefore include authorization evidence.
export interface AuthorizationProof {
authorizationId: string;
authorizedBy: string;
authorizedAt: string;
scope: "release_and_replace";
source: string;
}
The orchestrator should confirm that the authorization scope covers the intended action.
Technical access is not equivalent to authority.
This is especially important where the original reservation was created through a third-party distributor, travel agency, corporate booking platform, or other intermediary.
Model financial safety directly
Reservation transitions involve more than inventory.
They may involve:
- deposits;
- pending authorizations;
- prepaid rates;
- refunds;
- cancellation penalties;
- virtual cards;
- merchant-of-record differences;
- currency conversion;
- taxes and fees;
- provider commissions.
These conditions should be explicit.
export interface FinancialVerificationResult {
safeToProceed: boolean;
duplicateChargePrevented: boolean;
cancellationFeeAmount: number;
refundRequired: boolean;
refundConfirmed: boolean;
originalPaymentCaptured: boolean;
replacementPaymentAuthorized: boolean;
reason?: string;
}
The system should refuse completion when the financial state is ambiguous.
A reservation transition that preserves inventory but mishandles payment is still a failed transition.
Test refusal paths first
The most important tests are not the successful ones.
The critical tests prove that the engine refuses unsafe completion.
The test suite should include:
- replacement hold creation fails;
- replacement reference is missing;
- replacement details do not match the requested stay;
- release route is unsupported;
- cancellation response lacks machine-verifiable proof;
- cancellation fee is detected;
- refund remains pending;
- duplicate-charge prevention is not confirmed;
- virtual payment method has already been charged;
- provider returns contradictory states;
- release succeeds but replacement promotion fails;
- the same transition is submitted twice;
- provider times out after performing an action;
- terminal completion is attempted without prerequisite evidence.
A central invariant is:
expect(
canCompleteTransition(context)
).toBe(
context.replacementVerified === true &&
context.releaseProofVerified === true &&
context.replacementPromoted === true &&
context.financialSafetyVerified === true
);
The test is not proving that the engine can finish.
It is proving that the engine cannot finish incorrectly.
Scope and safety boundaries
A public reference implementation should not contain:
- production credentials;
- real reservation records;
- customer information;
- private hotel information;
- proprietary provider documentation;
- production API routes;
- internal database names;
- payment credentials;
- contractual assumptions about provider permissions;
- code that modifies live reservations by default.
The purpose of the project is to document and test the transaction-safety pattern.
It is not a turnkey system for modifying live bookings.
Where the pattern is useful
The architecture can support authorized hotel workflows such as:
- overbooking relocation;
- sister-property relocation;
- property closure;
- emergency displacement;
- room-type substitution;
- duplicate-reservation correction;
- failed-booking recovery;
- guest-authorized itinerary changes;
- replacement of unavailable inventory.
The same sequence can also apply outside hospitality:
Qualify
Secure replacement
Verify replacement
Authorize release
Release original
Verify release
Activate replacement
Reconcile
Complete or escalate
The domain changes.
The transaction principle does not.
Conclusion
Replacing a reservation is not merely a booking workflow.
It is a distributed transaction involving independent systems, financial state, external side effects, uncertain rollback, and incomplete evidence.
A safer architecture requires:
- replacement before release;
- explicit state transitions;
- machine-verifiable proof;
- fail-closed validation;
- provider-neutral adapters;
- idempotent execution;
- first-class exception handling;
- financial safety gates;
- immutable event history;
- clear authorization boundaries.
The most important feature is not automation.
It is refusal.
A trustworthy transition engine must know when the available evidence is insufficient and stop before an uncertain workflow becomes an irreversible customer problem.
Top comments (0)