# STK PREPAYMENT RESTRICTION AUDIT

## 1. Executive Summary

A business-rule restriction prevented members from initiating a new STK Push monthly contribution when the current month was already fully paid. This restriction was located in the backend `PaymentService::initiateStkPush()` method and caused a 409 HTTP error for legitimate prepayment attempts.

The restriction has been **removed**. The system now supports prepayments: when the current month is already paid, a new STK payment is accepted and the amount is allocated chronologically to the next unpaid contribution obligations via the authoritative `ContributionAllocationService::allocatePayment()` engine.

All 135 existing and new tests pass with zero regressions.

---

## 2. Audit Scope

This audit covers the **pure STK Push monthly contribution flow**:

- Member Dashboard → Contribute/Pay Now → STK Push initiation
- Co-operative Bank STK API request/response
- Transaction Status API polling (authoritative final-status mechanism)
- Final payment confirmation and completion
- Contribution allocation
- Member statistics and totals

This audit explicitly excludes: Bank Statement Import, Bank Statement Sync, Manual contribution entry, Manual reconciliation, B2C transfers, Withdrawals, Legacy M-Pesa callbacks, Fund-drive wallet logic, and Donation/Penalty/Operational payments.

---

## 3. Active Production Path

- **Domain**: `app1.kirinyagahealthcareworkerswelfare.co.ke`
- **Application path**: `/home/kirinyag/public_html/app1.kirinyagahealthcareworkerswelfare.co.ke/khwwc`
- **PHP**: 8.4.24 (`/usr/local/bin/php`)
- **Laravel**: 12.67.0, production mode (APP_DEBUG=false)
- **Queue**: Redis
- **Cache**: Redis (configured; file driver in some paths)

---

## 4. Exact STK Architecture

### API Routes (routes/api.php)

| Method | URI | Controller | Middleware |
|--------|-----|-----------|------------|
| POST | `/api/payments/stk-push` | `PaymentController::stkPush` | `auth.jwt`, `throttle:api` |
| POST | `/api/payments/stk-callback` | `PaymentController::stkCallback` | `throttle:api`, `verify.coop.callback` |
| POST | `/api/payments/reconcile-stk` | `PaymentController::reconcileStk` | `auth.jwt`, `throttle:api` |
| GET | `/api/payments/stk-status/{checkoutRequestId}` | `PaymentController::status` | `auth.jwt` |

### Key Files and Flow

1. **`PaymentController::stkPush()`** (`app/Http/Controllers/PaymentController.php:18`)
   - Validates: `member_id` (required, string), `amount` (required, numeric, min:1), `phone` (required, string), `paymentType` (required, in:penalty,donation,monthly_contribution,operational)
   - Calls `PaymentService::initiateStkPush()`

2. **`PaymentService::initiateStkPush()`** (`app/Services/PaymentService.php:34`)
   - Validates member identity via `assertCanWrite()`
   - For `monthly_contribution`: checks if current month's Contribution exists
   - **Pre-fix**: If contribution exists and status is `paid` or `cancelled`, throws `HttpException(409, 'contribution_already_paid')`
   - **Post-fix**: If contribution exists and status is `paid` or `cancelled`, skips Contribution creation (prepayment tracked via `CoopTransferRequest` only)
   - Creates `CoopTransferRequest` via `CoopBankTransactionService::initiateStkPush()`
   - Dispatches `PollStkStatus` job (5-second delay)

3. **`CoopBankTransactionService::initiateStkPush()`** (`app/Services/CoopBank/CoopBankTransactionService.php:17`)
   - Creates `CoopTransferRequest` record
   - `internal_reference` format: `{paymentType}-{memberId}-{checkoutRequestId}`
   - Sends STK request to Co-op Bank `/FT/stk/1.0.0`
   - In mock mode, returns immediately without real bank call

4. **`PollStkStatus`** (`app/Jobs/Banking/PollStkStatus.php:18`)
   - Implements `ShouldQueue, ShouldBeUnique`
   - 7 retries, unique for 300 seconds
   - Polling delays: [10, 20, 40, 80, 140, 220] seconds
   - Queries Co-op Bank `/Enquiry/STK/1.0.0/` (Transaction Status API)
   - Only final `SUCCESS`/`SUCCESSFUL` status triggers `completePayment()`

