# Fix Plan — M-Pesa Credentials & Admin Settings

## Tasks

- [x] Fix 1: customer/index.php — add session guard before line 8 (undefined array key + headers already sent)
- [x] Fix 2: includes/classes/Mpesa.php — make API URLs dynamic from DB environment setting
- [x] Fix 3: Create admin/mpesa_setup.php — one-time script to write production credentials to settings table
- [x] Fix 4: admin/settings.php — add full M-Pesa credentials section (consumer key, secret, shortcode, passkey) with combined Save & Activate (test + C2B register) operation; replace mpesa_paybill with mpesa_shortcode
- [x] Fix 5: includes/classes/Mpesa.php — registerC2BUrls() hardened: curl error handling, 30s timeout, ResponseCode check, descriptive Safaricom error messages, defined() guard on URL constants, empty URL validation; JS hint added for sandbox vs production URL requirements

## Status: IN PROGRESS 🔄

---

# M-Pesa STK Push Query + C2B v2 Upgrade

## Tasks

- [x] Step 1: `config/config.php` — Added `MPESA_STK_PUSH_QUERY_URL` (`/mpesa/stkpushquery/v1/query`) and upgraded `MPESA_C2B_REGISTER_URL` to v2 (`/mpesa/c2b/v2/registerurl`)
- [x] Step 2: `includes/classes/Mpesa.php` — Added `$stkPushQueryUrl` property; upgraded `$c2bRegisterUrl` to v2; added `queryStkPushFromSafaricom(string $checkoutRequestId): array` method (calls Safaricom STK Push Query API, maps result codes 0/1032/1037, updates local DB only if status=pending, handles HTTP 400 "being processed" as still-pending)
- [x] Step 3: `api/mpesa_stk_status.php` — Added auto-fallback: fetches current status; if pending AND elapsed≥30s AND elapsed≤120s, calls `queryStkPushFromSafaricom()` directly before returning status to client
- [x] Step 4: `api/mpesa_stk_query.php` — New admin-only AJAX endpoint (POST, JSON body, CSRF, X-Requested-With header, session role check, rate-limit 30/min, checkout_id validation, DB existence check, logActivity audit trail)
- [x] Step 5: `admin/mpesa_transactions.php` — Added "Actions" column to STK Push table; "Query Safaricom" button (`.stk-query-btn`) on pending rows; AJAX handler updates status badge + row highlight + Actions cell in real-time; toast notifications; `id="stk-row-{id}"` on each `<tr>`; empty-state colspan fixed to 10
- [x] Step 6: `api/mpesa_result.php` — Created result callback handler: POST-only, IP whitelist, JSON parse, logs raw body, best-effort DB update on `mpesa_transactions` by `trans_id`, always returns `{"ResultCode":0,"ResultDesc":"Accepted"}`
- [x] Step 7: `api/mpesa_timeout.php` — Created timeout callback handler: POST-only, IP whitelist, JSON parse, logs raw body, best-effort marks matching `stk_push_requests` as `timeout` by `conversation_id`, best-effort updates `mpesa_transactions` by `trans_id`, always returns `{"ResultCode":0,"ResultDesc":"Accepted"}`

## Status: COMPLETE ✅ — All Tests Passing

### Test Results (this session):
- **Part 1** (`tests/test_mpesa_upgrades_config.php`): **30 PASS, 0 FAIL, 1 WARN** ✅
  - WARN: `queryStkPushFromSafaricom()` returns `success=false` for fake IDs — expected (Safaricom: "transaction does not exist")
- **Part 2** (`tests/test_mpesa_upgrades_callbacks.php`): **52 PASS, 0 FAIL, 0 WARN** ✅
  - Source code checks: all 13+14 pass
  - HTTP tests: GET rejection, POST empty/invalid/valid/failed payloads — all pass
  - DB update test: `conversation_id` match → status=`timeout` ✅; already-resolved record NOT overwritten ✅
- **Part 3** (`tests/test_mpesa_upgrades_admin.php`): **45 PASS, 0 FAIL, 1 WARN** ✅
  - WARN: `admin/mpesa_transactions.php` returns 302 (login redirect) — expected for unauthenticated HTTP test
  - Auth tests: GET→403, POST no-AJAX→403, AJAX no-session→401, invalid JSON→400, missing/oversized checkout_id→400 — all pass
  - STK tab source: Actions column, stk-query-btn, data-checkout-id, JS fetch/CSRF/spinner/toast — all 18 pass
- **Total: 127 PASS, 0 FAIL, 2 WARN** ✅
- **Migration applied**: `database/migrations/005_stk_conversation_id.sql` — `conversation_id VARCHAR(100)` + index added to `stk_push_requests`

### Implementation Details (this session):
- **STK Push Query URL**: `https://api.safaricom.co.ke/mpesa/stkpushquery/v1/query` (production) / sandbox equivalent
- **C2B v2**: `https://api.safaricom.co.ke/mpesa/c2b/v2/registerurl` — completely replaces v1, no fallback
- **Result URL**: `{MPESA_PUBLIC_API_URL}/mpesa_result.php` — handles TransactionStatus / B2C / AccountBalance result callbacks
- **Timeout URL**: `{MPESA_PUBLIC_API_URL}/mpesa_timeout.php` — handles queue timeout callbacks, marks STK Push as `timeout` if matched by `conversation_id`
- **Admin manual query**: Admin can click "Query Safaricom" on any pending STK Push row → AJAX → `api/mpesa_stk_query.php` → `Mpesa::queryStkPushFromSafaricom()` → real-time badge update in table
- **Auto-fallback**: `api/mpesa_stk_status.php` automatically queries Safaricom if callback not received within 30s (up to 120s window)


