# Order Flow Edge Cases & Silent Failures — Beyond Invitations

**Date:** July 17, 2026
**Companion to:** `INVITATION_FLOW_ANALYSIS.md` (Bugs 1–4 there are not repeated here).
Scope: OrderCompleteSubscriber (all branches), all checkout panes, NotificationService/queue, waitlist, cancellation/refund, credits, cart flows.

Severity legend: 🔴 users affected silently, money or registrations at stake · 🟡 data drifts / admin burden · ⚪ hygiene.

---

## 🔴 S1 — Notification preferences suppress transactional email (receipts, invitations)

`NotificationService::send()` honors `field_notification_preference` for **everything**, including money-related and workflow-critical messages:

- Preference `none` → registration/payment confirmations, invitations, player-joined/declined notices are all skipped. `sendRegistrationConfirmation()` (OrderCompleteSubscriber ~line 1086) goes through `send()` — an opted-out player pays and receives **no receipt of any kind**.
- Preference `text` → email skipped entirely. If the user has no phone on file, or Clickatell fails/credentials missing, the result is a log line and nothing delivered. SMS bodies also omit detail the email carries (order breakdown, group instructions).
- The inviter is told "Invitation sent to X" regardless — same false-success shape as the Bug 4 email-only case.

A preference field named "notification preference" reads to players like game reminders — not "suppress my purchase receipts." Recommend: transactional sends (receipt, invitation, waitlist offer) bypass preference or at minimum always email; keep preferences for reminders/announcements.

## 🔴 S2 — Tournament registration silently declines ALL pending team invitations

`createTournamentRegistration()` calls `declinePendingInvitationsForRegistration()` **unconditionally** (line ~759) — including the `'none'` (no team yet) and `ccsoccer_pool` branches. The season branch was deliberately fixed to only decline when an invitation was actually accepted (comment at line ~454: keep them pending for "figure out later") — the tournament branch never got the same fix.

Consequence: a player with pending team invites who registers "I'll pick a team later" or joins the CCSoccer pool has every invitation auto-declined — the player never saw or touched any of them. Captains see "Declined" on their group management page and don't re-invite ("they declined"). *(Correction July 17: no decline emails are sent from this path — `declinePendingInvitationsForRegistration()` only flips status; `sendInvitationDeclined` fires solely from the player-facing decline route. The declines are silent.)* This is the tournament twin of the season "register first, accept later" path Andrew flagged — it works for seasons, is silently destroyed for tournaments. The unconditional decline also breaks the capacity-exceeded fallback two lines above it, which promises to "leave the invitation pending" for admin follow-up.

**Recommended fix:** wrap the call in `if ($team)` — non-NULL exactly when the player joined/created/token-accepted onto a team (declining competitors is then correct), NULL for `'none'`, pool, and the team-full fallbacks (where invitations must stay pending). One-line change mirroring the season branch's `if ($invitation_status === 'accepted')` guard.

**✅ IMPLEMENTED July 17, 2026** — guard added in `createTournamentRegistration()` (uncommitted working-tree change). Deploy: code only, `drush cr`. Test on LOCAL: register for a tournament with a pending team invite using "no team yet" or pool → invitation stays pending and remains acceptable from My Registrations; register via token-accept → competing invitations declined as before.

## 🔴 S3 — Flagged "paid but broken" orders have no surface (known, but now live-critical)

Five money-collected-but-action-failed states exist only as order data + a dblog error: `ccsoccer_season_registration_failed` (paid, **no registration created** — season inactive or capacity race), `ccsoccer_credit_shortfall`, `ccsoccer_team_capacity_exceeded`, `ccsoccer_team_name_collision`, `ccsoccer_tournament_full`. The admin report (checklist item 18, `FLAGGED_ORDERS_REPORT_PROPOSAL.md`) isn't built. With the site live, a paid-but-unregistered player is invisible until they complain — and dblog rotates. Interim suggestion at minimum: `sendBoardNotification()` on each flag write (the pipeline already exists and is used for jersey purchases), so flags become push instead of pull.

## 🔴 S4 — Waitlist offers expire in the email, but never in the system

`offerSpot()` creates a 7-day override and tells the player "expires @date" — but nothing ever expires the **waitlist entry**:

- No cron touches waitlists or overrides. After day 7 the override merely stops validating (`getValidSeasonOverride` checks dates), silently.
- The entry stays `offered` forever: `getNextPending()` skips it — **the next person in line is never offered**; `getUserWaitlistEntry()` still returns it — the player can't rejoin.
- The reserved spot (incremented at cancellation, decremented only when an override is *used*) is held indefinitely → season sells out one seat short per expired offer.
- Post-expiry, the player can still click through; `addSeasonToCart` has no override/capacity gate of its own — if the season shows open they register normally; if full they hit the capacity flag (S3) **after paying**.

Fix shape: cron pass — expired `offered` entries → `expired`, decrement reserved_spots, optionally auto-offer next pending + notify admin.