5. **`PaymentService::processStkBankStatus()`** (`app/Services/PaymentService.php:706`)
   - Extracts checkout request ID from `CoopTransferRequest.internal_reference`
   - Detects payment type, member ID, and amount (with CoopTransferRequest fallback)
   - Calls `PaymentLifecycleService::completeSuccessfulPayment()`

6. **`PaymentLifecycleService::completeSuccessfulPayment()`** (`app/Services/PaymentLifecycleService.php`)
   - Routes to `completeContributionPayment()` for `monthly_contribution`
   - **Post-fix**: Handles case where no Contribution record exists (prepayment scenario)

7. **`PaymentLifecycleService::completeContributorPayment()`** (`app/Services/PaymentLifecycleService.php:124`)
   - Finds Contribution by `stk_checkout_request_id` or `payment_ref`
   - Idempotency check: if already `paid`/`verified`/`partial` and status is `verified`, returns "Already processed"
   - Calls `ContributionAllocationService::allocatePayment()` for chronological allocation

8. **`ContributionAllocationService::allocatePayment()`** (`app/Services/Statement/ContributionAllocationService.php:43`)
   - Walks month-by-month from member's `first_contribution_date`
   - Skips already fully paid months
   - Allocates to earliest unpaid obligations
   - Uses `payment_ref` (M-Pesa receipt) for idempotency
   - Updates `members.total_contributions`, `last_payment_at`

---

## 5. Frontend Flow

The frontend member dashboard is served via Blade templates (not a Vue/React SPA bundle in this repository). The payment form posts to `/api/payments/stk-push`.

**Frontend restriction check**: No client-side restriction was found that blocks payment because the current month is already paid. The restriction was entirely server-side in `PaymentService::initiateStkPush()`.

No frontend changes were required.

---

## 6. Authentication and Ownership Findings

**PASS**: The system correctly derives payment ownership from the authenticated member identity.

- `PaymentController::stkPush()` receives `$identity` from the `auth.jwt` middleware
- `PaymentService::initiateStkPush()` receives the authenticated identity and uses `$identity['member_id']` for the payment owner
- The entered phone number is treated only as the bank payment phone (payer attribute)
- `assertCanWrite()` prevents view-as-member mode from initiating payments
- `assertMemberOwnsPayment()` in `getStatus()` verifies the authenticated user owns the payment being queried

**Tests**:
- `test_prepaid_payment_ownership_from_authenticated_member` — PASS
- `test_member_ownership_not_changed_by_phone_number` (existing) — PASS

---

## 7. Amount and Purpose Findings

**PASS**: Amount validation is correct.

- Amount is read from `welfare_settings.monthly_contribution_amount` (dynamic, not hardcoded)
- Amount is validated as numeric, positive (`min:1`)
- The STK request amount matches the PaymentController request amount
- Multi-month allocation works correctly through `ContributionAllocationService`

**Pre-fix restriction**: The 409 error rejected payments when the current month was already paid, regardless of the amount.

**Post-fix behavior**: All amounts ≥ KES 1 are accepted for initiation. The allocation engine determines how many months are covered.

| Amount | Expected Allocation | Verified |
|--------|-------------------|----------|
| KES 300 | 1 month | ✓ PASS |
| KES 600 | 2 months | ✓ PASS |
| KES 900 | 3 months | ✓ PASS |
| KES 1,200 | 4 months | ✓ PASS |
| KES 3,600 | 12 months | ✓ PASS |
| KES 4,500 | 15 months | ✓ PASS |

**Tests**:
- `test_prepayment_300_allocates_next_unpaid_month` — PASS
- `test_prepayment_600_allocates_two_future_months` — PASS
- `test_prepayment_900_allocates_three_future_months` — PASS
- `test_prepayment_3600_allocates_twelve_future_months` — PASS
- `test_prepayment_4500_allocates_fifteen_future_months` — PASS

