Files

89 lines
12 KiB
PHP
Raw Permalink Normal View History

<?php
class RosterService extends Base {
public function administrator(): bool {return (new TenantAccess())->managesAny();}
public function requireAdministrator(): void {if(!$this->administrator())throw new AppException('Only assigned club administrators may manage clubs and dues.',403);}
public function manages(int $club,?PDO $db=null): bool {return (new TenantAccess())->canManage($club,$db);}
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','club_invitations','club_tickets','club_ticket_messages')")->fetchColumn()===11 && (new TenantAccess())->available();}
public function clubs(?int $page=null,bool $managedOnly=false): array {
$db=$this->connect();
if((new TenantAccess())->isSuper($db))return BoundedList::rows($db,'SELECT * FROM clubs ORDER BY club_name,club_serial',[],$page,'clubs');
return BoundedList::rows($db,"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' OR m.membership_role='administrator') AND u.user_status='active' AND u.user_email_verified_at IS NOT NULL".($managedOnly?" AND m.membership_role='administrator'":'')." ORDER BY c.club_name,c.club_serial",[$_SESSION[self::USER_SERIAL]??0],$page,'clubs');
}
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->manages($id,$db)){
if($writing)throw new AppException('You cannot manage this club.',403);
$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 LOCK IN SHARE MODE");$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,?int $page=null): array {
$this->club($club);$manage=$this->manages($club);
return BoundedList::rows($this->connect(),"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=?".($manage?'':" AND m.user_serial=? AND m.membership_status='active'").' ORDER BY u.user_last_name,u.user_first_name,m.membership_serial',$manage?[$club]:[$club,$_SESSION[self::USER_SERIAL]??0],$page,'members');
}
public function seasons(int $club,?int $page=null): array {
$this->club($club);return BoundedList::rows($this->connect(),'SELECT * FROM club_seasons WHERE club_serial=? ORDER BY starts_on DESC,season_serial DESC',[$club],$page,'seasons');
}
public function member(int $club,int $membership): array {
$this->club($club);$manage=$this->manages($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=? AND m.membership_serial=?'.($manage?'':" AND m.user_serial=? AND m.membership_status='active'"));
$stmt->execute($manage?[$club,$membership]:[$club,$membership,$_SESSION[self::USER_SERIAL]??0]);
$member=$stmt->fetch();if(!$member)throw new AppException('Membership not found in this club.',404);return $member;
}
public function dues(int $club,?int $page=null,?int $membership=null): array {
$this->club($club);$manage=$this->manages($club);
if($membership!==null)$this->member($club,$membership);
$args=$manage?[$club]:[$club,$_SESSION[self::USER_SERIAL]??0];if($membership!==null)$args[]=$membership;
return BoundedList::rows($this->connect(),'SELECT d.due_serial,d.club_serial,d.membership_serial,d.season_serial,d.created_at,d.recorded_by,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=?'.($manage?'':' AND m.user_serial=?').($membership===null?'':' AND d.membership_serial=?').' ORDER BY s.starts_on DESC,u.user_last_name,d.due_serial',$args,$page,'dues');
}
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 {
if(!$id)(new TenantAccess())->requireSuper();else (new TenantAccess())->requireManager($id);$name=$this->text($name,128);if(!in_array($status,['active','archived'],true))throw new AppException('Invalid club status.');
$db=$this->connect();
$id=DatabaseTransaction::run($db,function(PDO $db)use($id,$name,$status){
if(!$id)(new TenantAccess())->requireSuper($db);
$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)(new TenantAccess())->requireManager($id,$db);
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]));return $id;
});
return $id;
}
public function saveMember(int $club,int $membership,string $identifier,string $status,string $joined,string $memberRole='member'): void {
$this->requireAdministrator();(new TenantAccess())->requireManager($club);$joined=self::date($joined);if(!in_array($memberRole,['member','administrator'],true))throw new AppException('Invalid membership role.');if(!in_array($status,['active','inactive'],true))throw new AppException('Invalid membership status.');
$db=$this->connect();
DatabaseTransaction::run($db,function(PDO $db)use($club,$membership,$identifier,$status,$joined,$memberRole){
// 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{(new TenantAccess())->requireSuper($db);$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(!(new TenantAccess())->isSuper($db) && (($before['membership_role']??'member')==='administrator'||$memberRole!=='member'))throw new AppException('Only the Super System Administrator can assign or change club administrators.',403);
if(($before['membership_role']??'')==='administrator' && $before['membership_status']==='active' && ($memberRole!=='administrator'||$status!=='active')){
$stmt=$db->prepare("SELECT membership_serial FROM club_memberships WHERE club_serial=? AND membership_role='administrator' AND membership_status='active' ORDER BY membership_serial FOR UPDATE");$stmt->execute([$club]);if(count($stmt->fetchAll())<=1)throw new AppException('Assign another club administrator before removing the last one.',409);
}
if($membership){$stmt=$db->prepare('UPDATE club_memberships SET membership_status=?,joined_on=?,membership_role=? WHERE membership_serial=? AND club_serial=?');$stmt->execute([$status,$joined,$memberRole,$membership,$club]);}
else{$stmt=$db->prepare('INSERT INTO club_memberships(club_serial,user_serial,membership_status,joined_on,membership_role) VALUES(?,?,?,?,?)');$stmt->execute([$club,$user,$status,$joined,$memberRole]);$membership=(int)$db->lastInsertId();}
if($status==='inactive'||($before&&$before['membership_role']!==$memberRole)){$stmt=$db->prepare("UPDATE club_invitations SET invitation_status='revoked' WHERE club_serial=? AND invite_email=(SELECT user_email FROM users WHERE user_serial=?) AND invitation_status='pending'");$stmt->execute([$club,$user]);}
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,'membership_role'=>$memberRole,'joined_on'=>$joined]));
});
}
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();DatabaseTransaction::run($db,function(PDO $db)use($club,$name,$start,$end){$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]));});
}
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);}
}