Files
huntingclubroster/classes/MemberSummary.php
jric11 b851712968
Security and workflow regressions / regression (push) Has been cancelled
updates from chat
2026-10-21 20:41:57 -04:00

127 lines
17 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/** A member summary always belongs to one authorized club membership. */
class MemberSummary extends Base {
private string $memberLabel='';
private static function h(mixed $value): string {return htmlspecialchars((string)$value,ENT_QUOTES,'UTF-8');}
private static function money(int $cents): string {return '$'.number_format($cents/100,2);}
private function form(string $step,int $club,int $membership): string {
$url='main.php?'.http_build_query(['Xaction'=>'Roster.memberSummary','club_serial'=>$club,'membership_serial'=>$membership,'members_page'=>BoundedList::page('members'),'dues_page'=>BoundedList::page('dues'),'payments_page'=>BoundedList::page('payments'),'adjustments_page'=>BoundedList::page('adjustments'),'seasons_page'=>BoundedList::page('seasons'),'notes_page'=>BoundedList::page('notes')]);
return '<form method="post" action="'.self::h($url).'" class="row g-2 mb-3"><p class="text-secondary">Member: '.self::h($this->memberLabel).'</p><input type="hidden" name="action" value="Roster.memberSummary"><input type="hidden" name="csrf_token" value="'.self::h($this->csrfToken()).'"><input type="hidden" name="step" value="'.$step.'"><input type="hidden" name="club_serial" value="'.$club.'"><input type="hidden" name="membership_serial" value="'.$membership.'">';
}
private static function field(string $name,string $label,string $type='text',string $value='',int $max=128): string {
$id='member_'.$name.'_'.bin2hex(random_bytes(3));
return '<div class="col-md"><label class="form-label" for="'.$id.'">'.$label.'</label><input class="form-control" id="'.$id.'" name="'.$name.'" type="'.$type.'" value="'.self::h($value).'" maxlength="'.$max.'" required></div>';
}
private function noteForm(int $club,int $membership,?array $note=null): string {
$id='club_note_'.($note?(int)$note['note_serial']:'new');
$form=$this->form($note?'note_edit':'note_add',$club,$membership);
if($note)$form.='<input type="hidden" name="note_serial" value="'.(int)$note['note_serial'].'"><input type="hidden" name="note_version" value="'.hash('sha256',$note['note_text']).'">';
return $form.'<label class="form-label" for="'.$id.'">Note</label><textarea class="form-control" id="'.$id.'" name="note_text" rows="6" maxlength="2000" required>'.self::h($note['note_text']??'').'</textarea><p>Up to 2,000 characters. Only administrators of this club and Super Administrators can see this note.</p><button class="btn btn-primary">Save note</button></form>';
}
private function deleteNoteForm(int $club,int $membership,array $note): string {
return $this->form('note_delete',$club,$membership).'<input type="hidden" name="note_serial" value="'.(int)$note['note_serial'].'"><input type="hidden" name="note_version" value="'.hash('sha256',$note['note_text']).'"><p>Delete this administrative note? This cannot be undone. A record of the deletion will remain in the audit trail.</p><blockquote style="white-space:pre-wrap;overflow-wrap:anywhere">'.self::h($note['note_text']).'</blockquote><button class="btn btn-danger">Confirm deletion</button></form>';
}
public function show(): void {
$club=MaintenanceService::id($_GET['club_serial']??$_POST['club_serial']??null);
$membership=MaintenanceService::id($_GET['membership_serial']??$_POST['membership_serial']??null);
$service=new RosterService();$member=$service->member($club,$membership);$selected=$service->club($club);
$this->memberLabel=trim($member['user_first_name'].' '.$member['user_last_name']).' ('.$member['user_email'].')';
$manage=$service->manages($club);$super=(new TenantAccess())->isSuper();$active=$selected['club_status']==='active';
if(($_SERVER['REQUEST_METHOD']??'GET')==='POST'){
$this->requireCsrf();(new TenantAccess())->requireManager($club);
if(!$active)throw new AppException('Reactivate the club before changing records.',409);
if((int)($_POST['membership_serial']??0)!==$membership||(int)($_POST['club_serial']??0)!==$club)throw new AppException('The selected membership changed. Reload its summary.',409);
switch($_POST['step']??''){
case 'member':$service->saveMember($club,$membership,'',$_POST['membership_status']??'',$_POST['joined_on']??'',$_POST['membership_role']??'member');break;
case 'charge':$service->charge($club,$membership,MaintenanceService::id($_POST['season_serial']??null),$_POST['amount']??'');break;
case 'payment':(new LedgerService())->payment($club,MaintenanceService::id($_POST['due_serial']??null),$_POST['amount']??'',$_POST['paid_on']??'',$_POST['request_key']??'',$_POST['payment_method']??'unspecified',$_POST['payment_reference']??'',$membership);break;
case 'adjust':(new LedgerService())->adjust($club,MaintenanceService::id($_POST['due_serial']??null),$_POST['corrected_amount']??'',$_POST['reason']??'',$_POST['request_key']??'',$membership);break;
case 'reverse':(new LedgerService())->reverse($club,MaintenanceService::id($_POST['payment_serial']??null),$_POST['reason']??'',$_POST['request_key']??'',$membership);break;
case 'note_add':(new ClubMemberNotes())->save($club,$membership,0,$_POST['note_text']??'');break;
case 'note_edit':(new ClubMemberNotes())->save($club,$membership,MaintenanceService::id($_POST['note_serial']??null),$_POST['note_text']??'',$_POST['note_version']??'');break;
case 'note_delete':(new ClubMemberNotes())->delete($club,$membership,MaintenanceService::id($_POST['note_serial']??null),$_POST['note_version']??'');break;
default:throw new AppException('Unknown member action.');
}
$_SESSION['roster_notice']='Changes saved.';
$redirect='main.php?'.http_build_query(['Xaction'=>'Roster.memberSummary','club_serial'=>$club,'membership_serial'=>$membership,'members_page'=>BoundedList::page('members'),'dues_page'=>BoundedList::page('dues'),'payments_page'=>BoundedList::page('payments'),'adjustments_page'=>BoundedList::page('adjustments'),'seasons_page'=>BoundedList::page('seasons'),'notes_page'=>(in_array($_POST['step']??'',['note_add','note_edit','note_delete'],true)?1:BoundedList::page('notes'))]);
if(in_array($_POST['step']??'',['note_add','note_edit','note_delete'],true))$redirect.='#admin-notes';
if(($_SESSION[self::AJAX]??false)&&($_SERVER['HTTP_X_HCR_MODAL']??'')==='1'){header('Content-Type: application/json');echo json_encode(['ok'=>true,'redirect'=>$redirect]);return;}
header('Location: '.$redirect,true,303);return;
}
$dues=$service->dues($club,BoundedList::page('dues'),$membership);
$history=(new LedgerService())->history($club,BoundedList::page('payments'),BoundedList::page('adjustments'),$membership);
// Totals include every season, independently of the visible history pages.
$stmt=$this->connect()->prepare('SELECT COALESCE(SUM(d.amount_cents+COALESCE((SELECT SUM(a.amount_cents) FROM club_due_adjustments a WHERE a.due_serial=d.due_serial),0)),0) AS dues_cents,COALESCE(SUM(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)),0) AS paid_cents FROM club_dues d WHERE d.club_serial=? AND d.membership_serial=?');
$stmt->execute([$club,$membership]);$totals=$stmt->fetch();
$back='main.php?'.http_build_query(['Xaction'=>'Roster.workspace','club_serial'=>$club,'members_page'=>BoundedList::page('members')]).'#roster';
$name=trim($member['user_first_name'].' '.$member['user_last_name']);
$html='<main class="container-fluid py-3 memberSummary"><header class="mb-4"><h1>'.self::h($name).'</h1><p>'.self::h($member['user_email']).'</p><p>Club: '.self::h($selected['club_name']).' · '.self::h(ucfirst($selected['club_status'])).'</p><p>Membership: '.self::h(ucfirst($member['membership_status'])).' · '.self::h(ucfirst($member['membership_role'])).'<br>Joined: '.self::h($member['joined_on']).'</p><a class="btn btn-outline-primary" href="'.self::h($back).'">Back to club roster</a></header>';
if(!empty($_SESSION['roster_notice'])){$html.='<p class="alert alert-success" role="status">'.self::h($_SESSION['roster_notice']).'</p>';unset($_SESSION['roster_notice']);}
if($manage&&$active){
$html.='<nav class="d-flex flex-wrap gap-2 mb-3" aria-label="Member actions">';
if($super||$member['membership_role']!=='administrator'){
$html.=$this->form('member',$club,$membership).self::field('joined_on','Joined on','date',$member['joined_on']).'<label class="form-label">Membership status<select class="form-select" name="membership_status"><option value="active"'.($member['membership_status']==='active'?' selected':'').'>Active</option><option value="inactive"'.($member['membership_status']==='inactive'?' selected':'').'>Inactive</option></select></label>';
if($super)$html.='<label class="form-label">Club role<select class="form-select" name="membership_role"><option value="member"'.($member['membership_role']==='member'?' selected':'').'>Member</option><option value="administrator"'.($member['membership_role']==='administrator'?' selected':'').'>Administrator</option></select></label>';
else $html.='<input type="hidden" name="membership_role" value="member">';
$html.='<button class="btn btn-primary">Save membership</button></form>';
}
if($member['membership_status']==='active'){
$seasons=$service->seasons($club,BoundedList::page('seasons'));
if($seasons){$html.=$this->form('charge',$club,$membership).'<p>Add dues for '.self::h($name).'.</p><label class="form-label" for="member_dues_season">Season</label><select class="form-select" name="season_serial" id="member_dues_season">';foreach($seasons as $season)$html.='<option value="'.(int)$season['season_serial'].'">'.self::h($season['season_name']).'</option>';$html.='</select>'.self::field('amount','Dues (USD)').'<button class="btn btn-primary">Save dues</button>'.BoundedList::navigation('seasons','Roster.memberSummary').'</form>';}
else $html.='<p>Add a season from the club page before recording dues.</p>';
}
$html.='</nav>';
}
if($manage){
$notes=(new ClubMemberNotes())->listing($club,$membership,BoundedList::page('notes'));
$html.='<section class="mb-4 member-admin-notes" id="admin-notes"><h2 class="h4">Administrative notes</h2><p>Visible only to this club’s administrators and Super Administrators.</p>';
if($active)$html.=$this->noteForm($club,$membership);
$html.='<div class="table-responsive"><table class="table"><thead><tr><th>Note</th><th>Updated</th><th>By</th>'.($active?'<th>Actions</th>':'').'</tr></thead><tbody>';
foreach($notes as $note){
$html.='<tr><td style="white-space:pre-wrap;overflow-wrap:anywhere">'.self::h($note['note_text']).'</td><td>'.self::h($note['note_timestamp']).'</td><td>'.self::h($note['note_origin']).'</td>';
if($active)$html.='<td>'.$this->noteForm($club,$membership,$note).$this->deleteNoteForm($club,$membership,$note).'</td>';
$html.='</tr>';
}
if(!$notes)$html.='<tr><td colspan="'.($active?4:3).'">No administrative notes yet.</td></tr>';
$html.='</tbody></table></div>'.BoundedList::navigation('notes','Roster.memberSummary').'</section>';
}
$html.='<div class="row g-3 mb-4">';
foreach(['Total dues'=>(int)$totals['dues_cents'],'Payments received'=>(int)$totals['paid_cents'],'Balance due'=>(int)$totals['dues_cents']-(int)$totals['paid_cents']] as $label=>$amount)
$html.='<div class="col-12 col-md-4"><section class="card h-100"><div class="card-body"><h2 class="h6">'.$label.'</h2><p class="h4 mb-0">'.self::money($amount).'</p></div></section></div>';
$html.='</div><p>Amounts are in USD. Totals include all seasons and exclude reversed payments.</p><h2 class="h4 mt-4">Season dues</h2><div class="table-responsive"><table class="table"><thead><tr><th>Season</th><th>Dues</th><th>Paid</th><th>Balance</th>'.($manage&&$active?'<th>Actions</th>':'').'</tr></thead><tbody>';
foreach($dues as $due){
$balance=(int)$due['amount_cents']-(int)$due['paid_cents'];
$html.='<tr><td>'.self::h($due['season_name']).'</td><td>'.self::money((int)$due['amount_cents']).'</td><td>'.self::money((int)$due['paid_cents']).'</td><td>'.self::money($balance).'</td>';
if($manage&&$active){
$html.='<td>';
if($balance>0)$html.=$this->form('payment',$club,$membership).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('amount','Payment (USD)').self::field('paid_on','Received on','date',date('Y-m-d')).'<label class="form-label">Method<select class="form-select" name="payment_method"><option value="unspecified">Unspecified</option><option value="cash">Cash</option><option value="check">Check</option><option value="transfer">Transfer</option><option value="other">Other</option></select></label><label class="form-label">Reference (optional)<input class="form-control" name="payment_reference" maxlength="100"></label><button class="btn btn-primary">Record payment</button></form>';
$html.=$this->form('adjust',$club,$membership).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('corrected_amount','Corrected total (USD)','text',number_format((int)$due['amount_cents']/100,2,'.','')).self::field('reason','Reason','text','',500).'<p>This records an adjustment and preserves the original dues and payment history.</p><button class="btn btn-primary">Save correction</button></form>';
$html.='</td>';
}
$html.='</tr>';
}
if(!$dues)$html.='<tr><td colspan="'.($manage&&$active?5:4).'">No dues recorded for this membership.</td></tr>';
$html.='</tbody></table></div>'.BoundedList::navigation('dues','Roster.memberSummary').'<h2 class="h4 mt-4">Payment history</h2><div class="table-responsive"><table class="table"><thead><tr><th>Payment</th><th>Date</th><th>Amount</th><th>Method / reference</th><th>Status</th>'.($manage&&$active?'<th>Actions</th>':'').'</tr></thead><tbody>';
foreach($history['payments'] as $payment){
$status=$payment['reversal_serial']?'Reversed: '.$payment['reversal_reason'].' ('.$payment['reversed_at'].' UTC)':'Received';
$html.='<tr><td>#'.(int)$payment['payment_serial'].' · Dues #'.(int)$payment['due_serial'].'</td><td>'.self::h($payment['paid_on']).'</td><td>'.self::money((int)$payment['amount_cents']).'</td><td>'.self::h($payment['payment_method'].' / '.$payment['payment_reference']).'</td><td>'.self::h($status).'</td>';
if($manage&&$active){
$html.='<td>';
if(!$payment['reversal_serial'])$html.=$this->form('reverse',$club,$membership).'<input type="hidden" name="payment_serial" value="'.(int)$payment['payment_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('reason','Reversal reason','text','',500).'<p>This preserves the original payment and restores the amount owed. It does not issue a refund.</p><button class="btn btn-outline-danger">Confirm reversal</button></form>';
$html.='</td>';
}
$html.='</tr>';
}
if(!$history['payments'])$html.='<tr><td colspan="'.($manage&&$active?6:5).'">No payments recorded for this membership.</td></tr>';
$html.='</tbody></table></div>'.BoundedList::navigation('payments','Roster.memberSummary').'<h2 class="h4 mt-4">Dues correction history</h2><div class="table-responsive"><table class="table"><thead><tr><th>Date (UTC)</th><th>Adjustment</th><th>Corrected total</th><th>Reason</th></tr></thead><tbody>';
foreach($history['adjustments'] as $adjustment)$html.='<tr><td>'.self::h($adjustment['created_at']).'</td><td>'.self::money((int)$adjustment['amount_cents']).'</td><td>'.self::money((int)$adjustment['corrected_amount_cents']).'</td><td>'.self::h($adjustment['reason']).'</td></tr>';
if(!$history['adjustments'])$html.='<tr><td colspan="4">No dues corrections recorded for this membership.</td></tr>';
$html.='</tbody></table></div>'.BoundedList::navigation('adjustments','Roster.memberSummary').'</main>';
$this->displayContent($manage&&$active?ClubFormModals::render($html):$html);
}
}