$attributes */ public function create(Event $event, Artist $artist, array $attributes): ArtistEngagement { return DB::transaction(function () use ($event, $artist, $attributes): ArtistEngagement { $engagement = new ArtistEngagement($attributes); $engagement->artist_id = $artist->id; $engagement->event_id = $event->id; // D26 — Buma auto-flip. If artist's agent_company handles // BUMA reporting itself, default the engagement to // BookingAgency; otherwise default to Organisation. Caller // may override via $attributes. if (! array_key_exists('buma_handled_by', $attributes)) { $artist->loadMissing('agentCompany'); $engagement->buma_handled_by = ($artist->agentCompany?->handles_buma === true) ? BumaHandledBy::BookingAgency : BumaHandledBy::Organisation; } $status = $this->coerceStatus($engagement->booking_status ?? ArtistEngagementStatus::Draft); $this->validateInitialStatus($status, $engagement); $engagement->booking_status = $status; $engagement->save(); return $engagement->refresh(); }); } /** * Partial update. Status changes route through transitionStatus so * the state machine fires on every status change regardless of the * other fields in the payload. * * @param array $attributes */ public function update(ArtistEngagement $engagement, array $attributes): ArtistEngagement { return DB::transaction(function () use ($engagement, $attributes): ArtistEngagement { unset($attributes['organisation_id'], $attributes['artist_id'], $attributes['event_id']); if (array_key_exists('booking_status', $attributes)) { $newStatus = $this->coerceStatus($attributes['booking_status']); unset($attributes['booking_status']); $engagement->fill($attributes); $this->transitionStatus($engagement, $newStatus, $attributes); $engagement->save(); } else { $engagement->fill($attributes); $engagement->save(); } return $engagement->refresh(); }); } /** * Atomic transition with full state-machine validation. Throws * InvalidStatusTransitionException on illegal moves. * * @param array $contextAttributes fields already filled on $engagement */ public function transitionStatus( ArtistEngagement $engagement, ArtistEngagementStatus $to, array $contextAttributes = [], ): ArtistEngagement { $from = $this->coerceStatus($engagement->booking_status); $this->validateTransition($from, $to, $engagement); $engagement->booking_status = $to; $engagement->save(); return $engagement; } /** * Cancel = transition to Cancelled, then soft-delete. Observer * cascades to performances + advance_sections per RFC §5.4. */ public function cancel(ArtistEngagement $engagement): ArtistEngagement { return DB::transaction(function () use ($engagement): ArtistEngagement { $from = $this->coerceStatus($engagement->booking_status); if ($from !== ArtistEngagementStatus::Cancelled) { $this->transitionStatus($engagement, ArtistEngagementStatus::Cancelled); } $engagement->delete(); return $engagement->refresh(); }); } /** * Soft delete only. Observer cascades to children. Use cancel() if * the booking_status should also flip — the API destroy endpoint * routes here directly to keep status untouched (§6.2 — DELETE * /engagements/{id} is "remove the booking", semantically distinct * from "cancel"). */ public function softDelete(ArtistEngagement $engagement): void { $engagement->delete(); } private function validateInitialStatus(ArtistEngagementStatus $status, ArtistEngagement $engagement): void { if ($status === ArtistEngagementStatus::Option) { $expires = $engagement->option_expires_at; if ($expires === null || $expires->isPast()) { throw new InvalidStatusTransitionException( ArtistEngagementStatus::Draft, $status, 'Option requires option_expires_at in the future.', ); } } if ($status === ArtistEngagementStatus::Contracted) { $fee = $engagement->fee_amount; if ($fee === null || (float) $fee <= 0) { throw new InvalidStatusTransitionException( ArtistEngagementStatus::Draft, $status, 'Contracted requires fee_amount.', ); } } } private function validateTransition( ArtistEngagementStatus $from, ArtistEngagementStatus $to, ArtistEngagement $engagement, ): void { if ($from === $to) { return; } if (in_array($from, self::TERMINAL, true)) { throw new InvalidStatusTransitionException( $from, $to, sprintf('%s is terminal — re-booking creates a new engagement.', $from->value), ); } if ($to === ArtistEngagementStatus::Option) { $expires = $engagement->option_expires_at; if ($expires === null || $expires->isPast()) { throw new InvalidStatusTransitionException( $from, $to, 'Option requires option_expires_at in the future.', ); } } if ($to === ArtistEngagementStatus::Contracted) { $fee = $engagement->fee_amount; if ($fee === null || (float) $fee <= 0) { throw new InvalidStatusTransitionException( $from, $to, 'Contracted requires fee_amount.', ); } } } private function coerceStatus(ArtistEngagementStatus|string $value): ArtistEngagementStatus { return $value instanceof ArtistEngagementStatus ? $value : ArtistEngagementStatus::from($value); } }