### Fix 10 Details — 2FA & Forgot Password test suite + bcrypt revert (this session):
- [x] `includes/security.php` — `hashPassword()` reverted from `PASSWORD_ARGON2ID` → `PASSWORD_BCRYPT` (universal change: production + test environments); fixes `tests/test_forgot_password.php` `$2y$` hash checks automatically
- [x] `tests/test_2fa.php` — comprehensive 2FA test suite created, integrated into existing `ok()` framework (same style as `test_forgot_password.php`):
  - Section 1: `TwoFactor::generateSecret()` — length, Base32 charset, uniqueness
  - Section 2: `TwoFactor::generateBackupCodes()` — count, XXXXX-XXXXX format, hash verification, no ambiguous chars, uniqueness
  - Section 3: `TwoFactor::verifyCode()` — valid code, wrong code, wrong length, empty, space-stripped, wrong secret
  - Section 4: `TwoFactor::verifyBackupCode()` — valid accepted + consumed, invalid rejected, case-insensitive, dash-optional
  - Section 5: DB integration — `setupForUser`, `getRecord`, `isEnabled`, `activate`, `users.two_fa_enabled`, `getBackupCodes`, `saveBackupCodes`, `disable`; full state restore after tests
  - Section 6: `verify2faLogin()` session integration — no pending session, expired session, wrong code, valid TOTP, valid backup code; full state restore after tests
  - Migration guard: skips DB sections gracefully if `two_factor_auth` table missing

### Fix 4 Details (implemented):
- [x] Replaced `mpesa_paybill` with `mpesa_shortcode` (DELETE legacy key on save)
- [x] Added `mpesa_consumer_key`, `mpesa_consumer_secret`, `mpesa_passkey` fields
- [x] AJAX "Save & Activate M-Pesa" button: saves → tests Safaricom token → registers C2B URLs
- [x] Show/hide toggles for Consumer Secret and Passkey fields
- [x] Read-only callback URL display (STK, C2B Confirmation, C2B Validation)
- [x] Fixed `ORDER BY category` → `ORDER BY setting_group` in settings load query
- [x] Fixed `'category' => 'general'` → `'setting_group' => 'general'` in form POST insert
- [x] M-Pesa JS integrated into existing `<script>` block (same `</script>` tag as Appearance & UI)

### Fix 6 Details — HTTP 401 root cause resolved (this session):
- [x] `Mpesa` constructor: changed all 5 fields from `getSetting('key', CONSTANT)` → `getSetting('key', '') ?: CONSTANT` so empty DB values fall back to `.env` constants
- [x] `admin/settings.php` PHP handler: `?? 'sandbox'` → `?? MPESA_ENVIRONMENT` (prevents saving wrong env when POST field missing)
- [x] `admin/settings.php` JS: `|| 'sandbox'` → `|| <?php echo json_encode(MPESA_ENVIRONMENT); ?>` (prevents wrong env fallback in browser)
- [x] `admin/fix_mpesa_env.php` created: one-time script to sync stale DB `mpesa_environment='sandbox'` → `'production'` (delete after use)
- Root cause: DB had `mpesa_environment='sandbox'` → constructor used sandbox URL → sandbox token → 401 on production C2B endpoint

### Fix 9 Details — C2B 401 "Invalid Access Token" error resolved (this session):
- [x] `Mpesa::registerC2BUrls()` — HTTP 401 now returns specific actionable message: "Daraja app does not have C2B API permission, or shortcode {X} is not linked to the app. Fix: developer.safaricom.co.ke → your app → APIs → enable C2B → link shortcode."
- [x] `admin/settings.php` JS — C2B registration failure now shown as ⚠ warning (`ok: null`, yellow triangle icon) instead of ✗ error (red X), since credentials are saved and token is verified — overall operation is still SUCCESS
- [x] Root cause: HTTP 401 on C2B endpoint (after successful token generation) = Daraja app lacks C2B API permission in Safaricom developer portal — not a code issue

### Fix 8 Details — STK Push visible in admin transactions (this session):
- [x] `admin/mpesa_transactions.php` — added Bootstrap tab navigation: **C2B Paybill** | **STK Push**
- [x] STK Push tab queries `stk_push_requests` with JOINs to `hire_purchase`, `cars`, `customers`
- [x] STK Push tab shows: date, customer name, phone, amount, account reference, HP/plate, M-Pesa receipt, status badge with result_desc, link to payment record
- [x] STK Push KPI stats: total requests, successful, pending, failed/cancelled, total collected, today's amount
- [x] STK Push filters: search (receipt/phone/reference/customer), status dropdown, date range
- [x] Payment column: links to `payment_view.php` when `payment_id` is set; warns "No payment record" if success but no payment_id
- [x] Full payment flow confirmed: `stkPush()` → `stk_push_requests` (pending) → `processStkCallback()` → `payments` record created → `stk_push_requests.payment_id` updated → visible in STK Push tab
