Files
huntingclubroster/classes/RosterService.php
T

71 lines
9.4 KiB
PHP
Raw Normal View History

<?php
class RosterService extends Base {
public function administrator(): bool {return in_array($_SESSION[self::USER_ROLE]??'', ['Administrator','System Administrator'],true);}
public function requireAdministrator(): void {if(!$this->administrator())throw new AppException('Only site administrators may manage clubs and dues.',403);}
public function ready(): bool {return (int)$this->connect()->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('clubs','club_memberships','club_seasons','club_dues','club_payments','club_due_adjustments','club_payment_reversals','email_verifications')")->fetchColumn()===8;}
public function clubs(): array {
$db=$this->connect();
if($this->administrator())return $db->query('SELECT * FROM clubs ORDER BY club_name,club_serial')->fetchAll();
$stmt=$db->prepare("SELECT c.* FROM clubs c JOIN club_memberships m ON m.club_serial=c.club_serial JOIN users u ON u.user_serial=m.user_serial WHERE m.user_serial=? AND m.membership_status='active' AND c.club_status='active' AND u.user_status='active' AND u.user_email_verified_at IS NOT NULL ORDER BY c.club_name,c.club_serial");
$stmt->execute([$_SESSION[self::USER_SERIAL]??0]);return $stmt->fetchAll();
}
public function club(int $id,?PDO $db=null,bool $writing=false): array {
$db??=$this->connect();$stmt=$db->prepare('SELECT * FROM clubs WHERE club_serial=?'.($writing?' FOR UPDATE':''));$stmt->execute([$id]);$club=$stmt->fetch();
if(!$club)throw new AppException('Club not found.',404);
if(!$this->administrator()){
$stmt=$db->prepare("SELECT m.membership_serial FROM club_memberships m JOIN users u ON u.user_serial=m.user_serial WHERE m.club_serial=? AND m.user_serial=? AND m.membership_status='active' AND u.user_status='active' AND u.user_email_verified_at IS NOT NULL");$stmt->execute([$id,$_SESSION[self::USER_SERIAL]??0]);
if($club['club_status']!=='active'||!$stmt->fetch())throw new AppException('You do not have access to this club.',403);
}
if($writing && $club['club_status']!=='active')throw new AppException('Reactivate the club before adding or changing records.',409);
return $club;
}
public function members(int $club): array {
$this->club($club);$stmt=$this->connect()->prepare("SELECT m.*,u.user_name,u.user_first_name,u.user_last_name,u.user_email FROM club_memberships m JOIN users u ON u.user_serial=m.user_serial WHERE m.club_serial=?".($this->administrator()?'':" AND m.membership_status='active'").' ORDER BY u.user_last_name,u.user_first_name,m.membership_serial');$stmt->execute([$club]);return $stmt->fetchAll();
}
public function seasons(int $club): array {$this->club($club);$stmt=$this->connect()->prepare('SELECT * FROM club_seasons WHERE club_serial=? ORDER BY starts_on DESC,season_serial DESC');$stmt->execute([$club]);return $stmt->fetchAll();}
public function dues(int $club): array {
$this->club($club);$stmt=$this->connect()->prepare('SELECT d.*,d.amount_cents AS original_amount_cents,d.amount_cents+COALESCE((SELECT SUM(a.amount_cents) FROM club_due_adjustments a WHERE a.due_serial=d.due_serial),0) AS amount_cents,s.season_name,u.user_first_name,u.user_last_name,COALESCE((SELECT SUM(p.amount_cents) FROM club_payments p WHERE p.due_serial=d.due_serial AND NOT EXISTS(SELECT 1 FROM club_payment_reversals r WHERE r.payment_serial=p.payment_serial)),0) AS paid_cents FROM club_dues d JOIN club_memberships m ON m.membership_serial=d.membership_serial JOIN users u ON u.user_serial=m.user_serial JOIN club_seasons s ON s.season_serial=d.season_serial WHERE d.club_serial=?'.($this->administrator()?'':' AND m.user_serial=?').' ORDER BY s.starts_on DESC,u.user_last_name,d.due_serial');
$stmt->execute($this->administrator()?[$club]:[$club,$_SESSION[self::USER_SERIAL]??0]);return $stmt->fetchAll();
}
private function text(string $text,int $limit): string {$text=trim($text);if(!$text||strlen($text)>$limit)throw new AppException('A name is required and must fit the displayed length limit.');return $text;}
public static function date(string $value): string {$date=DateTime::createFromFormat('!Y-m-d',$value);if(!$date||$date->format('Y-m-d')!==$value)throw new AppException('Enter a valid date in YYYY-MM-DD format.');return $value;}
public static function cents(string $value): int {
if(!preg_match('/\A(?:0|[1-9][0-9]{0,6})(?:\.[0-9]{1,2})?\z/',$value))throw new AppException('Enter a nonnegative USD amount with at most two decimal places (maximum 9,999,999.99).');
[$whole,$fraction]=array_pad(explode('.',$value,2),2,'');return (int)$whole*100+(int)str_pad($fraction,2,'0');
}
public function saveClub(int $id,string $name,string $status='active'): int {
$this->requireAdministrator();$name=$this->text($name,128);if(!in_array($status,['active','archived'],true))throw new AppException('Invalid club status.');
$db=$this->connect();$db->beginTransaction();
try {
$before=null;if($id){$stmt=$db->prepare('SELECT * FROM clubs WHERE club_serial=? FOR UPDATE');$stmt->execute([$id]);$before=$stmt->fetch();if(!$before)throw new AppException('Club not found.',404);}
if($id){$stmt=$db->prepare('UPDATE clubs SET club_name=?,club_status=? WHERE club_serial=?');$stmt->execute([$name,$status,$id]);}
else{$stmt=$db->prepare('INSERT INTO clubs(club_name,club_status) VALUES(?,?)');$stmt->execute([$name,$status]);$id=(int)$db->lastInsertId();}
Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'saved club '.json_encode(['club'=>$id,'before'=>$before,'name'=>$name,'status'=>$status]));$db->commit();
}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
return $id;
}
public function saveMember(int $club,int $membership,string $identifier,string $status,string $joined): void {
$this->requireAdministrator();$joined=self::date($joined);if(!in_array($status,['active','inactive'],true))throw new AppException('Invalid membership status.');
$db=$this->connect();$db->beginTransaction();
try{
// Lock user before membership writes, matching account deletion's lock order.
if($membership){$stmt=$db->prepare('SELECT user_serial FROM club_memberships WHERE membership_serial=? AND club_serial=?');$stmt->execute([$membership,$club]);$user=$stmt->fetchColumn();if(!$user)throw new AppException('Membership not found in this club.',404);}
else{$stmt=$db->prepare('SELECT user_serial FROM users WHERE user_name=? OR user_email=?');$stmt->execute([trim($identifier),trim($identifier)]);$user=$stmt->fetchColumn();if(!$user)throw new AppException('No account matches that username or email. Create the account first.',404);}
$stmt=$db->prepare('SELECT user_serial,user_status,user_email_verified_at FROM users WHERE user_serial=? FOR UPDATE');$stmt->execute([$user]);if(!($account=$stmt->fetch()))throw new AppException('Account not found.',404);
if($status==='active' && ($account['user_status']!=='active'||!$account['user_email_verified_at']))throw new AppException('The account must be active and its email verified before membership can be activated.',409);
$this->club($club,$db,true);
$before=null;if($membership){$stmt=$db->prepare('SELECT * FROM club_memberships WHERE membership_serial=? AND club_serial=? FOR UPDATE');$stmt->execute([$membership,$club]);$before=$stmt->fetch();if(!$before)throw new AppException('Membership not found.',404);}
if($membership){$stmt=$db->prepare('UPDATE club_memberships SET membership_status=?,joined_on=? WHERE membership_serial=? AND club_serial=?');$stmt->execute([$status,$joined,$membership,$club]);}
else{$stmt=$db->prepare('INSERT INTO club_memberships(club_serial,user_serial,membership_status,joined_on) VALUES(?,?,?,?)');$stmt->execute([$club,$user,$status,$joined]);}
Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'saved membership '.json_encode(['membership'=>$membership?:((int)$db->lastInsertId()),'club'=>$club,'user'=>(int)$user,'before'=>$before,'status'=>$status,'joined_on'=>$joined]));
$db->commit();
}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
}
public function addSeason(int $club,string $name,string $start,string $end): void {
$this->requireAdministrator();$name=$this->text($name,100);self::date($start);self::date($end);if($end<$start)throw new AppException('Season end cannot precede its start.');
$db=$this->connect();$db->beginTransaction();try{$this->club($club,$db,true);$stmt=$db->prepare('INSERT INTO club_seasons(club_serial,season_name,starts_on,ends_on) VALUES(?,?,?,?)');$stmt->execute([$club,$name,$start,$end]);Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'created season '.json_encode(['season'=>(int)$db->lastInsertId(),'club'=>$club,'name'=>$name,'start'=>$start,'end'=>$end]));$db->commit();}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
}
public function charge(int $club,int $membership,int $season,string $amount): void {(new LedgerService())->charge($club,$membership,$season,$amount);}
public function payment(int $club,int $due,string $amount,string $paid,string $key,string $method='unspecified',string $reference=''): void {(new LedgerService())->payment($club,$due,$amount,$paid,$key,$method,$reference);}
}