# ADbotix — Build Progress Log

Running log of what's been built. Newest entries at the top. One entry per feature
(PROJECT_INSTRUCTIONS §9.7).

---

## Homepage reference-alignment refinement

- Preserved all seven homepage sections, copy, routes, tabs, timeline behavior, carousel,
  CTA actions, SEO metadata, and existing reveal animations.
- Matched the approved homepage reference more closely: taller image-led hero, four equal
  white engine cards around the capital image, contained dotted business-tabs shell,
  responsive timeline touch cards, equal-height ecosystem cards, and stronger cyan CTA glow.
- Added tab/timeline state attributes and lazy loading for below-the-fold photography.
- Rebuilt the frontend Tailwind bundle with the installed CLI entrypoint. Verified PHP lint,
  Yii bootstrap, HTTP 200, seven rendered sections, three accessible tabs, and generated CSS.
- Refined the homepage header/hero against the supplied close-up: homepage-only overlay
  glass pill navigation, compact brand lockup, tighter link/button spacing, full-viewport
  banner, two-line 62px display heading, calibrated body/stat rhythm, and layered CTAs.
  Inner pages retain the original sticky light header; homepage and About both return 200.
- Matched the supplied below-banner engines section: 1248px desktop container, equal thirds,
  353px center artwork, paired 170px cards with 13px row gaps, lighter neutral canvas, and
  screenshot-calibrated heading, label, body copy, icon chip, radius, padding, and colors.
- Matched the supplied How-to-invest timeline: layered teal vertical gradient, 123px section
  rhythm, 34px heading, 208px step columns, 138px gradient numbers, subdued inactive states,
  36px rail nodes, 3px progress track, and compact orange CTA. Existing four-step click state
  remains active; PHP lint, compiled utilities, four buttons/nodes, and HTTP 200 were verified.
- Matched the supplied Beyond ROI section: 84px/142px section rhythm, compact centered type,
  680px intro measure, 888px two-column stage, exact 392x475 image card, and a 440px diamond
  diagram offset by 65px. Diamond tiles were resized to the Figma geometry while preserving
  all four trust points. PHP lint, generated utilities, labels, and HTTP 200 were verified.
- Matched the latest full-width earning-ecosystem carousel: four equal desktop cards fill the
  viewport using a responsive width formula, with 28px gaps, 278px height, flatter Figma cards,
  44px active pagination, and 60px gradient controls on a 1128px rail. All six slides remain;
  navigation now reads the responsive CSS gap. PHP lint, compiled CSS, and HTTP 200 passed.
- Matched the supplied pre-footer CTA as a true full-width 780px cinematic band (no outer
  max-width/rounded box), with a centered 965x456 glass panel, teal side beams/particles,
  46px two-line heading, compact supporting copy, and layered orange CTA orbits. The signup
  route is unchanged; homepage, About, Contact, and Signup all return HTTP 200.
- Matched the supplied shared footer: 926px information grid, compact orange ADBOTIX lockup,
  single-row seven-icon social rail, calibrated link/legal typography, divider rhythm, and a
  responsive oversized metallic ADBOTIX wordmark. Added a subtle 9s sheen animation with a
  reduced-motion fallback. All routes remain unchanged; Home/About/Contact return HTTP 200.

---

## Six system adjustments — min withdrawal, re-topup rule, pool tree, date filters, monthly business

A batch of adjustments across backend + frontend, all verified (unit suite: no new
failures vs baseline; authenticated page renders return 200 with correct content).

- **Min withdrawal $10 → $5 everywhere.** `SettingsService` DEFAULTS `min_withdrawal`
  `5`; `WithdrawalService` fallback + comments; the withdraw view now reads the value
  live (`SettingsService::getDecimal`) instead of hardcoding — header + input placeholder
  + `min` attribute all show $5; DB value updated (cache flushed). No stale "$10" remains.
- **Re-topup rule (money-critical).** `InvestmentService::persistDeposit` now opens the
  Global Auto Pool entry ONLY on the account's first non-rejected deposit ($30 carve +
  remainder → Trading Pool). Every re-topup goes 100% to the Trading Pool with no carve
  and no new pool entry (`pool_entries = 0`, `working = full amount`). A second pool
  position requires a new account. A rejected first deposit (entries deleted) lets the
  next deposit become the opener. Tests updated/added in `InvestmentServiceTest`
  (only-first-opens, retopup-full-to-trading, rejected-first-reopens); `LifecycleTest`
  Phase 4 seeds extra entries directly since one account now holds one entry.
- **Global Auto Pool tree.** Extracted the pool-tree builder into a shared
  `common/services/PoolTreeService` (`forUser`, `peopleUnder`). Investor: a "View full
  pool tree" link on the Global Pool page deep-links into the IB-tree "Global Pool" view
  (`?view=pool`). Admin: new `PoolController::actionTree/actionTreeData` + `pool/tree.php`
  org chart with a "People under" count, linked from every pool-index row and the
  investor detail page. Backend got the orgchart JS/CSS assets.
- **Referral drill-down verified.** Levels 2/3/… expand-on-click confirmed end-to-end
  (service returns correct `under_count`/expandability; `isSelfOrDescendant` authorises;
  recursive expand/collapse JS intact). `direct_count` accurate for all real accounts.
- **Admin date filters + range totals** on Deposits, Withdrawals, Wallet Ledger. New
  `common/helpers/DateRange` (this week / this month / last month / custom, server-TZ)
  + `date_filter` component. Each screen shows a totals strip that recomputes from the
  filtered set (deposits: value + count; withdrawals: gross/fee/net/count; ledger:
  credits/debits/net/count). Verified totals change with range (all $2,690 · July $0 ·
  June $2,690).
- **Monthly business** on admin Global Pool (`RoyaltyService::monthlyVolume`) and Trading
  Pool (new `HoldingService::monthlyVolume` — Σ working investment of holding plans that
  month), via a shared `month_business` card with a calendar-month selector.

---

## Change-password feature — investor panel + admin backend (gap found via a locked-out login)

Root cause of "cannot login as atik": a QA temp password had stuck (a restore step failed),
AND the product had NO self-service password change anywhere — Profile only held
notification prefs, and the reset-by-email flow needs SMTP (off in dev). Built the missing
feature on both sides (§6-compliant):

- **Investor**: `ProfileController::actionChangePassword` (POST) — verifies the CURRENT
  password, ≥8 chars, confirm-match, ≠current; rate-limited 5/10min; `User::setPassword`.
  Profile page gains a "Security — change password" card.
- **Admin**: `SiteController::actionChangePassword` (GET form + POST) with the same
  validation + rate limit, **audit-logged** (`admin.password_change`); the topbar user
  chip now links to it.
- **Verified over HTTP**: wrong-current rejected; change accepted; old password refused,
  new password logs in (disposable test user aitest2); admin flow round-tripped and wrote
  2 audit rows. Lint clean, CSS rebuilt.

---

## Brand + website redesign — wealth narrative, logos, auth pages, spacing fix

- **Logos** (`media/Images/main-logo.svg` light / `main-for-black.svg` dark) replace the
  text brand marks everywhere, background-aware: site header + footer (light), auth aside
  (dark) + auth mobile (light), panel sidebar (light), admin sidebar (dark, + "Admin"
  chip). Copied to `backend/web/media/Images/` — cross-app URLs aren't stable between
  php -S and Apache.
- **Public site repositioned to the wealth narrative — NO plan rates/figures anywhere.**
  Home: "Every fortune starts with a first step." hero with an ascending-path motif SVG,
  four pillars (wealth building path / secure future / grows daily / network → net worth),
  path teaser (Begin·Fund·Grow·Harvest), six-engine ecosystem (concepts only, meaningful
  icons), "secure future" dark band. `/plans` is now **The Wealth Path** — a staged
  journey page (Begin/Build/Multiply/Secure with a connector-line timeline); controller
  slimmed (no config reads). Nav + footer: "Plans" → "Wealth Path". How-it-works, about,
  contact FAQ scrubbed of rates/fees/$ figures ("live figures in your panel").
  Verified: all public pages 200 with ZERO rate/tier/$ matches.
- **Auth pages**: login + signup get card-framed forms, wealth copy, icon chips, tidier
  links (Forgot right-aligned), 2-col name fields, primary-colored checkbox. Auth aside
  highlights rewritten to the four pillars with distinct icons (path/shield/sprout/network).
- **Icons**: Trading Pool nav icon (both sidebars) clock → growth chart; new meaningful
  set across site pillars/ecosystem/trust strip.
- **section_tag component**: proper vertical rhythm (mt-[30px]/mb-[14px], first:mt-0) +
  a fading hairline — fixes the cramped orange headings in the backend.
- Verified with screenshots: home, Wealth Path, login, admin dashboard (spacing) and
  admin drawer (white logo). CSS rebuilt (both bundles); all lint clean.

---

## Admin dashboard — Platform Volume chart fixes + widget audit

- **Platform Volume**: day buckets/labels moved from UTC to the SERVER timezone
  (`strtotime('today -N days')` / `date('D')`) — with `created_at` stamped by `time()`,
  UTC buckets shifted early-morning (00:00–05:30 IST) deposits to the previous day.
  `bar_chart` partial upgraded (backward-compatible `amount` param): compact $ value
  printed above non-zero bars, full-amount tooltip on every bar, non-zero bars keep a
  ≥5% visible height, today's label bolded. The hatched-vs-primary design pattern kept.
- **Total Investors** now excludes the internal `company` root account (was inflating
  the count by 1).
- **Audit**: every dashboard widget verified against DB truth — invested $2,690; pool
  12 entries / 2 placed / 2 active / 0 completed; Income Mix $14.67 = 1.77 ROI&Team
  (12.07%) + 12.90 Referral&Royalty (87.93%) + 0 Pool&Rebirth; chart $520 Sun / $80 Tue.
  All queries are live (no placeholders). "All Deposits" → deposit/index 200 and the
  bell's "View all notifications" → notification/log 200, both verified authenticated.

---

## Nav pairing, Blockchain-settings simplification, full pre-production QA

- **Panel nav**: the two engines now sit TOGETHER under one "My Income" group (Trading
  Pool + Global Auto Pool); Network keeps Referrals/Rank. Admin already paired them;
  Stacking hidden on both sides (dynamic — returns if a term is re-enabled).
- **Settings → Blockchain & Wallet simplified**: only the two operator credentials are
  shown up front (BscScan API key — needed for auto-detect, with where-to-get-it help;
  WalletConnect project id — optional), everything else pre-configured and collapsed
  under "Advanced chain settings". `deposit_scan_start_block` filled with the LIVE BSC
  block (107,773,854 via eth_blockNumber) so the watcher starts from now, not genesis.
  The `branding` group (plan display name) added to the Plans & Level Income tab.
- **QA (all green)**: transactional money-flow check (rolled back, zero residue):
  $500 → 470/30 split; $30 → 9/1.50/6.90/12.60; plan trading@0.20; day-1 ROI 0.94
  credited; L1 level income 0.24 (25%) to the sponsor; cap base/limit counting; wallet
  balance == ledger sum for both users. Full unit suite: 0 new failures vs baseline.
  Payment flow over real HTTP: wallet gate blocks deposits without a connected wallet
  (verified), then form POST → awaiting deposit → pay page (amount + company wallet +
  QR + status polling JSON) → admin queue → approve → trading@0.20 plan created.
- **Incident**: the QA script deleted its password-hash backups after a failed restore —
  original local dev passwords for `atik` (user 97) and `super_admin` were lost and reset
  to known temporary values; the operator was told to change both immediately. Dev DB
  only; production unaffected (not yet deployed).

---

## Business shift — Stacking retired, plan rebranded "Trading Pool" (Business details.xlsx)

Per the client sheet + confirmations (0.2%/day = 6%/month; trips from 5★; leg condition 2;
5.5% note informational; stacking rows were test data). Plan doc: GLOBAL_TRADING_SHIFT_PLAN.md.

- **Migration `m260703_000001_trading_pool_shift`**: ranks_catalog reseeded per the sheet
  (Star $2k/$100 … Triple Diamond $1M/$100k; trips tier≥5, cars removed, require_count=2;
  down() restores M8.3); `holding_tiers` → single active `trading` tier (min 80, 0.20%/day);
  seeds unlocked `plan_label_trading` = "Trading Pool" (group `branding`).
- **Dynamic display name**: views read `plan_label_trading` (panel+admin sidebars, dashboard,
  deposit page, holding screens, config/plans, site plans page) — admin edits the label in
  Settings and every surface follows. Internal keys (`plan_type=holding`, ledger sources,
  tables) unchanged — presentation-only rename, money-safe.
- **Stacking retired dynamically**: nav items render only while a term is enabled or legacy
  plans exist (panel per-user, admin global); config/plans keeps the terms card marked
  "retired" as the re-enable control; site plans page renders the Stacking section/compare
  column only from enabled terms (via StackingService::enabledTerms() in SiteController).
  Crons/services untouched (idempotent no-ops). Test stacking plans/accruals wiped (confirmed
  test data). Site copy rewritten to the two-engine story (hero, engines, about, HIW, footer).