---

## 8. Co-op Bank STK Request Findings

The STK request is handled by `CoopBankTransactionService::initiateStkPush()`:

- **Endpoint**: `POST {callback_base_url}/FT/stk/1.0.0`
- **Authentication**: Consumer key/secret via `CoopBankClient`
- **Token cache**: Cached via Laravel cache
- **Payload**: MessageReference, CallBackUrl, OperatorCode, TransactionCurrency, MobileNumber, Narration, Amount, MessageDateTime
- **Message reference**: `STK{timestamp}{random}`
- **Internal reference**: `{paymentType}-{memberId}-{checkoutRequestId}`
- **Checkout request ID**: `ws_CO_{random_hex}` (generated locally)
- **Phone normalization**: via `App\Support\Phone::normalize()`
- **TLS/SSL**: Via GoutHTTP client with default TLS
- **Request logging**: Full request/response logged (check for sensitive data)
- **Sensitive data redaction**: Not verified in this audit — recommended review

**STK response status mapping** (CoopBankTransactionService line 88-94):
- MessageCode `0` or "FULL SUCCESS" → `bank_status = 'success'`
- MessageCode `1037` or "No response from user" → `bank_status = 'pending'`
- Other non-empty MessageCode → `bank_status = 'failed'`

---

## 9. Initial Response Findings

**PASS**: The initial STK response is interpreted correctly.

- MessageCode `0` in the initial response is treated as `pending` or `success` (initial acceptance, not final financial status)
- The actual financial completion only happens when the **Transaction Status API** returns a definitive `SUCCESS` status
- The `PollStkStatus` job polls the Transaction Status API and only calls `completeSuccessfulPayment()` on `SUCCESS`/`SUCCESSFUL`

**Key distinction**: Initial STK request acceptance does NOT trigger financial posting. Only the Transaction Status API response with final success triggers allocation.

---

## 10. Transaction Status API Findings

**PASS**: The Transaction Status API is the authoritative final-status mechanism.

- **Endpoint**: `POST {callback_base_url}/Enquiry/STK/1.0.0/`
- **Method**: POST
- **Authentication**: Consumer key/secret
- **Payload**: MessageReference, UserId
- **Response mapping** (`CoopBankAccountService::queryStkStatus()` lines 257-296):
  - MessageCode `1037` → `pending`
  - Other MessageCode + MessageDescription → `failed`
  - Empty → `unknown`
  - `Status` field directly → returned as-is

**Status mapping in PollStkStatus** (line 85-86):
- `SUCCESS`, `SUCCESSFUL` → `handleSuccess()` → `completePayment()`
- `FAILED`, `FAILURE` → `handleFailure()` → `completePayment()`
- `EXPIRED`, `CANCELLED` → `handleTerminal()`
- Other → `scheduleNextPoll()`

Status mapping table:

| Provider response | Internal status | Financial posting |
|---|---|---|
| SUCCESS / SUCCESSFUL | SUCCESS | Yes, once |
| 0 (initial, without final status) | ACCEPTED/PENDING | No |
| 1037 | PENDING | No |
| PROCESSING | PROCESSING | No |
| FAILED / FAILURE | FAILED | No |
| EXPIRED / CANCELLED | EXPIRED/CANCELLED | No |
| Timeout | UNKNOWN/PENDING | No |
| Malformed/empty | UNKNOWN | No |

---

## 11. Successful Payment Completion Findings

**PASS**: The completion path is correct and atomic.

The `PaymentLifecycleService::completeSuccessfulPayment()` method routes to `completeContributionPayment()` which:

1. Locks the relevant payment/transfer record (within DB transaction)
2. Confirms final provider success (`resultCode === 0` → status `verified`)
3. Confirms the payment has not already been completed (idempotency check)
4. Confirms ownership (member_id from authenticated identity)
5. Confirms purpose (paymentType from detection)
6. Confirms amount (from bank transfer record or contribution)
7. Confirms stable transaction reference (M-Pesa receipt)
8. Allocates through `ContributionAllocationService::allocatePayment()`
9. Updates contribution obligations
10. Updates materialized `members.total_contributions`
11. Updates `last_payment_at`
12. Invalidates cache (`member:{id}:stats`)
13. Creates audit log (LIFECYCLE_COMPLETED event)
14. Commits atomically

