db = $db ?: new Db(); } public function ready(): bool { return $this->tableReady(); } /** * @param mixed[] $context */ public function recordAttempt(int $booking_id, Transaction $transaction, array $context = []): int { return $this->record(array_merge($this->transactionData($booking_id, $transaction), [ 'event_type' => 'attempt', 'status' => self::statusKey(PaymentStatus::UNPAID), 'context' => $context, 'idempotency_key' => $this->bookingIdempotencyKey($booking_id, 'attempt'), ])); } /** * @param int|string $from * @param int|string $to * @param mixed[] $context */ public function recordStateChange(int $booking_id, $from, $to, array $context = []): int { $transaction = $this->bookingTransaction($booking_id); $data = $transaction ? $this->transactionData($booking_id, $transaction) : $this->bookingData($booking_id); return $this->record(array_merge($data, [ 'event_type' => (string) ($context['payment_event_type'] ?? 'status_change'), 'status' => self::statusKey((string) $to), 'amount' => $this->contextAmount($context, $data), 'context' => array_merge($context, [ 'from_status' => self::statusKey((string) $from), 'to_status' => self::statusKey((string) $to), ]), 'idempotency_key' => isset($context['payment_idempotency_key']) ? (string) $context['payment_idempotency_key'] : null, ])); } /** * @param mixed[] $context */ public function recordRefund(int $booking_id, array $context = []): int { $transaction = $this->bookingTransaction($booking_id); $data = $transaction ? $this->transactionData($booking_id, $transaction) : $this->bookingData($booking_id); return $this->record(array_merge($data, [ 'event_type' => 'refund', 'status' => self::statusKey((string) ($context['payment_status'] ?? PaymentStatus::REFUNDED)), 'amount' => $this->contextAmount($context, $data), 'context' => $context, 'idempotency_key' => isset($context['payment_idempotency_key']) ? (string) $context['payment_idempotency_key'] : null, ])); } /** * @param mixed[] $context */ public function recordGatewayEvent( int $booking_id, string $gateway, string $gateway_ref_id, string $ref_id, string $status, array $context = [] ): int { return $this->record($this->gatewayEventData($booking_id, $gateway, $gateway_ref_id, $ref_id, $status, $context)); } /** * @param mixed[] $context * @return int|null Positive ID when claimed, zero when already claimed, null when the ledger is unavailable. */ public function claimGatewayEvent( string $idempotency_key, int $booking_id, string $gateway, string $gateway_ref_id, string $ref_id, string $status, array $context = [] ): ?int { if (!$idempotency_key || !$this->tableReady()) return null; $context['payment_idempotency_key'] = $idempotency_key; $data = $this->filteredRecordData($this->gatewayEventData($booking_id, $gateway, $gateway_ref_id, $ref_id, $status, $context)); $data['idempotency_key'] = sanitize_text_field($idempotency_key); $id = $this->insertNormalizedRecord($data); if ($id) return $id; $existing_id = $this->idByIdempotencyKey($idempotency_key); if ($existing_id) { do_action('DayTidy/A__DayTidy/Booking/PaymentTransactionLedger__record/duplicate', $existing_id, $data); return 0; } return null; } /** * @param mixed[] $context */ public function updateGatewayEventClaim( string $idempotency_key, int $booking_id, string $gateway, string $gateway_ref_id, string $ref_id, string $status, array $context = [] ): int { if (!$idempotency_key || !$this->tableReady()) return 0; if (!$this->idByIdempotencyKey($idempotency_key)) return 0; $context['payment_idempotency_key'] = $idempotency_key; $data = $this->filteredRecordData($this->gatewayEventData($booking_id, $gateway, $gateway_ref_id, $ref_id, $status, $context)); $updated = $this->db->update(self::TABLE, [ 'booking_id' => $data['booking_id'], 'ref_id' => $data['ref_id'], 'gateway' => $data['gateway'], 'gateway_name' => $data['gateway_name'], 'gateway_ref_id' => $data['gateway_ref_id'], 'event_type' => $data['event_type'], 'status' => $data['status'], 'amount' => $data['amount'], 'payable_amount' => $data['payable_amount'], 'currency' => $data['currency'], 'coupon_codes' => $data['coupon_codes'], 'context' => $data['context'], 'created_at' => $data['created_at'], ], [ 'idempotency_key' => $idempotency_key, ], [ '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%f', '%f', '%s', '%s', '%s', '%s', ], [ '%s', ]); return $updated === false ? 0 : $this->idByIdempotencyKey($idempotency_key); } public function recoverStaleGatewayEventClaim(string $idempotency_key, string $status, int $ttl_seconds = self::PENDING_GATEWAY_CLAIM_TTL): int { if (!$idempotency_key || !$this->tableReady()) return 0; $ttl_seconds = max(1, absint($ttl_seconds)); $updated = $this->db->query($this->db->prepare( "UPDATE `#__" . self::TABLE . "` SET `created_at` = %s WHERE `idempotency_key` = %s AND `status` = %s AND `created_at` < %s LIMIT 1", current_time('Y-m-d H:i:s', true), $idempotency_key, sanitize_key($status), gmdate('Y-m-d H:i:s', time() - $ttl_seconds) )); if ($updated === false || $updated < 1) return 0; return $this->idByIdempotencyKey($idempotency_key); } /** * @param mixed[] $data */ public function record(array $data): int { if (!$this->tableReady()) return 0; $data = $this->filteredRecordData($data); $idempotency_key = $data['idempotency_key']; if ($idempotency_key) { $existing_id = $this->idByIdempotencyKey($idempotency_key); if ($existing_id) { do_action('DayTidy/A__DayTidy/Booking/PaymentTransactionLedger__record/duplicate', $existing_id, $data); return $existing_id; } } $id = $this->insertNormalizedRecord($data); if (!$id && $idempotency_key) { return $this->idByIdempotencyKey($idempotency_key); } return $id; } public function gatewayIdempotencyKey(string $gateway, string $gateway_ref_id, string $event_type): string { return substr(md5(sanitize_key($gateway) . '|' . sanitize_key($event_type) . '|' . trim($gateway_ref_id)), 0, 32); } public function bookingIdempotencyKey(int $booking_id, string $event_type): string { return substr(md5(absint($booking_id) . '|' . sanitize_key($event_type)), 0, 32); } public function bookingIdForIdempotencyKey(string $idempotency_key): int { $event = $this->eventForIdempotencyKey($idempotency_key); return (int) ($event['booking_id'] ?? 0); } /** * @return mixed[] */ public function eventForIdempotencyKey(string $idempotency_key): array { if (!$idempotency_key || !$this->tableReady()) return []; $event = $this->db->getRow($this->db->prepare( "SELECT * FROM `#__" . self::TABLE . "` WHERE `idempotency_key` = %s ORDER BY `id` DESC LIMIT 1", $idempotency_key ), ARRAY_A); return is_array($event) ? $event : []; } /** * @return mixed[] */ public function history(int $booking_id): array { $history = $this->histories([$booking_id]); return $history[$booking_id] ?? []; } /** * @param int[] $booking_ids * @return array>> */ public function histories(array $booking_ids): array { $booking_ids = array_values(array_unique(array_filter(array_map('absint', $booking_ids)))); if (!count($booking_ids) || !$this->tableReady()) return []; $placeholders = implode(',', array_fill(0, count($booking_ids), '%d')); $rows = $this->db->getResults($this->db->prepare( "SELECT * FROM `#__" . self::TABLE . "` WHERE `booking_id` IN ($placeholders) ORDER BY `booking_id` ASC, `created_at` ASC, `id` ASC", $booking_ids ), ARRAY_A); $history = array_fill_keys($booking_ids, []); foreach ($rows as $row) { $booking_id = absint($row['booking_id'] ?? 0); if (isset($history[$booking_id])) $history[$booking_id][] = $row; } return $history; } public function derivedPaymentStatus(int $booking_id): string { $history = array_reverse($this->history($booking_id)); foreach ($history as $row) { $status = self::statusValue((string) ($row['status'] ?? '')); if ($status !== null) return $status; } $stored = get_post_meta($booking_id, 'daytidy_payment_status', true); if ($stored !== '') return PaymentStatus::normalize((string) $stored); return PaymentStatus::UNPAID; } public static function statusKey(string $status): string { return PaymentStatus::normalize($status); } public static function statusValue(string $status): ?string { $status = PaymentStatus::normalize($status, ''); return PaymentStatus::valid($status) ? $status : null; } private function tableReady(): bool { $table = $this->db->table(self::TABLE); $found = $this->db->getVar($this->db->prepare('SHOW TABLES LIKE %s', $table)); if ($found !== $table) return false; $columns = array_map('strval', $this->db->getCol("SHOW COLUMNS FROM `$table`")); return in_array('gateway_name', $columns, true) && in_array('idempotency_key', $columns, true) && in_array('payable_amount', $columns, true); } private function idByIdempotencyKey(string $idempotency_key): int { if (!$idempotency_key || !$this->tableReady()) return 0; return (int) $this->db->getVar($this->db->prepare( "SELECT `id` FROM `#__" . self::TABLE . "` WHERE `idempotency_key` = %s ORDER BY `id` DESC LIMIT 1", $idempotency_key )); } /** * @param mixed[] $data * @return mixed[] */ private function filteredRecordData(array $data): array { /** @var mixed[] $data */ $data = (array) apply_filters('DayTidy/F__DayTidy/Booking/PaymentTransactionLedger__record/data', $data, $this); return $this->normalizeRecord($data); } /** * @param mixed[] $data */ private function insertNormalizedRecord(array $data): int { do_action('DayTidy/A__DayTidy/Booking/PaymentTransactionLedger__record/before', $data, $this); $inserted = $this->db->insert(self::TABLE, [ 'booking_id' => $data['booking_id'], 'ref_id' => $data['ref_id'], 'gateway' => $data['gateway'], 'gateway_name' => $data['gateway_name'], 'gateway_ref_id' => $data['gateway_ref_id'], 'idempotency_key' => $data['idempotency_key'] ?: null, 'event_type' => $data['event_type'], 'status' => $data['status'], 'amount' => $data['amount'], 'payable_amount' => $data['payable_amount'], 'currency' => $data['currency'], 'coupon_codes' => $data['coupon_codes'], 'context' => $data['context'], 'created_at' => $data['created_at'], ], [ '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%f', '%f', '%s', '%s', '%s', '%s', ]); if ($inserted === false) return 0; $id = $this->db->insertId(); if ($id) do_action('DayTidy/A__DayTidy/Booking/PaymentTransactionLedger__record/after', $id, $data, $this); return $id; } /** * @param mixed[] $context * @return mixed[] */ private function gatewayEventData( int $booking_id, string $gateway, string $gateway_ref_id, string $ref_id, string $status, array $context = [] ): array { $transaction = $this->bookingTransaction($booking_id); $data = $transaction ? $this->transactionData($booking_id, $transaction) : $this->bookingData($booking_id); $data['booking_id'] = $booking_id; $data['gateway'] = $gateway; $data['gateway_name'] = (string) ($context['gateway_name'] ?? $gateway); $data['gateway_ref_id'] = $gateway_ref_id; $data['ref_id'] = $ref_id ?: (string) ($data['ref_id'] ?? ''); if (isset($context['amount']) || isset($context['payable_amount'])) { $data['amount'] = $this->contextAmount($context, $data); $data['payable_amount'] = $this->contextPayable($context, $data); } if (!empty($context['currency'])) $data['currency'] = (string) $context['currency']; if (!empty($context['coupon_codes'])) $data['coupon_codes'] = $context['coupon_codes']; return array_merge($data, [ 'event_type' => (string) ($context['event_type'] ?? 'gateway_callback'), 'status' => $status, 'context' => $context, 'idempotency_key' => (string) ($context['payment_idempotency_key'] ?? ($gateway_ref_id !== '' ? $this->gatewayIdempotencyKey($gateway, $gateway_ref_id, 'gateway_callback_' . $status) : '')), ]); } /** * @param mixed[] $data * @return mixed[] */ private function normalizeRecord(array $data): array { $context = isset($data['context']) && is_array($data['context']) ? $this->sanitizeContext($data['context']) : []; $encoded_context = wp_json_encode($context); return [ 'booking_id' => absint($data['booking_id'] ?? 0), 'ref_id' => sanitize_text_field((string) ($data['ref_id'] ?? '')), 'gateway' => sanitize_key((string) ($data['gateway'] ?? '')), 'gateway_name' => sanitize_text_field((string) ($data['gateway_name'] ?? '')), 'gateway_ref_id' => sanitize_text_field((string) ($data['gateway_ref_id'] ?? '')), 'idempotency_key' => !empty($data['idempotency_key']) ? sanitize_text_field((string) $data['idempotency_key']) : null, 'event_type' => sanitize_key((string) ($data['event_type'] ?? '')), 'status' => sanitize_key((string) ($data['status'] ?? '')), 'amount' => Price::normalize((float) ($data['amount'] ?? 0)), 'payable_amount' => Price::normalize((float) ($data['payable_amount'] ?? 0)), 'currency' => strtoupper(sanitize_text_field((string) ($data['currency'] ?? ''))), 'coupon_codes' => $this->couponCodes($data['coupon_codes'] ?? ''), 'context' => $encoded_context ?: '{}', 'created_at' => sanitize_text_field((string) ($data['created_at'] ?? current_time('Y-m-d H:i:s', true))), ]; } /** * @return mixed[] */ private function bookingData(int $booking_id): array { return [ 'booking_id' => $booking_id, 'ref_id' => sanitize_text_field((string) get_post_meta($booking_id, 'daytidy_ref', true)), 'gateway' => '', 'gateway_name' => '', 'gateway_ref_id' => sanitize_text_field((string) get_post_meta($booking_id, 'daytidy_gateway_ref_id', true)), 'amount' => Price::normalize((float) get_post_meta($booking_id, 'daytidy_total', true)), 'payable_amount' => 0.0, 'currency' => '', 'coupon_codes' => get_post_meta($booking_id, 'daytidy_coupon', true), ]; } /** * @return mixed[] */ private function transactionData(int $booking_id, Transaction $transaction): array { $payable = $transaction->getPayable(); return [ 'booking_id' => $booking_id, 'ref_id' => $transaction->getRefId() ?: sanitize_text_field((string) get_post_meta($booking_id, 'daytidy_ref', true)), 'gateway' => $transaction->hasGateway() ? $transaction->getGateway()->key() : '', 'gateway_name' => $transaction->hasGateway() ? $transaction->getGateway()->name() : '', 'gateway_ref_id' => $transaction->getGatewayRefId(), 'amount' => $payable > 0 ? $payable : $transaction->getTotal(), 'payable_amount' => $payable, 'currency' => $transaction->getCurrency(), 'coupon_codes' => $transaction->getCouponCodes(), ]; } private function bookingTransaction(int $booking_id): ?Transaction { try { $transaction = (new BookingEntity($booking_id))->getTransaction(); return $transaction->hasGateway() ? $transaction : null; } catch (Throwable $e) { return null; } } /** * @param mixed[] $context * @param mixed[] $data */ private function contextAmount(array $context, array $data): float { if (isset($context['amount']) && is_numeric($context['amount'])) return Price::normalize((float) $context['amount']); return Price::normalize((float) ($data['amount'] ?? 0)); } /** * @param mixed[] $context * @param mixed[] $data */ private function contextPayable(array $context, array $data): float { if (isset($context['payable_amount']) && is_numeric($context['payable_amount'])) { return Price::normalize((float) $context['payable_amount']); } return Price::normalize((float) ($data['payable_amount'] ?? 0)); } /** * @param mixed $codes */ private function couponCodes($codes): string { if (is_array($codes)) { $codes = implode(',', array_filter(array_map('sanitize_text_field', $codes))); } return sanitize_text_field((string) $codes); } /** * @param mixed[] $context * @return mixed[] */ private function sanitizeContext(array $context): array { $clean = []; foreach ($context as $key => $value) { $key = sanitize_key((string) $key); if ($key === '') continue; if ($this->isSensitiveKey($key)) { $clean[$key] = '[redacted]'; continue; } if (is_array($value)) { $clean[$key] = $this->sanitizeContext($value); } else if (is_bool($value) || is_int($value) || is_float($value) || $value === null) { $clean[$key] = $value; } else if (is_scalar($value)) { $clean[$key] = sanitize_textarea_field((string) $value); } } return $clean; } private function isSensitiveKey(string $key): bool { $search_key = str_replace('-', '_', $key); foreach (['secret', 'token', 'password', 'authorization', 'api_key', 'apikey', 'client_secret', 'private'] as $needle) { if (strpos($search_key, $needle) !== false) return true; } return false; } }