# WC Affiliate — Full Audit (branch: develop @ cdcf5540 "release v3.5")

Date: 2026-06-21. Method: 7 parallel specialist passes (data layer, REST API,
business logic, security, consistency/i18n, frontend SPA, stale/dead code).
Severity re-normalized and findings deduplicated across passes.

Excluded (already fixed on a separate branch): referral `unpaid` → `approved`
status vocabulary alignment.

---

## CRITICAL

1. **Email-verification auth bypass via hardcoded encryption key**
   `app/Helpers/Utilities/platform-integration-functions.php:16` →
   `app/Controllers/Front/Init.php:215-239`.
   `wca_ncrypt()` uses the `mukto90\Ncrypt` library default key/IV (public,
   shipped in vendor); `set_secret_key()` never called. `Front/Init.php`
   decrypts `$_GET['validate']` into `{id,time}` and flips any user's
   `_wc_affiliate_status` to active. Attacker can forge `validate` for any user
   ID and any `time` (defeating expiry) → activate arbitrary affiliate accounts,
   no auth/nonce. **Fix:** derive secret from `wp_salt()` via
   `set_secret_key()`/`set_secret_iv()`, and bind the token to a server-side
   nonce/hash instead of an unauthenticated decrypt.

2. **IDOR — affiliates can read all affiliates'/referrals'/visits'/transactions' data**
   `app/API/Affiliate.php:61,177`, `app/API/Visit.php:43,279`,
   `app/API/Referral.php:44`, `app/API/Transaction.php:40`, root cause
   `app/Controllers/Common/API.php:2755`.
   `check_affiliate_permissions` passes for any active affiliate when no
   `affiliate_id` is sent (defaults to caller), but the list handlers only add
   the affiliate filter when the param is present → a non-admin affiliate gets
   every row, including `token` and `payment_details`. `get_visit` has no
   ownership check at all. **Fix:** for non-admins force
   `affiliate_id = get_current_user_id()` (never trust empty/foreign), enforce
   per-resource ownership for `get_visit`/`get`, strip token/payment_details.

3. **`Transaction::get_unpaid_amount()` binds the table name with `%s`**
   `app/Models/Transaction.php:1028`. `... FROM %s WHERE ...` quotes the table
   as a string literal → invalid SQL → always 0/null. Affiliate unpaid balances
   read 0, breaking payout decisions. **Fix:** interpolate trusted `$table`,
   drop it from `prepare()` args.

---

## HIGH

