key(), self::chargeGateways(), true); } public static function defaultAmount(BookingEntity $booking): float { try { $transaction = $booking->getTransaction(); $amount = $transaction->getPayable(); if ($amount <= 0) $amount = $transaction->getTotal(); } catch (Throwable $e) { $amount = (float) $booking->meta_get('daytidy_total', 0); } return Price::normalize((float) $amount); } public static function currency(BookingEntity $booking): string { try { $currency = $booking->getTransaction()->getCurrency(); if ($currency) return $currency; } catch (Throwable $e) { } $offering = $booking->offering(); if ($offering) return $offering->getCurrency(); return Config::general()->currency(); } /** * @param mixed[] $args * @return mixed[] */ public function perform(int $booking_id, array $args = []): array { $booking = new BookingEntity($booking_id); $transaction = $this->transaction($booking); $gateway = $this->gateway($transaction); $amount = $this->amount($booking, $args); $currency = self::currency($booking); $charge = !empty($args['charge']); $context = [ 'source' => isset($args['source']) ? sanitize_key((string) $args['source']) : 'manual', 'charge' => $charge, 'charged' => false, 'noop' => false, 'amount' => $amount, 'currency' => $currency, 'gateway' => $gateway ? $gateway->key() : '', 'gateway_ref_id' => $transaction ? $transaction->getGatewayRefId() : '', 'response' => [], ]; $context = $this->limitAmountToRemaining($booking_id, $context); if ($charge) { $charge_enabled = (bool) apply_filters( 'DayTidy/F__DayTidy/Booking/Refund__charge/enabled', false, $booking_id, $context ); if (!$charge_enabled) { return [ 'success' => false, 'message' => esc_html__('Online gateway refunds require DayTidy Pro.', 'daytidy'), 'data' => $context, ]; } if (!$this->lock($booking_id, $context)) { $context['noop'] = true; return [ 'success' => true, 'message' => esc_html__('Gateway refund is already being processed.', 'daytidy'), 'data' => $context, ]; } try { if ((float) $context['amount'] <= 0) { $context['noop'] = true; } else { $preflight = $this->preflight($booking_id, $context); if (empty($preflight['success'])) return $preflight; $result = $this->refundCharge($booking, $transaction, $gateway, $context); if (empty($result['success'])) return $result; $context['noop'] = !empty($result['data']['noop']); $context['charged'] = !$context['noop']; $context['response'] = $result['data']['response'] ?? []; if (!empty($result['data']['currency'])) $context['currency'] = $result['data']['currency']; if (!$context['noop'] && !empty($result['data']['amount'])) $context['amount'] = (float) $result['data']['amount']; } $marked = $this->mark($booking_id, $context); if (empty($marked['success'])) return $marked; } finally { $this->unlock($booking_id); } } else { $marked = $this->mark($booking_id, $context); if (empty($marked['success'])) return $marked; } return [ 'success' => true, 'message' => esc_html__('Booking marked as refunded.', 'daytidy'), 'data' => $context, ]; } /** * @param mixed[] $context * @return mixed[] */ private function preflight(int $booking_id, array $context): array { $payment_status = $this->refundStatus($booking_id, $context); $dry_run_context = array_merge($context, ['dry_run' => true]); $states = new StateService(); $transitioned = $payment_status === PaymentStatus::PARTIALLY_REFUNDED ? $states->markPartiallyRefunded($booking_id, $dry_run_context) : $states->markRefunded($booking_id, $dry_run_context); if (!$transitioned) { return [ 'success' => false, 'message' => $states->lastError(), 'data' => array_merge($context, ['payment_status' => $payment_status]), ]; } return [ 'success' => true, 'message' => '', 'data' => array_merge($context, ['payment_status' => $payment_status]), ]; } /** * @param mixed[] $context * @return mixed[] */ private function refundCharge( BookingEntity $booking, ?Transaction $transaction, ?Gateway $gateway, array $context ): array { if (!$transaction || !$gateway || !$gateway instanceof OnlineGateway || !self::supportsGateway($gateway)) { return [ 'success' => false, 'message' => esc_html__('This payment gateway does not support charge refunds.', 'daytidy'), ]; } if (!$transaction->getGatewayRefId()) { return [ 'success' => false, 'message' => esc_html__('The gateway reference ID is missing for this booking.', 'daytidy'), ]; } $amount = (float) $context['amount']; $maximum = self::defaultAmount($booking); if ($amount <= 0 || $amount > $maximum) { return [ 'success' => false, 'message' => esc_html__('Refund amount is not valid.', 'daytidy'), ]; } $result = $gateway->refund([ 'booking' => $booking, 'transaction' => $transaction, 'amount' => $amount, 'currency' => $context['currency'], ]); if (!$result->successful()) { return [ 'success' => false, 'message' => $result->message() ?: esc_html__('Gateway refund failed.', 'daytidy'), 'data' => $result->response(), ]; } $data = $result->data(); return [ 'success' => true, 'message' => esc_html__('Gateway charge refunded.', 'daytidy'), 'data' => [ 'amount' => $data['amount'] ?? $context['amount'], 'currency' => $data['currency'] ?? $context['currency'], 'noop' => !empty($data['noop']), 'response' => $result->response(), ], ]; } /** * @param mixed[] $context * @return mixed[] */ private function mark(int $booking_id, array $context): array { do_action('DayTidy/A__DayTidy/Booking/Booking__refund/before', $booking_id, $context); $context['payment_status'] = $this->refundStatus($booking_id, $context); $states = new StateService(); $transitioned = $context['payment_status'] === PaymentStatus::PARTIALLY_REFUNDED ? $states->markPartiallyRefunded($booking_id, $context) : $states->markRefunded($booking_id, $context); if (!$transitioned) { return [ 'success' => false, 'message' => $states->lastError(), 'data' => $context, ]; } update_post_meta($booking_id, 'daytidy_refunded_at', current_time('Y-m-d H:i:s', true)); update_post_meta($booking_id, 'daytidy_refund_amount', $context['amount']); update_post_meta($booking_id, 'daytidy_refund_currency', $context['currency']); update_post_meta($booking_id, 'daytidy_refund_charge', $context['charge'] ? 1 : 0); update_post_meta($booking_id, 'daytidy_refund_gateway', $context['gateway']); update_post_meta($booking_id, 'daytidy_refund_gateway_ref_id', $context['gateway_ref_id']); $this->history($booking_id, $context); do_action('DayTidy/A__DayTidy/Booking/Booking__refund/after', $booking_id, $context); do_action('DayTidy/A__DayTidy/Booking/Refund__processed/after', $booking_id, $context); return [ 'success' => true, 'message' => esc_html__('Booking marked as refunded.', 'daytidy'), 'data' => $context, ]; } /** * @param mixed[] $context * @return void */ private function history(int $booking_id, array $context): void { $history = get_post_meta($booking_id, 'daytidy_refunds', true); if (!is_array($history)) $history = []; $history[] = [ 'created_at' => current_time('Y-m-d H:i:s', true), 'source' => $context['source'], 'amount' => $context['amount'], 'currency' => $context['currency'], 'charge' => $context['charge'] ? 1 : 0, 'charged' => $context['charged'] ? 1 : 0, 'noop' => $context['noop'] ? 1 : 0, 'gateway' => $context['gateway'], 'gateway_ref_id' => $context['gateway_ref_id'], 'response' => $context['response'], ]; update_post_meta($booking_id, 'daytidy_refunds', $history); (new PaymentTransactionLedger())->recordRefund($booking_id, $context); } /** * @param mixed[] $context */ private function lock(int $booking_id, array $context): bool { $key = $this->lockKey($booking_id); $lock = get_option($key, []); if (is_array($lock) && time() - (int) ($lock['created_at'] ?? 0) > self::LOCK_TTL) { delete_option($key); } return add_option($key, [ 'created_at' => time(), 'source' => $context['source'], 'amount' => $context['amount'], 'currency' => $context['currency'], 'gateway' => $context['gateway'], 'gateway_ref_id' => $context['gateway_ref_id'], ], '', false); } private function unlock(int $booking_id): void { delete_option($this->lockKey($booking_id)); } private function lockKey(int $booking_id): string { return 'daytidy_refund_lock_' . $booking_id; } /** * @param mixed[] $args */ private function amount(BookingEntity $booking, array $args): float { $amount = $args['amount'] ?? null; if (is_numeric($amount) && (float) $amount > 0) return Price::normalize((float) $amount); return self::defaultAmount($booking); } /** * @param mixed[] $context * @return mixed[] */ private function limitAmountToRemaining(int $booking_id, array $context): array { if (!empty($context['noop'])) return $context; $booking = new BookingEntity($booking_id); $maximum = self::defaultAmount($booking); $amount = Price::normalize((float) ($context['amount'] ?? 0)); if ($maximum <= 0 || $amount <= 0) return $context; $currency = (string) ($context['currency'] ?? ''); $remaining = Price::normalize(max(0, $maximum - $this->refundedAmount($booking_id, $currency))); if ($remaining <= 0) $context['amount'] = 0.0; else if ($amount > $remaining) $context['amount'] = $remaining; return $context; } /** * @param mixed[] $context */ private function refundStatus(int $booking_id, array $context): string { if (!empty($context['noop'])) return PaymentStatus::REFUNDED; $booking = new BookingEntity($booking_id); $maximum = self::defaultAmount($booking); $amount = Price::normalize((float) ($context['amount'] ?? 0)); $currency = (string) ($context['currency'] ?? ''); $refunded = Price::normalize($this->refundedAmount($booking_id, $currency) + $amount); if ($maximum > 0 && $refunded > 0 && $refunded < $maximum) return PaymentStatus::PARTIALLY_REFUNDED; return PaymentStatus::REFUNDED; } private function refundedAmount(int $booking_id, string $currency): float { $history = get_post_meta($booking_id, 'daytidy_refunds', true); if (!is_array($history)) return 0.0; $currency = strtoupper($currency); $total = 0.0; foreach ($history as $refund) { if (!is_array($refund) || !empty($refund['noop'])) continue; if ($currency && strtoupper((string) ($refund['currency'] ?? '')) !== $currency) continue; $total = Price::normalize($total + (float) ($refund['amount'] ?? 0)); } return $total; } private function transaction(BookingEntity $booking): ?Transaction { try { return $booking->getTransaction(); } catch (Throwable $e) { return null; } } private function gateway(?Transaction $transaction): ?Gateway { if (!$transaction) return null; try { return $transaction->getGateway(); } catch (Throwable $e) { return null; } } }