# Season ↔ Tournament Drift Audit

**Date:** July 17, 2026
**Method:** Parallel review of every player-facing "twin" — code copied from the season flow to create the tournament flow (or vice versa) — hunting fixes that landed on one side only. Five review passes: cancellation forms, GroupController branches, RegistrationController branches, OrderCompleteSubscriber branches, notifications + admin GroupInvitationsForm. Key findings spot-verified against source.
**Companions:** `INVITATION_FLOW_ANALYSIS.md`, `ORDER_FLOW_EDGE_CASES.md` (findings there are not repeated).

> **STATUS as of July 28, 2026 — see `OUTSTANDING_ISSUES.md` for the live list.**
> **Shipped to PROD:** D1 (100× refund money bug), D8 (`removeMember` cleanup on both branches).
> **Fixed, awaiting deploy:** D4 (co-captain remove) + T9 — in `fix/team_display`, untested; D14 (tournament
> nudge 500) — in `fix/tournament_nudge_500`, on TEST, not PROD.
> **Still open and relevant to the roster/schedule/tournament work:** D2 (admin-accept of a tournament
> invitation crashes mid-transaction), D3 (tournament completion doesn't re-check active/visible), D6/D7
> (tournament cancel + admin accept/decline desync `Team.players` → ghost roster members), D13 (season group
> counts include cancelled regs — P1 roster hygiene), D5 (tournament cancel notifies nobody), D12 (tournament
> close date has no fallback), D17 (no tournament `groups_locked` — board decision if rosters need freezing).
> **Lower/parked:** D9–D11, D15, D16, D18, D20.
>
> **UPDATE July 30, 2026 — D19 promoted from ⚪ Low to 🟠 High and moved.** The conditional it was parked
> under ("flag in case shared UI ever counts on it") has fired: the admin Group Invitations page counts on
> it in two places that now contradict each other on screen, and its **Save** button writes the wrong value
> as real data. Fix is scoped as **CF12** in `ROSTER_RECONCILIATION_PLAN.md` §5/§10.3, shipping with CF2+D2
> in commit 2. Half of **D15** is retired by the same change. Branch/deploy states above are as of July 28
> and are superseded by `SESSION_HANDOFF.md` (July 29) — `fix/team_display` has since merged to main.

---

## 🔴 Critical

### D1 — Season Commerce refund is off by 100x (money bug) — VERIFIED
`CancelRegistrationForm.php:526-527`:
```php
$amount_cents = (string) ($amount_dollars * 100);
$refund_amount = new Price($amount_cents, ...);
```
`commerce_price\Price` takes **dollars**, not cents — the tournament twin does it right (`TournamentCancelRegistrationForm.php:376`, no `*100`), as does `TournamentDepositRefundForm.php:220`. Practical behavior: refunds larger than 1/100 of the captured payment throw/decline (refund > capture), so **the season "Refund via Commerce" option is effectively broken** — and the dangerous case is a small partial refund: refund $0.50 on a $50 order → succeeds as a **$50 refund**. Do not use the season Commerce-refund option until fixed (credit option is unaffected). One-line fix; tournament side is the template.

**Confirmed in production July 17:** order 87 (Avi, $133, cancelled Jul 8) — payment shows "Refunded: $0.00", state Completed, no credits issued. The refund silently failed via this exact path; player was never notified. Remediated manually via the order's Payments tab (Commerce core refund).

**✅ IMPLEMENTED July 17, 2026** (uncommitted working-tree change, `CancelRegistrationForm.php`): dropped the `* 100` (Price takes dollars, matching the tournament forms); validation now rejects `$0` refunds; the refund-failure message now states explicitly that no money moved and the player was not notified, with manual-remediation instructions. Deploy: code only, `drush cr`. Test on LOCAL/TEST with a test-gateway order: commerce refund of a normal amount succeeds and payment state flips to Refunded; refund > captured amount shows the failure message.

### D2 — Admin-accepting a tournament invitation crashes mid-transaction — VERIFIED
`GroupInvitationsForm.php:1279` queries `loadByProperties(['tournament' => $context_id, ...])`, but the Invitation entity **has no `tournament` field** (only `season`, `group_id`, `team` — `Invitation.php:83-112`). Entity query on an undefined field throws. The call happens *after* the registration was already mutated and saved (`acceptInvitation()` → line ~1238), so the admin gets an error page with half-committed state: player joined the team, invitation left `pending`, competing invites never declined. Correct pattern exists in `GroupController::acceptTeamInvitation()` (filter by `isTeamInvite()` + team's tournament). Season branch uses the real `season` field and works.

### D3 — Tournament order completion never re-checks that the tournament is still open
Season completion hard-blocks and flags the order if the season went inactive/hidden mid-checkout (`OrderCompleteSubscriber.php:364`, Finding F). The tournament branch has **no check of `active`, `registration_visible`, or `status`** — a player checking out while an admin cancels the tournament still gets a `paid` registration, and the `create` branch still spawns a Team entity + team-name taxonomy term for a dead tournament. Money captured, no flag, no admin surface. Port the season guard (+ flag write) to `createTournamentRegistration()`.

---

## 🟠 High

### ~~D4 — Co-captains can see manager controls but every remove fails~~ ✅ IMPLEMENTED July 27, 2026
`manage()` treats captain OR co-captain as manager (`isTeamLeader()`, GroupController:404) and renders Remove buttons — but `removeMember()` gates on the season rule only (`invited_by` empty, line 1465). A co-captain always has `invited_by` set → **"Only the group manager can remove members"** on every click. Meanwhile co-captain *invite* works. Fix: tournament branch of `removeMember()` should use `isTeamLeader()`.

**Fixed as prescribed.** `removeMember()` now resolves `$is_tournament`/`$team` before the authorization check and branches: tournaments authorize via `$team->isTeamLeader()`, seasons keep the `invited_by` rule. Two things had to come with it:

- **Captain protection (new, required by this fix).** Nothing stopped a co-captain from removing the *captain* — it had never mattered, because no co-captain could get past the auth check. `removeMember()` splices `Team.players` inline and never calls `TournamentTeamManager::removePlayerFromTeam()`, where the equivalent guard already lives. Added an explicit guard; the captain must be transferred first.
- **T9 co-captain cleanup.** Removing a co-captain now clears `Team.co_captain`, which otherwise kept pointing at someone off the roster — and `isTeamLeader()` would have kept granting them rights over a team they had left.

Also fixed in the same pass, both surfaced by a live report (Yong Pong, Lunch Crew, SLO Friendly 2026):

- **Membership check was group_id-based.** A tournament player placed as a free agent by the Roster Builder has `group_id` NULL by design, so the captain got "Member is not in your group" for someone visibly on the roster. Now accepts either source of truth — `Registration.team` match **or** presence in `Team.players` — because the two are known to drift (T2/T5/D7) and the on-screen roster renders from `Team.players`. A strict check would have refused to remove rows the page itself drew.
- **Missing status filter on the roster's registration lookup** (`manage()`, tournament branch). It fed the Remove button's member id via `reset()`, which takes the lowest id — so a player with a cancelled *and* a live registration had the cancelled row wired to the button. **6th confirmed instance** of this shape. Extracted as `pickLiveRegistration()`.

### D5 — Tournament cancellation notifies nobody
Season cancel emails the player in all three refund branches (`sendRegistrationCancelled`, CancelRegistrationForm:392-435). Tournament cancel (`TournamentCancelRegistrationForm.php:238-327`) sets status, processes the refund, messages the **admin's screen only** — the player is silently dropped (and possibly silently refunded). Also cross-check with ORDER_FLOW S1: even the season email is preference-gated.

### D6 — Tournament cancel leaves the player on the team roster
Neither cancel form removes the player from `Team.players` or clears `group_id`/`invited_by` — but the module already has the correct helper doing exactly this: `TournamentTeamManager::unassignPlayerFromTeam()` (TournamentTeamManager.php:470-503). The cancel form just doesn't call it. Ghost roster entries inflate `isFull()` and block real joins. (Season half of this is INVITATION doc E4.)

### D19 — `invitation_status` is never written on tournament registrations, and the admin page renders the gap as "Pending" — VERIFIED
*(Promoted from ⚪ Low July 30, 2026. Original entry: "model difference, flag in case shared UI ever counts on it." It does.)*

`createTournamentRegistration()` (`OrderCompleteSubscriber.php` ~850-864) builds the registration with
`team`, `group_id` and `invited_by` but **no `invitation_status`** — the season branch 300 lines earlier
passes it explicitly (`:559`). So every tournament registration stores the field default `'none'`
(`Registration.php:166-168`).

`'none'` is not among `GroupInvitationsForm::buildMembersTable()`'s three select options (`:548`). A
`<select>` whose `#default_value` matches no option renders nothing selected and the browser displays the
**first** option — **"Pending"** — for players who are paid, on a team and in the captain's group.
Meanwhile `buildStatsBar()` (`:363`) buckets those same rows through `match($status)` with
`default => $accepted_count++`. **Two widgets on one page therefore disagree about the same rows**, which
is the signature of an out-of-range value rather than real pending state. Live example (Andrew, July 30):
Lunch Crew, SLO Friendly 2026 — stats bar reads `Accepted: 7` while three member dropdowns read Pending.

**The damage is a write, not a display.** `original_status` is stored as `'none'` (`:576`), the browser
submits `'pending'`, so the change-detector fires at `:909` and `set('invitation_status', 'pending')`
persists at `:919`. **An admin clicking Save with no edits turns the display fault into real data**, three
rows per affected team — on the page the tournament director is being given access to. That is what moved
this out of Low: the Group Invitations page is currently a new-drift source.

**Why it surfaced now — a June 26 regression, not a longstanding gap.** Before `84dac45` (Jun 26) checkout
wrote the legacy `group_id = 'team_<teamID>_<captainID>'` while the Team entity held a UUID, so a
checkout registration matched no group and **never appeared on this page at all** — that commit's own
message says these players "looked like free agents". After it they appear, carrying `'none'`. The same
commit shipped update hook 9068 (`ccsoccer.install:4572`), which repoints legacy group_ids **and sets
`invitation_status = 'accepted'`** — so pre-Jun-26 players were repaired and read Accepted, while
post-Jun-26 players were never touched by the hook's `STARTS_WITH 'team_'` query and read "Pending".
`8483a20` (Jun 23, invite-only join) is *not* the cause — it reroutes joins to the `token_accept` branch,
which never set the field either.

**Fix: CF12** in `ROSTER_RECONCILIATION_PLAN.md` §10.3. Two halves — (a) set `invitation_status` in
`createTournamentRegistration()`'s create array (`accepted` for `token_accept`/`join`, `none` for `create`,
per decisions D-10/D-11); (b) drop **Pending** from the *tournament* member dropdown, since it is not a
reachable state for a tournament member row — a genuinely pending invitee has no registration in the group
yet and renders in the separate `inv_` rows. Half (b) corrects existing rows on screen with no data repair.
Ships with CF2 + D2 in commit 2; the Save trap lives inside the lines CF2 rewrites.

### D7 — Admin accept/decline desyncs `Team.players` from registrations
`GroupInvitationsForm::submitForm()` sets/clears `registration.team` (lines ~926-936) but never edits the Team entity's `players` list — the player-facing paths keep both in sync. Admin-declined players remain ghost roster members; admin-accepted players are missing from the roster. Tournament-only (seasons have no Team entity).

---

## 🟡 Medium

- ~~**D8 — `removeMember()` invitation cleanup missing on BOTH branches**~~ **✅ IMPLEMENTED July 20, 2026** (this entry was stale — the fix shipped in `086d04d` as part of the invitation-timing work and was verified on TEST, checklist section F). `removeMember()` now declines the removed player's `accepted` invitation on both branches, keyed to the removed player rather than the current user; re-invite confirmed working immediately afterward. Original finding: the tournament branch also leaves the accepted invitation, and the duplicate-invite check then blocks the captain from ever re-inviting the removed player. `leaveGroup()` (lines 1640-1652) is the correct pattern to copy into both branches.
- **D9 — Season list shows inactive seasons.** Season list query filters `registration_visible` only (RegistrationController:176-179); tournament list also excludes dead statuses. A season with `active=FALSE, visible=TRUE` renders a live Register button that dead-ends at `addSeasonToCart`'s active guard.
- **D10 — Tournament eligibility is enforced only at add-to-cart.** Seasons compute `ineligible` and hide the card; `getTournamentState` does no age check, so underage players see a live card and get rejected after clicking. Also: `checkUserAge`'s override branch is gated to `$entity->getEntityTypeId() === 'season'` (line ~1245) — an age-override granted for a tournament would not be honored.
- **D11 — Gender is checked for display, not action (season-internal).** `addSeasonToCart` re-checks age but not the mens35 gender rule — direct URL lets a wrong-gender user into checkout for a hidden season. Display-guard vs action-guard gap.
- **D12 — Tournament close date has no fallback.** Tournament "closed" state derives only from `registration_close`; if unset, registration stays open forever. Seasons route through `Season::isRegistrationOpen()`. (Tournament entity has its own `isRegistrationOpen()` — the controller just doesn't use it.)
- **D13 — Season group counts include cancelled registrations.** `getGroupSize()` and `manage()`'s roster query have no status filter (myRegistrations remembered the filter; capacity paths didn't). Tournament avoids this by deriving counts from the curated `players` field. Add `status NOT IN (cancelled, expired)` to the season queries.
- **D14 — Nudge redirect never adapted for tournaments.** `nudge()` looks up the registration by `season` only (GroupController:1102-1107) — NULL for team invites, so tournament nudges land on My Registrations instead of the team page. `deleteInvitation()` (1133-1159) is the already-fixed pattern to copy. Email itself sends fine (subject to the invitee_uid gate).
- **D15 — Admin form's paid-gating differs by type.** `buildMembersTable()` requires a `paid` season registration before offering accept, but applies no status filter for tournaments — an unpaid/cancelled tournament registrant can be attached to a team by an admin. **Partially retired by CF12 (July 30):** CF12 rewrites this method's tournament option list, so the two changes touch the same lines and should land together. What CF12 does *not* fix is the missing status filter on the tournament `has_registration` lookup (`GroupInvitationsForm:617-623`) — that half stays open and belongs with **CF3**'s status-filter sweep.

## ⚪ Low / policy decisions (flagged, possibly by design)

- **D16 — Tournament players have no self-leave path** (`leaveGroup` explicitly blocks tournaments) and co-captain removal is broken (D4) → a player who wants off a team has no path but the captain. Season members self-serve.
- **D17 — No tournament equivalent of `groups_locked`.** Seasons have a full roster-freeze lifecycle enforced across invite/accept/remove/leave; tournaments have none. If the board expects to freeze tournament rosters before scheduling, the mechanism doesn't exist.
- **D18 — Season cancel records no `cancellation_date`** (tournament does — port it); season refund validation lets `$0` through (tournament requires > 0); season redirect trusts a hidden field the tournament form defensively re-derives.
- ~~**D19 — `invitation_status` field written only on season registrations**~~ → **PROMOTED to 🟠 High, July 30, 2026.** The parking condition fired. See the full entry under 🟠 High above; fix scoped as CF12.
- **D20 — Admin accept/decline sends no notifications on either side** (player paths do). Admin-vs-player drift rather than season-vs-tournament, noted for completeness.

## Verified symmetric (no action)
Token branch of `available()`, pending-invitations banner, decline flows, `deleteInvitation`, `sendPlayerJoined/Declined/Removed` wiring in GroupController, order-completion dedupe/status/cache handling, capacity-check philosophy (season = player cap, tournament = team cap; `isFull` vs `isFullIncludingPending` used correctly per invited/uninvited join), tournament having no credit option (CreditsPane documented as season-only), waitlist/override being season-only features.

---

## Suggested sequencing

1. **D1 today** (one line; until then, don't use season "Refund via Commerce").
2. **D2, D3, ~~D4~~** — small, well-bounded fixes; D3 reuses the season guard verbatim. (D4 done July 27; D2 and D3 still open.)
2a. **D19 with D2** (added July 30) — same file, same method, and D2 already blocks the accept half of CF2. All three are commit 2 of the roster-sync plan. Until it ships, **do not click Save on the admin Group Invitations page for a tournament group** — see the D19 entry.
3. **D5–~~D8~~** as a "cancellation & roster hygiene" batch — overlaps heavily with the invitation PR's scope (removeMember, cleanup), so consider folding into or sequencing right after it. (D8 done July 20. D6 in particular is now the most adjacent remaining item: tournament cancel still leaves the player in `Team.players`, and the D4 work confirmed `TournamentTeamManager::unassignPlayerFromTeam()` is the helper it should call.)
4. D9–D15 as a checklist for a cleanup session; D16/D17 are board-policy questions to decide before next tournament season.

## Process note

Every serious finding across all three documents is the same failure mode: **twin code paths that drifted**. Two cheap guards for the future: (a) when fixing a bug in either flow, grep for the twin (`Tournament*` ↔ season equivalent) before closing the ticket; (b) longer term, the shared logic (invitation lifecycle, cancellation cleanup, refund math) wants to move into services both flows call — `TournamentTeamManager::unassignPlayerFromTeam()` shows the pattern, it's just not consistently used.