---

## 🟡 M1 — Registration confirmation can misstate what happened

`sendRegistrationConfirmation()` renders purely from order items and always says "You're registered." It sends even when `createSeasonRegistration()` bailed and flagged the order (S3 states) — the customer gets a confident confirmation for a registration that was never created. Worse combination: season branch flags + tournament twin (`ccsoccer_tournament_full` path) still create a teamless registration, and the email doesn't mention team/group outcome at all. Suggest: check for failure flags before sending, or include actual registration/team status in the email.

## 🟡 M2 — Manual "Complete checkout" / admin order edits re-fire the pipeline

`onOrderPlace` is guarded by `ccsoccer_completion_processed` (good — credits can't double-deduct). Two residuals: (a) the flag is written **after** registrations/credits but **before** confirmation emails — a crash between those two points means a retry is fully skipped and no confirmation ever goes out (correct trade-off, but no alert marks the half-done order); (b) registrations dedupe by "any non-cancelled registration exists," so an admin re-placing an order after a player cancelled **resurrects** a registration at `paid` with fresh group data — probably desired, but worth knowing it's possible.

## 🟡 M3 — Jersey add-on during re-registration reuses stale player info

`ccsoccer_player_info` is written by PlayerInfoPane at pane submit and consumed at completion (fine), but `field_jersey_size`/`field_has_jersey`/`field_self_score` are copied onto the **user entity** once per order. Nothing invalidates these when a player registers for a second season in the same order or edits mid-checkout via Back — low risk, but the Back-button behavior of every pane deserves a spot in the PR test matrix since GroupPane already proved this class of bug (INVITATION doc E1). For contrast, **CreditsPane handles Back correctly** (recomputes + replaces its adjustment on every submit) — it's the model to point to.

## 🟡 M4 — Credits: donate amount is decided at completion, not at consent

`donateCredits($user->id())` at order placement donates the user's **entire balance at that moment** — not the balance shown when they ticked "donate" at the credits step. A credit issued between checkout step 4 and payment (e.g., admin cancellation credit) gets donated without the user ever seeing it. Mirror-image of the shortfall guard (Finding C covers apply-side only). Low frequency; snapshot `balance_cents` into order data at pane submit and donate `min(snapshot, current)`.

## 🟡 M5 — Cancellation leaves group/team state behind (extends INVITATION E4)

`CancelRegistrationForm` sets `status = cancelled` and handles money, but: leaves `group_id`/`invited_by` on the registration (cancelled player still occupies a group slot everywhere counts are done); never removes a tournament player from `Team.players` (roster/capacity still counts them); doesn't touch their pending/accepted invitations. Also the reserved-spot bump for the waitlist happens on **every** cancellation while any pending waitlist exists — serial cancellations can over-reserve beyond actual waitlist depth (offers are manual, so reconciliation is on the admin).

---

## ⚪ Hygiene

- **H1 — DEBUG logging in the send path:** `send()` logs a notice-level `DEBUG:` line for every notification (NotificationService ~line 400). dblog is currently the *only* monitoring surface (S3) — this noise actively buries the signals that matter. Remove or drop to debug level.
- **H2 — `site_instance` defaults to `'production'`:** `Settings::get('site_instance', 'production')`. A TEST/LOCAL clone missing its `settings.local.php` line becomes a full-delivery environment emailing real players (with prod SMTP creds present, as on TEST). Safer default is non-production, or a boot-time warning when unset.
- **H3 — Confirmation email built from raw `getDisplayName()`/labels** into HTML without escaping in a few spots (`$user->getDisplayName()`, season labels in some bodies). Board-entered data, so low risk — consistent `htmlspecialchars` would close it.
- **H4 — Queue worker exceptions:** `NotificationQueueWorker::processItem` catches exceptions (check whether it rethrows for requeue or swallows — if swallowed, a transient SMTP outage permanently drops queued notifications; if rethrown, poison items can block cron). Worth a 2-minute look at the catch block.

---

## What checked out clean (no action)

Credit apply-side reconciliation (Finding C guard), idempotency guard on double order placement, duplicate-cart-item guard in `addSeasonToCart`/`addTournamentToCart`, capacity re-checks at completion for tournaments (`isFull`/`isFullIncludingPending` with pending-aware distinction between invited and uninvited joiners), team group_id unification via `getOrCreateTeamGroupId()`, CreditsPane Back-button handling, checkout-flow forcing after cart add, order-data staleness on player discount (recomputed every refresh by DiscountOrderProcessor).

## Suggested priority

1. S2 (one-line condition fix, mirrors existing season logic; actively sending false "declined" emails to captains during live SLO Friendly registration).
2. S3 interim board-notification on flag writes (site is live; dblog rotates).
3. S1 policy decision + fix (transactional bypass).
4. S4 waitlist cron (before fall-season waitlists get real use).
5. M-items alongside the invitation PR (M5 overlaps its scope directly); H-items opportunistically.
