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, encounter 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. 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.
The Problem With “Cancel, Then Book”
The simplest implementation looks like this:
Cancel original reservation
↓
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
A safer workflow looks like this:
Evaluate eligibility
↓
Create replacement hold
↓
Verify replacement
↓
Request release of original
↓
Verify release and financial proof
↓
Promote replacement
↓
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 all three critical outcomes:
- 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 involving 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:
Completion prerequisite check
│ 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 remains 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 a downstream system has not updated.
The orchestrator should therefore normalize raw provider responses into a structured proof object.
Verified release proof
│ 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.
A successful transport response is not the same thing as verified business completion.
Production-Derived Release Validation
The following validator is closely adapted from actual orchestration logic, with product-specific and provider-specific names removed.
Provider release response
│ 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>;
│ }
Release proof builder
│ 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 this 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 interprets incomplete proof as permission to continue.
A fail-closed system interprets incomplete proof as a reason to stop.
A generic blocked-response helper might look like this:
Blocked result helper
│ 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 should not hide these conditions behind a generic internal-server error.
Each one becomes a specific operational state.
That distinction matters.
A generic error says that the software encountered a problem.
A defined operational state says what is known, what is not known, what action has already occurred, and what must happen next.
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.
Reservation adapter
│ export interface ReservationAdapter {
│ createReplacementHold(
│ request: ReplacementRequest
│ ): Promise<ReplacementProof>;
│
│ promoteReplacement(
│ reference: string
│ ): Promise<PromotionProof>;
│ }
Release adapter
│ export interface ReleaseAdapter {
│ releaseOriginal(
│ request: ReleaseRequest
│ ): Promise<ProviderReleaseResponse>;
│ }
Financial verifier
│ 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 multiple 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:
Reservation transition orchestrator
│ 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 this:
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.
The system must retain the release confirmation, preserve the replacement reference, prevent automatic retries that could worsen the state, and route the transition into an explicit recovery process.
Exceptions Are Part of the Product
A robust transaction engine does not merely define the happy path.
It defines the failure topology.
Transition exception model
│ 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 currently 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.
Transition request
│ 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.
Existing transition check
│ 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.
Idempotency is not merely a technical optimization.
It is part of the customer-safety model.
Preserve an Immutable Timeline
Every meaningful event should be recorded.
Transition event
│ 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 was the replacement created?
- How was the replacement verified?
- When was the release requested?
- What evidence confirmed the release?
- Which financial checks were performed?
- Did a retry occur?
- Why did the workflow complete or stop?
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.
When external systems disagree, the timeline may be the only reliable means of establishing what happened, when it happened, and which system produced each claim.
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.
Authorization proof
│ 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.
The system should be capable of distinguishing between:
- A technically available route.
- A contractually authorized route.
- A customer-authorized transition.
- A provider-authorized transition.
- A route that requires human intervention.
A successful API call does not resolve those distinctions.
Model Financial Safety Directly
Reservation transitions involve more than inventory.
They may also 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 modeled explicitly.
Financial verification result
│ 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.
The engine should never infer financial safety from the fact that the inventory operation succeeded.
Inventory state and payment state are related, but they are not the same system.
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 at least the following cases:
- 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:
Completion invariant
│ expect(
│ canCompleteTransition(context)
│ ).toBe(
│ context.replacementVerified === true &&
│ context.releaseProofVerified === true &&
│ context.replacementPromoted === true &&
│ context.financialSafetyVerified === true
│ );
The test is not merely proving that the engine can finish.
It is proving that the engine cannot finish incorrectly.
That distinction defines the architecture.
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 a public project should be to document and test the transaction-safety pattern.
It should not become a turnkey system for modifying live bookings.
A reference implementation can demonstrate:
- State-machine design.
- Validation rules.
- Adapter boundaries.
- Idempotency handling.
- Evidence normalization.
- Failure classification.
- Testable invariants.
It should not expose the production mechanisms, credentials, relationships, or routing logic required to operate against real reservations.
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.
Any system replacing one external commitment with another faces the same structural problem: the original cannot safely be destroyed until the replacement is real, verified, and operationally defensible.
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.