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
+3
View File
@@ -1,5 +1,8 @@
# Administrator actions on member summaries # Administrator actions on member summaries
Follow-up: [dues corrections and payment reversals](MEMBER-CORRECTIONS-2026-10-21.md)
are also available directly from member summaries.
Administrators can now work directly from a member summary: Administrators can now work directly from a member summary:
- Edit membership opens a prefilled dialog for membership status and join date. - Edit membership opens a prefilled dialog for membership status and join date.
+49
View File
@@ -0,0 +1,49 @@
# Member-summary dues corrections and payment reversals
Administrators can now open Correct dues beside a member's season dues and
Reverse incorrect payment beside an unreversed receipt on the member summary.
The correction dialog prefills the current effective dues total and requires a
reason. The reversal dialog identifies the member and receipt, requires a reason
and confirmation, and explains that reversal does not issue a refund.
Successful actions return to the same member summary and preserve paging.
Totals, balances, correction history and payment status refresh immediately.
Cancel does not change the ledger. Reversed receipts remain visible with their
reason and no longer offer a reversal button. Members and archived clubs retain
their existing read-only behavior.
`classes/MemberSummary.php` adds the two dialogs and the explicit `adjust` and
`reverse` POST actions. They use the existing CSRF and club-management checks and
the existing modal validation/error handling. The summary remains free of inline
editing fields.
`classes/LedgerService.php` adds optional membership constraints to corrections
and reversals. These are checked against the locked dues record inside each
transaction, before writes or idempotent success. Forged requests cannot correct
another member's dues or reverse another member's receipt, including within the
same club. Existing club-page callers remain compatible.
Corrections still cannot reduce dues below payments received. Both operations
append records and require a successful audit write in the same transaction.
Duplicate request keys remain idempotent; already-reversed payments reject a new
reversal request. No financial history is deleted and no external payment or
refund is initiated. No schema change, dependency or migration is required.
Regression coverage includes prefilled correction values, successful browser
correction/reversal, cancellation, updated totals, removal of reversal buttons,
wrong-member and foreign-club requests, member access restrictions, correction
limits, duplicate requests, audit rollback and preserved reasons. The history
paging test counts actual payment-table records separately from hidden dialog
context, which now also identifies the selected receipt.
Validation passed 186 targeted checks: 35 member-action HTTP, 22 member-summary
HTTP, 28 tenant browser, 33 general browser, 41 security/capacity service,
11 paging/export HTTP and 16 deployment tooling checks. Source validation passed
for 67 PHP files, 58 JavaScript files and 39 XSL stylesheets.
The two application files were deployed to development and hash verified. A
protected temporary runtime probe confirmed the membership-scoped correction
and reversal methods, then was removed. Prior files were saved under
`.tools/deployment-backups/member-corrections-7f0ba3ed47d24a3789f30bc116f8a8a8`.
No development database records were changed. Tests used disposable data with
external email disabled; the isolated database server was stopped after cleanup.
+14
View File
@@ -0,0 +1,14 @@
# Administrative note deletion — October 21, 2026
- Added a red Delete note action on each administrative note in the member summary. A confirmation modal shows the member and escaped note text, an irreversible-deletion warning, Confirm deletion, and Cancel.
- Deletion is limited on the server to administrators of the selected club and Super Administrators. It requires CSRF, matching club/membership context, and a matching note version. Archived clubs remain read-only.
- The service locks the club and selected private membership note, checks permissions again, and deletes only that note. A changed note requires reloading before deletion.
- Deletion and its mandatory audit event share one database transaction. Audit failure rolls back deletion. The journal retains the actor, timestamp, IP, club, membership and deleted note identifiers, without retaining private note text. Repeated deletion returns not found without another deletion event.
- Restricted the legacy global note deletion route to global User notes so it cannot bypass the private-note audit service.
- Successful deletion returns to the member summary’s administrative notes section. No schema migration is required.
Validation covers tenant and membership isolation, member restrictions, shared administrators, Super Administrator access, CSRF, wrong note types, stale confirmations, mandatory-audit rollback, duplicate requests, archived clubs, escaped previews, and browser Cancel/Confirm behavior. Regression suites cover existing notes, member actions, club browser flows, and deployment tooling.
Validation result: all 161 targeted HTTP, browser, and deployment checks passed, plus source validation of 68 PHP files, 58 JavaScript files and 39 XSL stylesheets. The deletion suite contains 17 checks; browser coverage includes Cancel and Confirm deletion.
Deployed four PHP files to development after saving previous copies under `.tools/deployment-backups/member-note-deletion-29e446be674741c5a06f6cfca23aa947`. Deployed hashes and a temporary protected server runtime probe passed; the probe was removed. No development database records were changed, and no migration was required. Disposable local databases were cleaned up and the local database server stopped.
+71
View File
@@ -0,0 +1,71 @@
# Private administrative notes on member summaries
Authorized administrators now see Administrative notes on the selected member
summary, with Add note and Edit note dialogs. Notes show their last update and
editor, preserve line breaks, and are displayed as escaped plain text. The server
accepts up to 2,000 Unicode characters. Successful saves return to the notes
section on the same member summary and reset notes paging so the updated note is
visible. Cancel does not submit changes.
## Visibility and data separation
- Only administrators of the selected club and Super System Administrators can
read, add or edit the notes. Administrators of a shared club can edit each
other's notes.
- Members receive no note text, section, metadata, identifiers, hashes or hidden
edit fields in their response, including on their own member summary.
- Being an administrator of one club does not grant administrative-note access
in another club where the person is only a member.
- Notes are attached to the specific club membership. A person belonging to
multiple clubs has separate administrative notes in each club.
- Existing global account notes remain separate and are not copied into club
summaries. Global user summaries do not include these private club notes.
- Archived club notes remain readable to authorized administrators, with writes
disabled. No new note deletion action is provided.
## Implementation and safeguards
`classes/ClubMemberNotes.php` uses the existing `notes` table with the reserved
type `ClubMember` and the membership ID as its reference. Every read/write checks
club administration and the exact club/membership relationship. Edits additionally
require the note ID to match that type and membership. Publicly posted note types
or global-user references are not trusted.
Writes recheck permissions inside a transaction, require CSRF and a mandatory
audit entry, and store the editor from the authenticated session. Audit entries
record IDs rather than private note text. Storage must use InnoDB; writes fail
closed otherwise. The development table was checked and already supports these
transactions, so no migration or grant change is required.
Edits include a hash of the previously loaded note. If another administrator has
changed it, the stale form receives a conflict and cannot silently overwrite the
new text. Audit failure rolls back both new notes and edits. Note history is
paged with the same scoped count and row query.
`MemberSummary.php` renders the administrator-only section and handles the two
explicit note actions. `ClubFormModals.php` adds their dialog labels and avoids
duplicating the full note text above its edit field. `js/club-modals.js` now forces
a refresh when the save return address differs only by a page anchor; previously
that navigation could leave stale content and an open dialog. `Base.php` bumps
the asset version to 1.05 so browsers receive the updated script.
Tests cover note creation/editing, shared administrators, ordinary members,
cross-club access, separation from account notes, direct forged requests, CSRF,
stale edits, audit rollback, Unicode limits, paging, archived clubs, modal cancel,
immediate refresh and absence of hidden private fields in member responses.
The full test runner includes the new HTTP suite and release checks require the
new service. Tests use disposable local data with external email disabled.
Completed validation passed 216 targeted checks: 27 private-note HTTP,
22 member-summary HTTP, 31 tenant browser, 33 general browser, 35 member-action
HTTP, 41 security/capacity service, 11 paging/export HTTP and 16 deployment
tooling checks. Source validation passed for 68 PHP files, 58 JavaScript files
and 39 XSL stylesheets.
Five application files were deployed to development and hash verified. Live
checks confirmed transactional note storage, the private service, note-dialog
rendering, asset version and updated script. Temporary protected probes were
removed. Prior files were saved under
`.tools/deployment-backups/member-notes-0869c36d56e74d51adea3d7350ef45d3`.
No development database records were changed by deployment, and the isolated
test database server was stopped after cleanup.
+8
View File
@@ -1,5 +1,11 @@
# Hunting Club Roster # Hunting Club Roster
See [private member notes](MEMBER-NOTES-2026-10-21.md) for club-specific
administrative notes and their add/edit dialogs.
See [member corrections and reversals](MEMBER-CORRECTIONS-2026-10-21.md) for the
financial correction dialogs on member summaries.
See [member summary actions](MEMBER-ACTIONS-2026-10-21.md) for administrator See [member summary actions](MEMBER-ACTIONS-2026-10-21.md) for administrator
membership edits, dues and payment dialogs directly on a member summary. membership edits, dues and payment dialogs directly on a member summary.
@@ -35,3 +41,5 @@ See [tenant invitation and ticket repairs](TENANT-REPAIRS-2026-10-20.md) for the
invitation-role fix, transaction retries, Unicode limits, ticket paging and tests. invitation-role fix, transaction retries, Unicode limits, ticket paging and tests.
See [administrator security and capacity](SECURITY-CAPACITY-2026-10-20.md) for mandatory MFA, session expiration, bounded collections, shared club budgets, and the required new migration/key configuration. See [administrator security and capacity](SECURITY-CAPACITY-2026-10-20.md) for mandatory MFA, session expiration, bounded collections, shared club budgets, and the required new migration/key configuration.
See [administrative note deletion](MEMBER-NOTE-DELETION-2026-10-21.md) for confirmation dialogs, club permissions, and mandatory deletion auditing.
+1 -1
View File
@@ -15,7 +15,7 @@ class Base {
const APPLICATION_NAME = "My Hunting Club Roster"; const APPLICATION_NAME = "My Hunting Club Roster";
const APPLICATION_MNEMONIC = "HUNTINGCLUBROSTER"; const APPLICATION_MNEMONIC = "HUNTINGCLUBROSTER";
const VERSION = "1.04"; const VERSION = "1.05";
const TIMEZONE = "America/New_York"; const TIMEZONE = "America/New_York";
// ================= // =================
// Session Variables // Session Variables
+4 -3
View File
@@ -5,7 +5,8 @@ final class ClubFormModals {
public static function render(string $html): string { public static function render(string $html): string {
$titles=['club'=>'Edit or archive club','member'=>'Add member','invite'=>'Invite members', $titles=['club'=>'Edit or archive club','member'=>'Add member','invite'=>'Invite members',
'season'=>'Add season','charge'=>'Record season dues','payment'=>'Record payment', 'season'=>'Add season','charge'=>'Record season dues','payment'=>'Record payment',
'adjust'=>'Correct dues','reverse'=>'Reverse incorrect payment','revokeInvitation'=>'Revoke invitation']; 'adjust'=>'Correct dues','reverse'=>'Reverse incorrect payment','revokeInvitation'=>'Revoke invitation',
'note_add'=>'Add note','note_edit'=>'Edit note','note_delete'=>'Delete note'];
$doc=new DOMDocument('1.0','UTF-8');$previous=libxml_use_internal_errors(true); $doc=new DOMDocument('1.0','UTF-8');$previous=libxml_use_internal_errors(true);
try{$loaded=$doc->loadHTML('<?xml encoding="UTF-8">'.$html,LIBXML_NONET);} try{$loaded=$doc->loadHTML('<?xml encoding="UTF-8">'.$html,LIBXML_NONET);}
finally{libxml_clear_errors();libxml_use_internal_errors($previous);} finally{libxml_clear_errors();libxml_use_internal_errors($previous);}
@@ -20,7 +21,7 @@ final class ClubFormModals {
$title=$step==='member'&&!empty($values['membership_serial'])?'Edit membership':$titles[$step]; $title=$step==='member'&&!empty($values['membership_serial'])?'Edit membership':$titles[$step];
$id='club-action-'.(++$index);$parent=$form->parentNode; $id='club-action-'.(++$index);$parent=$form->parentNode;
$trigger=$doc->createElement('button');$trigger->appendChild($doc->createTextNode($title)); $trigger=$doc->createElement('button');$trigger->appendChild($doc->createTextNode($title));
foreach(['type'=>'button','class'=>'btn btn-outline-primary me-2 mb-2','data-bs-toggle'=>'modal','data-bs-target'=>'#'.$id,'data-club-step'=>$step] as $key=>$value)$trigger->setAttribute($key,$value); foreach(['type'=>'button','class'=>'btn '.($step==='note_delete'?'btn-outline-danger':'btn-outline-primary').' me-2 mb-2','data-bs-toggle'=>'modal','data-bs-target'=>'#'.$id,'data-club-step'=>$step] as $key=>$value)$trigger->setAttribute($key,$value);
$dialog=$doc->createElement('div'); $dialog=$doc->createElement('div');
foreach(['class'=>'modal fade club-action-modal','id'=>$id,'tabindex'=>'-1','aria-labelledby'=>$id.'-title','aria-hidden'=>'true'] as $key=>$value)$dialog->setAttribute($key,$value); foreach(['class'=>'modal fade club-action-modal','id'=>$id,'tabindex'=>'-1','aria-labelledby'=>$id.'-title','aria-hidden'=>'true'] as $key=>$value)$dialog->setAttribute($key,$value);
$layout=$dialog->appendChild($doc->createElement('div'));$layout->setAttribute('class','modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable'); $layout=$dialog->appendChild($doc->createElement('div'));$layout->setAttribute('class','modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable');
@@ -31,7 +32,7 @@ final class ClubFormModals {
$body=$content->appendChild($doc->createElement('div'));$body->setAttribute('class','modal-body'); $body=$content->appendChild($doc->createElement('div'));$body->setAttribute('class','modal-body');
// Keep the selected row visible in the dialog so financial/member actions are unambiguous. // Keep the selected row visible in the dialog so financial/member actions are unambiguous.
$row=$parent;while($row&&$row!==$main&&!in_array($row->nodeName,['tr','li'],true))$row=$row->parentNode; $row=$parent;while($row&&$row!==$main&&!in_array($row->nodeName,['tr','li'],true))$row=$row->parentNode;
if($row&&$row!==$main){ if($row&&$row!==$main&&!in_array($step,['note_edit','note_delete'],true)){
$text=$row->textContent; $text=$row->textContent;
if($row->nodeName==='tr')$text=implode(' · ',array_map(static fn($cell)=>trim($cell->textContent),array_slice(iterator_to_array($row->getElementsByTagName('td')),0,-1))); if($row->nodeName==='tr')$text=implode(' · ',array_map(static fn($cell)=>trim($cell->textContent),array_slice(iterator_to_array($row->getElementsByTagName('td')),0,-1)));
$context=$body->appendChild($doc->createElement('p'));$context->setAttribute('class','text-secondary');$context->appendChild($doc->createTextNode(trim(preg_replace('/\s+/u',' ',$text)))); $context=$body->appendChild($doc->createElement('p'));$context->setAttribute('class','text-secondary');$context->appendChild($doc->createTextNode(trim(preg_replace('/\s+/u',' ',$text))));
+49
View File
@@ -0,0 +1,49 @@
<?php
/** Private notes reference a club membership, never a global user account. */
class ClubMemberNotes extends RosterService {
public const TYPE='ClubMember';
public const LIMIT=2000;
public function listing(int $club,int $membership,int $page): array {
(new TenantAccess())->requireManager($club);$this->member($club,$membership);
return BoundedList::rows($this->connect(),'SELECT note_serial,note_text,note_origin,note_timestamp FROM notes WHERE note_type=? AND note_reference=? ORDER BY note_timestamp DESC,note_serial DESC',[self::TYPE,$membership],$page,'notes');
}
public function save(int $club,int $membership,int $id,string $text,string $version=''): int {
(new TenantAccess())->requireManager($club);
$this->requireTransactionalNotes();
$text=trim($text);
if(!mb_check_encoding($text,'UTF-8')||$text===''||mb_strlen($text,'UTF-8')>self::LIMIT)throw new AppException('Enter a note of up to 2,000 characters.');
return DatabaseTransaction::run($this->connect(),function(PDO $db)use($club,$membership,$id,$text,$version){
$this->club($club,$db,true);(new TenantAccess())->requireManager($club,$db);$this->member($club,$membership);
if($id){
$stmt=$db->prepare('SELECT note_text FROM notes WHERE note_serial=? AND note_type=? AND note_reference=? FOR UPDATE');$stmt->execute([$id,self::TYPE,$membership]);$old=$stmt->fetchColumn();
if($old===false)throw new AppException('Note not found in this membership.',404);
if(!hash_equals(hash('sha256',$old),$version))throw new AppException('This note changed since you opened it. Reload the summary before editing again.',409);
$stmt=$db->prepare('UPDATE notes SET note_text=?,note_origin=? WHERE note_serial=? AND note_type=? AND note_reference=?');$stmt->execute([$text,$_SESSION[self::USER_NAME]??'',$id,self::TYPE,$membership]);
}else{
$stmt=$db->prepare('INSERT INTO notes(note_type,note_reference,note_text,note_origin) VALUES(?,?,?,?)');$stmt->execute([self::TYPE,$membership,$text,$_SESSION[self::USER_NAME]??'']);$id=(int)$db->lastInsertId();
}
Audit::required($db,(int)$_SESSION[self::USER_SERIAL],'saved club member note '.json_encode(['club'=>$club,'membership'=>$membership,'note'=>$id]));
return $id;
});
}
public function delete(int $club,int $membership,int $id,string $version): void {
(new TenantAccess())->requireManager($club);$this->requireTransactionalNotes();
DatabaseTransaction::run($this->connect(),function(PDO $db)use($club,$membership,$id,$version){
$this->club($club,$db,true);(new TenantAccess())->requireManager($club,$db);$this->member($club,$membership);
$stmt=$db->prepare('SELECT note_text FROM notes WHERE note_serial=? AND note_type=? AND note_reference=? FOR UPDATE');$stmt->execute([$id,self::TYPE,$membership]);$old=$stmt->fetchColumn();
if($old===false)throw new AppException('Note not found in this membership.',404);
if(!hash_equals(hash('sha256',$old),$version))throw new AppException('This note changed since you opened it. Reload the summary before deleting it.',409);
$stmt=$db->prepare('DELETE FROM notes WHERE note_serial=? AND note_type=? AND note_reference=?');$stmt->execute([$id,self::TYPE,$membership]);
Audit::required($db,(int)$_SESSION[self::USER_SERIAL],'deleted club member note '.json_encode(['club'=>$club,'membership'=>$membership,'note'=>$id]));
});
}
private function requireTransactionalNotes(): void {
$engine=$this->connect()->query("SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='notes'")->fetchColumn();
if(strtoupper((string)$engine)!=='INNODB')throw new AppException('Administrative notes require transactional note storage. Contact support.',503);
}
}
+9 -6
View File
@@ -38,10 +38,12 @@ class LedgerService extends RosterService {
Audit::required($db,$this->actor(),'received payment '.json_encode(['payment'=>(int)$db->lastInsertId(),'club'=>$club,'due'=>$due,'amount_cents'=>$cents,'paid_on'=>$paid,'method'=>$method,'reference'=>$reference])); Audit::required($db,$this->actor(),'received payment '.json_encode(['payment'=>(int)$db->lastInsertId(),'club'=>$club,'due'=>$due,'amount_cents'=>$cents,'paid_on'=>$paid,'method'=>$method,'reference'=>$reference]));
}); });
} }
public function adjust(int $club,int $due,string $total,string $reason,string $key): void { public function adjust(int $club,int $due,string $total,string $reason,string $key,?int $membership=null): void {
$cents=self::cents($total);$reason=$this->reason($reason);$this->key($key); $cents=self::cents($total);$reason=$this->reason($reason);$this->key($key);
$this->transaction(function(PDO $db)use($club,$due,$cents,$reason,$key){ $this->transaction(function(PDO $db)use($club,$due,$cents,$reason,$key,$membership){
$row=$this->lockedDue($db,$club,$due);$stmt=$db->prepare('SELECT * FROM club_due_adjustments WHERE request_key=?');$stmt->execute([$key]);$existing=$stmt->fetch(); $row=$this->lockedDue($db,$club,$due);
if($membership!==null&&(int)$row['membership_serial']!==$membership)throw new AppException('Dues record does not belong to this membership.',404);
$stmt=$db->prepare('SELECT * FROM club_due_adjustments WHERE request_key=?');$stmt->execute([$key]);$existing=$stmt->fetch();
if($existing){if((int)$existing['due_serial']!==$due||(int)$existing['corrected_amount_cents']!==$cents||$existing['reason']!==$reason)throw new AppException('This request key was already used for different correction details.',409);return;} if($existing){if((int)$existing['due_serial']!==$due||(int)$existing['corrected_amount_cents']!==$cents||$existing['reason']!==$reason)throw new AppException('This request key was already used for different correction details.',409);return;}
if($cents<$row['paid'])throw new AppException('Corrected dues cannot be below received payments. Reverse an incorrect payment first.',409); if($cents<$row['paid'])throw new AppException('Corrected dues cannot be below received payments. Reverse an incorrect payment first.',409);
if($cents===$row['effective'])throw new AppException('The corrected amount must differ from the current dues.',409); if($cents===$row['effective'])throw new AppException('The corrected amount must differ from the current dues.',409);
@@ -49,11 +51,12 @@ class LedgerService extends RosterService {
Audit::required($db,$this->actor(),'corrected dues '.json_encode(['adjustment'=>(int)$db->lastInsertId(),'club'=>$club,'due'=>$due,'before_cents'=>$row['effective'],'after_cents'=>$cents,'reason'=>$reason])); Audit::required($db,$this->actor(),'corrected dues '.json_encode(['adjustment'=>(int)$db->lastInsertId(),'club'=>$club,'due'=>$due,'before_cents'=>$row['effective'],'after_cents'=>$cents,'reason'=>$reason]));
}); });
} }
public function reverse(int $club,int $payment,string $reason,string $key): void { public function reverse(int $club,int $payment,string $reason,string $key,?int $membership=null): void {
$reason=$this->reason($reason);$this->key($key); $reason=$this->reason($reason);$this->key($key);
$this->transaction(function(PDO $db)use($club,$payment,$reason,$key){ $this->transaction(function(PDO $db)use($club,$payment,$reason,$key,$membership){
$this->club($club,$db,true);$stmt=$db->prepare('SELECT p.* FROM club_payments p JOIN club_dues d ON d.due_serial=p.due_serial WHERE p.payment_serial=? AND d.club_serial=?');$stmt->execute([$payment,$club]);$row=$stmt->fetch();if(!$row)throw new AppException('Payment not found in this club.',404); $this->club($club,$db,true);$stmt=$db->prepare('SELECT p.* FROM club_payments p JOIN club_dues d ON d.due_serial=p.due_serial WHERE p.payment_serial=? AND d.club_serial=?');$stmt->execute([$payment,$club]);$row=$stmt->fetch();if(!$row)throw new AppException('Payment not found in this club.',404);
$this->lockedDue($db,$club,(int)$row['due_serial']); $dueRow=$this->lockedDue($db,$club,(int)$row['due_serial']);
if($membership!==null&&(int)$dueRow['membership_serial']!==$membership)throw new AppException('Payment does not belong to this membership.',404);
$stmt=$db->prepare('SELECT * FROM club_payment_reversals WHERE request_key=? OR payment_serial=?');$stmt->execute([$key,$payment]);$existing=$stmt->fetch(); $stmt=$db->prepare('SELECT * FROM club_payment_reversals WHERE request_key=? OR payment_serial=?');$stmt->execute([$key,$payment]);$existing=$stmt->fetch();
if($existing){if($existing['request_key']===$key&&(int)$existing['payment_serial']===$payment&&$existing['reason']===$reason)return;throw new AppException('Payment is already reversed or this request key was already used.',409);} if($existing){if($existing['request_key']===$key&&(int)$existing['payment_serial']===$payment&&$existing['reason']===$reason)return;throw new AppException('Payment is already reversed or this request key was already used.',409);}
$stmt=$db->prepare('INSERT INTO club_payment_reversals(payment_serial,reason,recorded_by,request_key) VALUES(?,?,?,?)');$stmt->execute([$payment,$reason,$this->actor(),$key]); $stmt=$db->prepare('INSERT INTO club_payment_reversals(payment_serial,reason,recorded_by,request_key) VALUES(?,?,?,?)');$stmt->execute([$payment,$reason,$this->actor(),$key]);
+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 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 static function money(int $cents): string {return '$'.number_format($cents/100,2);}
private function form(string $step,int $club,int $membership): string { 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.'">'; 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 { 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>'; 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 { public function show(): void {
$club=MaintenanceService::id($_GET['club_serial']??$_POST['club_serial']??null); $club=MaintenanceService::id($_GET['club_serial']??$_POST['club_serial']??null);
$membership=MaintenanceService::id($_GET['membership_serial']??$_POST['membership_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 '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 '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 '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.'); default:throw new AppException('Unknown member action.');
} }
$_SESSION['roster_notice']='Changes saved.'; $_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;} 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; header('Location: '.$redirect,true,303);return;
} }
@@ -59,6 +76,19 @@ class MemberSummary extends Base {
} }
$html.='</nav>'; $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">'; $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) 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 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){ if($manage&&$active){
$html.='<td>'; $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>'; 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.='</td>';
} }
$html.='</tr>'; $html.='</tr>';
} }
if(!$dues)$html.='<tr><td colspan="'.($manage&&$active?5:4).'">No dues recorded for this membership.</td></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){ foreach($history['payments'] as $payment){
$status=$payment['reversal_serial']?'Reversed: '.$payment['reversal_reason'].' ('.$payment['reversed_at'].' UTC)':'Received'; $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>';
} }
if(!$history['payments'])$html.='<tr><td colspan="5">No payments recorded for this membership.</td></tr>'; $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>'; $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>'; 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>'; if(!$history['adjustments'])$html.='<tr><td colspan="4">No dues corrections recorded for this membership.</td></tr>';
+1 -1
View File
@@ -15,7 +15,7 @@ class Notes extends Base {
$this->displayContent($this->applyXSL($note,$this->getXSL('editNote'))); $this->displayContent($this->applyXSL($note,$this->getXSL('editNote')));
} }
public function deleteNote(){ public function deleteNote(){
$stmt=$this->connect()->prepare('DELETE FROM notes WHERE note_serial=?');$stmt->execute([MaintenanceService::id($_POST['note_serial']??null)]);if(!$stmt->rowCount())throw new AppException('Note not found.',404); $stmt=$this->connect()->prepare("DELETE FROM notes WHERE note_serial=? AND note_type='User'");$stmt->execute([MaintenanceService::id($_POST['note_serial']??null)]);if(!$stmt->rowCount())throw new AppException('Note not found.',404);
} }
public function write(int $id,string $type,int $reference,string $text): int { public function write(int $id,string $type,int $reference,string $text): int {
$text=trim($text);if(!in_array($type,self::VALID_TYPES,true)||!$text||strlen($text)>65535)throw new AppException('Enter a valid user note (up to 65,535 bytes).'); $text=trim($text);if(!in_array($type,self::VALID_TYPES,true)||!$text||strlen($text)>65535)throw new AppException('Enter a valid user note (up to 65,535 bytes).');
+9 -1
View File
@@ -26,7 +26,15 @@ document.querySelectorAll('.club-action-modal').forEach(modal => {
}); });
const result = await response.json(); const result = await response.json();
if (!response.ok || !result.ok) throw new Error(result.error || 'Your change could not be saved.'); if (!response.ok || !result.ok) throw new Error(result.error || 'Your change could not be saved.');
window.location.assign(result.redirect); const target = new URL(result.redirect, window.location.href);
if (target.origin !== window.location.origin) throw new Error('The return address could not be verified. Reload this page.');
if (target.pathname === window.location.pathname && target.search === window.location.search) {
// A fragment-only navigation would leave the saved dialog and stale content open.
window.history.replaceState(null, '', target.href);
window.location.reload();
} else {
window.location.assign(target.href);
}
} catch (failure) { } catch (failure) {
error.textContent = failure instanceof SyntaxError || failure instanceof TypeError error.textContent = failure instanceof SyntaxError || failure instanceof TypeError
? 'Your change could not be confirmed. Reload the club page before retrying.' ? 'Your change could not be confirmed. Reload the club page before retrying.'
+1 -1
View File
@@ -19,7 +19,7 @@ try:
names=archive.namelist();manifest=json.loads(archive.read('release-manifest.json')) names=archive.namelist();manifest=json.loads(archive.read('release-manifest.json'))
check(not any(name.startswith(('.tools/','.git/','tests/','nbproject/')) or name.endswith('settings.local.xml') for name in names),'Release excludes local credentials, backups, Git metadata, and tests') check(not any(name.startswith(('.tools/','.git/','tests/','nbproject/')) or name.endswith('settings.local.xml') for name in names),'Release excludes local credentials, backups, Git metadata, and tests')
check(all(name in names for name in ['classes/LedgerService.php','classes/EmailVerification.php','bin/migrate-production.php','migrations/003_production.sql','js/jquery-3.7.1.min.js']),'Release includes new classes, migration, and updated jQuery') check(all(name in names for name in ['classes/LedgerService.php','classes/EmailVerification.php','bin/migrate-production.php','migrations/003_production.sql','js/jquery-3.7.1.min.js']),'Release includes new classes, migration, and updated jQuery')
check(all(name in names for name in ['classes/MemberSummary.php','classes/ClubFormModals.php','js/club-modals.js','classes/MfaSecurityNotifications.php','classes/MfaSetupQr.php','classes/MfaService.php','classes/MfaClock.php','classes/BoundedList.php','classes/ClubResources.php','bin/migrate-security-capacity.php','migrations/005_security_capacity.sql','classes/DatabaseTransaction.php','js/tickets.js','classes/TenantAccess.php','classes/ClubInvitations.php','classes/ClubTickets.php','bin/migrate-tenants.php','migrations/004_tenants.sql','classes/MaintenanceValidation.php','classes/BackupValidation.php','js/rich-text.js','js/suneditor-3.3.3.min.js','css/suneditor-3.3.3.min.css','THIRD-PARTY-NOTICES.md','browser-dependencies.json']) and 'js/ckeditor.js' not in names,'Release includes replacement editor, license, integrity metadata, and new validators') check(all(name in names for name in ['classes/ClubMemberNotes.php','classes/MemberSummary.php','classes/ClubFormModals.php','js/club-modals.js','classes/MfaSecurityNotifications.php','classes/MfaSetupQr.php','classes/MfaService.php','classes/MfaClock.php','classes/BoundedList.php','classes/ClubResources.php','bin/migrate-security-capacity.php','migrations/005_security_capacity.sql','classes/DatabaseTransaction.php','js/tickets.js','classes/TenantAccess.php','classes/ClubInvitations.php','classes/ClubTickets.php','bin/migrate-tenants.php','migrations/004_tenants.sql','classes/MaintenanceValidation.php','classes/BackupValidation.php','js/rich-text.js','js/suneditor-3.3.3.min.js','css/suneditor-3.3.3.min.css','THIRD-PARTY-NOTICES.md','browser-dependencies.json']) and 'js/ckeditor.js' not in names,'Release includes replacement editor, license, integrity metadata, and new validators')
check(all(hashlib.sha256(archive.read(name)).hexdigest()==digest for name,digest in manifest.items()),'Release manifest verifies every packaged file') check(all(hashlib.sha256(archive.read(name)).hexdigest()==digest for name,digest in manifest.items()),'Release manifest verifies every packaged file')
binary=os.environ.get('HCR_DUMP_BINARY') or shutil.which('mariadb-dump') binary=os.environ.get('HCR_DUMP_BINARY') or shutil.which('mariadb-dump')
if not binary: if not binary:
+20
View File
@@ -50,10 +50,30 @@ try:
check(save('member',a,ids['clubA_admina2'],membership_status='inactive',membership_role='member',joined_on='2026-02-01')[0]==403,'Club administrator cannot edit protected administrator memberships') check(save('member',a,ids['clubA_admina2'],membership_status='inactive',membership_role='member',joined_on='2026-02-01')[0]==403,'Club administrator cannot edit protected administrator memberships')
sid=next(c.value for h in app.client.handlers if hasattr(h,'cookiejar') for c in h.cookiejar if c.name=='HUNTINGCLUBROSTER');app.fixture('sessionAge',sid,'','','301') sid=next(c.value for h in app.client.handlers if hasattr(h,'cookiejar') for c in h.cookiejar if c.name=='HUNTINGCLUBROSTER');app.fixture('sessionAge',sid,'','','301')
check(save('member',a,member,membership_status='active',membership_role='member',joined_on='2026-03-01')[0]==403,'Membership edits require recent MFA on the new endpoint') check(save('member',a,member,membership_status='active',membership_role='member',joined_on='2026-03-01')[0]==403,'Membership edits require recent MFA on the new endpoint')
payment=app.fixture('inspect')['club_payments'][0]['payment_serial']
check(save('adjust',a,member,due_serial=neighbor_due,corrected_amount='80.00',reason='Wrong member',request_key=secrets.token_hex(32))[0]==404,'Correction rejects another member dues in the same club')
check(save('adjust',a,member,due_serial=foreign_due,corrected_amount='80.00',reason='Wrong club',request_key=secrets.token_hex(32))[0]==404,'Correction rejects another club dues')
check(save('adjust',a,member,due_serial=due,corrected_amount='24.00',reason='Below paid',request_key=secrets.token_hex(32))[0]==409,'Correction cannot reduce dues below received payments')
adjustment_key=secrets.token_hex(32)
status,result=save('adjust',a,member,due_serial=due,corrected_amount='80.00',reason='Corrected season rate',request_key=adjustment_key)
check(status==200 and 'Roster.memberSummary' in result['redirect'],'Correction saves and returns to the selected member summary')
check(save('adjust',a,member,due_serial=due,corrected_amount='80.00',reason='Corrected season rate',request_key=adjustment_key)[0]==200 and len(app.fixture('inspect')['club_due_adjustments'])==1,'Correction retries create only one adjustment')
check(save('reverse',a,neighbor,payment_serial=payment,reason='Wrong member',request_key=secrets.token_hex(32))[0]==404,'Reversal rejects another member payment in the same club')
app.fixture('mfaTestAuditFailure','on')
failed,_=save('reverse',a,member,payment_serial=payment,reason='Audit failure',request_key=secrets.token_hex(32))
app.fixture('mfaTestAuditFailure','off')
check(failed==500 and len(app.fixture('inspect')['club_payment_reversals'])==0,'Reversal rolls back completely when mandatory audit fails')
reversal_key=secrets.token_hex(32)
check(save('reverse',a,member,payment_serial=payment,reason='Wrong receipt',request_key=reversal_key)[0]==200,'Payment reverses from the member summary')
check(save('reverse',a,member,payment_serial=payment,reason='Wrong receipt',request_key=reversal_key)[0]==200 and len(app.fixture('inspect')['club_payment_reversals'])==1,'Reversal retries create only one reversal')
check(save('reverse',a,member,payment_serial=payment,reason='Repeat',request_key=secrets.token_hex(32))[0]==409,'Already reversed payment cannot be reversed again')
_,summary,_=app.request(url(a,member));check(b'$80.00' in summary and b'$0.00' in summary and b'Wrong receipt' in summary and b'Corrected season rate' in summary,'Summary reflects corrected dues, reversed payment and preserved reasons')
app.logout();app.login('membera@example.test','Tenant test password 123!') app.logout();app.login('membera@example.test','Tenant test password 123!')
_,body,_=app.request(url(a,member));check(b'club-action-modal' not in body,'Ordinary member receives no action dialogs') _,body,_=app.request(url(a,member));check(b'club-action-modal' not in body,'Ordinary member receives no action dialogs')
check(save('payment',a,member,due_serial=due,amount='1.00',paid_on='2026-01-01',request_key=secrets.token_hex(32))[0]==403,'Ordinary member cannot forge a summary payment request') check(save('payment',a,member,due_serial=due,amount='1.00',paid_on='2026-01-01',request_key=secrets.token_hex(32))[0]==403,'Ordinary member cannot forge a summary payment request')
check(save('charge',a,member,season_serial=season,amount='1.00')[0]==403,'Ordinary member cannot forge a dues request') check(save('charge',a,member,season_serial=season,amount='1.00')[0]==403,'Ordinary member cannot forge a dues request')
check(save('adjust',a,member,due_serial=due,corrected_amount='50.00',reason='Forged',request_key=secrets.token_hex(32))[0]==403,'Ordinary member cannot forge a dues correction')
check(save('reverse',a,member,payment_serial=payment,reason='Forged',request_key=secrets.token_hex(32))[0]==403,'Ordinary member cannot forge a payment reversal')
app.logout();app.login('adminb@example.test','Tenant test password 123!') app.logout();app.login('adminb@example.test','Tenant test password 123!')
check(save('member',a,member,membership_status='active',membership_role='member',joined_on='2026-03-01')[0]==403,'Other club administrator cannot modify the membership') check(save('member',a,member,membership_status='active',membership_role='member',joined_on='2026-03-01')[0]==403,'Other club administrator cannot modify the membership')
app.logout();app.login();app.post('Roster.workspace',step='club',club_serial=a,club_name='Tenant Alpha',club_status='archived') app.logout();app.login();app.post('Roster.workspace',step='club',club_serial=a,club_name='Tenant Alpha',club_status='archived')
+64
View File
@@ -0,0 +1,64 @@
import hashlib,json,urllib.request,urllib.parse,urllib.error
from application_harness import Application
from lxml import html
app=Application();checks=0
def check(ok,message):
global checks
assert ok,message
checks+=1;print('PASS:',message)
def url(club,member,extra=''):return '/main.php?Xaction=Roster.memberSummary&club_serial='+str(club)+'&membership_serial='+str(member)+extra
def save(step,club,member,**extra):
data={'action':'Roster.memberSummary','csrf_token':app.token,'step':step,'club_serial':club,'membership_serial':member,**extra}
req=urllib.request.Request(app.base+url(club,member),data=urllib.parse.urlencode(data).encode(),headers={'X-Requested-With':'XMLHttpRequest','X-HCR-Modal':'1'})
try:
with app.client.open(req) as r:return r.status,json.loads(r.read())
except urllib.error.HTTPError as e:
body=e.read()
try:result=json.loads(body)
except json.JSONDecodeError:result={'error':body.decode(errors='replace')}
return e.code,result
def notes(member):return [n for n in app.fixture('inspect')['notes'] if n['note_type']=='ClubMember' and n['note_reference']==member]
def version(text):return hashlib.sha256(text.encode()).hexdigest()
try:
ids=app.fixture('tenantSeed');a=ids['clubA'];b=ids['clubB'];member=ids['clubA_membera'];shared=ids['clubA_shared']
app.login();app.post('Notes.addNote',step='add',note_type='User',note_reference=ids['shared'],note_text='GLOBAL NOTE')
global_note=next(n for n in app.fixture('inspect')['notes'] if n['note_type']=='User')
app.logout();app.login('admina@example.test','Tenant test password 123!')
text='Private deletion test <script>neverRun()</script>'
save('note_add',a,member,note_text=text);note=notes(member)[0];nid=note['note_serial']
def delete(club=a,membership=member,**extra):return save('note_delete',club,membership,**{'note_serial':nid,'note_version':version(text),**extra})
_,page,_=app.request(url(a,member));doc=html.fromstring(page)
forms=doc.xpath('//form[input[@name="step" and @value="note_delete"]]')
check(len(forms)==1 and forms[0].xpath('.//button[text()="Confirm deletion"]') and b'&lt;script&gt;' in page,'Deletion has a separate confirmation form with escaped note preview')
check(delete(csrf_token='invalid')[0]==403,'Deletion requires valid CSRF')
check(delete(membership=shared)[0]==404,'Deletion is scoped to the selected membership')
check(delete(note_serial=global_note['note_serial'])[0]==404,'Club deletion cannot remove a global user note')
check(delete(note_version='')[0]==409,'Deletion requires the displayed note version')
save('note_edit',a,member,note_serial=nid,note_text='Changed by another administrator',note_version=version(text))
check(delete()[0]==409 and len(notes(member))==1,'Stale confirmation cannot delete an edited note')
text='Changed by another administrator'
app.fixture('mfaTestAuditFailure','on');before=app.fixture('inspect')['notes']
status,_=delete();app.fixture('mfaTestAuditFailure','off')
check(status==500 and before==app.fixture('inspect')['notes'],'Audit failure rolls back deletion')
app.logout();app.login('adminb@example.test','Tenant test password 123!')
check(delete()[0]==403,'Another club administrator cannot delete the note')
app.logout();app.login('membera@example.test','Tenant test password 123!')
check(delete()[0]==403,'Ordinary member cannot forge a deletion')
check(app.post('Notes.deleteNote',note_serial=nid)[0]==403,'Member cannot use the legacy global deletion route')
app.logout();app.login('admina2@example.test','Tenant test password 123!')
status,result=delete()
check(status==200 and not notes(member) and '#admin-notes' in result['redirect'],'Shared club administrator can delete another administrator note and return to notes')
events=[j for j in app.fixture('inspect')['journal'] if 'deleted club member note ' in j['journal_entry']]
check(len(events)==1 and str(events[0]['journal_origin'])==str(ids['admina2']) and all(('"'+key+'":'+str(value)) in events[0]['journal_entry'] for key,value in [('club',a),('membership',member),('note',nid)]) and events[0]['journal_timestamp'],'Audit retains actor, timestamp, club, membership and note identifiers')
check(text not in events[0]['journal_entry'],'Audit does not expose private note text')
check(delete()[0]==404 and len([j for j in app.fixture('inspect')['journal'] if 'deleted club member note ' in j['journal_entry']])==1,'Repeated deletion returns not found without duplicate audit')
save('note_add',a,member,note_text=text);nid=notes(member)[0]['note_serial']
app.logout();app.login()
check(app.post('Notes.deleteNote',note_serial=nid)[0]==404 and len(notes(member))==1,'Even Super Administrator cannot bypass club deletion audit through the global route')
check(delete()[0]==200 and not notes(member),'Super Administrator can delete club administrative notes')
save('note_add',a,member,note_text=text);nid=notes(member)[0]['note_serial']
app.post('Roster.workspace',step='club',club_serial=a,club_name='Tenant Alpha',club_status='archived')
check(delete()[0]==409 and len(notes(member))==1,'Archived club deletion is rejected')
print(checks,'member note deletion HTTP checks passed.')
finally:
app.fixture('mfaTestAuditFailure','off');app.close()
+76
View File
@@ -0,0 +1,76 @@
import hashlib,json,urllib.request,urllib.parse,urllib.error
from application_harness import Application
from lxml import html
app=Application();checks=0
def check(ok,message):
global checks
assert ok,message
checks+=1;print('PASS:',message)
def url(club,member,extra=''):return '/main.php?Xaction=Roster.memberSummary&club_serial='+str(club)+'&membership_serial='+str(member)+extra
def save(step,club,member,**extra):
data={'action':'Roster.memberSummary','csrf_token':app.token,'step':step,'club_serial':club,'membership_serial':member,**extra}
req=urllib.request.Request(app.base+url(club,member),data=urllib.parse.urlencode(data).encode(),headers={'X-Requested-With':'XMLHttpRequest','X-HCR-Modal':'1'})
try:
with app.client.open(req) as r:return r.status,json.loads(r.read())
except urllib.error.HTTPError as e:
body=e.read()
try:result=json.loads(body)
except json.JSONDecodeError:result={'error':body.decode(errors='replace')}
return e.code,result
def notes(member):return [n for n in app.fixture('inspect')['notes'] if n['note_type']=='ClubMember' and n['note_reference']==member]
def version(text):return hashlib.sha256(text.encode()).hexdigest()
try:
ids=app.fixture('tenantSeed');a=ids['clubA'];b=ids['clubB'];member=ids['clubA_membera'];sharedA=ids['clubA_shared'];sharedB=ids['clubB_shared']
app.login();app.post('Notes.addNote',step='add',note_type='User',note_reference=ids['shared'],note_text='GLOBAL ACCOUNT NOTE')
global_note=next(n for n in app.fixture('inspect')['notes'] if n['note_type']=='User')
app.logout();app.login('admina@example.test','Tenant test password 123!')
text='PRIVATE MEMBER NOTE <script>alert(1)</script>\nSecond line'
status,result=save('note_add',a,member,note_text=text,note_type='User',note_reference=ids['shared'])
note=notes(member)[0]
check(status==200 and '#admin-notes' in result['redirect'],'Club administrator adds a note and returns to the member notes section')
check(note['note_text']==text and note['note_type']=='ClubMember' and note['note_reference']==member,'Note uses the selected membership and fixed private type despite forged fields')
_,body,_=app.request(url(a,member));check(b'&lt;script&gt;alert(1)&lt;/script&gt;' in body and b'<script>alert(1)</script>' not in body,'Private notes render as escaped plain text')
_,other,_=app.request(url(a,sharedA));check(b'PRIVATE MEMBER NOTE' not in other,'Notes do not leak into another member summary')
check(save('note_add',a,member,note_text='')[0]==422,'Empty administrative notes are rejected')
check(save('note_add',a,member,note_text='x'*2001)[0]==422,'Oversized administrative notes are rejected')
check(save('note_add',a,member,note_text='x',csrf_token='invalid')[0]==403,'Note writes require CSRF')
check(save('note_edit',a,sharedA,note_serial=note['note_serial'],note_text='Wrong member',note_version=version(text))[0]==404,'Note IDs cannot be applied to a different membership')
check(save('note_edit',a,member,note_serial=global_note['note_serial'],note_text='Wrong type',note_version=version('GLOBAL ACCOUNT NOTE'))[0]==404,'Global account notes cannot be edited through the club notes API')
app.logout();app.login('admina2@example.test','Tenant test password 123!')
_,body,_=app.request(url(a,member));check(b'PRIVATE MEMBER NOTE' in body,'Another administrator of the shared club can read its notes')
check(save('note_edit',a,member,note_serial=note['note_serial'],note_text='EDITED PRIVATE NOTE',note_version=version(text))[0]==200,'Another club administrator can edit its note')
check(save('note_edit',a,member,note_serial=note['note_serial'],note_text='Stale overwrite',note_version=version(text))[0]==409 and notes(member)[0]['note_text']=='EDITED PRIVATE NOTE','Stale edits cannot overwrite a newer note')
before=app.fixture('inspect')['notes'];app.fixture('mfaTestAuditFailure','on')
failed_add,_=save('note_add',a,member,note_text='Must roll back')
failed_edit,_=save('note_edit',a,member,note_serial=note['note_serial'],note_text='Must also roll back',note_version=version('EDITED PRIVATE NOTE'))
app.fixture('mfaTestAuditFailure','off')
check(failed_add==500 and failed_edit==500 and app.fixture('inspect')['notes']==before,'Mandatory audit failures roll back both note additions and edits')
save('note_add',a,sharedA,note_text='ALPHA CLUB PRIVATE')
app.logout();app.login('adminb@example.test','Tenant test password 123!')
check(app.request(url(a,member))[0]==403,'Other club administrator cannot open the protected member summary')
check(save('note_edit',a,member,note_serial=note['note_serial'],note_text='Foreign overwrite',note_version=version('EDITED PRIVATE NOTE'))[0]==403,'Other club administrator cannot edit the note')
save('note_add',b,sharedB,note_text='BETA CLUB PRIVATE')
app.logout();app.login('admina@example.test','Tenant test password 123!')
check(app.request(url(b,sharedB))[0]==404,'An administrator with only member access in another club cannot read that club member notes')
app.logout();app.login('membera@example.test','Tenant test password 123!')
_,body,_=app.request(url(a,member));check(b'EDITED PRIVATE NOTE' not in body and b'Administrative notes' not in body and b'note_serial' not in body,'Member receives no private note content, section, identifiers or edit forms')
check(save('note_add',a,member,note_text='Forged')[0]==403 and save('note_edit',a,member,note_serial=note['note_serial'],note_text='Forged',note_version=version('EDITED PRIVATE NOTE'))[0]==403,'Member cannot forge add or edit note requests')
check(app.post('Notes.editNote',note_serial=note['note_serial'])[0]==403,'Legacy global note endpoints cannot bypass club-note permissions')
app.logout();app.login('shared@example.test','Tenant test password 123!')
_,alpha,_=app.request(url(a,sharedA));_,beta,_=app.request(url(b,sharedB))
check(b'ALPHA CLUB PRIVATE' not in alpha and b'BETA CLUB PRIVATE' not in beta and b'Administrative notes' not in alpha and b'Administrative notes' not in beta,'A member in multiple clubs cannot see administrative notes in either club')
app.logout();app.login()
_,alpha,_=app.request(url(a,sharedA));_,beta,_=app.request(url(b,sharedB))
check(b'ALPHA CLUB PRIVATE' in alpha and b'BETA CLUB PRIVATE' not in alpha and b'BETA CLUB PRIVATE' in beta and b'ALPHA CLUB PRIVATE' not in beta,'Super Administrator can read separate club-specific notes for the same person')
check(b'GLOBAL ACCOUNT NOTE' not in alpha and b'GLOBAL ACCOUNT NOTE' not in beta,'Global account notes remain separate from club administrative notes')
_,account,_=app.post('Users.userSummary',user_serial=ids['shared']);check(b'GLOBAL ACCOUNT NOTE' in account and b'ALPHA CLUB PRIVATE' not in account and b'BETA CLUB PRIVATE' not in account,'Global user summary does not mix in club membership notes')
check(save('note_add',a,member,note_text='😀'*2000)[0]==200,'Server accepts the full Unicode note limit within its transport budget')
for index in range(26):save('note_add',a,member,note_text='Paged admin note '+str(index))
_,body,_=app.request(url(a,member,'&notes_page=9999'));rows=html.fromstring(body).xpath('//section[contains(@class,"member-admin-notes")]//tbody/tr')
check(len(rows)==len(notes(member))-25 and b'Page 2 of 2' in body,'Administrative notes use bounded, clamped membership-specific paging')
app.post('Roster.workspace',step='club',club_serial=a,club_name='Tenant Alpha',club_status='archived')
_,body,_=app.request(url(a,member));check(b'Administrative notes' in body and b'data-club-step="note_' not in body,'Archived club notes remain readable to administrators without write dialogs')
check(save('note_add',a,member,note_text='Archived write')[0]==409,'Archived club note writes are rejected')
print(checks,'member note HTTP checks passed.')
finally:
app.fixture('mfaTestAuditFailure','off');app.close()
+3 -2
View File
@@ -1,4 +1,5 @@
import re import re
from lxml import html
from application_harness import Application from application_harness import Application
app=Application();checks=0 app=Application();checks=0
def check(ok,message): def check(ok,message):
@@ -21,11 +22,11 @@ try:
check(b'Own reversed receipt' in body and b'OWN-RECEIPT-32' in body,'Payment history preserves reversed receipts and their reasons') check(b'Own reversed receipt' in body and b'OWN-RECEIPT-32' in body,'Payment history preserves reversed receipts and their reasons')
check(b'&lt;script&gt;unsafe&lt;/script&gt;' in body and b'<script>unsafe</script>' not in body,'Correction reasons are escaped safely') check(b'&lt;script&gt;unsafe&lt;/script&gt;' in body and b'<script>unsafe</script>' not in body,'Correction reasons are escaped safely')
check(b'NEIGHBOR-PRIVATE' not in body and b'FOREIGN-PRIVATE' not in body,'Selected member summary excludes neighboring members and other clubs') check(b'NEIGHBOR-PRIVATE' not in body and b'FOREIGN-PRIVATE' not in body,'Selected member summary excludes neighboring members and other clubs')
check(body.count(b'OWN-RECEIPT-')==25 and b'31 records.' in body and b'32 records.' in body,'Member dues and payment histories are bounded and counted independently') check(html.fromstring(body).xpath('//h2[normalize-space(.)="Payment history"]/following-sibling::div[1]//table')[0].text_content().count('OWN-RECEIPT-')==25 and b'31 records.' in body and b'32 records.' in body,'Member dues and payment histories are bounded and counted independently')
links=re.findall(rb'href="([^"]+)"',body) links=re.findall(rb'href="([^"]+)"',body)
check(any(b'payments_page=2' in link and b'membership_serial='+str(ids['clubA_membera']).encode() in link and b'Roster.memberSummary' in link for link in links),'History pager links retain both club and membership scope') check(any(b'payments_page=2' in link and b'membership_serial='+str(ids['clubA_membera']).encode() in link and b'Roster.memberSummary' in link for link in links),'History pager links retain both club and membership scope')
status,last,_=summary(ids['clubA'],ids['clubA_membera'],'&payments_page=9999&dues_page=9999') status,last,_=summary(ids['clubA'],ids['clubA_membera'],'&payments_page=9999&dues_page=9999')
check(status==200 and last.count(b'OWN-RECEIPT-')==7 and b'Page 2 of 2' in last,'Final history pages are clamped and show only remaining records') check(status==200 and html.fromstring(last).xpath('//h2[normalize-space(.)="Payment history"]/following-sibling::div[1]//table')[0].text_content().count('OWN-RECEIPT-')==7 and b'Page 2 of 2' in last,'Final history pages are clamped and show only remaining records')
check(b'$3,150.00' in last and b'$31.00' in last and b'$3,119.00' in last,'Summary totals remain identical on later history pages') check(b'$3,150.00' in last and b'$31.00' in last and b'$3,119.00' in last,'Summary totals remain identical on later history pages')
check(summary(ids['clubB'],ids['clubA_membera'])[0]==404,'Membership IDs cannot be paired with another club') check(summary(ids['clubB'],ids['clubA_membera'])[0]==404,'Membership IDs cannot be paired with another club')
check(summary(ids['clubA'],'invalid')[0]==422,'Invalid membership identifiers are rejected') check(summary(ids['clubA'],'invalid')[0]==422,'Invalid membership identifiers are rejected')
+1 -1
View File
@@ -3,5 +3,5 @@ from pathlib import Path
ROOT=Path(__file__).resolve().parents[1] ROOT=Path(__file__).resolve().parents[1]
php=json.loads(os.environ.get('PHP_TEST_COMMAND','["php"]')) php=json.loads(os.environ.get('PHP_TEST_COMMAND','["php"]'))
if not os.environ.get('HCR_TEST_DSN'):raise SystemExit('Configure an isolated HCR_TEST_DSN before running integration tests.') if not os.environ.get('HCR_TEST_DSN'):raise SystemExit('Configure an isolated HCR_TEST_DSN before running integration tests.')
for suite in ['tests/source_checks.py','tests/mfa_key_configuration.php','tests/mfa_qr_browser_runner.py','tests/mfa_lifecycle_http.py','tests/mfa_notifications_runner.py','tests/mfa_concurrency.py','tests/auth_integration.php','tests/maintenance_unit.php','tests/auth_http.py','tests/club_onboarding_http.py','tests/member_summary_http.py','tests/member_actions_http.py','tests/project_http.py','tests/maintenance_http.py','tests/production_runner.py','tests/concurrency.py','tests/tenants_migration.py','tests/tenant_repairs_runner.py','tests/tenant_repair_concurrency.py','tests/ticket_repairs_http.py','tests/ticket_paging_http.py','tests/tenants_runner.py','tests/tenants_http.py','tests/browser_runner.py','tests/tenants_browser_runner.py','tests/ticket_repairs_browser_runner.py','tests/backup_runner.py','tests/deployment_tools.py','tests/security_capacity_http.py','tests/security_capacity_runner.py','tests/resource_http.py','tests/resource_concurrency.py']: for suite in ['tests/source_checks.py','tests/mfa_key_configuration.php','tests/mfa_qr_browser_runner.py','tests/mfa_lifecycle_http.py','tests/mfa_notifications_runner.py','tests/mfa_concurrency.py','tests/auth_integration.php','tests/maintenance_unit.php','tests/auth_http.py','tests/club_onboarding_http.py','tests/member_summary_http.py','tests/member_actions_http.py','tests/member_notes_http.py','tests/member_note_deletion_http.py','tests/project_http.py','tests/maintenance_http.py','tests/production_runner.py','tests/concurrency.py','tests/tenants_migration.py','tests/tenant_repairs_runner.py','tests/tenant_repair_concurrency.py','tests/ticket_repairs_http.py','tests/ticket_paging_http.py','tests/tenants_runner.py','tests/tenants_http.py','tests/browser_runner.py','tests/tenants_browser_runner.py','tests/ticket_repairs_browser_runner.py','tests/backup_runner.py','tests/deployment_tools.py','tests/security_capacity_http.py','tests/security_capacity_runner.py','tests/resource_http.py','tests/resource_concurrency.py']:
subprocess.run((php if suite.endswith('.php') else [sys.executable])+[suite],cwd=ROOT,check=True) subprocess.run((php if suite.endswith('.php') else [sys.executable])+[suite],cwd=ROOT,check=True)
+10 -1
View File
@@ -31,7 +31,16 @@ const assert=require('node:assert/strict');
if(process.env.HCR_UI_SCREENSHOT)await admin.screenshot({path:process.env.HCR_UI_SCREENSHOT.replace('.png','-member.png')});await admin.setViewportSize({width:390,height:844});assert(await admin.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth));pass('Member summary totals and history fit phone-width screens');await admin.setViewportSize({width:1280,height:720}); if(process.env.HCR_UI_SCREENSHOT)await admin.screenshot({path:process.env.HCR_UI_SCREENSHOT.replace('.png','-member.png')});await admin.setViewportSize({width:390,height:844});assert(await admin.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth));pass('Member summary totals and history fit phone-width screens');await admin.setViewportSize({width:1280,height:720});
await admin.locator('nav[aria-label="Member actions"] [data-club-step=member]').click();await admin.locator('.club-action-modal.show').getByLabel('Joined on',{exact:true}).fill('2026-02-01');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save membership',exact:true}).click()]);assert((await admin.locator('.memberSummary header').textContent()).includes('2026-02-01'));assert(new URL(admin.url()).searchParams.get('membership_serial')===String(ids.clubA_membera));pass('Administrator edits membership in a modal and remains on the same member summary'); await admin.locator('nav[aria-label="Member actions"] [data-club-step=member]').click();await admin.locator('.club-action-modal.show').getByLabel('Joined on',{exact:true}).fill('2026-02-01');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save membership',exact:true}).click()]);assert((await admin.locator('.memberSummary header').textContent()).includes('2026-02-01'));assert(new URL(admin.url()).searchParams.get('membership_serial')===String(ids.clubA_membera));pass('Administrator edits membership in a modal and remains on the same member summary');
await admin.locator('[data-club-step=charge]').click();await admin.locator('.club-action-modal.show').getByLabel('Season',{exact:true}).selectOption({label:'Available new season'});await admin.locator('.club-action-modal.show').getByLabel('Dues (USD)',{exact:true}).fill('20.00');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save dues',exact:true}).click()]);assert((await admin.locator('.memberSummary').textContent()).includes('$3,170.00'));pass('Administrator adds dues for the preselected member without returning to the club'); await admin.locator('[data-club-step=charge]').click();await admin.locator('.club-action-modal.show').getByLabel('Season',{exact:true}).selectOption({label:'Available new season'});await admin.locator('.club-action-modal.show').getByLabel('Dues (USD)',{exact:true}).fill('20.00');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save dues',exact:true}).click()]);assert((await admin.locator('.memberSummary').textContent()).includes('$3,170.00'));pass('Administrator adds dues for the preselected member without returning to the club');
await admin.getByRole('row').filter({hasText:'Available new season'}).locator('[data-club-step=payment]').click();await admin.locator('.club-action-modal.show').getByLabel('Payment (USD)',{exact:true}).fill('10.00');await admin.locator('.club-action-modal.show').getByLabel('Reference (optional)',{exact:true}).fill('Summary modal payment');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Record payment',exact:true}).click()]);assert((await admin.locator('.memberSummary').textContent()).includes('$3,129.00'));await admin.getByText('Summary modal payment',{exact:false}).waitFor();pass('Administrator records a selected season payment and sees updated totals on the member summary'); await admin.getByRole('row').filter({hasText:'Available new season'}).locator('[data-club-step=payment]').click();await admin.locator('.club-action-modal.show').getByLabel('Payment (USD)',{exact:true}).fill('10.00');await admin.locator('.club-action-modal.show').getByLabel('Reference (optional)',{exact:true}).fill('Summary modal payment');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Record payment',exact:true}).click()]);assert((await admin.locator('.memberSummary').textContent()).includes('$3,129.00'));await admin.getByRole('cell',{name:'unspecified / Summary modal payment',exact:true}).waitFor();pass('Administrator records a selected season payment and sees updated totals on the member summary');
await admin.getByRole('row').filter({hasText:'Available new season'}).locator('[data-club-step=adjust]').click();assert.equal(await admin.locator('.club-action-modal.show').getByLabel('Corrected total (USD)',{exact:true}).inputValue(),'20.00');await admin.locator('.club-action-modal.show').getByLabel('Corrected total (USD)',{exact:true}).fill('15.00');await admin.locator('.club-action-modal.show').getByLabel('Reason',{exact:true}).fill('Summary dues correction');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save correction',exact:true}).click()]);assert((await admin.locator('.memberSummary').textContent()).includes('$3,124.00'));pass('Member-summary correction modal prefills dues and refreshes totals without leaving the member');
const receipt=admin.getByRole('row').filter({hasText:'Summary modal payment'});await receipt.locator('[data-club-step=reverse]').click();await admin.locator('.club-action-modal.show').getByRole('button',{name:'Cancel',exact:true}).click();await admin.locator('.club-action-modal.show').waitFor({state:'hidden'});assert((await admin.locator('.memberSummary').textContent()).includes('$3,124.00'));await receipt.locator('[data-club-step=reverse]').click();await admin.locator('.club-action-modal.show').getByLabel('Reversal reason',{exact:true}).fill('Summary wrong receipt');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Confirm reversal',exact:true}).click()]);assert((await admin.locator('.memberSummary').textContent()).includes('$3,134.00'));const reversed=admin.getByRole('row').filter({hasText:'Summary modal payment'});assert((await reversed.textContent()).includes('Summary wrong receipt'));assert.equal(await reversed.locator('[data-club-step=reverse]').count(),0);pass('Member-summary reversal supports Cancel, preserves the receipt and removes its reversal button');
const privateText='Private browser member note\nSecond line';await admin.locator('.member-admin-notes [data-club-step=note_add]').click();await admin.locator('.club-action-modal.show').getByLabel('Note',{exact:true}).fill('Unsaved private draft');await admin.locator('.club-action-modal.show').getByRole('button',{name:'Cancel',exact:true}).click();await admin.locator('.club-action-modal.show').waitFor({state:'hidden'});assert.equal(await admin.locator('.member-admin-notes').getByRole('cell',{name:'Unsaved private draft',exact:true}).count(),0);await admin.locator('.member-admin-notes [data-club-step=note_add]').click();await admin.locator('.club-action-modal.show').getByLabel('Note',{exact:true}).fill(privateText);await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save note',exact:true}).click()]);await admin.locator('.member-admin-notes').getByRole('cell',{name:'Private browser member note Second line',exact:true}).waitFor();assert(new URL(admin.url()).hash==='#admin-notes');pass('Administrative note add modal supports Cancel and saves back to the member notes section');
await admin.locator('.member-admin-notes [data-club-step=note_edit]').click();assert.equal(await admin.locator('.club-action-modal.show').getByLabel('Note',{exact:true}).inputValue(),privateText);await admin.locator('.club-action-modal.show').getByLabel('Note',{exact:true}).fill('Updated private browser note');await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Save note',exact:true}).click()]);await admin.locator('.member-admin-notes').getByRole('cell',{name:'Updated private browser note',exact:true}).waitFor();pass('Administrative note edit modal prefills and saves the selected note');
const privacyContext=await browser.newContext(),privacyPage=await privacyContext.newPage();await login(privacyPage,'membera@example.test');await privacyPage.getByRole('link',{name:'Tenant Membera',exact:true}).click();assert.equal(await privacyPage.locator('.member-admin-notes').count(),0);assert(!(await privacyPage.content()).includes('Updated private browser note'));assert.equal(await privacyPage.locator('.memberSummary [name=note_text]').count(),0);await privacyContext.close();pass('Member browser receives no administrative note section, text or hidden note fields');
await admin.locator('.member-admin-notes [data-club-step=note_delete]').click();await admin.locator('.club-action-modal.show').getByRole('button',{name:'Confirm deletion',exact:true}).waitFor();assert((await admin.locator('.club-action-modal.show').innerText()).includes('Updated private browser note'));await admin.locator('.club-action-modal.show').getByRole('button',{name:'Cancel',exact:true}).click();await admin.locator('.club-action-modal.show').waitFor({state:'hidden'});assert.equal(await admin.locator('.member-admin-notes').getByRole('cell',{name:'Updated private browser note',exact:true}).count(),1);pass('Note deletion confirmation shows selected note and Cancel preserves it');
await admin.locator('.member-admin-notes [data-club-step=note_delete]').click();await Promise.all([admin.waitForNavigation(),admin.locator('.club-action-modal.show').getByRole('button',{name:'Confirm deletion',exact:true}).click()]);assert.equal(await admin.locator('.member-admin-notes [data-club-step=note_delete]').count(),0);assert(!(await admin.content()).includes('Updated private browser note'));assert(new URL(admin.url()).hash==='#admin-notes');pass('Confirmed note deletion refreshes summary and removes note and hidden dialogs');
await admin.getByRole('link',{name:'Back to club roster',exact:true}).click();await admin.getByRole('heading',{name:'Tenant Alpha',exact:true}).waitFor(); await admin.getByRole('link',{name:'Back to club roster',exact:true}).click();await admin.getByRole('heading',{name:'Tenant Alpha',exact:true}).waitFor();
await admin.locator('header [data-club-step=invite]').click();const invite=admin.locator('form').filter({has:admin.getByRole('button',{name:'Send club invitation',exact:true})});await invite.getByLabel('Invitation email').fill('invitee@example.test');await invite.getByRole('button',{name:'Send club invitation',exact:true}).click();await admin.getByText('The invitation email could not be sent.',{exact:false}).waitFor();pass('Administrator sends a club invitation and sees delivery failure clearly'); await admin.locator('header [data-club-step=invite]').click();const invite=admin.locator('form').filter({has:admin.getByRole('button',{name:'Send club invitation',exact:true})});await invite.getByLabel('Invitation email').fill('invitee@example.test');await invite.getByRole('button',{name:'Send club invitation',exact:true}).click();await admin.getByText('The invitation email could not be sent.',{exact:false}).waitFor();pass('Administrator sends a club invitation and sees delivery failure clearly');
await login(member,'invitee@example.test');assert(await member.getByText('You are not assigned to an active club yet.',{exact:false}).count());pass('Accounts without a club retain the empty workspace and invitation access');await member.goto(base+'/main.php?Xaction=Roster.invitations');await member.getByRole('heading',{name:'Tenant Alpha',exact:true}).waitFor();await member.getByRole('button',{name:'Accept invitation',exact:true}).click();await member.getByRole('heading',{name:'Tenant Alpha',exact:true}).waitFor();assert.equal(await member.getByRole('button',{name:'Send club invitation',exact:true}).count(),0);pass('Invited member accepts the club invitation and receives a read-only workspace'); await login(member,'invitee@example.test');assert(await member.getByText('You are not assigned to an active club yet.',{exact:false}).count());pass('Accounts without a club retain the empty workspace and invitation access');await member.goto(base+'/main.php?Xaction=Roster.invitations');await member.getByRole('heading',{name:'Tenant Alpha',exact:true}).waitFor();await member.getByRole('button',{name:'Accept invitation',exact:true}).click();await member.getByRole('heading',{name:'Tenant Alpha',exact:true}).waitFor();assert.equal(await member.getByRole('button',{name:'Send club invitation',exact:true}).count(),0);pass('Invited member accepts the club invitation and receives a read-only workspace');