$this->policies->rules($id)];} public function list(int $page,int $per_page,array $filters=[]): array {$result=parent::list($page,$per_page,$filters);foreach($result['items'] as &$p)$p['rules']=$this->policies->rules($p['id']);unset($p);return $result;} public function save(array $input,?int $id=null): array {return $this->locks->run(null,fn()=>parent::save($input,$id));} protected function validate(array $input,array $current): array { $d=array_replace(['name'=>'','sport_id'=>null,'resource_id'=>null,'no_show_fee_percentage'=>'0','priority'=>0,'is_active'=>true,'rules'=>[]],$current,$input); $d['name']=MembershipValidator::text($d['name'],'name',150); foreach(['sport_id','resource_id'] as $key)$d[$key]=CatalogValidator::integer($d[$key],$key,1,PHP_INT_MAX,true); if($d['sport_id']&&!$this->sports->find($d['sport_id']))CatalogValidator::invalid('sport_id',__('The selected sport does not exist.','abece-booking')); $resource=$d['resource_id']?$this->resources->find($d['resource_id']):null; if($d['resource_id']&&!$resource)CatalogValidator::invalid('resource_id',__('The selected resource does not exist.','abece-booking')); if($resource&&$d['sport_id']&&$resource['sport_id']!==$d['sport_id'])CatalogValidator::invalid('sport_id',__('The resource must belong to the selected sport.','abece-booking')); $d['no_show_fee_percentage']=self::percentage($d['no_show_fee_percentage']);$d['priority']=CatalogValidator::integer($d['priority'],'priority',-2147483648,2147483647);$d['is_active']=CatalogValidator::boolean($d['is_active'],'is_active'); if(!is_array($d['rules'])||!array_is_list($d['rules'])||count($d['rules'])>100)CatalogValidator::invalid('rules',__('Enter at most 100 cancellation rules, including a zero-minute rule.','abece-booking')); $thresholds=[]; if(!array_key_exists('rules',$input))$d['rules']=array_map(fn($r)=>array_intersect_key($r,array_flip(['min_minutes_before','fee_percentage','fee_amount','priority'])),$d['rules']); foreach($d['rules'] as &$r){ if(!is_array($r))CatalogValidator::invalid('rules',__('Each cancellation rule must be an object.','abece-booking')); BookingValidator::fields($r,['min_minutes_before','fee_percentage','fee_amount','priority']);$r=array_replace(['fee_percentage'=>null,'fee_amount'=>null,'priority'=>0],$r); $r['min_minutes_before']=CatalogValidator::integer($r['min_minutes_before']??null,'min_minutes_before',0,4294967295); if(in_array($r['min_minutes_before'],$thresholds,true))CatalogValidator::invalid('min_minutes_before',__('Cancellation thresholds must be unique within a policy.','abece-booking')); $thresholds[]=$r['min_minutes_before'];$r['priority']=CatalogValidator::integer($r['priority'],'priority',-2147483648,2147483647); if((null===$r['fee_percentage'])===(null===$r['fee_amount']))CatalogValidator::invalid('fee_percentage',__('Choose either a percentage or a fixed fee for each rule.','abece-booking')); if(null!==$r['fee_percentage'])$r['fee_percentage']=self::percentage($r['fee_percentage']); if(null!==$r['fee_amount'])$r['fee_amount']=CatalogValidator::integer($r['fee_amount'],'fee_amount',0,Money::MAX); }unset($r); if(!in_array(0,$thresholds,true))CatalogValidator::invalid('rules',__('A cancellation rule with a zero-minute threshold is required.','abece-booking')); return $d; } protected function persist(array $d,?int $id): array { if($d['is_active'])foreach($this->policies->all() as $p){if($p['id']===$id||!$p['is_active']||$p['priority']!==$d['priority'])continue; $same=$d['resource_id']?($p['resource_id']===$d['resource_id']):(!$p['resource_id']&&$p['sport_id']===$d['sport_id']); if($same)throw new CatalogException('abece_booking_cancellation_policy_conflict',__('An active cancellation policy with the same scope and precedence already exists. Change its precedence or deactivate it.','abece-booking'),409); } $rules=$d['rules'];unset($d['rules']);$p=parent::persist($d,$id);$this->policies->replaceRules($p['id'],$rules);return $this->get($p['id']); } public function snapshot(int $resource_id): array { $r=$this->resources->find($resource_id);$winner=null;$rank=null; foreach($this->policies->all() as $p){ if(!$p['is_active'])continue; if($p['resource_id']){if($p['resource_id']!==$resource_id)continue;$specificity=2;} elseif($p['sport_id']){if($p['sport_id']!==$r['sport_id'])continue;$specificity=1;}else $specificity=0; $candidate=[$specificity,$p['priority']]; if($rank===$candidate)throw new CatalogException('abece_booking_cancellation_policy_conflict',__('The cancellation policy configuration is ambiguous.','abece-booking'),409); if(null===$rank||$candidate>$rank){$winner=$p;$rank=$candidate;} } return ['policy_id'=>$winner['id']??null,'policy_name'=>$winner['name']??null,'rules'=>$winner?$this->policies->rules($winner['id']):[],'no_show_fee_percentage'=>$winner['no_show_fee_percentage']??'0.000','snapped_at'=>current_time('mysql',true)]; } public static function percentage(mixed $value): string { $units=Money::percentageUnits($value); if($units%10)CatalogValidator::invalid('percentage',__('Use at most three decimal places for a penalty percentage.','abece-booking')); return substr(Money::percentage($units),0,-1); } public static function terms(array $b): ?array { if(null===$b['cancellation_policy_snapshot'])return null; $snapshot=json_decode($b['cancellation_policy_snapshot'],true); if(!is_array($snapshot)||!isset($snapshot['rules'],$snapshot['no_show_fee_percentage']))throw new CatalogException('abece_booking_invalid_policy_snapshot',__('The stored cancellation policy cannot be read. Contact an administrator.','abece-booking'),409); return $snapshot; } public static function cancellation(array $b,?int $now=null): array { $terms=self::terms($b);$seconds=Time::fromSql($b['starts_at'])-($now??time());$rule=null; foreach($terms['rules']??[] as $r)if($seconds>=60*$r['min_minutes_before']&&(!$rule||$r['min_minutes_before']>$rule['min_minutes_before']))$rule=$r; // Admin cancellation after start uses the required zero-minute fallback. if(!$rule&&$terms)foreach($terms['rules'] as $r)if(0===$r['min_minutes_before'])$rule=$r; $fee=$rule?(null!==$rule['fee_percentage']?Money::roundRatio($b['final_amount'],Money::percentageUnits($rule['fee_percentage']),1000000):$rule['fee_amount']):0; return ['policy_id'=>$terms['policy_id']??null,'legacy_snapshot'=>null===$terms,'minutes_before_start'=>$seconds/60,'rule'=>$rule,'currency'=>$b['currency'],'base_final_amount'=>$b['final_amount'],'calculated_fee'=>min($fee,$b['final_amount'])]; } public static function noShow(array $b,int $actor): array { $terms=self::terms($b);$percentage=$terms['no_show_fee_percentage']??'0.000'; return ['policy_id'=>$terms['policy_id']??null,'percentage'=>$percentage,'currency'=>$b['currency'],'base_final_amount'=>$b['final_amount'],'calculated_fee'=>Money::roundRatio($b['final_amount'],Money::percentageUnits($percentage),1000000),'processed_at'=>current_time('mysql',true),'processed_by_user_id'=>$actor]; } public function options(): array { $result=[];foreach(['sports'=>$this->sports,'resources'=>$this->resources] as $key=>$repo){$rows=[];for($page=1;;$page++){$batch=$repo->list($page,100);foreach($batch['items'] as $r)$rows[]=array_intersect_key($r,array_flip(['id','name','sport_id']));if(count($rows)>=$batch['total'])break;}$result[$key]=$rows;}return $result; } }