updates from chat
Security and workflow regressions / regression (push) Has been cancelled

This commit is contained in:
2026-10-21 20:41:57 -04:00
parent acc8cb70f5
commit b851712968
19 changed files with 435 additions and 22 deletions
+42 -5
View File
@@ -6,7 +6,7 @@ class MemberSummary extends Base {
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')]);
$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 {
@@ -14,6 +14,17 @@ class MemberSummary extends Base {
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);
@@ -28,10 +39,16 @@ class MemberSummary extends Base {
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')]);
$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;
}
@@ -59,6 +76,19 @@ class MemberSummary extends Base {
}
$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>';
@@ -69,17 +99,24 @@ class MemberSummary extends Base {
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></tr></thead><tbody>';
$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></tr>';
$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="5">No payments recorded for this membership.</td></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>';