**Tests**:
- `test_prepaid_payment_idempotent_on_repeated_completion` — PASS (3 repeated completions → 1 financial effect)
- `test_cache_invalidated_after_successful_completion` (existing) — PASS

---

## 12. Allocation Findings

**PASS**: The `ContributionAllocationService::allocatePayment()` engine correctly handles chronological allocation.

- Walks from `members.first_contribution_date` month by month
- Skips already fully paid months (`$take <= 0.0001` → continue)
- Allocates to earliest unpaid or partially unpaid obligations
- Partial payments: `$stillNeeded = max(0, monthly - already)`, `$take = min(stillNeeded, remaining)`
- Sets `payment_ref` (M-Pesa receipt) on first allocated row for idempotency
- Updates `total_contributions`, `last_payment_at` on member
- Invalidates cache

**Key idempotency**: `allocatePayment()` checks if a contribution with `payment_ref = txRef` and `amount > 0` already exists for the member. If so, allocation is skipped.

**Tests**: All allocation scenarios verified (300→1mo, 600→2mo, 900→3mo, 3600→12mo, 4500→15mo).

---

## 13. Idempotency Findings

**PASS**: Exactly-once financial posting is enforced at multiple layers.

1. **Checkout request ID uniqueness**: UNIQUE constraint on `contributions.stk_checkout_request_id`
2. **M-Pesa receipt uniqueness**: UNIQUE constraint on `contributions.payment_ref`
3. **Allocation idempotency**: `allocatePayment()` checks `payment_ref` before allocating
4. **Lifecycle idempotency**: `completeContributorPayment()` checks contribution status before proceeding
5. **No-Contribution idempotency**: `completeContributorPayment()` checks `payment_ref` on contributions before calling `allocatePayment()`
6. **Queue isolation**: `PollStkStatus` implements `ShouldBeUnique` with 300s lock
7. **Cache lock**: `stk_poll_lock:{checkoutRequestId}` prevents concurrent polling

**Tests**:
- `test_prepaid_payment_idempotent_on_repeated_completion` — 3 completions → 1 effect ✓
- `test_concurrent_polling_and_reconciliation_posts_once` (existing) — PASS ✓

---

## 14. Queue and Scheduler Findings

- `PollStkStatus` job dispatched with 5-second delay after STK initiation
- 7 retry attempts with exponential backoff (10, 20, 40, 80, 140, 220s)
- `ShouldBeUnique` prevents duplicate concurrent polls
- `reconcileStkTransfers()` serves as safety-net reconciliation for stuck transfers
- Both paths call the same `completeSuccessfulPayment()` → idempotent completion engine

---

## 15. Dashboard and Statement Findings

The frontend is not bundled in this repository. However, the backend services that feed the dashboard (`MemberStatsService`, `ContributionObligationService`) receive updated data through:

1. `ContributionAllocationService::allocatePayment()` → updates `members.total_contributions`
2. Cache invalidation (`member:{id}:stats`)
3. The frontend polling `getStatus()` → which now falls back to `CoopTransferRequest` when no Contribution exists

**Pre-fix**: `getStatus()` would return "unknown" for prepayment payments (no Contribution found).

**Post-fix**: `getStatus()` falls back to `CoopTransferRequest` to find the payment and return its status.

---

## 16. Callback Architecture Findings

**PASS**: The callback path does NOT create financial effects.

- `PaymentController::stkCallback()` only logs the callback and returns `{"processed": false}`
- `PaymentService::processCallback()` is a stub that logs but does not process financially
- Financial completion only occurs via Transaction Status API (polling or reconciliation)
- No callback-specific contribution allocation, wallet credit, or receipt generation exists

---

## 17. Security Findings

**PASS**: No security issues identified.

