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.rbtw-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:
- Existence —
by_order_referencemust return at least one record (→ 404 if not) - Time — new booking time must not be in the past (→ 422)
- Reference consistency — the set of
treatwell_booking_referencevalues on existing appointments must exactly match those in the incoming request (prevents cross-booking accidents) - State — all existing appointments must be
reschedulable?(:bookedstate) - Service match —
venue_treatment_idset 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_commithooks have already fired on theupdate_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
| Step | Risk | Severity |
|---|---|---|
| No pessimistic lock on replacing_appointments | Concurrent cancel + reschedule race | P0 |
| No slot lock during validation | Double-booking new slot | P0 |
| Asymmetric deletion (delete before confirm) | Customer loses booking if confirm fails | P0 |
TrackBookingModificationJob swallows error, return false doesn't rollback | DB committed but 422 returned — state inconsistency | P1 |
| Sequential entity moves, no compensation | Partial move leaves orphaned entities | P1 |
BookingDuplicatedTreatment caught after commit | Error reported but appointments already in DB | P1 |
| Stale availability matrix | New slot appears free when already booked | P1 |