- **Verified**: full suite 0 new failures vs baseline; deposit gate — holding $80→working $50
  (the sheet's $50 Trading Pool + $30 Global Auto Pool split), $5000 accepted (single tier),
  stacking rejected; approved plan lands at tier `trading` 0.20%/day; label round-trip
  propagates; authenticated sweep: investor/admin/public pages 200 with ZERO "Stacking"
  mentions and "Trading Pool" present on every surface.

---

## Wallet connect v2 — device-aware options, multi-wallet + account picker (M13)

**Bug fixed (the empty-QR WalletConnect modal):** `.env` shipped the placeholder
`WALLETCONNECT_PROJECT_ID=your-reown-project-id`; `walletConnectProjectId()` treated any
non-empty env as real, so the WC modal opened against Reown's relay with a garbage id →
blank QR, "Search Wallet 0". The resolver now accepts only a well-formed 32-hex Reown id
(placeholders → unset, option hidden); `.env` value blanked with a how-to comment.

**Connector rebuilt for real-world investors** (`wallet-connect.js` + `connect/index.php`):
- **EIP-6963 multi-wallet discovery** — MetaMask AND Trust extensions side-by-side each get
  their own labelled button (wallet icon included); legacy `window.ethereum(.providers)` fallback.
- **Account picker** — if the extension authorises >1 account, a modal lists them and the
  investor picks WHICH address to link; the nonce is then signed BY that account
  (`getSigner(address)`). MetaMask connects always re-ask `wallet_requestPermissions` so the
  selection can be changed later.
- **Device-aware options** — in-app/dApp browser or extension: per-wallet Connect buttons;
  mobile browser without a wallet: "Open in MetaMask" + "Open in Trust Wallet" deep links;
  desktop without a wallet: Install MetaMask; "Other wallets (WalletConnect)" only when a
  real project id is configured. Auto-connect only when exactly ONE in-app wallet exists.
- Server verification unchanged (nonce + ecrecover).

**Verified in-browser (harness driving the real JS):** two-extension case → 2 labelled
buttons; 3-account case → picker shown, account #2 chosen, signature+POST made by that exact
address; mobile-no-wallet → both deep links; +WC id → WalletConnect option appears;
desktop-no-wallet → install CTA. Zero console errors; SettingsServiceTest green (11/125).

---

## Deposit auto-detection (M14) setup + mobile responsiveness (admin + user + site)

**Auto-detection verified & made operable.** The ChainWatcherService/BscScanClient flow was
already complete (7/7 ChainWatcherServiceTest pass: match+confirm, idempotent re-scan,
mismatch/unknown flagged, manual coexists). Added an **admin readiness banner** on the
Deposits screen (`DepositController::autoDetectStatus()` + `deposit/index.php`): shows
"active" (watching wallet · N confirmations · last scan block/time) when a default deposit
wallet + BscScan API key are set, else "needs configuration" with a Configure link. Staging
still needs: BSCSCAN_API_KEY, a recent `deposit_scan_start_block`, and the `deposits-scan` cron.

**Mobile responsiveness — both shells reworked to an off-canvas drawer.** The panel
(`panel.php` + `_panel_sidebar`/`_panel_topbar`) and admin (`main.php` + `_sidebar`/`_topbar`)
used a fixed `grid-cols-[248px/256px_1fr]` that never collapsed. Now: `lg:grid` only, the
sidebar is a `fixed -translate-x-full` off-canvas drawer on mobile (hamburger in the topbar,
dimmed backdrop, close button) and `lg:sticky` inline on desktop; topbars hide the search pill
< sm, compact the actions, and the panel adds a compact invest icon. Reduced main padding on
mobile. Fixed the dashboard referral-link card (fixed 400px input → stacks & fills on mobile).
**Verified on a 375px viewport (preview screenshots): panel + admin drawers open/close, nav
groups render, content is full-width.** Also fixed the stale **"AI BOX" → "ADbotix"** brand
across the site header/footer, auth layout, panel sidebar and dashboard card.

---

## Payments — wallet connect (MetaMask-first) + deposit approval QA + prod-readiness

**Wallet connect (M13) reworked for the MetaMask-in-app-browser model.** `wallet-connect.js`
+ `connect/index.php`: the injected `window.ethereum` provider is now the PRIMARY path
(no Reown project_id needed) — a single "Connect Wallet" button, plus **auto-connect** on
the connect page when injected + no wallet. A public browser instead sees **"Open in
MetaMask"** which deep-links (`metamask.app.link/dapp/…`) into MetaMask's in-app browser.
WalletConnect is shown only when a project_id is configured (no dead button). Server nonce
+ ecrecover verification unchanged. **Verified in a real browser (preview)**: injected →
Connect shown; no-injected → Open-in-MetaMask shown; deep-link well-formed; no console
errors. Added **filemtime asset cache-busting** (`?v=<mtime>`) so the JS updates on a
long-cached prod deploy (guarded so it can't error if @webroot is unresolved).

**Admin receiving wallet** added by the operator via Company Wallets and verified end-to-end
(masked): default + active, valid BEP-20, `defaultDepositWallet()` returns it, the investor
Pay page renders address+amount+QR, chain-watcher targets it, audit log recorded the change.

**Deposit approval flow QA'd (M3/M11).** `confirmDeposit()` is the single approval path
(admin + chain-watcher + tests): approve → status approved + referral first-investment +
Stacking/Holding plan created; reject → pool entries voided. End-to-end test (Bronze $500):
approve created the bronze holding plan + set first_invested_at; reject voided the entry.

**Fixed corrupted `pool_carveout` (was 3, spec is $30, M6.2)** — a full config-integrity scan
of all locked money values found only this drift; restored to 30 (a $500 deposit again carves
$30 → working $470). All other locked money values match spec.

---

## Configuration module — fully dynamic end-to-end (cache propagation + structural constants)

**Problem reported:** admin config changes (e.g. plan rates) did not take effect in the rest of
the system. Root cause was NOT the read path (services already read `SettingsService`) — it was
**cache propagation across the three apps**.

**Root-cause fix (the real bug):** the `cache` (FileCache) defaulted to `@runtime/cache`, which
resolves per-app → three isolated stores (`frontend`/`backend`/`console/runtime/cache`). The
settings map is cached with no TTL and `flushCache()` only cleared the local app's store, so a
backend admin save never invalidated the frontend/cron caches. Fixed in `common/config/main.php`:
single shared `cachePath => @common/runtime/cache`, fixed `keyPrefix`, **and `dirMode 0777` /
`fileMode 0666`** — essential because Apache (frontend/backend = `daemon`) and cron (the OS user)
are different OS users; without world-writable cache dirs the cross-user `unlink` in `flushCache()`
silently failed. Tests get an isolated cache (`common/config/test.php` → `@common/runtime/test-cache`)
so the test DB never poisons the dev/prod settings cache. **Verified end-to-end over real HTTP:**
backend admin POST changes a setting → DB updated → a *fresh* console process reads the new value.

**Structural constants made config-driven** (per request — "everything dynamic"):
- `matrix_width` setting (default 3). `PoolService::width()/poolSizes()/treeSize()` derive 1/W/W²/W³
  and tree size (40 at W=3); `RebirthService` uses them for cycle economics + completion check.
- `capped_sources` setting (JSON) → `CappingService::cappedSources()` (default stacking/holding/level/rank;
  Global Pool income never cappable).
- `rank_strong_leg_pct` / `rank_weak_leg_pct` (60/40) → `RankService::qualifiedBusiness()`.
- Referral payout depth derives from `pool_referral_levels` (`PoolService::referralLevels()`);
  30-level depth derives from the `level_income` bands (`LevelIncomeService::maxLevel()`).
- Constants kept as documented defaults/fallbacks. New keys seeded by
  `m260701_000001_seed_dynamic_config_settings`.

**Admin UI:** matrix width (with a STRUCTURAL warning — affects new entries only) on Config·Pool;
capped-income-source checkboxes on Config·Cap; 60:40 weighting on Config·Ranks.

**Verified:** money/config unit suite green (Pool/Rebirth/Capping/LevelIncome/Stacking/Holding/
Royalty/DynamicConfig/ConfigRetune/Investment/PoolReferral/Settings); **full suite shows the exact
pre-existing 19 errors + 5 failures — 0 new regressions** (those are an unrelated AuthService/
ReferralService/Rank "Affiliate code does not exist" fixture issue, present before this work). All
new dynamic values proven to change behaviour via stub injection, and defaults reproduce the spec
(W3→tree40/pools 3·9·27, 15 referral levels, 30 income levels, 60/40, the 4 capped sources).

---

## Brand rename — "AI BOX" → "ADbotix" / "adbotix" (full project)

**Status:** done; behaviour unchanged. **Full suite green** (common unit 222/926, frontend 33/86;
backend's one pre-existing `LoginCest.loginUser` failure is unrelated — no brand coupling), and
**`reconcile()` holds** (platform RECONCILED; "balance == ledger sum" invariant test passes). No
protected money/crypto value touched (USDT contract, wallet addresses, tx-hashes, RPC/explorer
URLs, WalletConnect project_id, API keys).

**Display (user-visible):** all public site + panel + admin view strings, page `<title>`s,
footers, SEO meta, sidebar/topbar ("ADbotix" / "ADbotix Admin"), login/register copy. Email:
`MailService` From-name + `mail_from_name` default → ADbotix; test-email subject/body; From-address
standardised on **`noreply@adbotix.local`** (dev `.local`, env-overridable); contact `support@`/
`partners@` → `adbotix.local`. WalletConnect modal metadata; the wallet **signing message**
(`nonceMessage`, transient per-nonce, verify-once — round-trip test stays green); CSV slugs
(`adbotix-…`). Brand mark: kept the **"A"** letter-mark.

**Code identifiers:** Yii app `name` (both configs); `package.json` name+description (+ lockfile);
JS global `window.AIBOX_WALLET` → `ADBOTIX_WALLET` (view + connector); admin-Settings localStorage
key → `adbotix.settings.activeTab`; root-account email `root@adbotix.local` (live row + seed
migration value + test fixtures); the `EthCryptoTest` round-trip message (self-consistent, safe).
**Untouched:** PHP namespaces, env-var prefixes, session/cookie names, migration **class** names.

**Database (dev + test):** renamed the live MySQL schemas **`aibox` → `adbotix`** and
**`aibox_test` → `adbotix_test`** via atomic `RENAME TABLE` (all 40 tables, data + FKs preserved,
reversible); repointed `common/config/main-local.php` (dev fallback) + `test-local.php` (test DSN)
+ `.env.example` `DB_NAME`/`DB_USER` + `deploy/backup.sh`. App verified connected to `adbotix`;
the empty old schemas are left in place as a rollback marker (drop when satisfied).

**Files:** `AI_BOX_Development_Specification.md` → `ADbotix_Development_Specification.md` and
`ui-improvements/ai-box-components.html` → `adbotix-components.html`, with every in-repo reference
updated. Docs/prose (PROJECT_INSTRUCTIONS, spec, DESIGN_SYSTEM, guides, deploy comments) → ADbotix.

**Method/guardrail:** matched the brand as a **whole token** (`\bAI BOX\b`, explicit `aibox.local`
/ `aibox_test` / `aibox-…`), never as a substring — so the `Aibox@2026` dev-password literal and
unrelated words were left alone. No code references the absolute project path, so renaming the
working folder `htdocs/aibox` → `htdocs/adbotix` is a **manual filesystem step** (left to the
operator; nothing in the app depends on the folder name — `launch.json` uses relative paths).

---

## UI — Admin Settings as a two-column sub-nav + panel (view-only refactor)

**Status:** done, **view layer only** — `SettingsController` (`actionUpdate`/`actionTestEmail`),
every settings key, validation, locked-field logic and the single save handler are **unchanged**.
Replaces the single long scroll on `settings/index` with a sticky sub-nav + panel shell that
matches `ui-improvements/settings-subnav.html`.

**Built (`backend/views/settings/index.php` only)**
- The existing `group`-keyed `$settings` buckets are **regrouped into 8 tabs across 3 sections**
  — no key renamed or dropped: **Platform** → Investment & Fees (`investment`+`fees`), Capping
  (`cap`); **Income Engines** → Global Pool & Matrix (`pool`+`matrix`), Plans & Level Income
  (`plans`+`income`), Rebirth & Royalty (`rebirth`+`royalty`); **Infrastructure** → SMTP & Email
  (`smtp`, incl. the Test Email tool), Blockchain & Wallet (`chain`+`wallet`), Notifications
  (`notify`).
- Sticky left sub-nav card (`lg:sticky lg:top-[88px]`) with active item in `primary-50`/primary
  text; right panel card per tab with heading + description. `grid-cols-1 lg:grid-cols-[230px_1fr]`
  so the sub-nav **collapses above the panel on narrow screens**.
- Field rendering is the **same** `renderField` (locked → read-only + LOCKED badge, bool →
  checkbox, JSON → textarea, `smtp_pass` → password); JSON fields now span both columns.
- **One Save** persists the whole form (single `#settings-form` wrapping all panels). Tab
  switching is vanilla JS (`type="button"` so it never submits); the **active tab is preserved
  after Save** via `localStorage` + URL hash restore on load.
- The **Test Email** tool lives inside the SMTP tab but posts to its own action via an HTML5
  `form="settings-test-email"` companion form, so it never nests inside the main settings form.

**Verified** (admin `company` on `:8081`, screenshot-checked desktop 1280 + mobile 375): all
fields present and regrouped; tab switching works; **Save persists** (`settings.update` audit row,
47 settings rows) **and stays on the active tab**; success flash renders ("Saved N settings");
locked values stay read-only; sub-nav stacks above the panel on mobile. Backend Tailwind rebuilt.

---

## Fix — post-login lands on the correct dashboard (no more 404)

**Symptom:** after login the admin showed `Not Found (#404)` at
`8081/?r=panel/dashboard/index`. **Cause:** `panel/*` is a **frontend-only** module — it does
not exist on the admin app (8081), so that URL 404s there. Both logins also used a bare
`goBack()`, so the investor landed on the marketing home (not their dashboard) and the admin
could honour a stale/foreign `returnUrl`.

**Fixed (view/controller only):**
- **Frontend login → `panel/dashboard/index`** (`goBack(['/panel/dashboard/index'])`, and the
  already-signed-in guard redirects there too) — the investor now lands on their dashboard.
- **Admin login → `site/index`** and is **never bounced to a `panel/*` route** (the returnUrl is
  sanitised) — the admin always lands on the admin dashboard.
- Correct URLs: **investor** `http://127.0.0.1:8080/index.php?r=panel/dashboard/index`,
  **admin** `http://127.0.0.1:8081/` (= `site/index`). `LoginCest.checkValidLogin` updated to
  assert the dashboard landing; frontend suite green.

---

## Feature H — Announcements (broadcast banner)

**Status:** done, additive — no money logic; reconciliation unaffected. A **broadcast**
banner (distinct from per-user Notifications, F): one message, audience-targeted, scheduled,
dismissed per-user. Suites: **common unit 222/926**, frontend **33/84**.

**Built**
- Migration `m260610_000028` — `announcements` (variant/audience/schedule + capped field
  lengths at the DB level) + `announcement_dismissals` (unique per announcement+user). Down/up
  clean.
- Models `Announcement` (limit constants headline 60 / subheading 90 / oneliner 120 / button
  24, enforced in rules; `isLive`/`status` draft|scheduled|live|expired) + `AnnouncementDismissal`.
- `AnnouncementService`: `activeFor(user)` returns the one banner the user should see (active +
  in schedule + audience-matched [all / active-investor / by-rank] + **not dismissed by this
  user**); `dismiss(user, id)` (per-user, idempotent); audited `create`/`update`/`toggleActive`/
  `delete`.
- Admin (backend) `AnnouncementController` + views: a list with **status badges**
  (scheduled/live/expired/draft) + activate/delete, and a **create/edit form with live char
  counters** (so the banner can't break — also rejected server-side) and a **live preview** of
  the exact banner (variant teal/orange, updates as you type). All writes audited.
- Panel: the active banner renders at the top of the panel layout, **dismissible** (X →
  per-user POST); `info` = deep teal, `alert` = warm orange, optional CTA button. A new
  announcement (no dismissal row) shows again.

**Tests** — `AnnouncementServiceTest` (7): active/schedule gating; **dismiss is per-user, not
global; a new announcement re-shows**; audience `active`/`by_rank` targeting; char limits +
button-needs-URL rejected server-side; reconciliation unaffected. `AnnouncementCest` (2): the
banner renders on the panel + dismiss hides it; inactive/future banners don't show.

---

## SMTP fix — admin-configured email was being swallowed to a file

**Symptom:** "test email sent" but nothing arrives. **Causes (both fixed/identified):**
1. The dev mailer defaults to `useFileTransport = true` (no `MAILER_DSN` env). `MailService`
   applied the admin SMTP **DSN** but never flipped `useFileTransport` off → every mail was
   written to `runtime/mail/*.eml`, never sent. **Fixed:** `applyTransport()` now sets
   `Yii::$app->mailer->useFileTransport = false` when applying SMTP.
2. The **"Send real email via SMTP" toggle (`smtp_enabled`) is OFF** in your settings, so
   normal app mail wasn't using SMTP at all. The **test button now FORCES** the SMTP transport
   (validates creds even before you flip the toggle on) and reports the *real* result.
3. **Gmail caveats (your config — info@onexcell.com via smtp.gmail.com):** Gmail rejects a
   plain account password — use a 16-char **App Password** (2-Step Verification on). Set the
   From to your authenticated mailbox/alias — `noreply@adbotix.local` will be rejected/spam-
   binned. Turn `smtp_enabled` ON for platform-wide email.

---

## Feature F — Notifications (in-app bell + optional email)

**Status:** done, additive — no money/ledger change; reconciliation unaffected. Suites:
**common unit 215/906**, frontend **31/78**.

**Core**
- Migration `m260610_000027` — `notifications` (indexed by user+is_read+created_at) +
  `users.email_notifications` (default on). Down/up clean.
- `Notification` model (type/icon/label maps, relative-time); `NotificationService`:
  `notify` (BEST-EFFORT like AuditService — a failure is logged, never thrown, never rolls back
  the triggering action), `notifyAdmins`, `markRead`/`markAllRead`/`unreadCount`/`recent`.
  In-app is always created; **email is the add-on** — only for important (money) events AND
  only when the user's toggle is on, sent via the existing MailService.

**Triggers** (best-effort, post-commit; money logic untouched): new direct → sponsor;
deposit detected / confirmed (email); manual deposit → admin; withdrawal requested (+admin) /
paid (email, BscScan link) / rejected (email); rank achieved (email, cron); pool position
first-placed; pool cycle completed / rebirth (cron); daily-income milestone (configurable
`income_milestone_threshold`, new idempotent `income_milestones` cron step); unidentified /
mismatched on-chain transfer → admin.

**UI**
- Panel: the topbar **bell** now shows a live unread badge + a dropdown (recent, mark-all,
  view-all) via the shared `notif_bell` partial; a full paginated **Notifications page**
  (filter by type, click marks read + follows the link); sidebar link; **Profile** email
  toggle.
- Admin: the same bell for admin events, plus a read-only **Notification Log** (who was
  notified of what, for support); sidebar link.

**Tests** — `NotificationServiceTest` (5: creates a row; best-effort failure never throws/rolls
back; unread+mark-read; own-only; money event), `WithdrawalServiceTest` +1 (a real
withdrawal-paid fires the right notification with the tx link, reconcile holds), `CronServiceTest`
updated for the new daily job, `NotificationCest` (3: bell badge renders, page lists, click
marks-read + follows link, mark-all). Cleaned the `notifications` table in the trigger-firing
tests' `_before` to prevent cross-run accumulation.

---

## E1 — Admin config tooltips (plain-English, spec-sourced)

**Status:** done. No logic change. A reusable `common/views/components/info_tip.php`
("ⓘ" with a native `title` + a CSS hover popover) now explains every field on the admin
Config screens, copy sourced from the spec and saying where each value reflects.

- **pool** — $30 split (referral 30% → "$9 feeds the 15-level referral payout on the
  investor's Referral page", royalty/matrix/company), matrix pools 1·3·9·27, qualify-directs,
  carve-out. **cap** — 3×/4× multipliers, leader threshold, minimum. **royalty** — Pool 1/2
  thresholds + %. **rebirth** — first-cycle 58% ("deducted once on the original $30 entry;
  rebirths deduct 23%"), rebirth 23%, user share, ladder (forward-only note). **income** —
  15-level referral + 30-level bands (forward-only notes). **plans** — stacking/holding
  ("rate snapshots at purchase; edits apply to new plans only"). **ranks** — business
  threshold / reward / global-share column tips.
- Backend Tailwind rebuilt (named-group `group/tip` hover popover verified in the bundle).

---

## E2 — Dynamic-level hardening (forward-only, non-destructive)

**Status:** done. Verified + proven by test that config edits are forward-only — already-
credited income and active plans never change; only NEW income uses new config. No schema
change was needed: the architecture already guarantees it.

- **Why it's safe:** the wallet/level/referral ledgers are append-only and immutable;
  income services read config only at credit time and write the result as an immutable row;
  **plans snapshot their rate at creation** (`StackingPlan.rate/daily_accrual`,
  `HoldingPlan.daily_rate`) so accrual never re-reads tier/term config. Rebirth/pool cycles
  credit immutably as they fire — a config edit can't rewrite a past credit.
- **`DynamicConfigTest` (3):** (1) credit 30-level income, **remove the L4 band**, credit a
  new day → the day-1 $0.75 credit and balances are **unchanged**, reconciliation holds, and
  the removed level pays nothing on the new day (only new income uses new config). (2) an
  active Holding plan keeps its snapshotted 0.50%/day after the tier rate is slashed to
  0.10% — edits apply to new plans only. (3) the 15-level referral depth and matrix pools are
  freely add/removable (config-driven).

---

## E3 — Split-screen register + login

**Status:** done. View-layer only — `RegistrationForm`/`LoginForm`, CSRF, validation, the
sponsor-required + email-not-unique rules and `#login-form`/`LoginForm[...]` field names are
all unchanged (LoginCest 4/4 still green).

- New `frontend/views/layouts/auth.php` — split-screen shell: **left** a deep-teal
  gradient-mesh + grain panel with the brand mark, a "Refined · transparent · money-grade"
  badge, a rotating headline, and **3 auto-rotating feature highlights** (JS, 3.5s); **right**
  the form. Stacks to a single column on mobile (left panel hides, compact brand on top).
- `signup.php` / `login.php` rebuilt for the right column in our palette/fonts (Bricolage +
  Plus Jakarta), big orange CTAs ("Create Account" / "Log in"), footer cross-links, and a
  view-only consent checkbox on signup. Views switch layout via `$this->context->layout`.
- **Screenshot-verified** desktop (1320px — full split-screen, rotation working) and mobile
  (375px — clean single column) for both pages.

**Suites:** common unit **209/876** (+3 DynamicConfigTest), frontend **28/72** (LoginCest
green), backend unchanged (the one red `LoginCest` is the pre-existing icon-only-logout
assertion, untouched here).

---

## M15 — On-chain withdrawal payouts (extends M10, not a rebuild)

**Status:** done, additive. The existing withdrawal engine is **unchanged** — $10 min, 5%
server-computed fee, weekly cycle, idempotency (`request_key`), and Stacking-maturity-by-
construction all stay exactly as built; completion still never touches the wallet (gross was
reserved at request). Suites green: **common unit 206/856**, frontend **28/72**;
reconciliation holds.

**What changed**
- **Request** now snapshots the investor's **active connected wallet** (M13) as
  `payout_to_address`, and **rejects with a clear message if no wallet is connected** — the
  payout destination must be known up front.
- **Completion** now takes the **chosen company payout wallet** (M13) + the payout tx-hash:
  the admin pays `net` manually in their own MetaMask, then records `payout_tx_hash`,
  `company_wallet_id` and `paid_from_address`. Fee/net/ledger logic untouched.
- **Pluggable payout seam** — `PayoutMethod::pay(withdrawal, companyWallet): txHash`, with
  only `ManualPayout` implemented (carries the admin-entered hash). A future Tier-2
  `SystemPayout` (hot-wallet signing) can be added as an isolated module **without touching
  WithdrawalService** — it just returns a broadcast hash. (SystemPayout NOT built.)

**Built**
- Migration `m260610_000026_withdrawal_payout_fields` — `withdrawals` gains
  `payout_to_address`, `company_wallet_id` (FK → company_wallets SET NULL), `payout_tx_hash`,
  `paid_from_address` (all nullable; existing rows backfill). Down/up clean.
- `Withdrawal` model — new fields + `companyWallet` relation.
- `common/services/payout/PayoutMethod` + `ManualPayout`.
- `WithdrawalService` — injects `WalletAddressService`; `requestWithdrawal` snapshots/gates;
  `completeWithdrawal(Withdrawal, CompanyWallet, PayoutMethod)` records the payout details.
- Admin `WithdrawalController`/view — queue shows the **destination address + gross/fee/net**;
  "Mark Paid" form picks a **payout wallet** (multiple selectable, from `activePayoutWallets`)
  + the payout tx-hash, with an optional **EIP-681 "Open wallet" prefill** link (admin still
  signs/sends); audited with the from/to/hash/wallet.
- Panel `withdraw/index` — history shows the **destination** + **payout tx-hash as a BscScan
  link**, status pending → completed.

**Tests** — `WithdrawalServiceTest` (14): the original M10 rules (min/fee/net, ROI+commission,
Stacking-only-after-maturity, idempotency, reject-refund, weekly queue) **unchanged and
green**, plus M15 — request rejected without a connected wallet, destination snapshot correct,
completion records tx-hash + chosen wallet + from-address, and a non-payout wallet is refused.
`LifecycleTest` updated (connects a wallet + company payout wallet before the withdraw step;
asserts the dest snapshot + payout hash) and still reconciles end-to-end.

---

## M14 — Live Deposit Detection (auto-confirm + manual override)

**Status:** done, additive. Deposits can now move from "type tx-hash → admin verifies" to
"send USDT → system detects → auto-confirms", with the manual path retained. No money/ledger
table changed; confirmation flows through the EXISTING audited approval path, so
reconciliation is unaffected. Suites green: **common unit 203/848**, frontend **28/72**.

**Flow**
- Investor picks plan + amount → an **"awaiting transfer"** deposit is created (no hash yet)
  → the pay page shows the **company deposit address + exact amount + QR** → they send USDT
  (BEP-20) from their connected wallet → the watcher detects it and **auto-confirms at N
  confirmations** through `InvestmentService::confirmDeposit` (approve + plan), so the $30
  carve-out, working amount, plan creation and leader/pool qualification all run unchanged.
- **Manual fallback retained:** "Already sent? Enter tx-hash" still creates a pending deposit
  the admin verifies; admins can still approve/reject any deposit (override).

**Service — `ChainWatcherService`** (read-only; no signing)
- Polls a BscScan-compatible explorer (`BscScanClient`, behind `ExplorerClientInterface` so
  tests inject a fake) for incoming USDT transfers to the active company deposit wallet since
  `chain_scan_state.last_scanned_block`.
- Matches a transfer to a pending deposit by **sender = the investor's connected wallet +
  amount (± tolerance) within a time window**; confirms at ≥ N confirmations.
- **Safety:** right sender / wrong amount → `mismatch` (flagged, never credited); unknown
  sender → `unidentified`; below threshold stays pending. Every observed transfer is logged
  in `chain_deposits` (unique per tx+log) so a **re-scan never double-credits**; the real
  on-chain hash is also written to `deposits.detected_tx_hash` (unique).

**Built**
- Migration `m260610_000025_live_deposit_detection` — `deposits` gains
  detected_tx_hash/from_address/confirmations/detected_at/auto_confirmed (and `tx_hash`
  becomes nullable for awaiting deposits); new `chain_scan_state`, `chain_deposits`. Down/up
  clean.
- Models `ChainScanState`, `ChainDeposit`; `Deposit` (nullable tx, detection fields,
  isAwaitingTransfer/isDetectedPending).
- `InvestmentService` — `createAwaitingDeposit` + centralized `confirmDeposit`/
  `activateApprovedPlan` (manual admin approve refactored to use the same one path).
- Config in `SettingsService` (group `chain`, env-overridable, never hardcoded):
  `bsc_rpc_url`, `bsc_explorer_api_url`, `bsc_explorer_api_key`, `usdt_contract`
  (default canonical BSC USDT), `usdt_decimals`, `deposit_confirmations` (12),
  `deposit_amount_tolerance`, `deposit_match_window_hours`, `deposit_scan_start_block`;
  getters `bscRpcUrl()/bscExplorerApiUrl()/bscExplorerApiKey()/usdtContract()` (env wins).
  Documented in `.env.example` (`BSC_RPC_URL`, `BSC_EXPLORER_API_URL`, `BSCSCAN_API_KEY`,
  `USDT_CONTRACT`).
- Cron `deposits_scan` (CronService, per-minute job_runs key) + `cron/deposits-scan` action +
  `deploy/crontab.example` every-3-minutes entry.
- Panel: live pay page (`deposit/pay`, address+amount+QR via local `qrcode.js`, status
  polling `status-json`), invest form now defaults to live with manual fallback.
- Admin: deposit queue shows confirmations + `auto`/`detected` badges; **Unidentified
  Transfers** page (`deposit/unidentified`) lists unmatched/mismatched transfers; deposit
  detail shows the confirmation source + from-address + confirmations.

**Tests** — `ChainWatcherServiceTest` (7): match→confirm after N, idempotent re-scan,
amount-mismatch flagged-not-credited, unknown-sender unidentified, within-tolerance match,
manual override, no-wallet short-circuit — each asserting `reconcile()` holds.
`LiveDepositCest` (2): live flow renders the pay page + creates an awaiting deposit; status
JSON reports pending. Existing InvestmentService/Lifecycle/Cron suites still green (the
approve-path refactor is behaviour-preserving).

---

## M13 Feature B — client connector swap → WalletConnect v2 / Reown

**Status:** done, **client-side only**. The whole server side is untouched —
`user_wallets`, `company_wallets`, `WalletAddressService` (ecrecover ownership),
`CompanyWalletService`, gating, audit, and all M13 tests remain exactly as built and pass.
Reconciliation unaffected.

**Change** — replaced the raw `window.ethereum` / ethers-only connect in the panel Wallet
section with a framework-free **WalletConnect v2 (Reown)** connector so the same
Connect / Replace / sign-nonce flow works across **MetaMask and Trust Wallet, on desktop
(extension) and mobile (deep-link / QR)** — including a normal mobile browser.

**How** — new vanilla ES module `frontend/web/media/js/wallet-connect.js` (no React/framework):
- **WalletConnect path** — dynamically imports `@walletconnect/ethereum-provider@2` (Reown
  WC v2) from an ESM CDN with its built-in modal: QR on desktop, installed-wallet deep-links
  on mobile. Targets **BSC (chain 56)**; prompts switch/add network if the wallet is elsewhere.
- **Injected path** — when `window.ethereum` exists, a "Use browser extension" button is
  revealed and offered directly (desktop MetaMask/Trust).
- **Ownership proof unchanged** — every path wraps the EIP-1193 provider with ethers, calls
  `personal_sign` on the **same server-issued nonce**, and POSTs `{address, signature}` to the
  existing `WalletAddressService` connect endpoint, which verifies with ecrecover as before.
  Graceful messages when no wallet is available / WC unconfigured / request rejected.

**Config (new)** — `walletconnect_project_id` added to `SettingsService::DEFAULTS` (empty,
group `wallet`) with `SettingsService::walletConnectProjectId()` resolving **env
`WALLETCONNECT_PROJECT_ID` → setting → ''** (verified: env wins, default empty). The
project_id is a *public* client id (free from the Reown dashboard) — **never hardcoded**;
documented in `.env.example`. `ConnectController` passes it to the view; `connect/index.php`
injects it as `window.ADBOTIX_WALLET` and loads ethers (local UMD) + the connector module.

**Touched (client/config only)** — `frontend/web/media/js/wallet-connect.js` (new),
`frontend/modules/panel/views/connect/index.php`, `frontend/modules/panel/controllers/
ConnectController.php`, `common/services/SettingsService.php`, `.env.example`,
`tailwind.frontend.config.js` (scan media/js for the busy-state classes).

**Verified** — M13 server tests unchanged & green (EthCrypto 5, WalletAddress 8, CompanyWallet
9); frontend functional green incl. `WalletGateCest` (connect page renders with the new
connector, gate 403s intact); **common unit 196/795**, frontend **26/62**; reconciliation
holds. The full Reown AppKit modal needs a bundler; the WC v2 EthereumProvider is its
supported framework-free core and delivers the required cross-wallet / mobile behavior.

---

## M13 — Wallet Connection (investor MetaMask + company wallets + gate)

**Status:** done, additive — no money/ledger table or logic changed; reconciliation
unaffected. Suites: **common unit 196/793**, frontend **26/62** (incl. 4 wallet-gate
functional). (Backend functional `LoginCest` was already red pre-M13 — a stale assertion
on the icon-only topbar logout button; not touched here.)

**What it does**
- Investors connect / disconnect / replace a BSC MetaMask wallet, proving ownership by
  signing a server-issued nonce (EIP-191 `personal_sign`); the server recovers the signer
  with a BSC-compatible **ecrecover** and confirms it matches. No private keys are ever
  stored (§6). One active wallet per user; history retained.
- Admins manage multiple company wallets (add/edit/activate/set-default/label, deposit vs
  payout); exactly one default deposit wallet; payout wallets selectable at payout time.
  Every change audited.
- New verified accounts with no wallet and/or no investment reach the dashboard but see an
  **"Activate earning"** alert and are blocked from deposit/withdraw/transfer until a wallet
  is connected. The email-verification gate is unchanged — the wallet gate runs alongside it.

**Crypto (pure PHP, no gmp — this XAMPP lacks it)**
- Vendored `kornrunner/keccak` (pure PHP) for Keccak-256.
- `common/helpers/Secp256k1.php` — bcmath secp256k1 (Jacobian scalar-mult, one inverse per
  result) doing `recoverAddress(hash, sig)` and a test-only `sign()`. `EthAddress` (EIP-55
  checksum/validate) and `EthPersonalSign` (EIP-191 hash + verify/recover).
- Correctness pinned to **external vectors**: the 3 EIP-55 examples, famous key→address
  vectors (key 1 → `0x7E5F…Bdf`, key 2/3), plus a sign→recover round-trip. (Fixed a
  negative-`bcmod` normalisation bug found via a 20-nonce round-trip sweep.)

**Built**
- Migration `m260610_000024_create_wallet_connection` — `user_wallets`, `company_wallets`.
- Models `UserWallet`, `CompanyWallet`; services `WalletAddressService`
  (connectInvestor/replace/disconnect/getActive/hasActiveWallet, rate-limited, audited) and
  `CompanyWalletService` (CRUD + single-default invariant + payout selection, audited).
  Audit actions added to `AuditService`.
- Panel: `ConnectController` + `connect/index.php` (ethers.js from
  `frontend/web/media/js/ethers.umd.min.js`, nonce-in-session sign flow, connect/replace/
  disconnect), sidebar "Connect Wallet" link. `BaseController::requireWallet()` +
  `activationState()`; wired into Deposit/Withdraw/Wallet-transfer; dashboard banner.
- Admin: `CompanyWalletController` + `company-wallet/index.php` (add/edit/default/toggle/
  delete), System nav link; investor detail shows connected wallet + full history.

**Tests** — `EthCryptoTest` (5), `WalletAddressServiceTest` (8: ownership accept/reject,
one-active invariant, replace→old disconnected, idempotency, disconnect+gate),
`CompanyWalletServiceTest` (9: default/activate/payout/delete-guard/dup/format),
`WalletGateCest` (4: banner shown, deposit+withdraw 403 without wallet, connect page
reachable). 

**Deploy note** — `kornrunner/keccak` is now a runtime dependency (`composer install` picks
it up). `composer.phar` was added at the repo root (no composer binary was present).

---

## M8 rank rule change — qualification = total business × 60% (threshold-only)

**Status:** done. All suites green (**common unit 174/738**), reconciliation holds.

**Change** — replaced the leg-weighted 60:40 formula with a simpler rule, leaving every
other income engine untouched:
- **Qualified Business = (total of ALL direct legs, recursive) × 60%.** Previously it was
  `strong×60% + weak×40%`.
- **Strong Leg** (largest) and **Weak Leg** (sum of the rest) are still computed and stored
  in `business_snapshots`, but are now **display-only** — they no longer drive qualification.
- **Threshold-only qualification:** a rank is met when Qualified Business ≥ its
  `business_required`. The legacy "3× N-Star" leg conditions (`condition_rule`) are kept in
  the catalog **for reference** but no longer gate qualifying. Removed the now-dead
  `countLegsWithRank()` gating path.
- **Unchanged:** rank permanence, rank rewards routing through the cap (source `rank`),
  monthly global-share logic, and all other engines.

**Worked examples (encoded as tests)**
- total legs **$10,000 → qualified $6,000 ≥ $5,000 Star → $150** reward.
- total legs **$8,000 → qualified $4,800 < $5,000 → no Star.**

**Touched**
- `common/services/RankService.php` — `qualifiedBusiness()` now `total × 60%` and returns a
  `total` key; `evaluate()` drops the leg-condition gate; removed `countLegsWithRank()`;
  constant `QUALIFIED_PCT='60'` (was `STRONG_PCT`/`WEAK_PCT`).
- `common/services/CronService.php` — `monthlyRankEval` doc updated (threshold-only).
- `common/tests/unit/services/RankServiceTest.php` — total×60% assertions, the two worked
  examples, and `testLegConditionsNoLongerBlockHigherRanks` (5-Star granted on business
  alone, no 4-Star legs).
- `common/tests/unit/LifecycleTest.php` — rank-step comment: A's total legs $14,910 × 60% =
  ~$8,946 ≥ $5,000 Star; `reconcile()` still asserted after the rank step.
- Panel `views/rank/index.php` + `RankController.php` — show **Total Team Business**, the
  **qualified (×60%)** figure, a leg breakdown (strong/weak marked display-only), and
  progress to the next **threshold**; dropped the leg-condition gating text.
- Admin `backend/views/rank/index.php` — subtitle reworded to the new rule.
- Marketing `frontend/views/site/index.php` + `how-it-works.php` — rank wording updated.
- `common/models/BusinessSnapshot.php` — docblock updated.

**Acceptance** — $10k→$6k→Star and $8k→$4.8k→no-Star proven; conditions no longer block
(5-Star on business alone); permanence intact; cap routing intact; **174 tests / 738
assertions green**; ledger reconciles after the rank step and end-to-end.

---

## Phase 11, step 11.3 — Deployment  ·  P11 SIGN-OFF ✅

**Status:** done. Production-ready, secured, deployable; dev unchanged. Suites green:
**common unit 172/732**, frontend 12, functional 10.

**Built**
- **Env-based config** — `env.php` lightweight `.env` loader (no override of real env vars); the
  three entry scripts (`frontend/web`, `backend/web`, `yii`) now read **YII_DEBUG / YII_ENV from the
  environment** (dev default if unset). DB credentials + `cookieValidationKey` come from env
  (`DB_*`, `COOKIE_VALIDATION_KEY_*`); mailer already env-driven (`MAILER_DSN`). `.env.example`
  documents every variable; `.env` + `/backups/` added to `.gitignore`.
- **Prod hardening (fixed a real hole)** — the debug toolbar + Gii were gated on `!YII_ENV_TEST`,
  which would have loaded them **in production**; now gated on `YII_ENV_DEV` only. Secure, http-only,
  SameSite cookies enabled in **prod only** (`YII_ENV_PROD`), schema cache on when not debugging.
- **Cron scheduling** — console `CronController` (`cron/daily`, `cron/weekly`, `cron/monthly`) runs
  the M12 jobs in dependency order (each idempotent via `job_runs`); `deploy/crontab.example` wires
  them + the nightly backup.
- **Backups** — `deploy/backup.sh`: `mysqldump --single-transaction` (money tables stay consistent)
  + media tar, gzipped, with N-day rotation; reads `DB_*` from `.env`/env.
- **HTTPS / web server** — `deploy/nginx.conf.example`: HTTP→HTTPS redirect, separate TLS server
  blocks for the public site (`frontend/web`) and admin (`backend/web`), HSTS + security headers,
  PHP-FPM with `YII_ENV=prod`, dotfile/PHP lockdown, optional admin IP allow-list.

**Verified** — prod mode (`YII_ENV=prod YII_DEBUG=0`): **debug toolbar off, Gii off, secure cookies
on**, console + crons boot clean (`cron/daily` ran). Dev mode (no env): frontend/backend still 200,
debug toolbar present, all suites green.

### Deployment runbook

1. **Provision** — PHP 8.2 + ext (pdo_mysql, mbstring, intl, bcmath, gd), MySQL 8, nginx, Node (for
   the asset build), Composer. Clone to `/var/www/adbotix`.
2. **Secrets** — `cp .env.example .env`; set `YII_ENV=prod`, `YII_DEBUG=0`, `DB_*`, fresh
   `COOKIE_VALIDATION_KEY_FRONTEND/BACKEND` (`php -r 'echo bin2hex(random_bytes(16));'`),
   `MAILER_DSN`, `FRONTEND_URL`. `chmod 600 .env`.
3. **Install** — `composer install --no-dev --optimize-autoloader`; `npm ci && npm run build`
   (compiles both Tailwind bundles).
4. **Permissions** — make `frontend/runtime backend/runtime console/runtime *​/web/assets` writable
   by the web user; create `runtime/logs` and `backups/`.
5. **Database** — create the DB + user; `./yii migrate --interactive=0` (creates every table, seeds
   settings/ranks/RBAC + the `company` root). Set the admin password and assign the role:
   set a password for `company`, then `./yii rbac/assign company superAdmin` (already seeded; re-run
   if needed).
6. **Web server** — install `deploy/nginx.conf.example` (adjust domains/paths), obtain TLS with
   certbot, reload nginx. Confirm HTTP→HTTPS and that the admin is on its own subdomain.
7. **Cron** — `crontab -u www-data deploy/crontab.example` (adjust `APP`). Verify
   `./yii cron/daily` once by hand.
8. **Backups** — confirm `deploy/backup.sh` runs and writes a gzip to `backups/`; verify a restore
   into a scratch DB.
9. **Smoke test** — register an investor, verify the email (real SMTP), make a deposit, approve it in
   admin, run `cron/daily`, request a withdrawal, complete it; check the Reports → Reconciliation
   page shows **RECONCILED**.
10. **Go-live checks** — debug toolbar/Gii absent, cookies `Secure`, HSTS present, rate-limit a few
    bad logins, confirm the audit log records admin actions.

**Sign-off P11:** production-ready · secured (env secrets, debug off, RBAC, rate-limited, secure
cookies, audited) · deployable (migrations, cron, backups, HTTPS, runbook). Full lifecycle proven
end-to-end (11.2) and reconciles.

**Flags (deploy)**
- For multi-node, point `Yii::$app->cache` at Redis (shared rate-limit + schema cache) and use a
  shared session store.
- `.env` is the single-server convenience; prefer real env vars (systemd/container) at scale.

---

## Phase 11, step 11.2 — End-to-end lifecycle test ✅

**Status:** done. One integration test exercises the whole platform and asserts ledger
reconciliation + cap behaviour at every step. Suites green: **common unit 172/732**, frontend 12,
functional 10.

**Built** — `common/tests/unit/LifecycleTest.php` (`testFullLifecycle`, 42 assertions, ~5s,
self-cleaning). A single realistic scenario walks the full journey through the real services:

1. **Register → verify** — A under company; B/C/D under A. Asserts account activation + the
   `tree_closure` genealogy (A is B's depth-1 ancestor).
2. **Invest** — A a small $80 deposit (→ $50 working, small cap); B/C/D $5,000 each. After approval A
   has 2+ invested directs → **Leader (4X)**, 3 directs → **pool-qualified**; `capLimit == $200`.
3. **Daily cron** — `CronService::runDaily` credits Holding ROI + 30-level team income; capped total
   is tracked and **≤ the cap**; **reconciles**.
4. **Pool placement → rebirth** — A opens 40 × $30 entries; `poolMatrixFill` places them FIFO and the
   root's 1·3·9·27 cycle **completes** → a `rebirth_ledger` row + **matrix income credited
   (uncapped, excluded from the cap)**; **reconciles**.
5. **Rank** — `monthlyRankEval`: A's 60:40 qualified business (~$6,958 ≥ $5,000) earns the **Star**
   rank + cash reward (capped source); **reconciles**.
6. **Cap enforcement** — more cron days; capped total **never exceeds the cap and clamps exactly at
   $200**, `cap_reached_at` set, while **pool/referral income keeps flowing past the cap**;
   **reconciles**.
7. **Withdraw** — request reserves the gross (5% fee = $2.50 on $50), admin completes with a tx-hash;
   final **platform-wide reconciliation holds** (Σ all wallet balances == net signed ledger).

**Coverage:** registration, email verification, genealogy/closure, deposit + approval + plan
creation, leader/pool qualification, daily ROI, 30-level income, capping (track + clamp + exclude
pool), Global Pool placement, rebirth cycle, ranks, and withdrawal — all in one assertion chain with
**`ReportService::reconcile()` checked after every phase**.

---

## Phase 11, step 11.1 — Security pass (§6) ✅

**Status:** done. Full review against PROJECT_INSTRUCTIONS §6; two gaps found and fixed
(rate-limiting + admin-login audit). Suites green: **common unit 171/690**, frontend 12, functional 10.

**Audit result (§6 line by line)**
| § item | Status |
|---|---|
| Panel/admin routes behind auth | ✅ panel → `BaseController` (login); backend → `AdminController` (RBAC); SiteController self-gates |
| Admin uses RBAC roles | ✅ DbManager + per-controller permission + login gate (8.1) |
| CSRF on all forms | ✅ never disabled; ActiveForm / `Html::beginForm` everywhere |
| ActiveRecord / param binding only | ✅ no string-interpolated SQL in app code |
| Validate/whitelist inputs (amounts, plan ids, usernames, tx-hashes) | ✅ services validate (min, plan-type whitelist, term-against-config, working>0); tx-hash unique; model `number` rules |
| Passwords via Yii security | ✅ `generatePasswordHash` / `validatePassword` only |
| Withdrawals/transfers recompute fee+balance server-side | ✅ WalletService.transfer & WithdrawalService recompute the 5% fee; forms send only the gross amount |
| Admin actions & config → immutable audit_log | ✅ (8.2/8.3) + **admin login now audited (fixed)** |
| Rate-limit login + withdrawal-request | ✅ **added (fixed)** |

**Fixes**
- `common/services/RateLimitService.php` — cache-backed fixed-window limiter (`isBlocked`/`hit`/
  `clear`/`count`); fails OPEN if the cache is down (a cache outage can't lock everyone out).
- **Login lockout** wired into both apps' `site/login` — 8 attempts per IP per 10 min, then a
  temporary lockout; cleared on success. An investor probing the admin login also counts.
- **Withdrawal-request throttle** in `panel/withdraw` — 6 requests per investor per 10 min.
- **Admin login audited** — `AuditService::ADMIN_LOGIN` records `admin.login` (user + IP) on a
  successful backend login.

**Tests** — `RateLimitServiceTest` (4): blocks after max, clear resets, running count, window
expiry. E2E verified: 8 bad frontend logins pass through, the **9th is locked out**; a successful
admin login writes `admin.login · 127.0.0.1`.

**Decisions / flags**
- Limits are constants (login 8/10min, withdrawal 6/10min) — easy to move to SettingsService if the
  client wants them admin-tunable. **Flag.**
- Rate-limit state lives in `Yii::$app->cache` (FileCache). For a multi-server deploy, point the
  cache at Redis/Memcached so limits are shared across nodes. **Flag (deploy).**
- Admin/investor logins on the same host share cookies only by name-spacing (already configured);
  no change needed.

---

## M? (Phase 10, step 10.1) — Public marketing site  ·  P10 SIGN-OFF ✅

**Status:** done. Five detailed, responsive marketing pages live, on-brand (refined-minimal fintech),
all linked to register/login. Suites green: common unit 167/679, frontend 12, functional 10.

**Built** (light, no-auth public site — `frontend/views/site` + frontend `SiteController`):
- **Shared partials** — `_site_header.php` (sticky glass nav: brand, Home/Plans/How It Works/About/
  Contact, guest → Log in + Get started, logged-in → Dashboard + Logout, mobile hamburger) and
  `_site_footer.php` (final teal CTA band + brand + link columns + legal). Marketing layout
  `main.php` with an on-brand teal **gradient-mesh hero + grain**, staggered page-load reveals, and a
  JS-gated scroll-reveal (no-JS safe).
- **Home** — hero (orange-accented headline, dual CTA, 1·3·9·27 matrix motif, stat band) → trust
  strip → 3 income engines → how-it-works teaser → 6-stream income ecosystem → teal "built for trust"
  feature.
- **Plans** — hero → **Stacking** (live terms table from `stacking_terms`) → **Holding** (live tier
  cards from `holding_tiers`) → **Global Pool** (qualify/fill/rebirth + live `rebirth_ladder`) →
  comparison table → CTA.
- **How It Works** — 4-step flow (Register → Invest → Earn → Withdraw) → every income stream
  explained → income-cap/security band.
- **About** — mission, stat band, four principles, a "single ledger" standard quote.
- **Contact** — hero → working contact form (existing ContactForm + captcha) → support/partnership
  channels → FAQ accordion.
- `SiteController::actionPlans` (passes live settings) + `actionHowItWorks`.

**Verified** — screenshot-checked desktop (1440) + **mobile (375)**; hero, tables, reveals,
hamburger all responsive. Guest header shows Log in + Get started; logged-in shows Dashboard +
Logout. **P10 sign-off:** every page → HTTP 200 with signup + login links and a primary CTA.
Functional tests updated for the new headings + header logout.

**Decisions / flags**
- Followed the **locked DESIGN_SYSTEM brand** (deep teal / warm-orange / Bricolage + Plus Jakarta)
  at marketing grade rather than inventing a new aesthetic — consistent with the panel/admin.
- Visuals are CSS gradients + inline SVG (no photography); real imagery can drop into
  `frontend/web/media` later. Fonts load via Google CDN (as the rest of the app does).
- Plans page pulls **live** stacking terms / holding tiers / rebirth ladder from SettingsService, so
  marketing copy stays in sync with admin Config.

---

## M? (Phase 9, step 9.2) — Panel actions  ·  P9 SIGN-OFF ✅

**Status:** done. An investor can run the full lifecycle end-to-end through the panel. Suites
green: **common unit 167/679**, frontend 12, functional 10. New migration reverses cleanly.

**Built / wired** (all via existing services, thin controllers):
- **Investor chooses the Stacking term + pool entries at investment time** — migration
  `m260610_000023_add_deposit_term` adds `deposits.term_days`; `InvestmentService::createDeposit`
  takes the term (validated against `stacking_terms`) and pool-entry count; the admin approval now
  **honours the investor's term** (override still possible). Resolves the Step E flag.
- **Unified "Make Investment" form** (`panel/deposit/index`) — amount, plan type (Stacking/Holding),
  Stacking **term dropdown**, Holding **tier reference**, **pool entries** (editable when the pool
  mode is user_selectable), tx-hash → one deposit. The topbar "New Investment" + stacking/holding
  CTAs all point here; `stacking/create` redirects to it.
- **Panel Rank status** (`panel/rank`) — current rank, 60:40 qualified business (strong×60 + weak×40),
  progress bar to the next rank (+ leg conditions), global-share received, full rank ladder.
- **Panel Income report** (`panel/report`) — the investor's own income by source + line-by-line
  statement, with a **CSV export** of their statement.
- Confirmed working (Step E + now): deposit (tx-hash), withdraw (request, idempotent), transfer
  (5% fee server-side), referral link + team, pool status. Added **Rank** and **Income** to the
  panel sidebar.

**P9 SIGN-OFF — full investor lifecycle through the panel (HTTP e2e):**
1. **Invest** via panel — Stacking, term 90 → deposit `term_days=90` (**investor's term persisted**).
2. **Admin approve** → stacking plan term=90d, rate 12% (honoured the investor's choice).
3. **ROI** credited.
4. **Withdraw** request via panel → pending withdrawal (302).
5. **Transfer** via panel → transfer recorded (302).
6. **Reports** — income / rank / referral / pool all render.

**Decisions / flags**
- Holding tier is auto-derived from deposit size (shown as a reference table); only Stacking takes
  an investor-chosen term.
- Pool-entry selection is enabled only when `pool_entries_mode = user_selectable` (default
  `fixed_one` shows "1 (fixed)") — flip the mode in admin Config to let investors buy multiple entries.

---

## M? (Phase 9, step 9.1) — Panel dashboard (balances by source) ✅

**Status:** done. The auth-gated investor dashboard now shows balances **by source** alongside the
existing daily ROI, cap progress, leader status, and active stacking/holding plans. Suites green:
frontend unit 12, functional 10 (no service changes).

**Built / enhanced** — `frontend/modules/panel/controllers/DashboardController` + dashboard view
(the Step-D dashboard, extended):
- **Balances by Source** — wallet-ledger credits for the investor grouped into ROI (Stacking +
  Holding), Team (Level + Rank), Pool (Matrix + Rebirth), Referral + Royalty, and Transfers-in;
  rendered as an income-mix **donut** + a per-source breakdown list. The group totals sum to the
  wallet balance (verified: 451.31 + 135 + 63 + 46 = $695.31).
- Already present (Step D), confirmed for 9.1: auth-gating (panel BaseController), **daily ROI**
  (today's stacking+holding accruals), **cap progress** (used/limit + bar), **leader status** (pill
  + cap multiplier), **active stacking/holding plans** list, and Global Pool progress.

**Verified** — browser screenshot with seeded multi-source income for `aamir`: the donut + breakdown
populate and reconcile with the balance; then dev data cleaned to pristine.

**Decisions / flags**
- "Balances by source" = cumulative income **credited** per source (debits like withdrawals/
  transfers/fees are not income sources). The single withdrawable balance is shown separately on
  the Wallet Balance card.

---

## M11 (Phase 8, step 8.4) — Admin reports + CSV  ·  P8 SIGN-OFF ✅

**Status:** done. Income / team-business / rank / pool / royalty / wallet-ledger reports, each
exportable to CSV, all derived from (and reconciling with) the wallet ledger. Suites green:
**common unit 167/679**, frontend 12, functional 10.

**Built**
- `common/services/ReportService.php` — every report as one structured dataset
  (`{title, headers, rows, summary}`) so the on-screen table and the CSV export show identical
  numbers (§4 fat service):
  - **income** — ledger credits by source (stacking/holding/level/rank/referral/royalty/matrix/rebirth)
  - **business** — downline working-investment volume per investor (one-pass `tree_closure` join)
  - **ranks** — ranks achieved + global-share paid
  - **pool** — entries / placed / cycles + matrix/rebirth/referral distributed
  - **royalty** — monthly Pool 1 / Pool 2 payouts
  - **ledger** — full wallet ledger (capped on screen, complete in CSV)
  - **reconcile** — Σ all wallet balances vs net signed ledger → **RECONCILED** (M11.3 #3)
- `backend/controllers/ReportController` (perm `viewReports`) — landing + one action per report,
  each streaming **CSV** on `?export=csv` (text/csv, attachment, dated filename). Generic report
  view + a reconciliation view with a pass/fail banner. **Reports** added to the sidebar (Overview).

**Tests** — `ReportServiceTest` (3): income report total **== independent ledger credit sum**;
Σ balances == net signed ledger → reconciled; stable report shape. HTTP-verified: all 8 report
pages render; CSV export returns `text/csv` + attachment headers + correct rows.

**P8 SIGN-OFF — all M11 Acceptance Criteria (self-cleaning e2e):**
1. **Everything tunable** (M11.3 #1) — retuned 90-day stacking 12%→20% via SettingsService → a new
   plan computed profit 194.00, no code change.
2. **Everything audited** (M11.3 #2) — an admin action wrote an immutable `audit_log` row; the
   append-only guard rejected an update attempt.
3. **Reports reconcile** (M11.3 #3) — income report total == ledger credit sum ($280.00);
   Σ wallet balances == net signed ledger → **RECONCILED**.

**M11 complete:** 8.1 RBAC + dashboard · 8.2 operations + immutable audit · 8.3 configuration ·
8.4 reports + reconciliation.

**Decisions / flags**
- Reports are read-only and derive entirely from the ledger / source tables, so they reconcile by
  construction; `reconcile()` is the live cross-check (Σ balances == net ledger).
- CSV export streams server-side (no client JS); large ledger exports are uncapped — **flag** if a
  row cap / async export is wanted for very large datasets.
- Reports aren't currently date-range filtered (company-wide totals) — **flag** if per-period
  (month/quarter) filtering + per-investor drill-down is wanted next.

---

## M11 (Phase 8, step 8.3) — Admin configuration screens ✅

**Status:** done. Every economic constant is now retunable from the admin UI — no code change
required (M11.3 #1). Suites green: **common unit 164/655**, frontend 12, functional 10.

**Built** — `backend/controllers/ConfigController` (permission `manageSettings`) + a new
**Configuration** sidebar section (System group), with purpose-built editors backed by
SettingsService (these are the sanctioned place to edit the "locked" constants; the generic
Settings page keeps them read-only). Every save is audited (`settings.update`):
- **Pool & Matrix** — the $30 split (referral/royalty/matrix/company, **validated to total 100%**),
  matrix 1·3·9·27 %, pool-qualify directs, carve-out.
- **Income** — 15-level Global Pool referral % (sum vs the referral split shown live) and the
  30-level team-income bands (from/to/% /directs, add or remove rows).
- **Rebirth** — the entry **ladder (add/remove levels)** + first-cycle / rebirth / user-share %.
- **Royalty** — Pool 1 / Pool 2 monthly direct thresholds and payout %.
- **Cap & Leader** — investor / leader multipliers + leader threshold + minimum investment.
- **Plan Rates** — stacking terms (days → %, add/remove) and holding tiers (band → daily ROI,
  add/remove, VIP foreign-trip).
- **Rank Catalog** — DB-table editor: business threshold, lower-rank condition (tier+count),
  cash/trip/car rewards, global-share % per rank.
- Array editors use a JS-free "render rows + blank rows to add; clear a row to remove" pattern.

**Tests** — `ConfigRetuneTest` (3): proves a setting change flows into the services with **no code
change** — pool-split retune → `PoolService::splitFor` matrix 6.90→9.90; 90-day stacking rate
12%→20% → new plan profit 116.40→194.00; cap multiplier read by `CappingService`. HTTP e2e: a
bad $30 split (≠100%) is rejected; a valid one persists and writes a `settings.update` audit row.
Screenshot-verified: Configuration landing + Pool editor (live 100% indicator).

**Acceptance (M11.3 #1):** all percentages and rules editable without code changes ✓.

**Decisions / flags**
- "Locked" now means **"edit via the Config screen, not the generic Settings page"** — the Config
  screens write locked keys with validation; the Settings page still shows them read-only.
- The $30 split is hard-validated to 100%. The 15-level referral sum is shown vs the referral split
  but **not hard-blocked** (admin may intentionally under/over-distribute) — flag if it should be
  enforced.
- Rank catalog edits write the `ranks_catalog` table directly (not SettingsService).

---

## M11 (Phase 8, step 8.2) — Admin operations + immutable audit log ✅

**Status:** done. Every admin approval, rejection, status change, and config edit is written to an
immutable `audit_log` (M11.3 #2 / §6). Suites green: **common unit 161/647**, frontend 12,
functional 10. Migration reverses cleanly.

**Built**
- Migration `m260610_000022_create_audit_log` — append-only `audit_log` (admin_id + **admin_name
  snapshot** that survives account deletion, action, entity+id, summary, JSON meta, IP, created_at;
  no `updated_at`). FK SET NULL so the log outlives admins.
- Model `AuditLog` — **append-only**: `beforeSave` blocks any update; `getMetaData()` decodes meta.
- `common/services/AuditService` — `record(action, entity, id, summary, meta)`; captures the actor
  from the web request (admin username + IP) or "console" for CLI; best-effort (a logging failure
  is warned, never rolls back a completed action). Action constants for every admin verb.
- **Audit wired into every admin action:** deposit approve/reject, withdrawal complete/reject, user
  verify/suspend/activate, **leader-flag grant/revoke**, settings update, mail test, and console
  RBAC assign/revoke.
- **User management enhancements:** manual **leader-flag toggle** (grant/revoke → syncs the 3X↔4X
  cap multiplier, audited); **accounts-per-email** (sibling accounts on the same email, M1 multi-
  account) on the investor detail.
- **Deposit detail** (`deposit/view`) — full tx-hash, $30 carve-out, working amount, and the
  individual pool entries with their matrix positions; linked from the queue.
- **Audit Log page** (`audit/index`) — filterable by action, shows when / admin (+IP) / action /
  summary + meta. Replaces the placeholder.

**Tests** — `AuditServiceTest` (4): record creates a row with meta; **append-only update is blocked**;
unauthenticated actor anonymised; empty meta omitted. HTTP-verified e2e: approving a deposit as
`company` wrote `deposit.approve` (entity deposit/#, admin=company, IP); a leader-grant wrote
`user.leader_grant` with `cap_multiplier: 4` — both visible on the Audit Log page.

**Acceptance (M11.3):** #1 rules editable (Step E) ✓ · **#2 every approval/config change audit-logged
✓** · #3 reports reconcile with ledgers — partially (ledger viewer exists; full reporting/exports
are a later 8.x step).

**Decisions / flags**
- Audit recording is **at the admin-controller layer** (it has the web actor + IP); cron income
  isn't "admin action" so it isn't audited here. Recording is best-effort and outside the action's
  DB transaction — a completed payout is never rolled back by a log hiccup. **Flag** if the client
  wants audit writes inside the same transaction as the action.
- The `audit_log` is append-only by app enforcement (model blocks updates; no delete path); DB-level
  immutability (triggers/grants) can be added at deployment if required.
- Leader flag can now be set manually by an admin (override of the earned 2-invested-directs rule);
  the toggle keeps `cap_multiplier` in sync.

---

## M11 (Phase 8, step 8.1) — Admin auth + RBAC + dashboard ✅

**Status:** done. The admin panel is behind real RBAC; investors cannot enter the backend even
though both apps share the `users` table. Suites green: **common unit 157/633**, frontend 12,
functional 10. RBAC migration reverses cleanly.

**Built**
- `authManager` = `yii\rbac\DbManager` in common config (shared by all apps).
- Migration `m260610_000021_create_rbac` — creates Yii's 4 RBAC tables and **seeds the
  hierarchy**: 8 permissions (`accessAdmin`, `manageInvestors`, `manageDeposits`,
  `manageWithdrawals`, `viewLedger`, `managePlans`, `manageSettings`, `viewReports`) + 4 roles
  (`support` ⊂, `finance` ⊂, **`admin`** = support+finance+managePlans, **`superAdmin`** =
  admin+manageSettings). Assigns the root `company` account → `superAdmin`. Reversible.
- `backend/controllers/AdminController` — base controller: AccessControl requires the
  controller's `$permission`; guests → login, authorised-but-wrong-role → **403**. Subclasses just
  declare `$permission` + `$verbActions`.
- Refactored all 10 admin controllers onto it with per-module permissions (Investors→manageInvestors,
  Deposits→manageDeposits, Withdrawals→manageWithdrawals, Ledger→viewLedger, Stacking/Holding/Pool/
  Rank→managePlans, Settings→manageSettings, Audit→viewReports).
- `SiteController` — **login gate**: after auth, a user lacking `accessAdmin` is logged out with
  "not authorised"; dashboard + styleguide require `accessAdmin`.
- Console `RbacController` — `list` / `assign <user> <role>` / `revoke` / `who <role>` for ops.
- **Dashboard pool status** — added Global Pool Status row (Pool Entries, Placed Positions, Active
  Cycles, Completed Cycles) beside the existing key counts (users, pending deposits/withdrawals).

**Tests** — `RbacTest` (6): hierarchy seeded (4 roles/8 perms), investor has no admin access, and
each role grants exactly its scope (support/finance/admin/superAdmin inheritance). HTTP-verified:
investor aamir bounced from the backend (302→login); `company` reaches every page; a `finance`-role
user gets deposits/withdrawals (200) but **403 on settings/investors**.

**Acceptance (M11.3 #1 covered here):** all percentages/rules already editable via Settings (Step E);
admin access is now RBAC-gated. (M11.3 #2 audit_log + #3 reports reconcile come in later 8.x steps.)

**Decisions / flags**
- Roles seeded: `support`, `finance`, `admin`, `superAdmin`. Assign staff with
  `./yii rbac/assign <username> <role>`. Only `company` is superAdmin by default.
- The immutable **`audit_log`** (M11.3 #2) and **rate-limiting** login/withdrawal endpoints (§6) are
  the next 8.x steps — not in 8.1.

---

## Step E (M1–M4 vertical-slice pages) + Step F (SMTP/email) ✅

**Status:** done. Every M1–M4 feature is now operable on both sides; transactional
email (verification + withdrawal) goes out via MailService. All 22 admin/panel pages
render HTTP 200. Suites green: **common unit 151/609**, frontend unit 12, functional 10.

**Step E — Admin (backend/controllers + views)**
- `InvestorController` (M1/M2) — searchable/filterable list; detail with KPIs, genealogy
  (directs + invested flags), recent deposits; verify / suspend / re-activate (POST).
- `DepositController` (M3) — review queue by status; **approve** verifies the tx-hash, runs
  `InvestmentService::approveDeposit` (activates the referral link) **and creates the matching
  Stacking/Holding plan** (admin picks the stacking term); **reject** voids pool entries.
- `LedgerController` (M3) — wallet-ledger viewer, per-investor filter, balance = Σ rows.
- `StackingController` (M4) — all plans table. `HoldingController`/`PoolController`/`RankController`
  read-only tables; `AuditController` placeholder (M11). (No nav item 404s.)
- `SettingsController` (M4 + F) — edit admin-configurable settings (locked constants read-only),
  **SMTP config**, and a **send-test-email** action.
- `WithdrawalController` (M10) — weekly queue; **complete** records the tx-hash (emails the
  investor), **reject** refunds + emails.

**Step E — Panel (frontend/modules/panel)**
- `ProfileController` (M1), `ReferralController` (M2 — link active only after investing + team
  tree), `WalletController` (M3 — balance + ledger + **transfer**, 5% fee recomputed server-side),
  `DepositController` (M3 — MetaMask tx-hash deposit form, `requireTransactable` gated),
  `StackingController` (M4 — my plans + new investment), `WithdrawController` (M10 — request,
  idempotency-keyed), `HoldingController`/`PoolController` (my plans / pool progress).
- All pages render inside the panel shell, reuse the `@common` partials, and gate money actions.

**Step F — Email**
- `MailService` — single send entry point: `sendVerification`, `sendWithdrawalUpdate`,
  `sendGenericNotice`. Transport resolves admin **SMTP settings → env (`MAILER_DSN`) → file
  transport**; credentials never hardcoded. Best-effort (logs, never throws).
- Brand-themed mail layout + views (`verify`, `withdrawal`, `notice`, HTML + text).
- Wired: `AuthService` verification → MailService; `WithdrawalService` complete/reject →
  MailService. SMTP keys added to `SettingsService::DEFAULTS` (group `smtp`).
- Console `urlManager.hostInfo` (`FRONTEND_URL` env, default `http://localhost:8080`) so
  cron/console-triggered emails build absolute verification links.

**Tests** — `MailServiceTest` (4). E2E verified: themed verification email written to
`runtime/mail` with brand header + CTA + absolute link; panel deposit → admin approve(term) →
Stacking plan #N created (30d/8%/$77.60) active. Dev DB returned to pristine (2 users).

**Decisions / flags**
- **Stacking term is chosen by the admin at approval** (the `deposits` table has no term column —
  avoiding a migration). The panel form captures amount + tx-hash; the term is locked on approval.
  **Flag** if the client wants the investor's term choice persisted (needs a `deposits.term` column).
- Holding/Pool/Rank admin pages and panel Holding/Pool are lightweight read-only views (their full
  M5/M6/M8 admin tooling lands with those modules); Audit Log is a placeholder until M11.
- `aamir`'s dev password is `Aibox@2026`.

---

## Dashboard Shell & UI — Steps A–D ✅  (DASHBOARD_SHELL_GUIDE, before Phase 8)

**Status:** done. Both sides now have a real dashboard shell + widget library; verified
in-browser. **No service or migration changes** — pure view/asset work.

**Step A — Admin shell** (`backend/views/layouts/`): `main.php` is a CSS-grid shell
(256px deep-teal sidebar + sticky white topbar + scrollable content on the canvas);
`_sidebar.php` (brand, grouped nav Overview/Operations/Plans & Income/System, solid-primary
active pill, **live count badges**, active-by-route); `_topbar.php` (search pill, notification
dot, user chip, logout). Guests (login) get a minimal centered layout. Matches admin-shell.html.

**Step B — Widget partials** (`common/views/components/`): added `_bar_chart`, `_donut`,
`_table`, `_list_row`, `_field` (joining the existing `_stat_card`, `_card`, `_card_dark`,
`_pill`, `_status`, `_btn`, `_section_tag`) — pure Tailwind, params only, no logic. Stat-card
chip now forces `stroke-current` on its icon. A living styleguide at **`backend site/components`**
reproduces admin-shell.html from the partials.

**Step C — Real Admin Dashboard** (`backend site/index` + `SiteController::dashboardData()`):
4 live KPI cards (investors, total invested, pending deposits, pending withdrawals + $),
7-day Platform Volume bar chart, Income Mix donut (grouped wallet-ledger credits), and the
Pending-Deposits action queue with Verify links. Sidebar badges wired to live pending counts.
Verified populated (seeded demo, then cleaned to a pristine dev DB).

**Step D — Investor panel shell + dashboard** (`frontend/views/layouts/panel.php` +
`_panel_sidebar`/`_panel_topbar`, `panel/BaseController::$layout`): slim white sidebar (teal
active) — Dashboard, My Investments (Stacking, Holding), Network (Global Pool, Referrals),
Money (Wallet, Withdraw), Account (Profile); topbar with a New-Investment CTA. Real panel
dashboard: Wallet Balance card + credit card, Daily ROI, Income Cap %, Direct Team, Global
Auto Pool progress (dark card), Active Plans list, cap-progress. Reuses the shared `@common`
partials (frontend Tailwind already scans `common/views`). Verified populated for `aamir`,
then cleaned. Cap-tier label now derives from the authoritative `cap_multiplier`.

**Verification:** both shells, the styleguide, and both dashboards screenshot-verified in the
browser; no console errors. All suites green: common unit 147/588, frontend unit 12, functional 10.

**Notes / flags**
- Nav links to not-yet-built admin/panel controllers (Investors, Deposits, Stacking pages, …)
  point at their intended routes and 404 until **Step E** back-fills the M1–M4 pages.
- `aamir`'s dev password was reset to a known value (`Aibox@2026`) so the panel is testable.
- **Pending (not in these 4 steps):** Step E (back-fill M1–M4 admin + panel pages) and Step F
  (SMTP + MailService). Per the guide's revised rule, M5+ modules should ship admin + panel
  pages as part of "done" (vertical slices) — worth adopting in PROJECT_INSTRUCTIONS §9.

---

## M12 (step 7.1) — Scheduled Jobs (Cron)  ·  P7 SIGN-OFF ✅

**Status:** done. All 9 jobs run idempotently and reconcile against the wallet ledger.

**Built**
- Migration `m260610_000020_create_job_runs` — `job_runs` (job, period, status running/completed/
  failed, started/finished). **Unique (job, period)** is the idempotency key (§5.7 / M12.3).
- Model `JobRun`.
- `common/services/CronService.php` — every time-driven job, each only orchestrating existing,
  already-tested services (adds **no** money logic). `runJob(job, period, work)` skips any period
  with a `completed` row, so re-running never double-credits; underlying services are *also*
  idempotent (unique per plan+date / source+date / month+pool / user+rank), a second guard.
  - **Daily:** `dailyHoldingRoi`, `dailyStackingAccrual`, `dailyLevelIncome` (reads the day's
    accruals → runs after the two accrual jobs), `stackingMaturity`, `poolMatrixFill` (place
    qualified FIFO + complete full 1/3/9/27 cycles → rebirth).
  - **Monthly:** `monthlyRoyalty`, `monthlyGlobalShare`, `monthlyRankEval` (fixpoint-iterated so
    lower-rank leg conditions see ranks granted earlier in the same run).
  - **Weekly:** `weeklyWithdrawals` (assembles the queue; credits nothing — admin pays manually).
  - `runDaily()` / `runMonthly()` convenience runners in dependency order.
  - Per-plan day index derived from UTC-midnight date diff → the cron's date maps to the right
    accrual date; the service-level unique (plan, accrual_date) backs it up.
- All 7 console controllers rewritten from stubs to thin wrappers (9 commands):
  `holding/daily-roi`, `stacking/daily-accrual`, `stacking/maturity`, `level-income/daily-distribute`,
  `pool/matrix-fill`, `royalty/monthly`, `rank/monthly`, `rank/global-share`, `withdrawal/weekly`.
  Each accepts an optional date/month arg (defaults to now) and reports executed/skipped.

**Tests** — `common/tests/unit/services/CronServiceTest.php` (7 tests): **running a day twice →
no double credit** (balances + accrual/ledger row counts unchanged) + reconcile; job_runs keyed by
(job, period) and completed; executed-vs-skipped return value; a different day runs again
(period keying isn't "always skip"); Holding ROI respects the cap; weekly assembles-but-credits-
nothing + idempotent; monthly royalty idempotent. Suites green: **common unit 147/588**, frontend
unit 12, frontend functional 10.

**Acceptance criteria (M12) — all verified:**
1. Every scheduled calc is a console command on the 9-job schedule ✓
2. Each job idempotent by (job, period) — re-running a period never double-credits ✓ (test + e2e)
3. All credits go through the wallet ledger in transactions; cap checked at credit time ✓

**P7 sign-off:** all jobs run idempotently · reconcile against the wallet ledger.

**Decisions / flags**
- **Two-layer idempotency:** `job_runs` (coarse, per period) + per-service unique constraints
  (fine). Even a forced re-run with the `job_runs` row deleted cannot double-credit.
- **`daily_level_income` runs after the accrual jobs** (it reads the day's stacking/holding
  accruals); `runDaily()` enforces this order.
- **`monthly_rank_eval` is fixpoint-iterated** (≤10 passes) so a rank whose condition depends on a
  leg's *this-run* rank is granted in the same monthly run.
- **`pool_matrix_fill` completes only level-0 cycles** whose 1/3/9/27 matrix is full; deeper rebirth
  levels fire as their own trees fill (consistent with RebirthService). **Flag** if the client wants
  the cron to also force-advance partially-filled rebirth trees.
- **`weekly_withdrawals` is read-only** — it assembles the pending queue; the payout + tx-hash are
  entered by an admin (M10/M11). No crediting in the cron.
- A real scheduler entry (crontab / systemd timer) wiring these commands to clock times is an
  **ops/deploy concern (M11+)**, out of scope here.

**Migrations verified:** up and down clean.

**Remaining module:** M11 Admin Panel (deposit/withdrawal approvals, config screens, audit_log, RBAC,
rate-limiting the request endpoints). All time-driven and money engines (M1–M10, M12) are complete.

---

## M10 (step 6.1) — Withdrawal Engine  ·  P6 SIGN-OFF ✅

**Status:** done. Fee/min/maturity rules correct; tx-hash recorded on completion.

**Built**
- Migration `m260610_000019_create_withdrawals` — `withdrawals` (gross/fee/net, status
  pending/completed/rejected, **request_key** unique idempotency token, cycle_week, requested_at,
  processed_at, tx_hash). FK RESTRICT.
- Model `Withdrawal`.
- `common/services/WithdrawalService.php`:
  - `requestWithdrawal()` — enforces the **$10 minimum**, checks the withdrawable balance,
    **server-computes the 5% fee** (`net = gross − fee`, never trusts the client — §6), **reserves
    the gross** from the wallet (debits net `withdrawal` + fee `fee`), and records a pending
    withdrawal. **Idempotent** per `request_key` (M10.5).
  - `withdrawableBalance()` — the wallet balance, which **excludes un-matured Stacking income by
    construction** (M4 credits Stacking only at maturity) → the balance check IS the maturity check
    (M10.2 / AC2).
  - `completeWithdrawal()` — admin records the blockchain **tx-hash** and marks completed (AC3);
    requires a non-empty hash; wallet unchanged (reserved at request).
  - `rejectWithdrawal()` — refunds the reserved gross (reverses the debits), marks rejected.
  - `weeklyQueue()` — the pending list for an ISO cycle week.
- Console `WithdrawalController::actionWeekly` — weekly-queue cron stub (M12 / P7).

**Tests** — `common/tests/unit/services/WithdrawalServiceTest.php` (11 tests): $10 minimum, 5%
fee/net + reservation + reconcile (AC1), insufficient-balance reject, ROI+commission withdrawable
(AC2), **Stacking blocked until maturity then withdrawable** (AC2), idempotent duplicate request,
**complete records tx-hash** + requires-hash + no-double-complete (AC3), reject refunds, weekly
queue. Suites green: **common unit 140/552**, frontend unit 12, frontend functional 10.

**Acceptance criteria (M10.6) — all verified:**
1. 5% fee applied; net correct; $10 minimum enforced ✓
2. Stacking income blocked until maturity; ROI/commission withdrawable ✓
3. Weekly processing records tx-hash on completion ✓

**P6 sign-off:** withdrawal fee/min/maturity rules correct · tx-hash recorded.

**Decisions / flags**
- **Gross is reserved (debited) at request time**, so funds can't be double-spent and can never be
  "insufficient at processing" — I chose this over M10.5's debit-at-processing mention because it's
  the safer money model. Rejection refunds the reservation. **Flag.**
- **The withdrawal debit mirrors the transfer pattern:** `withdrawal` (net) + `fee` (fee) = gross;
  net is paid on-chain, the fee is company revenue.
- **Rate-limiting the withdrawal-request endpoint (§6)** is an HTTP/controller concern (panel, M11);
  the service is rate-limit-agnostic. **Flag for M11.**
- **Stacking-only-after-maturity is enforced by the wallet design** (un-matured Stacking is never in
  the balance), so no separate per-plan maturity check is needed in the withdrawal path.

**Migrations verified:** up and down clean.

**Remaining modules:** M11 Admin Panel (deposit/withdrawal approvals, config, audit_log, RBAC),
M12 Scheduled Jobs (wires every stubbed cron with job_runs idempotency).

---

## M8 (step 5.2) — Rank & Reward (60:40)  ·  P5 SIGN-OFF ✅

**Status:** done. 60:40 worked example + permanence pass; rewards respect the cap.

**Built**
- Migration `m260610_000018_create_rank_tables` — `ranks_catalog` (**seeded** with the 9 ranks:
  tier, business_required, require_tier/require_count for the lower-rank condition, cash_reward,
  trip, car, global_share_pct), `user_ranks` (unique user+rank, **permanent**),
  `business_snapshots` (monthly 60:40 figures), `global_share_payouts` (monthly, unique user+month).
- Models `RankCatalog`, `UserRank`, `BusinessSnapshot`, `GlobalSharePayout`.
- `common/services/RankService.php`:
  - `legBusiness()` — a leg's total Working Investment (leg root + descendants) via `tree_closure`.
  - `qualifiedBusiness()` — **Strong Leg × 60% + Weak Leg × 40%** (M8.2). Strong = single largest
    leg; weak = sum of the rest.
  - `countLegsWithRank()` — for the lower-rank conditions (e.g. 5 Star needs 3 legs containing a 4 Star).
  - `evaluate()` — records the snapshot and grants every newly-met rank (business + condition),
    **permanently**; multiple ranks crossed are all granted (M8.6). Cash rewards route through
    **CappingService** (source `rank`) → respect the cap (M8.4 / AC3).
  - `payGlobalShare()` — the monthly global-business share = highest rank's % × company monthly
    business, **via the cap**, idempotent per (user, month); zero when company business is zero (M8.6).
- Console `RankController::actionMonthly` — monthly rank/global-share cron stub (M12 / P7).

**Tests** — `common/tests/unit/services/RankServiceTest.php` (10 tests): the **$7,800 worked
example**, leg-includes-descendants, Star grant + reward through cap, multiple-ranks-at-once,
**reward respects cap** (capped to the limit), **permanence after business collapse**, the
**5 Star = 3× 4 Star** condition, global share (% of company business, via cap, idempotent),
zero-business month, unranked → no share. Suites green: **common unit 129/522**, frontend unit 12,
frontend functional 10.

**Acceptance criteria (M8.7) — all verified:**
1. Qualified business uses 60:40 across recursive leg totals ✓ ($7,800)
2. Ranks are permanent; global share pays monthly at the right % ✓
3. Rank rewards contribute to the cap ✓ (route through CappingService)

**P5 sign-off:** caps enforced exactly (M9) · ranks permanent (M8) · rewards respect the cap
(rank cash rewards + global share both routed through CappingService and verified capped).

**Decisions / flags**
- **"Business" = sum of approved-deposit Working Investment** across a leg (consistent with the
  cap base). The spec doesn't pin the unit precisely — flag if the client means gross deposit incl.
  the $30 carve.
- **Lower-rank conditions** encoded as `require_tier` + `require_count` (e.g. 5 Star → tier 4 ×3);
  a leg "contains" a rank if any member (root or descendant) has achieved ≥ that tier.
- **Global share = highest rank's % × company-wide monthly Working business.** Routed through the
  cap (M8.4). Monthly run order (deepest-first so leg conditions see fresh ranks) is the M12 cron's job.

**Migrations verified:** up and down clean.

**Remaining modules:** M10 Withdrawals, M11 Admin, M12 Cron (which wires all the stubbed crons).

---

## M9 (step 5.1) — Capping Engine

**Status:** done. The cap is now a single authority, wired into every capped income engine.

**Built**
- Migration `m260610_000017_create_capping_engine` — consolidates cap state onto `users`
  (M9.4): adds `capped_income_total` + `cap_reached_at` (cap_multiplier already from M2),
  migrates `wallets.capped_earned` → `users.capped_income_total` and **drops** the wallet
  column. Creates `cap_ledger` (user, source, amount, running_total). `cap_limit` is computed,
  not stored (it changes with deposits/Leader; M9.5).
- Model `CapLedger`; `User` gains the cap fields; `Wallet`/`WalletService` lose `capped_earned`
  (the old `cappedEarnedOf`/`addCappedEarned` helpers removed).
- `common/services/CappingService.php` — the single cap authority:
  - `capBase()` = Σ approved-deposit working (M9.2); `capLimit()` = cap_multiplier × base.
  - `consumeCap()` — takes only up to remaining room, discards overflow (M9.5), appends
    `cap_ledger`, updates the running total + `cap_reached_at`; **rejects pool sources**
    (referral/royalty/matrix/rebirth) so they can never be cap-counted (AC2).
  - `creditCapped()` = consumeCap + wallet credit (for Holding/Level/Rank).
  - Leader upgrade or a new deposit raises the limit → room reopens → `cap_reached_at`
    auto-clears → capped income resumes (M9.5 / AC3).
- **Wired into the crediting services:** StackingService (accrual `consumeCap`; maturity true-up
  via `consumeCap`), HoldingService (`creditCapped`), LevelIncomeService (`creditCapped`). Their
  per-engine `capLimit()` methods are gone — one cap base for all engines now.

**Tests** — `common/tests/unit/services/CappingServiceTest.php` (8 tests): 3X/4X tiers, cap base
across deposits, **halts exactly at the limit + overflow discarded**, consume-only (no wallet
credit, for Stacking), **pool sources rejected**, **partial-cap → Leader-upgrade resume**, and
new-deposit resume. The refactor kept the M4/M5/M7 cap tests passing unchanged in behaviour.
Suites green: **common unit 119/491**, frontend unit 12, frontend functional 10.

**Acceptance criteria (M9.6) — all verified:**
1. Capped income halts exactly at 3X (investor) / 4X (leader) ✓
2. Pool income is never blocked by the cap ✓ (sources rejected by CappingService)
3. Leader upgrade raises the cap and resumes capped income ✓

**Decisions / flags**
- **Cap state consolidated onto `users`** (per M9.4) — the running total moved off
  `wallets.capped_earned` (M3) to `users.capped_income_total`; the wallet column is dropped.
- **`cap_limit` is computed, not stored** — avoids staleness when deposits/Leader change (M9.5).
- **The cap base is now unified** (Σ approved-deposit working) across Stacking/Holding/Level,
  replacing the per-engine bases I flagged in M4/M5/M7. This is the consolidation those notes
  promised.
- Rank income (`rank` source) is already in `CAPPED_SOURCES`, ready for M8.

**Migrations verified:** up and down clean (the down restores `wallets.capped_earned`).

**Next:** M8 — Rank & Reward (60:40); its rewards credit through CappingService (source `rank`).

---

## M6 (step 4.3) — Matrix cycle + Rebirth ladder  ·  P4 SIGN-OFF ✅

**Status:** done. M6 complete; all five M6 acceptance criteria pass.

**Built**
- Migration `m260610_000016_create_rebirth_ledger` — `rebirth_ledger` (parent_entry_id,
  rebirth_level, entry_amount, tree_volume, distributed, user_share, company_share,
  next_amount); unique (parent_entry, level). DECIMAL(18,2), FK RESTRICT.
- Model `RebirthLedger`.
- `common/services/RebirthService.php`:
  - `cycleEconomics()` — tree = entry × 40, deduct%, 50/50 of the remainder, company =
    other − next. The same formula drives the first cycle (58%) and every rebirth (23%).
  - `ladder()` — the full computed table; verified against the spec **exactly**.
  - `completeCycle()` — processes the next cycle level: level 0 requires the entry's
    1/3/9/27 matrix to be complete (M6.3.4), rebirth levels run at the ladder amounts
    (M6.3.5). Pays the head (source `matrix` for the first cycle, `rebirth` after) — **NOT
    cap-counted** (M6.1) — records the ledger, marks the entry completed, funds the next.
  - Ladder amounts / deduction %s / user share are all admin-configurable (SettingsService).
- Console `PoolController::actionMatrixFill` — matrix/cycle cron stub (M12 / P7).

**Tests** — `common/tests/unit/services/RebirthServiceTest.php` (6 tests): first-cycle
economics ($30 → 1200 → **252 / 152 / 100**), the **rebirth ladder table exact**
($100→1540, $500→7700, $3,000→46,200, $10,000→154,000), 58% vs 23% deduction + 50/50 split,
first cycle on a real full 40-matrix, incomplete-matrix guard, and the full chain crediting the
head (also confirming the head earns referral $46.80 + cycle income, none cap-counted). Suites
green: **common unit 111/464**, frontend unit 12, frontend functional 10.

**P4 sign-off — all five M6 acceptance criteria (M6.6):**
1. $30 splits exactly 30/5/23/42; 58% distributed ✓ (4.1)
2. 3 directs auto-place into the FIFO pool, once per entry ✓ (4.1)
3. First cycle deducts 58%; every rebirth deducts 23%; 50/50 split holds ✓ (4.3)
4. Royalty pays only monthly-qualified members, equally split, from all deposits ✓ (4.2)
5. **No Global Pool income is counted toward the cap** ✓ — referral, royalty, matrix, and
   rebirth all credit the wallet **without** touching `capped_earned` (verified across
   PoolReferralTest, RoyaltyServiceTest, RebirthServiceTest).

**Decisions / flags**
- **Rebirth levels share one `parent_entry_id`** (the original $30 entry / chain root); the
  head (entry owner) receives every level's user share — an endless ladder bounded by the
  configured `rebirth_ladder` (admin extends to add levels; M6.5).
- **Ladder exhausted** → `next_amount` 0 and the company keeps the full other-50% (M6.5
  "stop until admin adds a level" — defaulted to company-retains). **Flag.**
- **The matrix-fill / cycle TRIGGER (which tree just completed, and per-rebirth tree fills) is
  the M12 cron's job.** RebirthService implements the economics + processing; the cron decides
  when each level fires. Nice find from the tests: a matrix root legitimately earns BOTH the
  15-level referral from its descendants ($46.80 for a full 3-level tree) AND the cycle bonus.

**Migrations verified:** up and down clean.

**M6 is complete.** Remaining modules: M8 Rank & Reward (60:40), M9 Capping (unifies the cap),
M10 Withdrawals, M11 Admin, M12 Cron.

---

## M6 (step 4.2) — Global Pool Referral (15-level) + Royalty (monthly)

**Status:** done. Both worked examples + referral distribution pass.

**Built**
- Migration `m260610_000015_create_pool_referral_and_royalty` — `pool_referral_ledger`,
  `royalty_qualifiers`, `royalty_payouts`. Seeds `pool_referral_levels` (15-level %, sum 30%).
- Models `PoolReferralLedger`, `RoyaltyQualifier`, `RoyaltyPayout`.
- `PoolService::payReferral()` (wired into `placeEntry`) — distributes the $9 referral portion
  up to **15 matrix levels** (M6.3.1): level L's owner gets `pool_referral_levels[L]`% × $30. The
  15 percentages sum to 30%, so a full chain pays exactly $9; missing upper levels (early entries)
  are unpaid. Credited as `referral` and **NOT cap-counted** (M6.1 / AC5). Idempotent per entry.
- `common/services/RoyaltyService.php` (M6.3.2):
  - `monthlyVolume()` — $30 pool volume from **all** deposits' entries in the month (incl. pending).
  - `poolFund()` / `perMember()` — Pool 1 = 3%, Pool 2 = 2% of volume, split equally.
  - `computeMonth()` — records each sponsor's directs-this-month + qualification (10 → Pool 1,
    21 → Pool 2), pays the equal split (source `royalty`, not cap-counted), idempotent per
    (month, pool). Single-calendar-month rule enforced (directs counted by `created_at` within the
    month — a 7+3 split across months does not combine).
- Console `RoyaltyController::actionMonthly` — cron stub (M12 / P7).

**Tests** — `PoolReferralTest` (6) + `RoyaltyServiceTest` (8): referral percentages sum to 30% /
= $9, per-level distribution up the matrix (L1 $3.00 / L2 $1.50 / L3 $0.90), root pays nothing,
referral not cap-counted, idempotency; royalty **worked examples ($180 / $120)**, volume from all
deposits, 10/21 qualification, single-month rule, equal split + credit, idempotency, zero
qualifiers. Suites green: **common unit 105/412**, frontend unit 12, frontend functional 10.

**Acceptance criteria touched (M6.6):**
4. Royalty pays only monthly-qualified members, equally split, from all deposits ✓
5. No Global Pool income is counted toward the cap ✓ (referral & royalty credit without `capped_earned`)

**Decisions / flags**
- **Referral 15-level is up the POOL MATRIX** (`pool_positions` parent chain), not the sponsor
  tree — the "tripling 3/9/27" is the matrix, and it's explicitly distinct from M7's 30-level
  (sponsor) income.
- **Royalty directs = accounts sponsored (by `created_at`) within the calendar month.** Whether
  they must also be *invested* is not stated in M6.3.2 — using registration-in-month for now;
  easy to switch to invested-in-month if the client clarifies. **Flag.**
- **Zero-qualifier months:** the fund is retained by the company (payout recorded, per_member 0).
  M6.5 lists "rolls to company OR carries forward — confirm" as open; defaulted to company. **Flag.**
- **`paid the following month`** is the cron schedule (run `computeMonth(lastMonth)` in M12); the
  service processes whatever month it's given.

**Still remaining in M6:** the **matrix (23%) payout + cascade and the rebirth ladder**
(M6.3.3–M6.3.5) — the next sub-step.

**Migrations verified:** up and down clean.

---

## M6 (step 4.1) — Global Auto Pool: $30 split + FIFO placement + matrix

**Status:** done. Split exact, FIFO matrix builds to 1/3/9/27, 3 directs place once.

**Built**
- Migration `m260610_000014_create_pool_placement` — extends `pool_entries` (M6.4) with
  `position_seq` (unique FIFO), `current_pool_level`, `cycle_status`, `placed_at`, and the
  recorded $30 split (`referral_share`/`royalty_share`/`matrix_share`/`company_share`).
  Creates `pool_positions` — a single company-wide 3-ary matrix tree
  (`parent_position_id` self-FK, `slot_index`, `pool_level` = global depth). FKs RESTRICT.
- Models `PoolPosition`; `PoolEntry` extended (status constants, `isPlaced()`, position relation).
- `common/services/PoolService.php` (replaces the M2 stub):
  - `splitFor()` — the exact $30 split (30/5/23/42 from settings → $9 / $1.50 / $6.90 / $12.60;
    58% distributed) (M6.2 / AC1).
  - `onReferralQualified()` — the **M2 hook**: on 3-direct qualification, places all of the
    account's unplaced pool entries.
  - `placeEntry()` — assigns the **next FIFO global seq**, locates the slot in the global 3-ary
    matrix (parent + slot + depth, breadth-first), creates the `pool_positions` node, and records
    the split. **Idempotent per entry** (AC2 — once per entry).
  - `poolFill()` — the 1/3/9/27 fill counts under an entry (matrix verification).

**Tests** — `common/tests/unit/services/PoolServiceTest.php` (7 tests): split exact + sum/58%,
split recorded on entry, FIFO seq + root/child placement, **matrix fills to 1/3/9/27 over 40
entries**, idempotent placement, qualify-places-all, and the **3-direct end-to-end** (M2
ReferralService → PoolService) placing the entry once. Suites green: **common unit 91/365**,
frontend unit 12, frontend functional 10.

**Acceptance criteria touched (M6.6):**
1. $30 splits exactly 30/5/23/42; 58% distributed ✓
2. 3 directs auto-place the account into the FIFO pool, once per entry ✓
(3 rebirth, 4 royalty, 5 cap-exclusion — later M6 sub-steps.)

**Decisions / flags**
- **The matrix is a single company-wide 3-ary tree filled FIFO** (M6.3 "next qualifier takes the
  next open global slot"). Parent is arithmetic: the Nth entry's parent seq = ceil((N-1)/3),
  slot = (N-2) mod 3. Each entry heads its own 1/3/9/27 subtree within it.
- **Step 4.1 records the $30 split but does not pay it out.** The referral (15-level), royalty,
  matrix, and rebirth PAYOUTS — and the 50/50 cycle economics — are the next M6 sub-steps. Pool
  income will NOT count toward the cap (M6.1) when those payouts land.
- **FIFO seq** is `max(position_seq)+1` with a unique-index backstop; the M12 cron is
  single-threaded so this serialises. Concurrency hardening (a locked counter) can come with M12.
- Placement currently triggers only from the M2 qualification hook. Wiring **post-qualification
  deposits** (an already-qualified user's new entries auto-placing) is a small follow-up when M3
  approval and M6 payouts are joined up.

**Migrations verified:** up and down clean.

**Next:** remaining M6 sub-steps (referral 15-level / royalty / matrix+rebirth payouts), or M8/M9
per the order you choose.

---

## M7 — 30-Level Team Income

**Status:** done. Worked example + both M7 acceptance criteria pass. (M6 Global Auto Pool
skipped for now — built later.)

**Built**
- Migration `m260610_000013_create_level_income_ledger` — `level_income_ledger`
  (beneficiary, source, level, roi_base, pct, amount, date, capped). Unique per
  (source, level, date) → idempotent daily run (§5.7). DECIMAL(18,2), FKs RESTRICT.
  Seeds the `level_income` setting.
- `level_income` added to SettingsService DEFAULTS — the 30-level table (band → % of ROI +
  directs required, M7.2), admin-tunable (M11).
- Model `LevelIncomeLedger`.
- `common/services/LevelIncomeService.php`:
  - `levelConfig()` — resolves a level (1..30) to {pct, directs} from settings; null beyond 30.
  - `dailyRoiFor()` — a member's combined daily ROI = Stacking accrual + Holding accrual for the
    date (M7.3).
  - `distribute()` — walks the source member's ancestors via **`tree_closure`** (depth = level,
    1..30), pays each upline `roi × pct%` **only if `direct_count ≥` the level's required directs**
    (M7.3); a gated level is **skipped, not rolled up** (M7.6). Each payout is cap-checked against
    the beneficiary's own cap (§5.5), credited to the wallet (source=`level`, withdrawable), and
    counts toward the cap. Idempotent per (source, date).
  - `distributeForMember()` — convenience: compute the member's ROI then distribute.
- Console `LevelIncomeController::actionDailyDistribute` — cron stub (M12 / P7).

**Tests** — `common/tests/unit/services/LevelIncomeServiceTest.php` (7 tests): the worked
example over a S ← A1 ← A2 ← A3 ← company chain (14.91 → L1 3.73 / L2 2.24 / L3 1.49 / L4 0.75),
directs-gating skip-without-rollup (L3 and L1), combined Stacking+Holding ROI base (16.20),
**level income stops at cap (AC2)**, idempotency, level-config bands + beyond-30. Suites green:
**common unit 84/337**, frontend unit 12, frontend functional 10.

**Acceptance criteria (M7.7) — all verified:**
1. Level income computed daily on combined ROI with correct directs-gating ✓
2. Level income contributes to cap and stops at cap ✓

**Decisions / flags**
- **Directs gate uses `users.direct_count`** (the M2-maintained counter, which is 0 until the
  upline has invested, then equals their directs). So inactive uplines earn nothing — consistent
  with the cap requiring an investment.
- **Gated levels are forfeited, not compressed/rolled up** (M7.6 default).
- **Cap base = cap_multiplier × total approved-deposit working** (M9.2). Still per-service across
  the engines — M9 unifies the cap base and the single `capped_earned`.
- `accrual_date` column = the spec's `date` (renamed to avoid the reserved word, consistent with
  the stacking/holding accrual tables).
- Level income is **paid daily and withdrawable** (one `level` ledger row per upline per day).

**Migrations verified:** up and down clean.

**Next:** M6 — Global Auto Pool (fills the PoolService stub) or M8 — Rank & Reward (60:40),
per the build order you choose.

---

## M5 — Holding Engine

**Status:** done. Worked example + all M5 acceptance criteria pass.

**Built**
- Migration `m260610_000012_create_holding_tables` — `holding_plans` (working, tier, daily_rate,
  total_earned, foreign_trip, cap_reached, status) + `holding_accruals` (unique per plan/day →
  idempotent cron, §5.7). Seeds the `holding_tiers` setting. DECIMAL(18,2), FKs RESTRICT.
- `holding_tiers` added to SettingsService DEFAULTS (M5.2 band → daily ROI %; admin-tunable).
- Models `HoldingPlan` (isForeignTripEligible), `HoldingAccrual`.
- `common/services/HoldingService.php`:
  - `tierFor()` — assigns the **highest band whose min the deposit meets**, making boundaries
    inclusive and sending the $1,001–$1,079 / $5,001–$5,079 gaps to the **nearest lower band** (M5.6).
  - `createPlanFromDeposit()` — tier from the deposit amount, daily ROI on the working amount;
    **VIP sets the Foreign Trip flag** (AC3).
  - `accrueDay()` — daily ROI = working × rate; **no lock → credited to the withdrawable wallet
    immediately** (the key difference from Stacking). Cap-checked at credit time (§5.5); when the
    cap is hit the plan **stops** and ROI halts (M5.6 / AC2). Idempotent per (plan, date).
- Console `HoldingController::actionDailyRoi` — cron stub (M12 / P7).

**Tests** — `common/tests/unit/services/HoldingServiceTest.php` (9 tests): the
$5,000 / Silver / $14.91 worked example, daily credit on the working amount (AC1), tier
boundaries + gap-to-nearest-lower, no-lock immediate withdrawability, VIP Foreign Trip flag
(AC3), **ROI halts at cap (AC2)**, idempotent accrual, validation. Suites green:
**common unit 77/303**, frontend unit 12, frontend functional 10.

**Acceptance criteria (M5.7) — all verified:**
1. Correct tier and daily ROI credited each day on the working amount ✓ ($4,970 → $14.91/day)
2. Holding ROI halts at the cap ✓
3. VIP flag sets Foreign Trip eligibility ✓

**Decisions / flags**
- **Tier is by the DEPOSIT amount** ("Deposit Band", M5.2), ROI is on the **working amount**
  (M5.3) — matches the worked example ($5,000 deposit → Silver; ROI on $4,970).
- **Holding credits the wallet daily** (one `holding` ledger row per day) — no lock, so each
  day's ROI is immediately withdrawable. (Stacking, by contrast, credits only at maturity.)
- **Gap/boundary resolution:** assignment by highest-min band → boundaries inclusive, gaps fall
  to the nearest lower band (M5.6 default). The $10,000 Gold/VIP overlap resolves to **VIP**.
- **Cap base is per-engine for now** (holding working × cap_multiplier); M9 will unify the cap
  base across all deposits and the single `capped_earned`.

**Migrations verified:** up and down clean.

**Next:** M6 — Global Auto Pool (fills the PoolService stub: $30 split, matrix, rebirth, royalty).

---

## M4 — Stacking Engine

**Status:** done. Worked example + all M4 acceptance criteria pass.

**Built**
- Migration `m260610_000011_create_stacking_tables` — `stacking_plans` (working, term_days,
  rate, total_profit, daily_accrual, accrued_to_date, cap_reached, start/maturity, status) +
  `stacking_accruals` (one row per plan per day, **unique (plan, date)** → idempotent cron,
  §5.7). DECIMAL(18,2), FKs RESTRICT. Seeds the `stacking_terms` setting.
- `stacking_terms` added to SettingsService DEFAULTS (M4.2 term→rate; admin-tunable, M11).
  Coin share kept in the data but **not implemented** (open §14.1 #2).
- Models `StackingPlan` (isMatured), `StackingAccrual`.
- WalletService gains `cappedEarnedOf()` / `addCappedEarned()` — cap progress accounting
  (not balance, not ledgered), used by crediting services at credit time (§5.5).
- `common/services/StackingService.php`:
  - `createPlanFromDeposit()` — profit = working × rate (rate from settings), daily accrual,
    maturity date. One plan per deposit.
  - `accrueDay()` — daily accrual, idempotent per (plan, date); applies the cap (limits the
    day's accrual to remaining cap room and marks the plan capped when it runs out, M4.6).
    Accrual counts toward cap but does NOT credit the withdrawable wallet.
  - `settleMaturity()` — at maturity credits the wallet with **profit only** (uncapped plans
    pay the exact total_profit, truing up daily rounding; capped plans pay only what accrued).
    The principal is never re-paid (M4.4 / AC2).
  - Multiple active plans per user supported.
- Console `StackingController::actionDailyAccrual` — **cron stub** (M12 / P7); the accrual math
  is in the service and fully tested.

**Tests** — `common/tests/unit/services/StackingServiceTest.php` (10 tests): the
$1,000/90-day/12% worked example (116.40 profit, 1.29/day, profit-only at maturity, principal
not re-paid), withdraw-only-after-maturity, accrual-locked-until-maturity, multiple plans
maturing independently, **accrual halts exactly at cap (AC3)**, rate-from-settings, idempotent
accrual, validation. Suites green: **common unit 68/267**, frontend unit 12, frontend functional 10.

**Acceptance criteria (M4.7) — all verified:**
1. profit = working × rate; accrues daily; withdrawable only at maturity ✓
2. Principal is not double-paid at maturity (wallet gets 116.40, not 1086.40) ✓
3. Stacking accrual halts exactly when the cap is reached ✓

**Decisions / flags**
- **Daily accrual is tracked + counts toward the cap, but the wallet is credited only at
  maturity** (one `stacking` ledger row). This is how "accrues daily but withdraw only at
  maturity" (M4.3) maps onto our single withdrawable balance.
- **Cap check lives in StackingService** (§5.5 — cap check at credit time in the crediting
  service), using `cap_multiplier` × stacking working as the limit and `capped_earned` as
  progress. M9 will generalise the cap across all income types and the leader-upgrade resume.
- **Coin share not implemented** (open §14.1 #2).
- **Bug fixed:** `SettingsService::serializeValue` json-encoded JSON strings twice and
  `array_values()`-stripped associative keys — corrupting keyed maps like `stacking_terms`.
  Now stores valid JSON strings as-is and preserves keys. (Affected any admin JSON-setting save.)
- **Plan creation seam:** `createPlanFromDeposit(deposit, termDays)` is called by the approval
  flow (M11). The chosen term isn't yet captured on the deposit row — the approval UI passes it
  (or we add `term_days` to deposits later); flagged.

**Migrations verified:** up and down clean.

**Next:** M5 — Holding Engine (tier-based daily ROI, no lock).

---

## M3 (step 2.2) — Deposits & the $30 carve-out

**Status:** done. All M3 acceptance criteria pass (automated + e2e).

**Built**
- Migration `m260610_000010_create_deposits_and_pool_entries` — `deposits` (amount,
  pool_entries, pool_amount, working_amount, plan_type ENUM, tx_hash **unique**, status ENUM
  pending/approved/rejected) + minimal `pool_entries` (user_id, deposit_id, entry_amount).
  DECIMAL(18,2), FKs RESTRICT (§7).
- Models `Deposit` (status/plan_type constants), `PoolEntry`.
- `common/services/InvestmentService.php`:
  - `createDeposit()` — validates the $80 minimum, resolves pool entries from
    `pool_entries_mode`, carves $30 × entries, computes `working = amount − pool`, rejects if
    working ≤ 0, creates the **pending** deposit + one `$30 pool_entries` row per entry —
    atomically. **Does not touch the wallet** (no income logic).
  - `resolvePoolEntries()` — `fixed_one` (default) and `user_selectable` implemented;
    `auto_by_size` refuses with a clear message (its formula is **open §14.1 #1** — not invented).
  - `approveDeposit()` — admin tx-hash verification (M11 calls it); on the user's first
    approved deposit it fires `ReferralService::recordFirstInvestment()` (M2 link activation).
  - `rejectDeposit()` — voids the deposit's pool entries (a rejected payment must not count).
- User-to-user transfer (5% fee) was already built in step 2.1 (`WalletService::transfer`);
  re-verified here in the M3 test for AC3/AC4.

**Tests** — `common/tests/unit/services/InvestmentServiceTest.php` (13 tests): single-entry
carve (AC1), 10 deposits → 10 pool entries (AC2), multi-entry per deposit, working-stays-positive,
min enforcement, invalid plan, duplicate tx_hash, pending-doesn't-credit-wallet, approve fires
M2 seam, reject voids entries, transfer 5% fee + reconciliation (AC3/AC4). Suites green:
**common unit 58/231**, frontend unit 12, frontend functional 10.

**Acceptance criteria (M3.8) — all verified:**
1. $30 × pool_entries carved before Working Investment ✓
2. 10 deposits create 10 independent pool entries ✓
3. Transfers apply exactly 5% fee and balance the ledger ✓ (step 2.1)
4. Wallet balance always equals the sum of its ledger rows ✓ (step 2.1)

**Decisions / flags**
- **Pool entries are created at deposit time (pending), not on approval** — pool/royalty volume
  counts from *all* deposits (M3.7 / M6.3.2). Rejection deletes them.
- **`pool_entries` kept minimal** (the $30 entry + its deposit link). M6 (Global Auto Pool)
  extends it with matrix placement columns (position_seq, pool level, cycle status).
- **"Plan record stub" = the deposit itself** (plan_type + working_amount). The real
  Stacking/Holding plan tables/engines are M4/M5, which consume approved deposits.
- **A deposit never credits the wallet** — the working amount is the ROI base on the plan, not
  withdrawable balance; the wallet only moves via income (M4–M8), transfers, withdrawals.
- **`auto_by_size` not implemented** — the derivation formula is open §14.1; the service
  refuses rather than guess.
- Shared SettingsService FileCache is the same path for dev and test console runs — tests that
  change a setting can leave stale dev cache; flush `console/runtime/cache` after a test run
  that mutated settings (done in verification).

**Migrations verified:** up and down clean.

**Next:** M4 — Stacking Engine (term/rate plans on approved deposits, daily accrual, maturity).

---

## M3 (step 2.1) — Wallet & Ledger

**Status:** done. Credit/debit/transfer primitives with a reconciling ledger. No income logic.

**Built**
- Migration `m260610_000009_create_wallet_and_ledger` — `wallets` (balance, capped_earned,
  DECIMAL(18,2)), `wallet_ledger` (direction + source ENUM, amount, balance_after, ref_id),
  `transfers` (gross/fee/net). FKs RESTRICT (money tables, §7). Seeds a zero wallet per user.
- `common/helpers/Money.php` — bcmath money math at scale 2 (add/sub/mul/cmp/round half-up).
  Single helper so rounding/scale are consistent across all money services.
- Models `Wallet`, `WalletLedger` (source/direction constants), `Transfer`.
- `common/services/WalletService.php` — **the only way balances change** (§5):
  - `credit()` / `debit()` / `transfer()` — each runs in a DB transaction, locks the wallet
    row `FOR UPDATE`, writes exactly one ledger row, and stores `balance_after`.
  - `transfer()` recomputes the 5% fee server-side from `SettingsService` (never trusts the
    client, §6); sender debited gross as transfer_out(net) + fee; recipient credited net;
    one `transfers` header; whole thing atomic.
  - `reconcile()` / `ledgerSum()` — assert/compute that balance == signed sum of ledger rows.
  - Rejects non-positive amounts, self-transfer, unknown recipient, and insufficient balance
    (atomic rollback).

**Tests** — `common/tests/unit/services/WalletServiceTest.php` (12 tests): the §5.3/AC4
invariant `balance == sum(ledger)` asserted after **every** operation; 5% fee exact (AC3);
fee conservation with rounding; insufficient-funds atomicity (no partial transfer); decimal
precision (no float drift). Suites green: **common unit 45/199**, frontend unit 12, functional 10.

**Acceptance criteria touched (M3.8):**
3. Transfers apply exactly 5% fee and balance the ledger ✓
4. Wallet balance always equals the sum of its ledger rows ✓
(1 & 2 — the $30 carve-out / deposit & pool entries — are the next M3 step, not this one.)

**Decisions / flags**
- **`wallet_ledger.type` omitted.** The spec M3.5 lists both `type` and `direction` with no
  enum for `type`; I model the sign as `direction` (credit/debit) and the reason as `source`
  (the §5.4 tag), which fully cover it. Trivial to add a `type` column later if the client
  defines its values — flagged rather than invented (§11).
- **Transfer fee is split into two sender rows** (transfer_out = net, fee = fee) so the fee is
  visible in the sender's ledger and the §5.4 `fee` source is exercised; sum still equals gross.
  The fee is company revenue (no user wallet gains it).
- **Test cleanup uses DELETE, not TRUNCATE.** TRUNCATE implicitly commits and breaks the test
  transaction / nested-rollback semantics — which is exactly what the atomicity test checks.
- `capped_earned` column exists but is untouched here; M9 manages cap accounting at credit time.

**Migrations verified:** up and down clean.

**Next:** M3 step 2.2 — Deposits & the $30 Global-Pool carve-out (`deposits` table,
InvestmentService), which credits the working amount and calls
`ReferralService::recordFirstInvestment()` on first deposit.

---

## M2 — Referral Network & Sponsor Tree

**Status:** done. All M2 acceptance criteria pass (automated + e2e).

**Built**
- Migration `m260610_000008_add_referral_network` — adds to `users`: `is_leader`,
  `leader_since`, `direct_count`, `direct_invested_count`, `first_invested_at`,
  `cap_multiplier` (default 3, →4 on Leader), `pool_qualified_at`. Creates `tree_closure`
  (ancestor_id, descendant_id, depth) with unique(pair) + indexes + FKs. Seeds root self-row
  and **backfills closure ancestor-chains for any pre-existing accounts** (e.g. the `aamir`
  signup from M1) in id order.
- Model `common/models/TreeClosure.php`; `User` gains `hasInvested()`, `isLeader()`, and the
  new attributes.
- `common/services/ReferralService.php` — the genealogy + qualification engine:
  - `onRegister()` — builds a new account's closure rows (self + ancestor chain) and refreshes
    its sponsor's qualification. Wired into `AuthService::register()` inside the same tx.
  - `recordFirstInvestment()` — the **hook M3 will call** on a user's first investment:
    activates their referral link and refreshes qualification for them and their sponsor.
  - `recomputeAndQualify()` — recomputes counters from the genealogy (no drift), applies Leader
    (≥2 invested directs → permanent, cap→4) and Global Pool (≥3 directs → fire hook once).
  - Thresholds read from `SettingsService` (`leader_direct_threshold`, `pool_qualify_directs`,
    `cap_leader_multiplier`). Counters are **gated**: they stay 0 until the referrer invests (M2.5).
  - `descendants()` / `ancestors()` closure queries for 30-level + 60:40 (M2.3).
- `common/services/PoolService.php` — **placeholder** (`onReferralQualified()`); full matrix
  placement is M6 (P4). ReferralService calls it once at the 3-direct threshold.

**Tests** — `common/tests/unit/services/ReferralServiceTest.php` (8 tests, DB-backed): closure
correctness at any depth, Leader on 2 invested directs + permanence + 4X, Pool hook exactly
once at 3 directs (verified with a spy PoolService), link-inactive gating until referrer
invests, register-but-never-invest, idempotent first-investment. Suites green: **common unit
33/144**, frontend unit 12, frontend functional 10.

**Acceptance criteria (M2.6) — all verified:**
1. 2 invested directs → Leader, 4X cap, permanent ✓
2. Closure returns correct descendant sets at any depth ✓
3. 3 directs → Global Pool placement hook exactly once ✓

**Decisions / flags**
- **Counters are gated, not raw structural counts:** `direct_count`/`direct_invested_count` are
  0 until the referrer invests, then reflect the genealogy (M2.2 "referral link active only
  after invested" / M2.5). Raw structure is always available via `tree_closure` depth 1.
- **Pool fires once** via a `pool_qualified_at` guard. The spec's "once per entry" (per $30
  matrix entry) is an M6 concern; M2 fires the qualification hook once when the threshold is
  first crossed.
- `cap_multiplier` added now (M2.4 sets it) though the cap is enforced in M9.
- `recordFirstInvestment()` is the integration seam for M3 — M3's InvestmentService will call
  it inside its deposit transaction.

**Migrations verified:** up and down clean; closure rebuilds from backfill on re-up.

**Next:** M3 — Investment & Wallet Core ($30 carve-out, wallet ledger, transfers), which will
call `ReferralService::recordFirstInvestment()` on first deposit.

---

## Design system applied (DESIGN_SYSTEM.md)

**Status:** done. Refined-minimal fintech theme applied to every page built so far.

**Built**
- `tailwind.tokens.js` — single source of design tokens (orange `primary`, teal `secondary`,
  `ink` text, `canvas`/`surface`, `pos`/`neg`, radii, shadows, fonts). Both Tailwind configs
  `require()` it so frontend + backend themes are identical (DESIGN_SYSTEM §7).
- Fonts: **Bricolage Grotesque** (display) + **Plus Jakarta Sans** (body) via Google Fonts in
  every layout; base layer sets canvas bg + display headings. **Zero Inter** in either bundle.
- Shared UI partials in `common/views/components/` (rendered from both apps via the `@common`
  alias): `btn`, `card`, `card_dark`, `stat_card`, `pill`, `status`, `section_tag`.
- `TwForm` + `Alert` widget re-bound to tokens (focus ring `primary-50`, error `neg`, etc.).
- Restyled: frontend + backend layouts (teal admin topbar), marketing home (teal hero + stat
  cards + section tags), about, contact, all auth forms, error pages, backend login + dashboard,
  panel dashboard. App name set to **ADbotix** in both apps.

**Verified**
- Both Tailwind bundles rebuild clean; token classes + both fonts present; no Inter.
- All pages render HTTP 200; screenshot confirms teal/orange aesthetic on the home page.
- Suites stay green (common unit 25, frontend unit 12, frontend functional 10) after updating
  the template's stale `HomeCest` name assertion.

**Notes / flags**
- `DESIGN_SYSTEM.md` references an **`adbotix-components.html`** visual target that is **not in
  the repo** — I built from the documented tokens/specs (complete) and matched the described
  aesthetic. Drop that file in and I'll reconcile any pixel differences.
- Partials consolidated in `common/views/components/` (single source) rather than duplicated
  per app as the doc's table loosely implies — cleaner per §8; both apps render the same files.
- Built only the partials the current pages need (btn/card/stat/pill/status/section_tag/card_dark);
  the data-heavy ones (table, donut, bar_chart, sidebar, credit_card) arrive with the modules
  that use them (M3+), to avoid premature abstraction (§8).
- Dev-server links now work via `./serve.sh` (frontend :8080, backend :8081); `/index.php`
  returns 200 — the earlier "broken" links were simply the servers not running.

**Reference added:** `DESIGN_SYSTEM.md` is now cited in PROJECT_INSTRUCTIONS §2 + repo structure.

---

## M1 — Authentication & Accounts

**Status:** done. All M1 acceptance criteria pass (automated + e2e).

**Built**
- Migrations (`console/migrations/`):
  - `m260610_000003_create_users_table` — `users`: username UNIQUE, **email NON-unique**
    (multi-account), `sponsor_id` self-FK (RESTRICT), `status` (pending/active/blocked),
    `email_verified_at`, `auth_key`, `password_reset_token`. Indexed username/email/sponsor/status.
  - `m260610_000004_create_email_verifications_table` — per-account tokens (token unique,
    `expires_at`, `used_at`); FK→users CASCADE.
  - `m260610_000005_create_login_audit_table` — user/ip/user_agent/created_at; FK→users CASCADE.
  - `m260610_000006_drop_legacy_user_table` — removes the base-template `user` table
    (down() recreates it; it was empty and is replaced by `users`).
  - `m260610_000007_seed_root_account` — seeds the genealogy ROOT `company` (the only
    `sponsor_id = NULL` row) so registrations always have a valid sponsor.
- Models (`common/models/`): rewrote `User` (→`users`, IdentityInterface, status helpers,
  `canTransact()`, `sponsor`/`directs` relations); new `EmailVerification` (`isUsable()`),
  `LoginAudit`.
- Service `common/services/AuthService.php` — all M1 logic: `register()` (transactional;
  resolves sponsor, creates pending user + token, emails after commit), `verifyEmail()`
  (consumes token, flips status→active + `email_verified_at`), `resendVerification()`
  (per-username, retires old tokens), `recordLogin()`.
- Forms (`frontend/models/`): `RegistrationForm` (adds required, must-exist `sponsor_username`;
  email NOT unique); `ResendVerificationEmailForm` reworked to username. Removed superseded
  `SignupForm`/`VerifyEmailForm`. `LoginForm` now writes a login-audit row.
- Controllers/views: thin `SiteController` (signup/verify/resend call the service); signup
  view gains sponsor field; resend view keyed by username. New **panel module**
  (`frontend/modules/panel`) — login-gated; `BaseController::requireTransactable()` is the
  AC4 enforcement point (M3/M10 deposit/withdraw will call it); dashboard shows verify state.
- Config/mail: `user.emailVerificationTokenExpire` param (24h); panel module registered;
  `emailVerify` mail templates take the token explicitly.

**Tests** (DB-backed against `adbotix_test`):
- `common/tests/unit/services/AuthServiceTest.php` — 11 tests (register, 3-accounts-one-email,
  sponsor-missing reject, dup-username reject, verify activates, reused/expired token reject,
  resend retires old, pending-cannot-transact, login audit).
- `frontend/tests/unit/models/RegistrationFormTest.php` — 7 tests (valid, sponsor required +
  must exist, dup username, email-not-unique, password length).
- `frontend/tests/functional/LoginCest.php` — login by username, wrong password, blocked
  account refused. Removed the template's superseded Signup/Verify/Resend tests; updated
  fixtures to the `users` schema.
- Suites green: common unit 25/107, frontend unit 12/31, frontend functional 10/23.

**Acceptance criteria** (M1.6) — all verified:
1. Register → verify email → login by username ✓ (AuthServiceTest + LoginCest)
2. One email → 3+ independent accounts ✓ (AuthServiceTest + e2e on dev DB)
3. Registration impossible without a valid existing sponsor ✓
4. Unverified accounts cannot transact ✓ (`User::canTransact()` + panel `requireTransactable()`)

**Decisions / flags**
- **Replaced the base-template `user`-table auth** with the spec `users` model. The template's
  unique-email, integer-status, token-on-row design conflicts with M1 (non-unique email,
  sponsor genealogy, separate token table) and every later module hangs off `users`+sponsor.
  Legacy `user` table dropped (reversibly).
- **Seeded a root sponsor `company`** (infrastructure, not business logic) — needed because
  every registration requires an existing sponsor and the tree needs a single NULL-sponsor
  root. It carries a random unusable password; **admin must set a real password before it is
  logged in**.
- Pending (unverified) accounts **may sign in** (to see the "verify your email" state) but are
  gated from transacting — matches the spec framing (verification gates *transacting*). Blocked
  accounts cannot sign in.
- Password-reset flow from the template is retained (column + forms) though not part of M1.

**Migrations verified:** up and down both clean (reverted 5, re-applied 5; root restored).

**Next:** M2 — Referral Network & Sponsor Tree (directs counting, `tree_closure`, leader
qualification).

---

## Settings (shared config store + SettingsService)

**Status:** done. Single source of tunable numbers in place.

**Built**
- Migration `console/migrations/m260610_000001_create_settings_table.php` — `settings`
  table: `key` (unique), `value` (text), `type`, `group`, `label`, `is_locked`,
  `created_at`, `updated_at`. InnoDB + utf8mb4. Indexed on `key` (unique) and `group`.
- Model `common/models/Setting.php` — ActiveRecord with validation (`type` whitelist,
  `key` pattern + unique) and `TimestampBehavior`.
- Service `common/services/SettingsService.php` — the single source of tunable numbers.
  - `DEFAULTS` constant holds every spec-locked value (the ONE place the numbers live).
  - Typed getters: `getInt`, `getDecimal`, `getPercent`, `getBool`, `getArray`,
    `getString`, plus `percentFraction()` (bcmath, exact). Money/percentages returned as
    exact strings — never float (§5.6).
  - Read resolution: DB row → spec default → caller fallback. So a correct value is
    always available, an admin edit always wins, and the app degrades to defaults if the
    table is unreachable (pre-migration / DB down).
  - `set()` upserts and invalidates the cache; reads are cached (FileCache) + memoised.
- Seed migration `console/migrations/m260610_000002_seed_settings.php` — materialises
  `SettingsService::DEFAULTS` into rows (does not duplicate the numbers). Idempotent up,
  targeted down.
- Test `common/tests/unit/services/SettingsServiceTest.php` — 11 tests / 71 assertions,
  green. Covers the locked $30 split (sums to 100, distributes 58, $9/$1.50/$6.90),
  matrix 3+3+4=10, scalar defaults, fee fraction, typed array, locked/open flags. Runs
  without a test DB (defaults-fallback path).

**Seeded locked values** (all `is_locked=1` unless noted), grouped:
- investment: `min_investment`=80, `pool_carveout`=30
- fees: `min_withdrawal`=10, `withdrawal_fee_pct`=5, `transfer_fee_pct`=5
- cap: `cap_investor_multiplier`=3, `cap_leader_multiplier`=4, `leader_direct_threshold`=2
- pool: `pool_split_referral_pct`=30, `pool_split_royalty_pct`=5,
  `pool_split_matrix_pct`=23, `pool_split_company_pct`=42, `pool_qualify_directs`=3
- matrix: `matrix_pool1_pct`=3, `matrix_pool2_pct`=3, `matrix_pool3_pct`=4
- rebirth: `first_cycle_deduct_pct`=58, `rebirth_deduct_pct`=23, `cycle_user_share_pct`=50,
  `rebirth_ladder`=[100,500,3000,10000,40000]
- royalty: `royalty_pool1_directs`=10, `royalty_pool2_directs`=21,
  `royalty_pool1_pct`=3, `royalty_pool2_pct`=2

**Decisions / flags**
- **Open §14.1 item seeded unlocked:** `pool_entries_mode` = `fixed_one`
  (`is_locked=0`), with options `fixed_one | auto_by_size | user_selectable`. Defaulted to
  the base worked example (M3.3, single $30 entry) per §11 — **needs client confirmation**,
  not a hardcoded guess.
- **Stacking term/rate (M4.2) and Holding tier (M5.2) tables deliberately NOT put in
  `settings`.** They are multi-row structured data with their own data models in M4/M5 and
  M11 "Plan config". Keeping them out of `settings` avoids a competing source of truth.
  `settings` holds the scalar/global tunables only.
- Reserved words `key`/`group` used as column names intentionally (matches the task's
  "key, value, type"); Yii quotes identifiers, verified working on MariaDB 10.4.

**Verified**
- `./yii migrate` up → 25 rows. `migrate/down 2` drops cleanly; re-`up` restores 25 rows
  (idempotent). Typed reads + write-path + spec invariants asserted (unit test, green).

**Next:** M1 Authentication & Accounts (or per EXECUTION_PLAN once provided).

---

## Foundation — Tailwind (frontend site+panel, backend admin)

- Tailwind via npm: `tailwind.frontend.config.js` (scans `frontend/views` +
  `frontend/modules/**/views` → `frontend/web/css/tailwind.css`) and
  `tailwind.backend.config.js` (→ `backend/web/css/tailwind.css`). One compiled CSS per app.
- Wired through `frontend/assets/AppAsset.php` and `backend/assets/AppAsset.php`.
- Bootstrap fully removed from app code: layouts, `common/widgets/Alert.php`, and all stock
  views rewritten in Tailwind; shared form classes in `common/widgets/TwForm.php`.
- Verified: all frontend + backend pages return 200 with no Bootstrap class leakage.

---

## Foundation — Yii2 Advanced scaffold

- Yii2 Advanced template installed (Yii 2.0.55), four apps: common / console / frontend /
  backend. Folder structure per PROJECT_INSTRUCTIONS §3 (incl. `frontend/modules/site`,
  `frontend/modules/panel`, `frontend/web/media`, `common/services`, `common/helpers`).
- DB: MySQL/MariaDB connection in `common/config/main-local.php` → database `adbotix`
  (utf8mb4). `./yii migrate` runs; both frontend and backend default pages load.