- Authenticated member identity is used for payment ownership
- `assertCanWrite()` prevents view-as-member from initiating payments
- `assertMemberOwnsPayment()` prevents cross-member payment status access
- `getStatus()` verifies ownership before returning payment status
- STK request phone is not used for contribution ownership
- No IDOR through checkout request ID (status is scoped to authenticated member)

**Recommendation**: Verify that `CoopTransferRequest.internal_reference` LIKE search in `detectPaymentType()` and `detectMemberId()` cannot be exploited to enumerate other members' payments. The search is internal (backend-to-backend) and scoped by the authenticated transfer ID.

---

## 18. Production Read-Only Data Findings

This audit was conducted on local code only. Production data inspection was not performed as part of this deployment task. The existing forensic audit (`CONTRIBUTION_ALLOCATION_FORENSIC_AUDIT.md`) provides production data context.

---

## 19. Test Commands and Exact Results

```
php artisan test tests/Feature/Payments/ tests/Feature/PaymentLifecycle/ --no-coverage
```

Result: **135 passed (503 assertions)** — 24.38s

New test file: `tests/Feature/Payments/StkPrepaymentTest.php` — 21 tests, 63 assertions — all PASS

```
php artisan test tests/Feature/Payments/StkPrepaymentTest.php --no-coverage
```

Result: **21 passed (63 assertions)** — 4.44s

---

## 20. Severity Classification

| Issue | Severity | Status |
|-------|----------|--------|
| 409 restriction on current-month-paid | **BLOCKER** | Fixed |
| Missing CoopTransferRequest fallback in detectPaymentType | N/A (new code path) | Implemented |
| Missing CoopTransferRequest fallback in detectMemberId | N/A (new code path) | Implemented |
| No Contribution fallback in completeContributorPayment | N/A (new code path) | Implemented |
| No CoopTransferRequest fallback in getStatus | N/A (new code path) | Implemented |

---

## 21. Root Causes

1. **Primary**: `PaymentService::initiateStkPush()` (line 78-79) threw `HttpException(409, 'contribution_already_paid', ...)` when the current month's contribution already had status `paid` or `cancelled`.

2. **Secondary**: The payment tracking design relied on a `Contribution` record (created during initiation) to carry the `stk_checkout_request_id` through to the completion path. When the current month was already paid, no new Contribution could be created without violating semantic expectations, so the flow blocked at initiation.

---

## 22. Changes Made

### Files Modified (local source → production deployed)

### File: `app/Services/PaymentService.php`

1. **`initiateStkPush()` (lines 67-108)**: Removed the 409 restriction; when current month is paid, STK initiation now skips Contribution creation and tracks the payment via `CoopTransferRequest`

2. **`extractPaymentInfoFromInternalRef()` (new method, ~line 360)**: Added a helper that parses `CoopTransferRequest.internal_reference` format `{paymentType}-{memberId}-{checkoutRequestId}` to extract all three fields. Handles UUIDs (which contain hyphens) by using the `ws_CO_` regex pattern as a boundary.

3. **`detectPaymentType()` (lines 258-293)**: Added fallback that checks `CoopTransferRequest` records when no Contribution is found by `stk_checkout_request_id`.

4. **`detectMemberId()` (lines 295-328)**: Added fallback for `monthly_contribution` that extracts member ID from `CoopTransferRequest.internal_reference` when no Contribution is found.

5. **`getStatus()` (lines 498-555)**: Added fallback that checks `CoopTransferRequest` when no Contribution is found by `stk_checkout_request_id`. Extracts payment info from `internal_reference`, verifies ownership, and returns the transfer status/amount.

### File: `app/Services/PaymentLifecycleService.php`

6. **`completeContributorPayment()` (lines 124-218)**: Added handling for the case where no Contribution record is found. When status is `verified` (final success), the method now:
   - Checks idempotency via `payment_ref` (M-Pesa receipt) on existing contributions
   - Calls `ContributionAllocationService::allocatePayment()` directly
   - Sends success SMS
   - Returns allocation results

### File: `tests/Feature/Payments/StkPrepaymentTest.php` (new)

