publicfunctionrequireAdministrator():void{if(!$this->administrator())thrownewAppException('Only assigned club administrators may manage clubs and dues.',403);}
publicfunctionready():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&&(newTenantAccess())->available();}
if((newTenantAccess())->isSuper($db))returnBoundedList::rows($db,'SELECT * FROM clubs ORDER BY club_name,club_serial',[],$page,'clubs');
returnBoundedList::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');
$db??=$this->connect();$stmt=$db->prepare('SELECT * FROM clubs WHERE club_serial=?'.($writing?' FOR UPDATE':''));$stmt->execute([$id]);$club=$stmt->fetch();
if(!$club)thrownewAppException('Club not found.',404);
if($writing)thrownewAppException('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]);
returnBoundedList::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');
$this->club($club);returnBoundedList::rows($this->connect(),'SELECT * FROM club_seasons WHERE club_serial=? ORDER BY starts_on DESC,season_serial DESC',[$club],$page,'seasons');
$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'"));
returnBoundedList::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');
privatefunctiontext(string$text,int$limit):string{$text=trim($text);if(!$text||strlen($text)>$limit)thrownewAppException('A name is required and must fit the displayed length limit.');return$text;}
publicstaticfunctiondate(string$value):string{$date=DateTime::createFromFormat('!Y-m-d',$value);if(!$date||$date->format('Y-m-d')!==$value)thrownewAppException('Enter a valid date in YYYY-MM-DD format.');return$value;}
publicstaticfunctioncents(string$value):int{
if(!preg_match('/\A(?:0|[1-9][0-9]{0,6})(?:\.[0-9]{1,2})?\z/',$value))thrownewAppException('Enter a nonnegative USD amount with at most two decimal places (maximum 9,999,999.99).');
if(!$id)(newTenantAccess())->requireSuper();else(newTenantAccess())->requireManager($id);$name=$this->text($name,128);if(!in_array($status,['active','archived'],true))thrownewAppException('Invalid club status.');
$before=null;if($id){$stmt=$db->prepare('SELECT * FROM clubs WHERE club_serial=? FOR UPDATE');$stmt->execute([$id]);$before=$stmt->fetch();if(!$before)thrownewAppException('Club not found.',404);}
Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'saved club '.json_encode(['club'=>$id,'before'=>$before,'name'=>$name,'status'=>$status]));return$id;
// 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)thrownewAppException('Membership not found in this club.',404);}
else{(newTenantAccess())->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)thrownewAppException('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()))thrownewAppException('Account not found.',404);
if($status==='active'&&($account['user_status']!=='active'||!$account['user_email_verified_at']))thrownewAppException('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)thrownewAppException('Membership not found.',404);}
if(!(newTenantAccess())->isSuper($db)&&(($before['membership_role']??'member')==='administrator'||$memberRole!=='member'))thrownewAppException('Only the Super System Administrator can assign or change club administrators.',403);
$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)thrownewAppException('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]);}
$this->requireAdministrator();$name=$this->text($name,100);self::date($start);self::date($end);if($end<$start)thrownewAppException('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]));});