last_error; } /** * @param mixed[] $context */ public function initialize(int $booking_id, array $context = []): bool { $post = $this->bookingPost($booking_id); if (!$post) return false; $context = $this->context($context, 'create'); $this->write($booking_id, self::DIMENSION_BOOKING, '', BookingStatus::PENDING, $context); $this->write($booking_id, self::DIMENSION_PAYMENT, '', PaymentStatus::UNPAID, $context); return true; } /** * @param mixed[] $context */ public function markPending(int $booking_id, array $context = []): bool { return $this->transition($booking_id, self::DIMENSION_BOOKING, BookingStatus::PENDING, $context); } /** * @param mixed[] $context */ public function confirm(int $booking_id, array $context = []): bool { return $this->transition($booking_id, self::DIMENSION_BOOKING, BookingStatus::CONFIRMED, $context); } /** * @param mixed[] $context */ public function decline(int $booking_id, array $context = []): bool { return $this->transition($booking_id, self::DIMENSION_BOOKING, BookingStatus::DECLINED, $context); } /** * @param mixed[] $context */ public function cancel(int $booking_id, array $context = []): bool { return $this->transition($booking_id, self::DIMENSION_BOOKING, BookingStatus::CANCELLED, $context); } /** * @param mixed[] $context */ public function complete(int $booking_id, array $context = []): bool { return $this->transition($booking_id, self::DIMENSION_BOOKING, BookingStatus::COMPLETED, $context); } /** * @param mixed[] $context */ public function fail(int $booking_id, array $context = []): bool { return $this->transition($booking_id, self::DIMENSION_BOOKING, BookingStatus::FAILED, $context); } /** * @param mixed[] $context */ public function expire(int $booking_id, array $context = []): bool { return $this->transition($booking_id, self::DIMENSION_BOOKING, BookingStatus::EXPIRED, $context); } /** * @param mixed[] $context */ public function markUnpaid(int $booking_id, array $context = []): bool { return $this->transition($booking_id, self::DIMENSION_PAYMENT, PaymentStatus::UNPAID, $context); } /** * @param mixed[] $context */ public function markPaymentPending(int $booking_id, array $context = []): bool { return $this->transition($booking_id, self::DIMENSION_PAYMENT, PaymentStatus::PENDING, $context); } /** * @param mixed[] $context */ public function markPaid(int $booking_id, array $context = []): bool { return $this->transition($booking_id, self::DIMENSION_PAYMENT, PaymentStatus::PAID, $context); } /** * @param mixed[] $context */ public function markPaymentFailed(int $booking_id, array $context = []): bool { return $this->transition($booking_id, self::DIMENSION_PAYMENT, PaymentStatus::FAILED, $context); } /** * @param mixed[] $context */ public function markPaymentNotRequired(int $booking_id, array $context = []): bool { return $this->transition($booking_id, self::DIMENSION_PAYMENT, PaymentStatus::NOT_REQUIRED, $context); } /** * @param mixed[] $context */ public function markRefunded(int $booking_id, array $context = []): bool { return $this->transition($booking_id, self::DIMENSION_PAYMENT, PaymentStatus::REFUNDED, $context); } /** * @param mixed[] $context */ public function markPartiallyRefunded(int $booking_id, array $context = []): bool { return $this->transition($booking_id, self::DIMENSION_PAYMENT, PaymentStatus::PARTIALLY_REFUNDED, $context); } /** * @param int|string $status * @param mixed[] $context */ public function transition(int $booking_id, string $dimension, $status, array $context = []): bool { $post = $this->bookingPost($booking_id); if (!$post) return false; $dimension = sanitize_key($dimension); $status = $this->normalizeStatus($dimension, $status); $current = $this->status($booking_id, $dimension); $context = $this->context($context); if (!$this->validStatus($dimension, $status)) { return $this->rejectTransition( $booking_id, // translators: Placeholder values. sprintf(esc_html__('The requested booking %s status is not valid.', 'daytidy'), $dimension), $context ); } if ($current === $status) return true; if (!$this->canTransition($booking_id, $dimension, $current, $status, $context)) { return $this->rejectTransition( $booking_id, // translators: Placeholder values. sprintf(esc_html__('The requested booking %s status transition is not allowed.', 'daytidy'), $dimension), $context ); } if (!empty($context['dry_run'])) return true; do_action( 'DayTidy/A__DayTidy/Booking/StateService__transition/before', $booking_id, $dimension, $current, $status, $context ); $this->write($booking_id, $dimension, $current, $status, $context); do_action( 'DayTidy/A__DayTidy/Booking/StateService__transition/after', $booking_id, $dimension, $current, $status, $context ); do_action( 'DayTidy/A__DayTidy/Booking/StateService__' . $dimension . '/after', $booking_id, $current, $status, $context ); if ($dimension === self::DIMENSION_PAYMENT) { (new PaymentTransactionLedger())->recordStateChange($booking_id, $current, $status, $context); } return true; } /** * @return array{booking: string, payment: string} */ public function state(int $booking_id): array { return [ self::DIMENSION_BOOKING => (string) $this->status($booking_id, self::DIMENSION_BOOKING), self::DIMENSION_PAYMENT => (string) $this->status($booking_id, self::DIMENSION_PAYMENT), ]; } public function status(int $booking_id, string $dimension): string { $dimension = sanitize_key($dimension); $value = get_post_meta($booking_id, $this->metaKey($dimension), true); if ($value === '') return $this->defaultStatus($dimension); return $this->normalizeStatus($dimension, $value); } /** * @param mixed[] $context * @return array */ private function context(array $context, string $default_source = 'state'): array { $context['source'] = sanitize_key((string) ($context['source'] ?? $default_source)); $context['user_id'] = isset($context['user_id']) ? absint($context['user_id']) : get_current_user_id(); $context['changed_at'] = current_time('Y-m-d H:i:s', true); if (!isset($context['reason']) && isset($context['override_reason'])) { $context['reason'] = $context['override_reason']; } if (isset($context['reason'])) { $context['reason'] = sanitize_textarea_field((string) $context['reason']); } return $context; } private function bookingPost(int $booking_id): ?WP_Post { $this->last_error = ''; $booking_id = absint($booking_id); $post = $booking_id ? get_post($booking_id) : null; if (!$post instanceof WP_Post || $post->post_type !== BookingPost::PT) { $this->last_error = esc_html__('Booking was not found.', 'daytidy'); return null; } return $post; } private function defaultStatus(string $dimension): string { if ($dimension === self::DIMENSION_BOOKING) return BookingStatus::PENDING; if ($dimension === self::DIMENSION_PAYMENT) return PaymentStatus::UNPAID; return ''; } private function metaKey(string $dimension): string { if ($dimension === self::DIMENSION_BOOKING) return self::META_BOOKING_STATUS; if ($dimension === self::DIMENSION_PAYMENT) return self::META_PAYMENT_STATUS; return ''; } /** * @param int|string $status */ private function normalizeStatus(string $dimension, $status): string { $status = sanitize_key((string) $status); if ($dimension === self::DIMENSION_BOOKING) { if ($status === 'cancelled') return BookingStatus::CANCELLED; if ($status === 'canceled') return BookingStatus::CANCELLED; return BookingStatus::valid($status) ? $status : ''; } if ($dimension === self::DIMENSION_PAYMENT) return PaymentStatus::normalize($status, ''); return ''; } private function validStatus(string $dimension, string $status): bool { if (!$this->metaKey($dimension)) return false; if ($dimension === self::DIMENSION_BOOKING) return BookingStatus::valid($status); if ($dimension === self::DIMENSION_PAYMENT) return PaymentStatus::valid($status); return false; } /** * @param mixed[] $context */ private function canTransition(int $booking_id, string $dimension, string $current, string $status, array $context): bool { if (!empty($context['force'])) return true; $state = $this->state($booking_id); $allowed = true; if ($dimension === self::DIMENSION_BOOKING) { $allowed = $this->canTransitionBooking($status, $state); } else if ($dimension === self::DIMENSION_PAYMENT) { $allowed = $this->canTransitionPayment($current, $status, $state); } return (bool) apply_filters( 'DayTidy/F__DayTidy/Booking/StateService__can_transition/allowed', $allowed, $booking_id, $dimension, $current, $status, $context, $state ); } /** * @param array{booking: string, payment: string} $state */ private function canTransitionBooking(string $status, array $state): bool { if ($status !== BookingStatus::COMPLETED) return true; if ($state[self::DIMENSION_BOOKING] !== BookingStatus::CONFIRMED) return false; return PaymentStatus::canCompleteBooking($state[self::DIMENSION_PAYMENT]); } /** * @param array{booking: string, payment: string} $state */ private function canTransitionPayment(string $current, string $status, array $state): bool { if ($current === PaymentStatus::REFUNDED && $status !== PaymentStatus::REFUNDED) return false; if ($current === PaymentStatus::PARTIALLY_REFUNDED && !in_array( $status, [PaymentStatus::REFUNDED, PaymentStatus::PARTIALLY_REFUNDED], true )) return false; if (in_array($state[self::DIMENSION_BOOKING], BookingStatus::terminal(), true)) { if (!in_array($status, [PaymentStatus::REFUNDED, PaymentStatus::PARTIALLY_REFUNDED], true)) return false; return in_array( $current, [ PaymentStatus::PAID, PaymentStatus::REFUNDED, PaymentStatus::PARTIALLY_REFUNDED, ], true ); } if (in_array($status, [PaymentStatus::REFUNDED, PaymentStatus::PARTIALLY_REFUNDED], true)) { return in_array( $current, [ PaymentStatus::PAID, PaymentStatus::REFUNDED, PaymentStatus::PARTIALLY_REFUNDED, ], true ); } return true; } /** * @param mixed[] $context */ private function write(int $booking_id, string $dimension, string $current, string $status, array $context): void { update_post_meta($booking_id, $this->metaKey($dimension), $status); update_post_meta($booking_id, self::META_CHANGED_AT, $context['changed_at']); update_post_meta($booking_id, self::META_CHANGED_BY, $context['user_id']); delete_post_meta($booking_id, self::META_LAST_ERROR); $this->history($booking_id, $dimension, $current, $status, $context); } /** * @param mixed[] $context */ private function history(int $booking_id, string $dimension, string $current, string $status, array $context): void { $history = get_post_meta($booking_id, self::META_HISTORY, true); if (!is_array($history)) $history = []; $history[] = [ 'dimension' => $dimension, 'from' => $current, 'to' => $status, 'source' => $context['source'], 'user_id' => $context['user_id'], 'reason' => $context['reason'] ?? '', 'changed_at' => $context['changed_at'], ]; if (count($history) > self::HISTORY_LIMIT) $history = array_slice($history, -1 * self::HISTORY_LIMIT); update_post_meta($booking_id, self::META_HISTORY, $history); } /** * @param mixed[] $context */ private function rejectTransition(int $booking_id, string $message, array $context): bool { $this->last_error = $message; update_post_meta($booking_id, self::META_LAST_ERROR, $message); do_action( 'DayTidy/A__DayTidy/Booking/StateService__transition/rejected', $booking_id, $message, $context ); return false; } }