### Data layer / SQL
- **SQL injection via unsanitized `orderby`/`order`/`limit`/`offset`** —
  `app/Models/Referral.php:386-389`, `app/Models/Transaction.php:243`,
  `app/Models/Visit.php:489-492`. Request args interpolated straight into
  `ORDER BY`/`LIMIT`. Reachable through the list endpoints (combined with IDOR
  #2). **Fix:** whitelist `orderby` columns, force `order` ∈ {ASC,DESC}, cast
  limit/offset to int.
- **`Referral::delete()` always returns `WP_Error` even on success** —
  `app/Models/Referral.php:655`. Deletes the row, clears state, then falls
  through to `return new WP_Error('delete_failed')`. Callers think every delete
  failed → risk of double-action / wrong UX. **Fix:** `return true` inside
  `if ($result)`.
- **`Visit::cleanup_old_visits()` binds table names with `%s` in JOIN DELETE** —
  `app/Models/Visit.php:739`. Invalid SQL → cleanup never runs → unbounded
  table growth. **Fix:** interpolate `$table`/prefix, bind only `$cutoff_time`.
- **Transaction queries filter a non-existent `time` column** —
  `app/Models/Transaction.php:217,223,756,762`. Table has `request_at`/
  `process_at`, no `time`; any date filter throws unknown-column. **Fix:** use
  `request_at`.
- **`Transaction::get_status_counts()` filters non-existent `method` column** —
  `app/Models/Transaction.php:939`. Should be `payment_method`.
- **`DataImporter::import_referrals()` inserts non-existent columns** —
  `app/Models/DataImporter.php:169-184`. Inserts `commission_type`/`status`
  which don't exist on `wca_referrals` → every insert fails, 0 imported.
  (Note: dead-code pass flags whole class as unused — see Stale.)
- **`get_referrals()` breaks on `limit = -1`** — `app/Models/Referral.php:393`.
  `Transaction::get_referrals()` passes `-1` → `LIMIT off, -1` (MySQL error) →
  transaction→referral lookups break. **Fix:** omit LIMIT when limit < 0.

### Business logic / money
- **`get_payable_referrals()` filters `'paid'` instead of `'approved'`** —
  `app/Models/Referral.php:1339`. Every other payout query uses `'approved'`
  + `transaction_id = 0`; `paid + transaction_id=0` is contradictory → the
  payable-referrals endpoint (`API/Referral.php:509`) returns nothing.
  **Fix:** `payment_status = 'approved'`.
- **On-hold order status never maps (hyphen vs underscore)** —
  `app/Controllers/Front/Init.php:276,434`, `app/Controllers/Common/Process.php:181`,
  `app/Controllers/Admin/Init.php:657`. WC returns `'on-hold'`; setting key is
  `referral_status.on_hold`. Lookup misses → silent fallback to `'pending'`,
  ignoring the admin's mapping. **Fix:** `str_replace('-','_',$order_status)`
  before lookup at all four sites. (Now centralizable through the
  `wca_get_referral_status_for_order_status()` helper added on the fix branch.)
- **`order_subtotal` commission base silently behaves like `order_total`** —
  `app/Models/Commission.php:84`. Only branches product_price vs else; subtotal
  routes to `$order->get_total()` (incl shipping/tax/fees). **Fix:** add a
  subtotal branch using `$order->get_subtotal()`.
- **Self-referral guard inverted in `set_token()`** —
  `app/Controllers/Front/Init.php:97-102`. Condition returns (skips token) only
  when self-referral IS allowed; permits the disallowed case. Impact limited
  (credit re-guarded in `get_allowed_users()`), but logic is wrong.
  **Fix:** `! wca_allow_self_referral() && current === affiliate`.
- **reCAPTCHA verified with the site key as the secret** —
  `app/API/Affiliate.php:602`. Uses `wca_get_recaptcha_site_key()` as the
  `secret` param → verification misconfigured (registration protection broken).
  **Fix:** use the secret key.
- **Public apply endpoint lets caller set their own commission rate** —
  `app/API/Affiliate.php:510,532-533,630-637` (route `__return_true`).
  Self-applying user can grant arbitrary `commission_type`/`commission_amount`.
  **Fix:** ignore client commission_* on public apply; defaults only.

### Frontend SPA (re-normalized from agent's CRITICAL)
- **`updateReferralStatus` stores wrong shape** —
  `spa/stores/referrals/actions.ts:362`. `setReferral(response)` writes the
  `{success,message,data}` envelope under `referrals[undefined]`. **Fix:**
  `setReferral(response.data)`.
- **Setup-wizard selectors crash on null state** —
  `spa/stores/setup-wizard/selectors.ts:18,29`. `Object.keys(state.config)`
  before hydration. **Fix:** guard `if (!state) return undefined`.
- **Store envelope mismatches (raw vs `{success,message,data}`)** —
  `spa/stores/dashboard/resolvers.ts:32`, `spa/stores/user/actions.ts:117` vs
  `:152`, `spa/stores/affiliates/resolvers.ts:43`,
  `spa/stores/referrals/resolvers.ts:25`. Selectors return the wrapper or
  `.map()` throws on an envelope. **Fix:** normalize `response.data ?? response`,
  guard `Array.isArray`.
- **Unstable selector references → re-render loops** —
  `spa/stores/affiliates/selectors.ts:26` (also referrals/visits selectors).
  Returns fresh `[]`/`.map().filter()` each call. **Fix:** memoize, share a
  frozen empty array.
- **Missing `loaded` flag (same class as the fixed Settings race)** —
  `spa/stores/export/resolvers.ts:19`, `spa/stores/import/resolvers.ts:19`.
  Initial `{}` indistinguishable from fetched-empty → UI shows defaults before
  data lands. **Fix:** add `*Loaded` flag, gate UI.
- **Edit-transaction screen is a hardcoded placeholder** —
  `spa/admin/components/tables/UpdateTransaction.tsx:22,81`. Shows
  "Transaction #21", never loads by route param; `payment_methods` Record
  defaulted to `[]`. **Fix:** fetch by id, default to `{}`.
- **`Form.tsx` never resyncs on async `initialValues`** —
  `spa/common/components/Form.tsx:50`. Stale/empty form when data loads after
  mount. **Fix:** `useEffect(() => setValues(initialValues), [initialValues])`.
- **`addFilter` called in Settings render body** —
  `spa/admin/pages/Settings/index.tsx:151`. Re-registers every render.
  **Fix:** move into `useEffect([])`.
- **`dateUtils` seconds-vs-ms bug** — `spa/utilities/dateUtils.ts:39`. Passes
  unix seconds to `new Date(number)` (expects ms) → ~Jan 1970. **Fix:** ×1000.
- **PII leak via debug log** — `spa/stores/user/reducer.ts:35` logs full user
  state on every profile update. **Fix:** delete.

---

## MEDIUM (selected — full list in pass output)

- `Referral::get_top_products()` aggregates onto the wrong product —
  `app/Models/Referral.php:991-993` (`$product_id` used outside inner loop;
  per-product array reset each row).
- `Referral::get_by_id()` reads undefined `$referral` on miss —
  `app/Models/Referral.php:431`. **Fix:** init `$referral = null`.
- `Visit::get_visit_id_by_time()` dereferences null on no match —
  `app/Models/Visit.php:397`.
- `Affiliate::load()` calls `wp_cache_flush()` on every load —
  `app/Models/Affiliate.php:99`. Full object-cache flush per instantiation;
  `get_affiliates()` builds many. **Fix:** remove.
- `product_id` LIKE patterns inconsistent and don't match serialized storage —
  `app/Models/Referral.php:357` vs `:828`.
- `update_pending_commissions` cron only scans `'pending'` —
  `app/Controllers/Common/Process.php:166`.
- Duplicate-credit guard meta written inside the per-affiliate loop —
  `app/Controllers/Front/Init.php:350` (multi-affiliate orders).
- SSRF via import `file_url` (admin-only) — `app/API/Import.php:659,794`.
- Response-shape inconsistency: get_* raw vs update/reset wrapped —
  `app/API/Settings.php:98` vs `:127`, `app/API/SetupWizard.php:82` vs `:98`.
- `get_by_user_id` can return `WP_Error`, then `->exists()` fatals —
  `app/API/Affiliate.php:964,993`.
- Status enums not re-validated in handlers (model is the boundary) —
  `app/API/Referral.php:227,256`, `app/API/Affiliate.php:1330`.
- Many SPA effect/closure/optional-chaining bugs (see frontend pass): e.g.
  `EditPopup.tsx:45` dep on whole object, `MarkAsPaidPopup.tsx:187` /
  `ProcessPayoutPopup.tsx:170` close-before-await, `migrations/*.tsx` stale
  setTimeout, `transactions/reducer.ts:97` phantom-key writes.

---

## CONSISTENCY (HIGH/MEDIUM)

- **`auto_payout` has 3 conflicting representations** —
  `app/Models/Affiliate.php:1657` (`sanitize_data` → 'yes'/'no'), `:434`
  (`update` → `(bool)`), `:173` (`load_meta` raw). `sanitize_data()` never
  called; `create()` has no `auto_payout` case → `Import.php:1052` silently
  drops it. **Fix:** one representation ('on'/'off') across all sites.
- **`wca_is_auto_approve_enabled()` uses 'yes'/'no'** —
  `app/Helpers/Settings/settings-functions.php:487`. Stored as a select, so not
  a checkbox-vocabulary bug per se, but the lone 'yes'/'no' among ~30 'on'/'off'
  toggles. Low-risk, flagged for uniformity.
- **Divergent status label lists** — `wca_get_affiliate_statuses()`
  (`affiliate-management-functions.php:88`) vs
  `Affiliate::get_available_statuses()` (`Affiliate.php:1455`) vs
  `Affiliate::get_statistics()` hardcoded keys (`:784`). Two filter names, can
  drift. **Fix:** single source via the model.
- **Hardcoded status literals instead of constants** —
  `Affiliate::save_commission_fields()` `:1484` (`'active'`), `API/Import.php:872`
  whitelist. **Fix:** use `self::STATUS_*` / `get_available_statuses()`.
- **Meta-key style inconsistency** — `'_wc-affiliate-applied_payout'` (hyphens)
  vs `_wc_affiliate_*` convention — `Affiliate.php:177,1229`, `Referral.php:1461`,
  `API/Affiliate.php:1442`, `Common/Init.php:726`. **Fix:** centralize as a
  constant; migrate.

---

## STALE / DEAD / DRIFT

- **Version drift** — `package.json:3` is `3.0.0` while header + constant +
  readme are `3.5`. **Fix:** bump to `3.5.0`.
- **Large commented-out blocks** — `app/Helpers/Email/Messages.php:550-684`
  (~135 lines), `app/Controllers/Common/Email.php:375-565` (~190),
  `app/Controllers/Common/Process.php:414-475` (~60). Delete or track as
  features.
- **Non-existent trait import** — `app/API/Import.php:16`
  `use WC_Affiliate\Traits\Cleaner;` — `Cleaner` does not exist. Remove.
- **Dead `DataImporter` class** — `app/Models/DataImporter.php` zero callers in
  app/ or spa/ (the import_referrals column bug above is moot if removed).
- **Large dead-method inventory (zero callers, grep-confirmed)** across
  `Abstracts/Controller.php`, `Abstracts/Model.php`, `Models/Database.php`,
  `Models/Commission.php`, `Models/Affiliate.php`, `Models/User.php`,
  `Models/Referral.php`, `Models/Transaction.php`, `Models/Visit.php`,
  `Models/Settings.php`, `Models/ExportJob.php`, `Models/ImportJob.php`,
  `API/Visit.php`, `API/Dashboard.php`, `API/Import.php`, traits
  `Hook/Menu/Auth/Asset/Limiter`, and several commented-out controller handlers.
  **CAVEAT:** verify each isn't part of a public extension API or referenced by
  `tests/` before deleting — "zero callers in app/spa" ≠ "safe to remove" for
  public model methods.
- **Duplicated SPA logic (drifting)** — admin vs dashboard copies of
  `DashboardChart`, `Conversions`, `RecentReferrals`, `TopLandingPages`,
  `TopReferrerURLs`, `StatsCard`, and two `StatusDropdown`s with inline color
  maps that already disagree with `statusUtils.ts`. **Fix:** extract shared
  components; source colors from `getStatusStyle()`.
- **Leftover `console.log`** — `spa/dashboard/components/Sidebar.tsx:88`,
  `spa/stores/user/reducer.ts:35`, `spa/admin/pages/Affiliates/index.tsx:148`,
  `spa/admin/components/tables/ProcessPayoutPopup.tsx:19`,
  `spa/admin/components/charts/DashboardChart/components/Chart.tsx:110`,
  `spa/admin/pages/Settings/migrations/import.tsx:119,156,195`,
  `spa/admin/pages/Settings/components/fields/IconBoxField.tsx:32`.

---

## Suggested fix sequencing (separate PRs)

1. **Security batch (CRITICAL #1, #2, reCAPTCHA, SQLi orderby)** — highest risk,
   small surface, ship first.
2. **Money/payout correctness** — `get_unpaid_amount` %s, `get_payable_referrals`
   status, `order_subtotal` base, on-hold mapping, `Referral::delete()` return.
3. **Transaction model column bugs** (`time`/`method`) + `limit=-1`.
4. **SPA store-shape + race batch** — envelope normalization, loaded flags,
   unstable selectors, `updateReferralStatus`, wizard null guards.
5. **Consistency batch** — auto_payout vocabulary, status constants, meta key.
6. **Cleanup batch** — dead code (after public-API/test verification), commented
   blocks, console.logs, version bump, SPA dedup.
