Skip to main content

02 — Reschedule Flow

Overview​

A reschedule request replaces a set of existing :booked appointments with new ones at a different time. The new appointments are created through the same BookingService machinery (via inheritance), while the old ones are soft-deleted.

Key files:

  • tw-integration/app/services/treatwell/booking_reschedule_service.rb
  • tw-integration/app/services/treatwell/booking_service.rb (parent)
  • tw-integration/app/entities/treatwell/base_booking.rb

Sequence Diagram​


Step-by-step narrative​

1–5. Pre-flight validations​

Before touching any data the service validates:

  1. Existence — by_order_reference must return at least one record (→ 404 if not)
  2. Time — new booking time must not be in the past (→ 422)
  3. Reference consistency — the set of treatwell_booking_reference values on existing appointments must exactly match those in the incoming request (prevents cross-booking accidents)
  4. State — all existing appointments must be reschedulable? (:booked state)
  5. Service match — venue_treatment_id set must be identical (no service swaps via reschedule)

These are all read-only and return clean errors with no side effects.

6. New slot resolution​

Order.new(_fetch_order_params) builds a transient order using data recovered from the existing appointments (currency, payment method, customer). to_appointments resolves the new slot via FinderOverMatricesService passing replacing_appointments_ids so that the current slot is excluded from occupancy checks.

The same no-lock problem applies: the new slot is not locked between validation and save.

7. Availability validation (inherited)​

Identical to creation — valid? calls _validate_availabilities, _slots_found, _non_overlapping_bookings. The replacing_appointments override ensures the old slot is correctly excluded from the has-room-for check.

8. Asymmetric deletion (critical risk)​

# booking_reschedule_service.rb:109
replacing_appointments.clone.update_all(state: :deleted, updated_at: Time.current)

# booking_reschedule_service.rb:113
_confirm_appointments(appointments, code: order_reference, ...)

The old appointments are soft-deleted before the new ones are confirmed.

If _confirm_appointments fails for any reason (DB error, uniqueness collision, multiple_book_by_customer! error), the transaction rolls back — but update_all inside a transaction is rolled back by PostgreSQL, so the old appointments are restored in theory. In practice this is safe only if:

  • No after_commit hooks have already fired on the update_all
  • No nested transactions / requires_new: savepoints are in play

The real risk is subtler: if _confirm_appointments partially succeeds (e.g. appointments saved but multiple_book_by_customer! raises), the rollback restores the old appointments but the TransactionCode destruction in step _confirm_appointments may have already unlinked the old TC (line 73 of booking_service.rb). The customer ends up with their original booking restored but the TC pointing to nothing.

9. TrackBookingModificationJob (direct bookings only)​

# booking_reschedule_service.rb:178-183
def _track_bookings_modification(replacing_appointments, appointments)
TrackBookingModificationJob.perform_now(...)
rescue => e
Sentry.capture_exception(e)
false
end

Runs synchronously inside the transaction. Any exception is silently swallowed and the transaction continues to commit. The reschedule succeeds but produces no tracking record. The false return value propagates to:

# line 118
if _direct_book_reschedule? && !_track_bookings_modification(...)
@http_status = :unprocessable_entity
errors.add :base, booking_modification_error
return false # ← returns false BUT we're inside the transaction
end

return false exits the run method but does not raise ActiveRecord::Rollback, so the transaction commits anyway. The caller then sees false and renders a 422, but the appointments have already been rescheduled in the database.

10. Entity move​

_move_appointment_entities pairs old and new appointments by treatwell_booking_reference and venue_treatment_id, then calls MoveAppointmentsAssociatedEntitiesService for each pair. This is a sequential loop with no rescue block — any failure mid-loop leaves some pairs moved and others not. There is no compensating transaction to undo partial moves.


State machine during reschedule​


Risk summary for this flow​

StepRiskSeverity
No pessimistic lock on replacing_appointmentsConcurrent cancel + reschedule raceP0
No slot lock during validationDouble-booking new slotP0
Asymmetric deletion (delete before confirm)Customer loses booking if confirm failsP0
TrackBookingModificationJob swallows error, return false doesn't rollbackDB committed but 422 returned — state inconsistencyP1
Sequential entity moves, no compensationPartial move leaves orphaned entitiesP1
BookingDuplicatedTreatment caught after commitError reported but appointments already in DBP1
Stale availability matrixNew slot appears free when already bookedP1