2026-10-21 06:53:45 -04:00
<? 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 {
2026-10-21 20:41:57 -04:00
$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' )]);
2026-10-21 06:53:45 -04:00
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>' ;
}
2026-10-21 20:41:57 -04:00
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>' ;
}
2026-10-21 06:53:45 -04:00
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 ;
2026-10-21 20:41:57 -04:00
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 ;
2026-10-21 06:53:45 -04:00
default : throw new AppException ( 'Unknown member action.' );
}
$_SESSION [ 'roster_notice' ] = 'Changes saved.' ;
2026-10-21 20:41:57 -04:00
$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' ;
2026-10-21 06:53:45 -04:00
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>' ;
}
2026-10-21 20:41:57 -04:00
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>' ;
}
2026-10-21 06:53:45 -04:00
$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>' ;
2026-10-21 20:41:57 -04:00
$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>' ;
2026-10-21 06:53:45 -04:00
$html .= '</td>' ;
}
$html .= '</tr>' ;
}
if ( ! $dues ) $html .= '<tr><td colspan="' . ( $manage && $active ? 5 : 4 ) . '">No dues recorded for this membership.</td></tr>' ;
2026-10-21 20:41:57 -04:00
$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>' ;
2026-10-21 06:53:45 -04:00
foreach ( $history [ 'payments' ] as $payment ){
$status = $payment [ 'reversal_serial' ] ? 'Reversed: ' . $payment [ 'reversal_reason' ] . ' (' . $payment [ 'reversed_at' ] . ' UTC)' : 'Received' ;
2026-10-21 20:41:57 -04:00
$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>' ;
2026-10-21 06:53:45 -04:00
}
2026-10-21 20:41:57 -04:00
if ( ! $history [ 'payments' ]) $html .= '<tr><td colspan="' . ( $manage && $active ? 6 : 5 ) . '">No payments recorded for this membership.</td></tr>' ;
2026-10-21 06:53:45 -04:00
$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 );
}
}