settings->receivables()['outstanding_balance_grace_hours'];$until=gmdate('Y-m-d H:i:s',($now??time())-$grace*3600);$rows=[]; foreach($this->repository->due($until,$customer!==null?$this->repository->identity($customer):null) as $b){ $state=$this->settlement->fromTotals($b,$b['ledger_paid'],$b['ledger_refunded'],(bool)$b['ledger_invalid']); if($state['outstanding_amount']<=0)continue; $type=$b['identity_member_id']?'member':($b['identity_user_id']?'registered_non_member':($b['external_contact_id']?'external':'booking')); $id=match($type){'member'=>$b['identity_member_id'],'registered_non_member'=>$b['identity_user_id'],'external'=>$b['external_contact_id'],default=>$b['id']}; $rows[]=['id'=>$b['id'],'reference'=>$b['reference'],'starts_at'=>Time::iso(Time::fromSql($b['starts_at'])),'ends_at'=>Time::iso(Time::fromSql($b['ends_at'])),'due_at'=>Time::iso(Time::fromSql($b['ends_at'])+$grace*3600),'resource_name'=>$b['resource_name'],'final_amount'=>$b['final_amount'],'currency'=>$b['currency'],'financial'=>$state,'payment_method'=>$b['payment_method'],'status'=>$b['status'],'customer_type'=>$type,'customer_id'=>$id,'customer_name'=>$b['customer_name'],'customer_email'=>$b['customer_email']]; }return $rows; } public function getOutstandingForCustomer(array $customer,?int $now=null): array { $items=$this->getOutstandingBookings($customer,$now);$amounts=[]; foreach($items as $b)$amounts[$b['currency']]=Money::add($amounts[$b['currency']]??0,$b['financial']['outstanding_amount']); ksort($amounts);return ['blocked'=>$this->settings->receivables()['block_booking_with_outstanding_balance']&&count($items)>0,'amounts_by_currency'=>$amounts,'items'=>$items]; } public function hasOverdueBalance(array $customer,?int $now=null): bool {return []!==$this->getOutstandingBookings($customer,$now);} /** Explicit projection: no query parameter may choose a different account. */ public function forUser(int $user): array { BookingValidator::login($user);$result=$this->getOutstandingForCustomer(['customer_user_id'=>$user]); foreach($result['items'] as &$item)unset($item['customer_type'],$item['customer_id'],$item['customer_name'],$item['customer_email']);unset($item); return $result+['timezone'=>wp_timezone()->getName()]; } public function clientDetail(int $user,int $id,PaymentService $payments): array { $result=$this->forUser($user); foreach($result['items'] as $item)if($item['id']===$id){ // Membership-only historical bookings are authorized through the same OR identity lookup. $b=$this->repository->booking($id); return $item+['payment_instructions'=>$payments->instructions($b),'timezone'=>wp_timezone()->getName()]; } throw new \ABeCe\Booking\Domain\CatalogException('abece_booking_not_found',__('The booking was not found.','abece-booking'),404); } public function getAdminDebtors(array $filters): array { $groups=[];$search=mb_strtolower($filters['search']??''); foreach($this->getOutstandingBookings() as $b){ if(!empty($filters['currency'])&&$b['currency']!==$filters['currency'])continue; if(!empty($filters['customer_type'])&&$b['customer_type']!==$filters['customer_type']&&!($filters['customer_type']==='external'&&$b['customer_type']==='booking'))continue; $key=$b['customer_type'].':'.$b['customer_id'].':'.$b['currency']; if(!isset($groups[$key]))$groups[$key]=['customer_type'=>$b['customer_type'],'customer_id'=>$b['customer_id'],'customer_name'=>$b['customer_name'],'currency'=>$b['currency'],'outstanding_amount'=>0,'item_count'=>0,'oldest_debt'=>$b['due_at'],'relevant_booking'=>$b['reference'],'booking_id'=>$b['id'],'matches_search'=>false]; $g=&$groups[$key];$g['outstanding_amount']=Money::add($g['outstanding_amount'],$b['financial']['outstanding_amount']);$g['item_count']++;$g['matches_search']=$g['matches_search']||$search===''||str_contains(mb_strtolower($b['customer_name'].' '.$b['customer_email'].' '.$b['reference']),$search);$g['relevant_booking']=$b['reference'];$g['booking_id']=$b['id'];unset($g); } $groups=array_values(array_filter($groups,static fn($g)=>$g['matches_search'])); // Largest balances are ordered within currency, never compared across currencies. usort($groups,static function($a,$b)use($filters){ if(($filters['sort']??'oldest')==='largest')return strcmp($a['currency'],$b['currency'])?:($b['outstanding_amount']<=>$a['outstanding_amount'])?:strcmp($a['oldest_debt'],$b['oldest_debt']); return strcmp($a['oldest_debt'],$b['oldest_debt'])?:strcmp($a['currency'],$b['currency']); }); foreach($groups as &$g)unset($g['matches_search']);unset($g); return ['items'=>array_slice($groups,(($filters['page']??1)-1)*($filters['per_page']??25),$filters['per_page']??25),'total'=>count($groups),'timezone'=>wp_timezone()->getName()]; } public function adminDetail(string $type,int $id,int $page,int $perPage): array { $identity=match($type){'member'=>['customer_member_id'=>$id],'registered_non_member'=>['customer_user_id'=>$id],'external'=>['external_contact_id'=>$id],'booking'=>['id'=>$id]}; $result=$this->getOutstandingForCustomer($identity);$total=count($result['items']);$result['items']=array_slice($result['items'],($page-1)*$perPage,$perPage);return $result+['total'=>$total,'timezone'=>wp_timezone()->getName()]; } }