methodAvailability ?? new PaymentMethodAvailabilityService($this->settings,$this->resources);} public function instructions(array $b): ?array {return $this->bankInstructions()->get($b);} /** A newly selected bank method/currency needs an audited account snapshot; unchanged currency retains its original account. */ public function refreshBankInstructions(array $before,array $after,int $actor):void{$this->bankInstructions()->refresh($before,$after,$actor);} private function bankInstructions(): \ABeCe\Booking\Contracts\PaymentInstructions{return \ABeCe\Booking\Edition\Modules::current()->instructions($this->settings,$this->bookings,$this->settlement);} public function settings(): array {return $this->settings->get();} public function history(int $id,bool $admin): array { $rows=$this->payments->forBooking($id);if(!$admin)foreach($rows as &$row)unset($row['metadata'],$row['provider_transaction_id']);unset($row);return $rows; } public function hasActivity(int $id): bool {return []!==$this->payments->forBooking($id);} public function requireEditablePrice(array $booking): void { if(in_array($booking['payment_status'],['paid','partially_paid','refunded','partially_refunded'],true)||$this->hasActivity($booking['id']))throw new CatalogException('abece_booking_financial_edit_blocked',__('A booking with financial history cannot be repriced. Notes can still be edited.','abece-booking'),409); } /** Called inside BookingService's transaction, after booking insertion and before commit/email. */ public function created(array $b,array $input,int $actor,bool $admin): void { $method=$input['payment_method']??null; if($admin&&in_array($method,['credit','cash','manual'],true))\ABeCe\Booking\Edition\FinanceAuthorization::requireAdmin($actor); if(null!==$method)$this->methods()->requireMethod($method,$b,$actor,$admin); if(!$b['final_amount']){$this->bookings->update($b['id'],['payment_status'=>'not_required','payment_method'=>null]);return;} $method??='pay_on_site';$this->methods()->requireMethod($method,$b,$actor,$admin);$settings=$this->settings(); if(in_array($method,['credit','pay_on_site'],true)&&!$settings['enable_'.$method])throw new CatalogException('abece_booking_payment_method_disabled',__('This payment method is disabled.','abece-booking'),400); if('credit'===$method){ if(!$b['customer_user_id'])CatalogValidator::invalid('payment_method',__('Credit requires a registered customer.','abece-booking')); if($admin){\ABeCe\Booking\Edition\FinanceAuthorization::requireAdmin($actor);if(true!==($input['confirm_customer_credit']??false))CatalogValidator::invalid('confirm_customer_credit',__('Confirm charging the customer credit account.','abece-booking'));} // Public BookingService derives customer_user_id from the authenticated actor. if(!$admin&&$b['customer_user_id']!==$actor)throw new CatalogException('abece_booking_credit_forbidden',__('You do not have permission to manage credit.','abece-booking'),403); $credit_id=$this->credit->bookingChange($b,-$b['final_amount'],'booking_payment',$actor,['currency'=>$b['currency'],'method'=>'credit']); $this->complete($b,$method,$actor,'credit_payment',null,['credit_transaction_id'=>$credit_id]); }elseif(in_array($method,['pay_on_site','bank_transfer','qr_payment'],true)){$this->bookings->update($b['id'],['payment_method'=>$method,'payment_status'=>'unpaid']);if($method!=='pay_on_site'){$snapshot=$this->bankInstructions()->snapshot($b);$this->bookings->appendHistory(['booking_id'=>$b['id'],'event_type'=>'bank_payment_instructions','old_status'=>$b['status'],'new_status'=>$b['status'],'performed_by_type'=>'wp_user','performed_by_user_id'=>$actor,'description'=>null,'metadata'=>wp_json_encode($snapshot),'created_at'=>current_time('mysql',true)]);}} else{$this->requireAdmin($actor);$this->complete($b,$method,$actor,'payment_completed');} } public function unpaidReprice(array $b,array $price): array { if(!$price['final_amount'])return ['payment_method'=>null]; $available=$this->methods()->methods($b['resource_id'],$price['currency'],$b['customer_user_id'],get_current_user_id(),true); $allowed=array_values(array_intersect([$b['payment_method'],'pay_on_site','bank_transfer','qr_payment'],array_intersect($available,['pay_on_site','bank_transfer','qr_payment']))); if(!$allowed)throw new CatalogException('abece_booking_payment_method_disabled',__('This payment method is disabled.','abece-booking'),400); return ['payment_method'=>$allowed[0]]; } public function markPaid(int $id,array $input,int $actor,BookingService $service): array { $this->requireAdmin($actor);BookingValidator::fields($input,['payment_method','payment_medium','reason']); $method=isset($input['payment_method'])?CatalogValidator::choice($input['payment_method'],'payment_method',['cash','manual','pay_on_site','bank_transfer','qr_payment']):null; $medium=isset($input['payment_medium'])?CatalogValidator::choice($input['payment_medium'],'payment_medium',['cash','card','multisport','qr','bank_transfer','other']):null; $reason=BookingValidator::note($input['reason']??null,'reason');$rid=$service->owned($id,$actor)['resource_id']; $this->bookings->customerTransaction($id,[$rid],function()use($id,$actor,$service,$rid,$method,$medium,$reason){ $b=$service->lockOwned($id,$actor,[$rid]);$state=$this->settlement->calculate($b);$effective=$method??$b['payment_method']; if(in_array($method,['bank_transfer','qr_payment'],true)&&$method!==$b['payment_method'])CatalogValidator::invalid('payment_method',__('Use the payment actions to change a booking payment.','abece-booking')); if(!$state['outstanding_amount']&&$state['paid_amount']>0&&($b['payment_method']===$effective||$medium!==null))return; if(!in_array($b['status'],['confirmed','cancelled','no_show'],true)||$state['outstanding_amount']<=0||$state['paid_amount']>0||!in_array($b['payment_method'],['pay_on_site','bank_transfer','qr_payment'],true))throw new CatalogException('abece_booking_payment_unavailable',__('This booking cannot be marked as paid.','abece-booking'),409); if($medium===null&&$effective==='pay_on_site'&&!$this->settings()['enable_pay_on_site'])throw new CatalogException('abece_booking_payment_method_disabled',__('This payment method is disabled.','abece-booking'),400); // Historical obligations can be settled after configuration changes. New booking validation remains strict. $this->complete($b,$medium!==null?$b['payment_method']:$effective,$actor,'payment_marked_paid',$reason,['payment_medium'=>$medium],$state['outstanding_amount']); });return $service->detail($id,$actor); } public function refund(int $id,array $input,int $actor,BookingService $service): array { $this->requireAdmin($actor);BookingValidator::fields($input,['reason','amount','idempotency_key']);$reason=MembershipValidator::text($input['reason']??null,'reason',255); $amount=array_key_exists('amount',$input)?CatalogValidator::integer($input['amount'],'amount',1,\ABeCe\Booking\Support\Money::MAX):null; $key=$input['idempotency_key']??null; if(null!==$key&&(!is_string($key)||!preg_match('/^[A-Za-z0-9_-]{8,64}$/D',$key)))CatalogValidator::invalid('idempotency_key',__('Use an idempotency key containing 8 to 64 letters, digits, underscores or hyphens.','abece-booking')); if(null!==$key)$key=strtolower($key); // Match the WordPress table collation for business-reference uniqueness. if(null!==$amount&&null===$key)CatalogValidator::invalid('idempotency_key',__('An idempotency key is required for a partial refund.','abece-booking')); $rid=$service->owned($id,$actor)['resource_id']; $this->bookings->customerTransaction($id,[$rid],function()use($id,$actor,$service,$rid,$reason,$amount,$key){ $b=$service->lockOwned($id,$actor,[$rid]);$reference='booking:'.$id.':refund:'.($key??'remaining'); foreach($this->payments->forBooking($id) as $row)if($row['provider_transaction_id']===$reference){ $meta=json_decode($row['metadata']??'{}',true); if((null!==$amount&&$row['amount']!==$amount)||($meta['reason']??null)!==$reason)throw new CatalogException('abece_booking_idempotency_conflict',__('This idempotency key was already used for a different refund.','abece-booking'),409); return; } $state=$this->settlement->calculate($b); // Backward-compatible retry of an already completely settled full refund performs no write. if(null===$amount&&0===$state['refund_due']&&$state['refunded_amount']>0)return; $this->refundLocked($b,$amount??$state['refund_due'],$actor,$reason,$reference);$this->project($b); });return $service->detail($id,$actor); } /** Resource -> booking -> credit account -> credit ledger, always within the caller's transaction. */ public function settle(array $b,int $actor,?string $reason=null): array { $state=$this->settlement->calculate($b); if($state['refund_due']>0&&'credit'===$b['payment_method'])$this->refundLocked($b,$state['refund_due'],$actor,$reason,'booking:'.$b['id'].':refund:'.$b['status']); $state=$this->project($b); if('refund_pending'===$state['payment_status'])$this->audit($b,'refund_pending',$actor,0,$state['refund_due'],$b['payment_method']??'',current_time('mysql',true),$reason,$state); return $state; } private function project(array $b): array {$state=$this->settlement->calculate($b);$this->bookings->update($b['id'],['payment_status'=>$state['payment_status'],'updated_at'=>current_time('mysql',true)]);return $state;} private function refundLocked(array $b,int $amount,int $actor,?string $reason,string $reference): void { $rows=$this->payments->forBooking($b['id']);$payment=null; foreach($rows as $row){if('completed'===$row['status']&&$row['provider_transaction_id']===$reference)return;if('payment'===$row['transaction_type']&&'completed'===$row['status'])$payment=$row;} $state=$this->settlement->calculate($b); if(!$payment||$amount<=0||$amount>$state['refund_due'])throw new CatalogException('abece_booking_refund_unavailable',__('The refund must be positive and must not exceed the amount due for refund.','abece-booking'),409); $metadata=['original_transaction_id'=>$payment['id'],'performed_by_user_id'=>$actor,'reason'=>$reason,'business_reference'=>$reference]; $financial_booking=array_replace($b,['currency'=>$payment['currency'],'customer_user_id'=>$payment['user_id']]); if('credit'===$payment['provider'])$metadata['credit_transaction_id']=$this->credit->bookingChange($financial_booking,$amount,'booking_refund',$actor,$metadata); $stamp=current_time('mysql',true);$id=$this->payments->append(['booking_id'=>$b['id'],'user_id'=>$payment['user_id'],'provider'=>$payment['provider'],'provider_transaction_id'=>$reference,'transaction_type'=>'refund','amount'=>$amount,'currency'=>$payment['currency'],'status'=>'completed','metadata'=>wp_json_encode($metadata),'created_at'=>$stamp,'updated_at'=>$stamp]); $this->audit($financial_booking,$amount===$payment['amount']?'refund':'partial_refund',$actor,$id,$amount,$payment['provider'],$stamp,$reason,$metadata); } private function complete(array $b,string $method,int $actor,string $event,?string $reason=null,array $metadata=[],?int $amount=null): void { $amount??=$b['final_amount'];$stamp=current_time('mysql',true);$metadata+=['performed_by_user_id'=>$actor,'account_user_id'=>$b['customer_user_id'],'reason'=>$reason]; $id=$this->payments->append(['booking_id'=>$b['id'],'user_id'=>$b['customer_user_id'],'provider'=>$method,'provider_transaction_id'=>'booking:'.$b['id'].':payment','transaction_type'=>'payment','amount'=>$amount,'currency'=>$b['currency'],'status'=>'completed','metadata'=>wp_json_encode($metadata),'created_at'=>$stamp,'updated_at'=>$stamp,'payment_medium'=>$metadata['payment_medium']??null]); $this->bookings->update($b['id'],['payment_method'=>$method,'payment_status'=>$this->settlement->calculate($b)['payment_status'],'updated_at'=>$stamp]);$this->audit($b,$event,$actor,$id,$amount,$method,$stamp,$reason,$metadata); } private function audit(array $b,string $event,int $actor,int $id,int $amount,string $method,string $stamp,?string $reason,array $metadata): void { $this->bookings->appendHistory(['booking_id'=>$b['id'],'event_type'=>$event,'old_status'=>$b['status'],'new_status'=>$b['status'],'performed_by_type'=>'wp_user','performed_by_user_id'=>$actor,'description'=>$reason,'metadata'=>wp_json_encode(['amount'=>$amount,'currency'=>$b['currency'],'method'=>$method,'transaction_id'=>$id]+$metadata),'created_at'=>$stamp]); } private function requireAdmin(int $actor): void {BookingValidator::login($actor);if(!user_can($actor,Capabilities::MANAGE_BOOKINGS))throw new CatalogException('abece_booking_booking_forbidden',__('You do not have permission to manage bookings.','abece-booking'),403);} }