7. **21 new tests** covering:
   - Initiation succeeds when current month is paid
   - No duplicate contribution creation
   - CoopTransferRequest creation as tracking record
   - Multi-month allocation (300→1mo, 600→2mo, 900→3mo, 3600→12mo, 4500→15mo)
   - All 5 scenarios (A-E) from the task description
   - Idempotency (3 repeated completions → 1 financial effect)
   - No wallet credit for monthly contributions
   - Current month not overwritten
   - Ownership from authenticated member
   - Failed status does not post financial records
   - Status lookup via CoopTransferRequest
   - Cache invalidation after completion

### File: `app/Jobs/Banking/PollStkStatus.php` (pre-existing missing on production)

8. **Discovered and deployed**: The `PollStkStatus` job file was missing from the production server. This was a **pre-existing issue** that was hidden by the 409 restriction — when the restriction threw an exception, the code never reached `PollStkStatus::dispatch()`. After removing the restriction, the missing file caused a 500 error (`Class "App\Jobs\Banking\PollStkStatus" not found`). The file has now been deployed and verified.

---

## 23. Database Constraint Findings

| Constraint | Type | Purpose | Preserved? |
|---|---|---|---|
| `uk_contributions_stk_checkout_request_id` | UNIQUE | One checkout ID per contribution (idempotency) | ✓ Yes |
| `uk_contributions_payment_ref` | UNIQUE | One M-Pesa receipt per contribution (idempotency) | ✓ Yes |
| No unique on (member_id, month, year) | N/A | Multiple contributions per month allowed (partial payments) | N/A |

No constraints were removed or modified. The `allocation` engine handles the case where multiple contribution rows exist for the same month via `->first()` (prefers the one created earlier, typically the paid one).

---

## 24. Production Deployment Status

**DEPLOYED** — Changes have been deployed to production.

Deployment actions performed:
1. ✓ Ran `php artisan test` on local — all 135 tests pass (503 assertions)
2. ✓ Deployed `app/Services/PaymentService.php` to production
3. ✓ Deployed `app/Services/PaymentLifecycleService.php` to production
4. ✓ Deployed `app/Jobs/Banking/PollStkStatus.php` to production (pre-existing missing file)
5. ✓ Cleared OPcache
6. ✓ Rebuilt config cache and route cache
7. ✓ Verified all 13 STK-related classes are loadable via PSR-4 autoloading
8. ✓ Verified PHP syntax on all deployed files
9. ✓ Confirmed restriction removed (`contribution_already_paid` no longer in code)
10. ✓ Confirmed `PollStkStatus` class is loadable

Production safety:
- No financial data was modified during deployment
- No destructive migrations
- No `FLUSHALL` or `FLUSHDB`
- Existing transaction idempotency constraints preserved
- No production smoke test performed (test payments not authorized)

---

## 25. Remaining Risks

1. **Reconciliation path**: `reconcileStkTransfers()` passes `$transfer->internal_reference` (full string) to `detectPaymentType()` instead of the extracted checkout ID. This may not correctly detect payment type for monthly_contribution payments initiated through the API. The `processStkBankStatus()` path (used by PollStkStatus) correctly extracts the checkout ID first. This is a **pre-existing issue** not introduced by these changes.

2. **No callback fallback**: The `completeContributorPayment()` no-Contribution path relies on `payment_ref` (M-Pesa receipt) for idempotency. If the bank status response does not include a `transaction_id`, `$mpesaReceipt` falls back to `$transfer->internal_reference`. The M-Pesa receipt should always be present in real bank responses. In mock mode, the fallback to `internal_reference` works correctly.

3. **Frontend**: The member dashboard frontend is not in this repository. Ensure the frontend does not have client-side restrictions that block prepayment. The backend API accepts the payment; if the frontend blocks it, the fix won't be visible to users.

---

## Final Verdict

**PASS**

The current-month-paid restriction has been fully removed. Prepayment payments are accepted, correctly allocate to future months, maintain exactly-once financial posting, and pass all idempotency and security tests.
