# UserProgramService Decomposition Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Break `app/Services/User/UserProgramService.php` (1,410 lines, 32 constructor dependencies) into six focused, interface-backed services behind a thin facade, without changing a single byte of observable behaviour on the 11 routes that use it.

**Architecture:** `UserProgramService` becomes a ~130-line facade whose 11 public methods delegate verbatim to six extracted collaborators under `App\Services\User\Program\`. Both controllers keep injecting `UserProgramService`, so their constructors, the routes, and the mobile clients never change. This is the same shape already applied to `FirebaseBusinessService` (228-line facade), `BaseAuthService` (234), `ChapterService` (140) and `MarketingV2Query` (496).

**Tech Stack:** Laravel 13 / PHP 8.3, Pest 3, Mockery, PHPStan level 5 (larastan) + `phpstan-baseline.neon`, Laravel Pint.

**Spec:** This plan is self-contained; the analysis it argues from is reproduced in "Measured Facts" below. No separate spec document exists.

## Global Constraints

- **PHP 8.3**, Laravel 13. Every **new** file carries `declare(strict_types=1)` and a class-level PHPDoc block.
- **Existing files that are modified** also get `declare(strict_types=1)` — but only after checking every call in the file for weak-mode coercion the declaration would reject, and preserving behaviour with explicit casts. If something would genuinely break, say so with the specific reason instead of silently omitting it.
  - **Exception, ruled 2026-08-25:** `app/Services/User/UserProgramService.php` does **not** get `declare(strict_types=1)` until **Task 12, Step 7**. Reason: adding it while the file is 1,410 lines of legacy code means auditing every call in code that Tasks 7–12 delete anyway. At Task 12 the file is a ~130-line facade of pure delegations whose parameters are already typed, making the declaration both trivially safe and actually reviewable. Tasks 1 and 7–11 must not add it, and must not treat its absence as an oversight. Every **new** file still gets it from birth.
- **Constructor style:** promoted `private readonly` parameters, matching `App\Services\Chapter\ChapterProgressReader`.
- **Every extracted class gets an interface**, bound in `AppServiceProvider::register()` with `$this->app->bind(FooInterface::class, Foo::class);`.
- **Namespace:** `App\Services\User\Program\` (directory `app/Services/User/Program/`). Do **not** name anything `ProgramEnrollmentService` — `App\Services\Auth\ProgramEnrollmentService` already exists.
- **Public method signatures on `UserProgramService` must not change.** They are listed verbatim in Task 1 and are the contract both controllers depend on.
- **Gates, run from the project root:**
  - `XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest` (serial; the default 128M CLI limit exhausts partway)
  - `XDEBUG_MODE=off vendor/bin/pest --parallel`
  - `XDEBUG_MODE=off vendor/bin/phpstan analyse --memory-limit=2G --no-progress` — must end `[OK] No errors`
  - `XDEBUG_MODE=off vendor/bin/pint <changed files>`
- **Baseline for "green":** 2,683 passing / 10 skipped / 0 failed as of 2026-08-25. Every task must leave that number the same or higher.
- **The user stages and commits their own work.** Do not run `git add`, `git commit` or `git push`. Where a task says "Commit", prepare the change and report it as ready.

---

## Measured Facts

Everything below was measured against the working tree on 2026-08-25.

### The 11 routes that reach this service

| Method | URI | Middleware | Service method |
|---|---|---|---|
| POST | `api/User/updateProgram` | api, ApiProfiler, ApiAuth, user.language | `updateProgram` |
| POST | `api/User/UpdateSubscription` | api, ApiProfiler, ApiAuth, user.language | `updateSubscription` |
| POST | `api/User/updateLanguage` | api, ApiProfiler, ApiAuth, user.language | `updateLanguage` |
| POST | `api/User/resetProgress` | api, ApiProfiler, ApiAuth, user.language | `resetProgress` |
| POST | `api/User/skipIntroSubScreen` | api, ApiProfiler, ApiAuth, user.language | `skipIntroSubScreen` |
| POST | `api/User/paymentFailed` | api, ApiProfiler, ApiAuth, user.language | `paymentFailed` |
| POST | `api/User/UpdateUserPayments` | api, ApiProfiler, ApiAuth, user.language | `UpdateUserPayments` |
| GET | `api/User/getSubDetails/{userId}` | api, ApiProfiler, ApiAuth, user.language | `getSubDetails` |
| GET | `api/User/getProgramList` | api, ApiProfiler **(no ApiAuth)** | `getProgramList` |
| POST | `users/update-program` | web, admin | `updateProgram` |
| POST | `users/reset-progress` | web, admin | `resetProgress` |

`users/add-program`, `users/active-day`, `users/refund` and `users/updateSubscription` live entirely in `App\Http\Controllers\UserProgramController` and never touch this service. Leave them alone.

### Existing test coverage — read this before trusting the suite

There are 3,354 lines of API feature tests across nine files, but **six of them mock `UserProgramService` itself** (`Mockery::mock(UserProgramService::class)` bound into the container). Those files test the *controller* and would stay green if the entire body of the service were deleted. They give this refactor **zero** protection.

| Test file | Exercises the real service? |
|---|---|
| `tests/Feature/Api/User/Program/UpdateUserPaymentsApiTest.php` (392) | **Yes** |
| `tests/Feature/Api/User/Program/PaymentFailedApiTest.php` (326) | **Yes** |
| `tests/Feature/Api/User/Program/SkipIntroSubScreenApiTest.php` (495) | **Yes** |
| `tests/Feature/Api/Public/GetProgramListApiTest.php` (537) | **Yes** |
| `tests/Feature/Api/User/Program/UpdateSubscriptionApiTest.php` (392) | No — mocks the service |
| `tests/Feature/Api/User/Program/UpdateProgramApiTest.php` (287) | No — mocks the service |
| `tests/Feature/Api/User/Program/UpdateLanguageApiTest.php` (310) | No — mocks the service |
| `tests/Feature/Api/User/Program/ResetProgressApiTest.php` (372) | No — mocks the service |
| `tests/Feature/Api/User/Program/GetSubDetailsApiTest.php` (243) | No — mocks the service |
| `tests/Feature/Admin/UserProgramControllerTest.php` (401) | No — mocks the service |
| `tests/Unit/Services/User/UserProgramServiceRedditEventTest.php` (68) | **Yes**, `sendRedditEvent` only |

So the methods with **no real coverage** are exactly the ones carrying the most logic: `updateSubscription` (330 lines with its helpers), `updateLanguage` (123), `updateProgram` (111), `resetProgress` (62), `getSubDetails` (75), `sendFBEvent` (45). Tasks 2–6 close that gap **before** any code moves. This is the same position `ChapterService` was in.

### Method inventory and ownership

Every private helper is used by exactly one public method — no helper is shared:

| Public method | Lines | at | Private helpers it owns |
|---|---|---|---|
| `updateLanguage` | 123 | 767 | `resolveOnBoardLocation` (7 @1373) |
| `updateProgram` | 111 | 293 | — |
| `updateSubscription` | 107 | 418 | `getTempExpiryDate` (7 @1041), `getCouponId` (9 @1136), `getLabel` (9 @1023), `updateUserProgramData` (62 @1065), `getDiscount` (14 @1156), `updateCouponAndAngelEmail` (35 @1181) → `sendIntroductionForNewAngelMail` (18 @1226), `resolveSubHistoryPricing` (28 @1258), `updateExpiryFromListeners` (41 @1298) |
| `resetProgress` | 62 | 694 | — |
| `getSubDetails` | 45 | 639 | `generateRazorpayBillingUrl` (18 @1391), `formatSingleDate` (12 @1348) |
| `sendFBEvent` | 45 | 535 | `sendEventWithLogging` (13 @1000) |
| `skipIntroSubScreen` | 41 | 901 | — |
| `sendRedditEvent` | 38 | 590 | `sendEventWithLogging` (shared with `sendFBEvent`) |
| `paymentFailed` | 37 | 953 | — |
| `UpdateUserPayments` | 34 | 226 | — |
| `getProgramList` | 11 | 270 | — |

### Dependency exclusivity

21 of the 31 dependencies actually used belong to exactly one target service:

| Target service | Exclusive dependencies |
|---|---|
| `ProgramAssignmentService` | `programQuery`, `userAppActivityQuery`, `partnerQuery`, `languageQuery`, `onBoardingQuery` |
| `SubscriptionUpdateService` | `userDataQuery`, `userSubscriptionQuery`, `couponQuery`, `productCodeQuery`, `iosListenerDataQuery`, `androidListenerDataQuery` |
| `ProgressResetService` | `userExerciseQuery`, `userActionQuery`, `userActivityQuery`, `userSmokeQuery`, `userQuitStatusQuery` |
| `PaymentRecordService` | `userTransactionQuery`, `failedUserPaymentQuery` |
| `ProgramEventDispatcher` | `metaCapiService`, `redditCapiService` |
| `SubscriptionDetailService` | `billingService` |

Shared across services: `userUtilityService`, `userQuery`, `userProgramQuery`, `userConfigQuery`, `activeDayService`, `userDayQuery`, `userSubHistoryQuery`, `userChapterQuery`, `userAngelQuery`, `userProfileQuery`.

`dayQuery` is injected and assigned but **never read** — see Task 1.

---

## File Structure

**Created:**

| File | Responsibility |
|---|---|
| `app/Services/User/Program/ProgressResetService.php` | Wipe a user's program progress (`resetProgress`) |
| `app/Services/User/Program/ProgressResetServiceInterface.php` | Contract for the above |
| `app/Services/User/Program/ProgramEventDispatcher.php` | Meta + Reddit conversion events for program changes |
| `app/Services/User/Program/ProgramEventDispatcherInterface.php` | Contract |
| `app/Services/User/Program/PaymentRecordService.php` | Record payment outcomes (`UpdateUserPayments`, `paymentFailed`) |
| `app/Services/User/Program/PaymentRecordServiceInterface.php` | Contract |
| `app/Services/User/Program/SubscriptionDetailService.php` | Read subscription state (`getSubDetails`, `skipIntroSubScreen`) |
| `app/Services/User/Program/SubscriptionDetailServiceInterface.php` | Contract |
| `app/Services/User/Program/SubscriptionUpdateService.php` | Apply subscription changes (`updateSubscription` + 8 helpers) |
| `app/Services/User/Program/SubscriptionUpdateServiceInterface.php` | Contract |
| `app/Services/User/Program/ProgramAssignmentService.php` | Assign/relocate a user's program (`updateProgram`, `updateLanguage`, `getProgramList`) |
| `app/Services/User/Program/ProgramAssignmentServiceInterface.php` | Contract |
| `tests/Unit/Services/User/Program/*Test.php` | One unit test per extracted service |
| `tests/Feature/Api/User/Program/*CharacterizationTest.php` | Pre-extraction safety net, Tasks 2–6 |

**Modified:**

| File | Change |
|---|---|
| `app/Services/User/UserProgramService.php` | Shrinks 1,410 → ~130 lines; becomes a delegating facade |
| `app/Providers/AppServiceProvider.php` | Six new interface bindings |
| `phpstan-baseline.neon` | Re-home entries anchored to `UserProgramService.php` |

**Untouched (verify at the end):** `app/Http/Controllers/Api/UserProgramController.php`, `app/Http/Controllers/UserProgramController.php`, `routes/api.php`, `routes/web.php`.

---

### Task 1: Remove the dead `dayQuery` dependency

`DayQuery $dayQuery` is injected at line 177 and assigned at line 213, but `$this->dayQuery` is never read anywhere in the 1,410 lines. Removing it is a free 32 → 31 dependency reduction with no behavioural risk, and it shrinks every later task.

**Files:**
- Modify: `app/Services/User/UserProgramService.php:177` (constructor parameter), `:213` (assignment), and the `use App\Http\Queries\DayQuery;` import
- Test: no new test — existing suite is the check

**Interfaces:**
- Consumes: nothing
- Produces: nothing. `UserProgramService`'s public API is unchanged.

- [ ] **Step 1: Prove it is genuinely unused**

Run:
```bash
grep -n 'this->dayQuery' app/Services/User/UserProgramService.php
```
Expected: exactly one line — the assignment at 213. If more than one line comes back, **stop and report**; the dependency is live and this task is void.

- [ ] **Step 2: Remove the three references**

Delete from `app/Services/User/UserProgramService.php`:
1. The `use App\Http\Queries\DayQuery;` import line.
2. The constructor parameter line `        DayQuery $dayQuery,`.
3. The assignment line `        $this->dayQuery = $dayQuery;`.
4. The `private DayQuery $dayQuery;` / `protected $dayQuery;` property declaration, if one exists — find it with `grep -n 'dayQuery' app/Services/User/UserProgramService.php` and remove only the declaration whose name is exactly `dayQuery`, never `userDayQuery`.

- [ ] **Step 3: Confirm nothing else referenced it**

Run:
```bash
grep -n 'dayQuery' app/Services/User/UserProgramService.php
```
Expected: only `userDayQuery` lines remain (there are 4).

- [ ] **Step 4: Run the gates**

Run:
```bash
XDEBUG_MODE=off vendor/bin/pest --parallel
XDEBUG_MODE=off vendor/bin/phpstan analyse --memory-limit=2G --no-progress
XDEBUG_MODE=off vendor/bin/pint app/Services/User/UserProgramService.php
```
Expected: 2,683 passed / 10 skipped / 0 failed; `[OK] No errors`; pint passes.

- [ ] **Step 5: Ready to commit**

Report the change as ready with the suggested message:
`refactor(user-program): drop unused DayQuery dependency`

---

### Task 2: Characterization test for `resetProgress`

`resetProgress` (62 lines, 13 dependencies, 5 of them exclusive) destructively wipes day, chapter, exercise, action, activity, smoke, angel and quit-status rows. `ResetProgressApiTest` mocks the service away, so nothing currently protects this behaviour. Write the net first.

**Files:**
- Create: `tests/Feature/Api/User/Program/ResetProgressCharacterizationTest.php`
- Read for reference: `app/Services/User/UserProgramService.php:694-755`, and `tests/Feature/Api/User/Program/UpdateUserPaymentsApiTest.php:1-45` for the setup idiom

**Interfaces:**
- Consumes: nothing
- Produces: a test file that must stay green through Task 7.

- [ ] **Step 1: Read the method and list every collaborator call**

Run:
```bash
sed -n '694,756p' app/Services/User/UserProgramService.php
```
Write down, in order, every `$this->someQuery->someMethod(...)` call and its arguments. That ordered list *is* the behaviour this test pins.

- [ ] **Step 2: Write the characterization test**

Create `tests/Feature/Api/User/Program/ResetProgressCharacterizationTest.php`. Mock every query class the method touches and assert each expected call. Use this exact skeleton — it mirrors the working idiom in `UpdateUserPaymentsApiTest.php`:

```php
<?php

/**
 * Characterization test for UserProgramService::resetProgress().
 *
 * Pins the CURRENT behaviour so the extraction in Task 7 can be proven
 * behaviour-neutral. It asserts which collaborators are called with which
 * arguments — not whether that behaviour is correct. Do not "fix" anything
 * this test documents; if it looks wrong, raise it separately.
 */

use App\Http\Queries\UserActionQuery;
use App\Http\Queries\UserActivityQuery;
use App\Http\Queries\UserAngelQuery;
use App\Http\Queries\UserChapterQuery;
use App\Http\Queries\UserConfigQuery;
use App\Http\Queries\UserDayQuery;
use App\Http\Queries\UserExerciseQuery;
use App\Http\Queries\UserProgramQuery;
use App\Http\Queries\UserQuery;
use App\Http\Queries\UserQuitStatusQuery;
use App\Http\Queries\UserSmokeQuery;
use App\Services\ActiveDayService;
use App\Services\User\UserProgramService;
use App\Services\User\UserUtilityService;
use Illuminate\Support\Facades\Log;

beforeEach(function () {
    Log::shouldReceive('error')->andReturn(null);
    Log::shouldReceive('info')->andReturn(null);
    Log::shouldReceive('warning')->andReturn(null);

    foreach ([
        UserQuery::class, UserDayQuery::class, UserChapterQuery::class,
        UserExerciseQuery::class, UserActionQuery::class, UserActivityQuery::class,
        UserConfigQuery::class, UserSmokeQuery::class, UserAngelQuery::class,
        UserQuitStatusQuery::class, UserProgramQuery::class,
        UserUtilityService::class, ActiveDayService::class,
    ] as $class) {
        $mock = Mockery::mock($class);
        $this->app->instance($class, $mock);
        $this->mocks[$class] = $mock;
    }
});

afterEach(fn () => Mockery::close());

it('calls each progress table in the recorded order for a valid reset', function () {
    // Arrange — set expectations for EVERY call listed in Step 1, e.g.:
    //   $this->mocks[UserDayQuery::class]->shouldReceive('deleteUserDays')
    //       ->once()->with(123, 3)->andReturn(true);
    // Fill these in from the real method body; the point is that they are exact.

    // Act
    $result = app(UserProgramService::class)->resetProgress(123, ['user_id' => 123, 'iProgramId' => 3]);

    // Assert — the returned array shape is part of the contract.
    expect($result)->toBeArray()->toHaveKey('Status');
});
```

Replace the commented placeholder with one `shouldReceive(...)->once()->with(...)` per call found in Step 1, and assert the full returned array. Add a second `it(...)` covering the failure branch (invalid user or program) that the method's guard clauses produce.

- [ ] **Step 3: Run it and confirm it passes against the CURRENT code**

Run:
```bash
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest tests/Feature/Api/User/Program/ResetProgressCharacterizationTest.php
```
Expected: PASS. A characterization test is written against working code, so it must be green immediately. If it fails, the expectations do not match reality — fix the **test**, not the service.

- [ ] **Step 4: Prove the test actually bites**

Temporarily comment out one collaborator call inside `resetProgress` (e.g. the `userExerciseQuery` delete), re-run the test, and confirm it **FAILS**. Then restore the line and confirm it passes again. A characterization test that cannot fail is worthless — this step is not optional.

- [ ] **Step 5: Style and ready to commit**

Run:
```bash
XDEBUG_MODE=off vendor/bin/pint tests/Feature/Api/User/Program/ResetProgressCharacterizationTest.php
```
Suggested message: `test(user-program): characterization test for resetProgress`

---

### Task 3: Characterization test for `updateSubscription` and `sendFBEvent`

The largest and riskiest cluster: `updateSubscription` (107 lines) plus 8 private helpers totalling 330 lines, including the coupon, angel-email, listener-expiry and Meta/Reddit event paths. `UpdateSubscriptionApiTest` mocks the service away, so none of it is covered.

**Files:**
- Create: `tests/Feature/Api/User/Program/UpdateSubscriptionCharacterizationTest.php`
- Read for reference: `app/Services/User/UserProgramService.php:418-524` (entry point), `:1023-1031`, `:1041-1047`, `:1065-1126`, `:1136-1144`, `:1156-1169`, `:1181-1215`, `:1226-1243`, `:1258-1285`, `:1298-1338` (helpers), `:535-579` (`sendFBEvent`)

**Interfaces:**
- Consumes: nothing
- Produces: a test file that must stay green through Tasks 8 and 11.

- [ ] **Step 1: Map the branches**

Run:
```bash
sed -n '418,525p' app/Services/User/UserProgramService.php
```
List every `if`/`else` branch and the collaborator calls inside each. `updateSubscription` calls `getTempExpiryDate`, `getCouponId`, `getLabel`, `updateUserProgramData`, `getDiscount`, `updateCouponAndAngelEmail`, `resolveSubHistoryPricing`, `updateExpiryFromListeners`, `sendRedditEvent` and `sendFBEvent` — each needs at least one test that reaches it.

- [ ] **Step 2: Write one `describe()` block per branch**

Create `tests/Feature/Api/User/Program/UpdateSubscriptionCharacterizationTest.php` using the same `beforeEach` mock-binding idiom as Task 2, binding: `UserQuery`, `UserProgramQuery`, `UserConfigQuery`, `UserDataQuery`, `UserSubscriptionQuery`, `UserSubHistoryQuery`, `CouponQuery`, `ProductCodeQuery`, `UserAngelQuery`, `UserProfileQuery`, `IosListenerDataQuery`, `AndroidListenerDataQuery`, `MetaCapiService`, `RedditCapiService`, `UserUtilityService`.

Structure:

```php
describe('updateSubscription', function () {
    it('records an ios purchase and updates the user program', function () {
        // Arrange: mock expectations for the ios branch
        // Act:     app(UserProgramService::class)->updateSubscription(123, $postData)
        // Assert:  returned array + every expected collaborator call
    });

    it('records an android purchase and resolves the label from listener data', function () { /* … */ });
    it('applies a coupon when the payload carries a coupon code', function () { /* … */ });
    it('applies a product-code discount when one is present', function () { /* … */ });
    it('sends the angel introduction email when a new angel email is supplied', function () { /* … */ });
    it('extends expiry from listener data when listener rows exist', function () { /* … */ });
    it('dispatches the Meta and Reddit conversion events on success', function () { /* … */ });
    it('returns the failure shape when the user is not found', function () { /* … */ });
});
```

Fill every block with real expectations taken from the method body. Aim for at least one test per named helper.

- [ ] **Step 3: Run and confirm green against current code**

Run:
```bash
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest tests/Feature/Api/User/Program/UpdateSubscriptionCharacterizationTest.php
```
Expected: PASS.

- [ ] **Step 4: Prove each block bites**

For three of the blocks in turn, comment out the corresponding helper call in the service, confirm that block **FAILS**, then restore. Confirm the suite is green again before moving on.

- [ ] **Step 5: Style and ready to commit**

Run:
```bash
XDEBUG_MODE=off vendor/bin/pint tests/Feature/Api/User/Program/UpdateSubscriptionCharacterizationTest.php
```
Suggested message: `test(user-program): characterization tests for updateSubscription`

---

### Task 4: Characterization test for `updateProgram`

**Files:**
- Create: `tests/Feature/Api/User/Program/UpdateProgramCharacterizationTest.php`
- Read for reference: `app/Services/User/UserProgramService.php:293-403`

**Interfaces:**
- Consumes: nothing
- Produces: a test file that must stay green through Task 12.

- [ ] **Step 1: Map the calls**

Run:
```bash
sed -n '293,404p' app/Services/User/UserProgramService.php
```
Note every call across its 10 dependencies: `userUtilityService`, `userAppActivityQuery`, `userProgramQuery`, `activeDayService`, `userDayQuery`, `userQuery`, `partnerQuery`, `userSubHistoryQuery`, `programQuery`, `userChapterQuery`.

- [ ] **Step 2: Write the test**

Create the file using the Task 2 `beforeEach` idiom, binding those ten classes. Cover at minimum: the happy path (program switch succeeds), the branch where the user already has the target program, and the not-found/failure branch. Assert the returned array in full for each, plus the collaborator calls.

- [ ] **Step 3: Run and confirm green**

Run:
```bash
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest tests/Feature/Api/User/Program/UpdateProgramCharacterizationTest.php
```
Expected: PASS.

- [ ] **Step 4: Prove it bites**

Comment out one collaborator call in `updateProgram`, confirm FAIL, restore, confirm PASS.

- [ ] **Step 5: Style and ready to commit**

Run: `XDEBUG_MODE=off vendor/bin/pint tests/Feature/Api/User/Program/UpdateProgramCharacterizationTest.php`
Suggested message: `test(user-program): characterization test for updateProgram`

---

### Task 5: Characterization test for `updateLanguage`

At 123 lines this is the single longest method in the class.

**Files:**
- Create: `tests/Feature/Api/User/Program/UpdateLanguageCharacterizationTest.php`
- Read for reference: `app/Services/User/UserProgramService.php:767-889` and `:1373-1379` (`resolveOnBoardLocation`)

**Interfaces:**
- Consumes: nothing
- Produces: a test file that must stay green through Task 12.

- [ ] **Step 1: Map the calls**

Run:
```bash
sed -n '767,890p' app/Services/User/UserProgramService.php
```
Dependencies in play: `userUtilityService`, `userQuery`, `languageQuery`, `programQuery`, `onBoardingQuery`, `userProgramQuery`, `userConfigQuery`, `activeDayService`, `userDayQuery`, `partnerQuery`, `userSubHistoryQuery`.

- [ ] **Step 2: Write the test**

Same idiom as Task 2. Cover: a successful language switch, the branch where the requested language has no matching program, the `resolveOnBoardLocation` path, and the failure branch. Assert the full returned array each time.

- [ ] **Step 3: Run and confirm green**

Run:
```bash
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest tests/Feature/Api/User/Program/UpdateLanguageCharacterizationTest.php
```
Expected: PASS.

- [ ] **Step 4: Prove it bites**

Comment out one collaborator call, confirm FAIL, restore, confirm PASS.

- [ ] **Step 5: Style and ready to commit**

Run: `XDEBUG_MODE=off vendor/bin/pint tests/Feature/Api/User/Program/UpdateLanguageCharacterizationTest.php`
Suggested message: `test(user-program): characterization test for updateLanguage`

---

### Task 6: Characterization test for `getSubDetails`

**Files:**
- Create: `tests/Feature/Api/User/Program/GetSubDetailsCharacterizationTest.php`
- Read for reference: `app/Services/User/UserProgramService.php:639-683`, `:1391-1408` (`generateRazorpayBillingUrl`), `:1348-1359` (`formatSingleDate`)

**Interfaces:**
- Consumes: nothing
- Produces: a test file that must stay green through Task 10.

- [ ] **Step 1: Map the calls**

Run:
```bash
sed -n '639,684p' app/Services/User/UserProgramService.php
sed -n '1348,1360p;1391,1409p' app/Services/User/UserProgramService.php
```
Dependencies: `userQuery`, `userUtilityService`, `userProgramQuery`, `billingService`.

- [ ] **Step 2: Write the test**

Same idiom as Task 2, binding those four. Cover: an active subscription (asserting the formatted dates and the generated Razorpay billing URL exactly), a user with no subscription, and the not-found branch. The date formatting and URL construction are pure functions of their input — assert their exact output strings, because those are what a client renders.

- [ ] **Step 3: Run and confirm green**

Run:
```bash
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest tests/Feature/Api/User/Program/GetSubDetailsCharacterizationTest.php
```
Expected: PASS.

- [ ] **Step 4: Prove it bites**

Change one character in the Razorpay URL template inside `generateRazorpayBillingUrl`, confirm FAIL, restore, confirm PASS.

- [ ] **Step 5: Style and ready to commit**

Run: `XDEBUG_MODE=off vendor/bin/pint tests/Feature/Api/User/Program/GetSubDetailsCharacterizationTest.php`
Suggested message: `test(user-program): characterization test for getSubDetails`

---

### Task 7: Extract `ProgressResetService`

Lowest-risk extraction: one public method, no private helpers, and five dependencies used by nothing else in the class.

**Files:**
- Create: `app/Services/User/Program/ProgressResetService.php`, `app/Services/User/Program/ProgressResetServiceInterface.php`
- Create: `tests/Unit/Services/User/Program/ProgressResetServiceTest.php`
- Modify: `app/Services/User/UserProgramService.php` (remove `resetProgress` body, delegate), `app/Providers/AppServiceProvider.php`

**Interfaces:**
- Consumes: nothing
- Produces: `App\Services\User\Program\ProgressResetServiceInterface::resetProgress(int $userId, ?array $postData): array`

- [ ] **Step 1: Write the interface**

Create `app/Services/User/Program/ProgressResetServiceInterface.php`:

```php
<?php

declare(strict_types=1);

namespace App\Services\User\Program;

/**
 * Wipes a user's recorded progress within a program — days, chapters,
 * exercises, actions, activity, smoke log, angel and quit status — and
 * returns them to the program's first active day.
 */
interface ProgressResetServiceInterface
{
    /**
     * Reset all recorded progress for a user within one program.
     *
     * @param  int  $userId  Authenticated user ID.
     * @param  array<string,mixed>|null  $postData  Request body; expects `user_id` and `iProgramId`.
     * @return array<string,mixed> Status envelope in the shape the API contract expects.
     */
    public function resetProgress(int $userId, ?array $postData): array;
}
```

- [ ] **Step 2: Create the service with the method moved verbatim**

Create `app/Services/User/Program/ProgressResetService.php` with `declare(strict_types=1)`, a class docblock, and a promoted `private readonly` constructor taking exactly the 13 dependencies `resetProgress` uses: `UserUtilityService`, `UserQuery`, `UserDayQuery`, `UserChapterQuery`, `UserExerciseQuery`, `UserActionQuery`, `UserActivityQuery`, `ActiveDayService`, `UserConfigQuery`, `UserSmokeQuery`, `UserAngelQuery`, `UserQuitStatusQuery`, `UserProgramQuery`.

Move the body of `resetProgress` from `app/Services/User/UserProgramService.php:694-755` **verbatim**, including its docblock. Do not reformat, reorder or "improve" any line. The only permitted change is that `$this->x` still resolves, because the constructor names match.

- [ ] **Step 3: Bind the interface**

In `app/Providers/AppServiceProvider.php`, add the two `use` statements and, inside `register()`, after the existing bindings:

```php
$this->app->bind(ProgressResetServiceInterface::class, ProgressResetService::class);
```

- [ ] **Step 4: Turn the facade method into a delegation**

Replace the body of `resetProgress` in `app/Services/User/UserProgramService.php` with:

```php
public function resetProgress(int $userId, ?array $postData): array
{
    return $this->progressResetService->resetProgress($userId, $postData);
}
```

Add `ProgressResetServiceInterface $progressResetService` to the facade's constructor and remove the five now-unused dependencies (`UserExerciseQuery`, `UserActionQuery`, `UserActivityQuery`, `UserSmokeQuery`, `UserQuitStatusQuery`) **only if** `grep -n 'this->userExerciseQuery'` and friends return nothing after the move.

- [ ] **Step 5: Run the characterization test from Task 2**

Run:
```bash
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest tests/Feature/Api/User/Program/ResetProgressCharacterizationTest.php
```
Expected: PASS, unchanged. **If this fails, the move was not verbatim — revert and redo it rather than editing the test.**

- [ ] **Step 6: Add the unit test for the new class**

Create `tests/Unit/Services/User/Program/ProgressResetServiceTest.php`, constructing `ProgressResetService` directly with Mockery doubles for its 13 dependencies, covering the happy path and one guard-clause branch.

- [ ] **Step 7: Run all the gates**

Run:
```bash
XDEBUG_MODE=off vendor/bin/pest --parallel
XDEBUG_MODE=off vendor/bin/phpstan analyse --memory-limit=2G --no-progress
XDEBUG_MODE=off vendor/bin/pint app/Services/User/Program app/Services/User/UserProgramService.php app/Providers/AppServiceProvider.php tests/Unit/Services/User/Program
```
Expected: test count ≥ 2,683 + the new unit tests; `[OK] No errors`; pint passes.

PHPStan may now report `ignore.unmatched` for baseline entries anchored to `UserProgramService.php`. Do **not** regenerate the whole baseline. Re-home only the affected entries by changing their `path:` to the new file, exactly as was done when `ModuleAnalyticsController`'s 64 entries were cleared.

- [ ] **Step 8: Ready to commit**

Suggested message: `refactor(user-program): extract ProgressResetService`

---

### Task 8: Extract `ProgramEventDispatcher`

`sendFBEvent` and `sendRedditEvent` are declared public but are called only from inside `updateSubscription` (lines 512–513). They share the private `sendEventWithLogging` helper.

**Files:**
- Create: `app/Services/User/Program/ProgramEventDispatcher.php`, `app/Services/User/Program/ProgramEventDispatcherInterface.php`
- Create: `tests/Unit/Services/User/Program/ProgramEventDispatcherTest.php`
- Modify: `app/Services/User/UserProgramService.php`, `app/Providers/AppServiceProvider.php`
- Read for reference: `app/Services/Chapter/ActivityEventDispatcher.php` — the same shape, already in the codebase

**Interfaces:**
- Consumes: nothing
- Produces:
  - `ProgramEventDispatcherInterface::sendFBEvent(array $postData, array $userData): void`
  - `ProgramEventDispatcherInterface::sendRedditEvent(array $postData, array $userData): void`

- [ ] **Step 1: Write the interface**

Create `app/Services/User/Program/ProgramEventDispatcherInterface.php` declaring both methods with the signatures above and a docblock on each explaining which conversion API it targets.

- [ ] **Step 2: Create the service**

Create `app/Services/User/Program/ProgramEventDispatcher.php` with `declare(strict_types=1)` and a promoted `private readonly` constructor taking `UserConfigQuery`, `MetaCapiService`, `RedditCapiService`.

Move verbatim: `sendFBEvent` (`:535-579`), `sendRedditEvent` (`:590-627`), and `sendEventWithLogging` (`:1000-1012`). Keep `sendEventWithLogging` `private`.

Note in the class docblock that this is a sibling of `App\Services\Chapter\ActivityEventDispatcher`, which fires a different event set; they are deliberately not merged.

- [ ] **Step 3: Bind the interface**

Add to `AppServiceProvider::register()`:

```php
$this->app->bind(ProgramEventDispatcherInterface::class, ProgramEventDispatcher::class);
```

- [ ] **Step 4: Delegate from the facade**

Keep both methods public on `UserProgramService` (they are part of its current public surface even though nothing external calls them) and delegate:

```php
public function sendFBEvent(array $postData, array $userData): void
{
    $this->programEventDispatcher->sendFBEvent($postData, $userData);
}

public function sendRedditEvent(array $postData, array $userData): void
{
    $this->programEventDispatcher->sendRedditEvent($postData, $userData);
}
```

Update the two internal call sites at `:512-513` to call `$this->programEventDispatcher->...` directly rather than `$this->sendFBEvent(...)`, so Task 11 can move `updateSubscription` without dragging these along.

- [ ] **Step 5: Run the existing Reddit unit test and the Task 3 characterization test**

Run:
```bash
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest tests/Unit/Services/User/UserProgramServiceRedditEventTest.php tests/Feature/Api/User/Program/UpdateSubscriptionCharacterizationTest.php
```
Expected: PASS. `UserProgramServiceRedditEventTest` exercises `sendRedditEvent` through the facade, so it proves the delegation works.

- [ ] **Step 6: Add the unit test for the new class**

Create `tests/Unit/Services/User/Program/ProgramEventDispatcherTest.php` covering: a Meta event fired when the FB event id is present, no event when it is absent, a Reddit event fired, and the logging path in `sendEventWithLogging`.

- [ ] **Step 7: Run all the gates**

Run the same three gate commands as Task 7, Step 7, with the new paths added to the pint invocation.

- [ ] **Step 8: Ready to commit**

Suggested message: `refactor(user-program): extract ProgramEventDispatcher`

---

### Task 9: Extract `PaymentRecordService`

Two independent public methods with two exclusive dependencies. Both already have real end-to-end coverage (`UpdateUserPaymentsApiTest`, 392 lines; `PaymentFailedApiTest`, 326 lines), so no characterization task was needed.

**Files:**
- Create: `app/Services/User/Program/PaymentRecordService.php`, `app/Services/User/Program/PaymentRecordServiceInterface.php`
- Create: `tests/Unit/Services/User/Program/PaymentRecordServiceTest.php`
- Modify: `app/Services/User/UserProgramService.php`, `app/Providers/AppServiceProvider.php`

**Interfaces:**
- Consumes: nothing
- Produces:
  - `PaymentRecordServiceInterface::UpdateUserPayments(int $userId, ?array $postData): array`
  - `PaymentRecordServiceInterface::paymentFailed(int $userId, ?array $postData, ?string $vAppVersion): array`

Keep the leading capital on `UpdateUserPayments` — it matches the route and the controller call, and renaming it is out of scope.

- [ ] **Step 1: Write the interface** with both signatures verbatim and a docblock each.

- [ ] **Step 2: Create the service** with `declare(strict_types=1)` and promoted `private readonly` dependencies `UserUtilityService`, `UserProfileQuery`, `UserTransactionQuery`, `FailedUserPaymentQuery`. Move `UpdateUserPayments` (`:226-259`) and `paymentFailed` (`:953-989`) verbatim.

- [ ] **Step 3: Bind** `$this->app->bind(PaymentRecordServiceInterface::class, PaymentRecordService::class);`

- [ ] **Step 4: Delegate** both facade methods to `$this->paymentRecordService`, and drop `UserTransactionQuery` / `FailedUserPaymentQuery` from the facade constructor once `grep` confirms they are unreferenced there.

- [ ] **Step 5: Run the two existing real-service API tests**

Run:
```bash
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest tests/Feature/Api/User/Program/UpdateUserPaymentsApiTest.php tests/Feature/Api/User/Program/PaymentFailedApiTest.php
```
Expected: PASS, unchanged. These bind the query classes into the container and drive the real HTTP route, so they cover the delegation end to end.

- [ ] **Step 6: Add** `tests/Unit/Services/User/Program/PaymentRecordServiceTest.php` covering one success and one failure branch per method.

- [ ] **Step 7: Run all the gates** (as Task 7, Step 7).

- [ ] **Step 8: Ready to commit** — `refactor(user-program): extract PaymentRecordService`

---

### Task 10: Extract `SubscriptionDetailService`

**Files:**
- Create: `app/Services/User/Program/SubscriptionDetailService.php`, `app/Services/User/Program/SubscriptionDetailServiceInterface.php`
- Create: `tests/Unit/Services/User/Program/SubscriptionDetailServiceTest.php`
- Modify: `app/Services/User/UserProgramService.php`, `app/Providers/AppServiceProvider.php`

**Interfaces:**
- Consumes: nothing
- Produces:
  - `SubscriptionDetailServiceInterface::getSubDetails(int $userId, array $headers, ?User $authenticatedUser = null): array`
  - `SubscriptionDetailServiceInterface::skipIntroSubScreen(int $userId, ?array $postData, ?User $authenticatedUser = null): array`

`User` here is `App\Models\User`; import it in the interface.

- [ ] **Step 1: Write the interface** with both signatures verbatim, including the nullable defaults.

- [ ] **Step 2: Create the service** with promoted `private readonly` dependencies `UserQuery`, `UserUtilityService`, `UserProgramQuery`, `BillingService`, `UserConfigQuery`. Move verbatim: `getSubDetails` (`:639-683`), `generateRazorpayBillingUrl` (`:1391-1408`), `formatSingleDate` (`:1348-1359`), `skipIntroSubScreen` (`:901-941`). Keep the three helpers `private`.

> **Grouping note:** `skipIntroSubScreen` is a *write* about subscription intent, placed here because it shares `userConfigQuery` + `userProgramQuery` with `getSubDetails` and has no other natural home. If it turns out to need `SubscriptionUpdateService`'s dependencies, move it there in Task 11 instead — decide deliberately, do not leave it split across both.

- [ ] **Step 3: Bind** `$this->app->bind(SubscriptionDetailServiceInterface::class, SubscriptionDetailService::class);`

- [ ] **Step 4: Delegate** both facade methods; drop `BillingService` from the facade once unreferenced.

- [ ] **Step 5: Run the Task 6 characterization test and the real-service skip-intro test**

Run:
```bash
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest tests/Feature/Api/User/Program/GetSubDetailsCharacterizationTest.php tests/Feature/Api/User/Program/SkipIntroSubScreenApiTest.php
```
Expected: PASS, unchanged.

- [ ] **Step 6: Add** `tests/Unit/Services/User/Program/SubscriptionDetailServiceTest.php` covering the active-subscription, no-subscription and not-found branches of `getSubDetails`, plus both branches of `skipIntroSubScreen`.

- [ ] **Step 7: Run all the gates** (as Task 7, Step 7).

- [ ] **Step 8: Ready to commit** — `refactor(user-program): extract SubscriptionDetailService`

---

### Task 11: Extract `SubscriptionUpdateService` and phase-split `updateSubscription`

The core of the class: 330 lines across one public method and eight private helpers.

**Files:**
- Create: `app/Services/User/Program/SubscriptionUpdateService.php`, `app/Services/User/Program/SubscriptionUpdateServiceInterface.php`
- Create: `tests/Unit/Services/User/Program/SubscriptionUpdateServiceTest.php`
- Modify: `app/Services/User/UserProgramService.php`, `app/Providers/AppServiceProvider.php`

**Interfaces:**
- Consumes: `ProgramEventDispatcherInterface` (from Task 8) — inject it, do not re-implement the event methods
- Produces: `SubscriptionUpdateServiceInterface::updateSubscription(int $userId, ?array $postData, ?User $authenticatedUser = null): array`

- [ ] **Step 1: Write the interface** with the signature verbatim.

- [ ] **Step 2: Create the service and move all nine methods verbatim**

Promoted `private readonly` dependencies: `UserUtilityService`, `UserQuery`, `UserProgramQuery`, `UserConfigQuery`, `UserDataQuery`, `UserSubscriptionQuery`, `UserSubHistoryQuery`, `CouponQuery`, `ProductCodeQuery`, `UserAngelQuery`, `UserProfileQuery`, `IosListenerDataQuery`, `AndroidListenerDataQuery`, plus `ProgramEventDispatcherInterface`.

Move verbatim, keeping the eight helpers `private`:
`updateSubscription` (`:418-524`), `getTempExpiryDate` (`:1041-1047`), `getCouponId` (`:1136-1144`), `getLabel` (`:1023-1031`), `updateUserProgramData` (`:1065-1126`), `getDiscount` (`:1156-1169`), `updateCouponAndAngelEmail` (`:1181-1215`), `sendIntroductionForNewAngelMail` (`:1226-1243`), `resolveSubHistoryPricing` (`:1258-1285`), `updateExpiryFromListeners` (`:1298-1338`).

- [ ] **Step 3: Run the Task 3 characterization test — before splitting anything**

Run:
```bash
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest tests/Feature/Api/User/Program/UpdateSubscriptionCharacterizationTest.php
```
Expected: PASS. Prove the pure move is neutral **before** changing any method shape.

- [ ] **Step 4: Phase-split `updateSubscription` below 100 lines**

`updateSubscription` is 107 lines. Extract named private phase methods from its body — following how `BaseAuthService::createNewUser()` went 296 → 116 with 13 named phases — until the entry point is under 100 lines. Suggested phases, each named for what it does rather than when it runs: `resolveSubscriptionSource()`, `persistSubscriptionRecord()`, `applyCouponAndDiscount()`, `syncProgramExpiry()`, `dispatchConversionEvents()`.

This is the step the last two decompositions skipped, which is why the codebase's >100-line method count barely moved (40 → 39) in the `BaseAuthService` period. Do not skip it.

- [ ] **Step 5: Re-run the characterization test after the split**

Run the same command as Step 3. Expected: PASS, unchanged. If it fails, the split changed behaviour — revert to the Step 2 state and split more conservatively.

- [ ] **Step 6: Bind and delegate**

Add `$this->app->bind(SubscriptionUpdateServiceInterface::class, SubscriptionUpdateService::class);` and reduce the facade's `updateSubscription` to a one-line delegation. Drop `UserDataQuery`, `UserSubscriptionQuery`, `CouponQuery`, `ProductCodeQuery`, `IosListenerDataQuery`, `AndroidListenerDataQuery` from the facade constructor once `grep` confirms each is unreferenced there.

- [ ] **Step 7: Add** `tests/Unit/Services/User/Program/SubscriptionUpdateServiceTest.php` with one test per phase method plus the branches from Task 3.

- [ ] **Step 8: Run all the gates** (as Task 7, Step 7), and additionally confirm the long-method census dropped:

```bash
grep -c 'private function' app/Services/User/Program/SubscriptionUpdateService.php
```
Expected: at least 13 (8 moved helpers + 5 new phase methods).

- [ ] **Step 9: Ready to commit** — `refactor(user-program): extract SubscriptionUpdateService; phase-split updateSubscription`

---

### Task 12: Extract `ProgramAssignmentService` and phase-split its two god methods

The last extraction. After this, `UserProgramService` holds no logic.

**Files:**
- Create: `app/Services/User/Program/ProgramAssignmentService.php`, `app/Services/User/Program/ProgramAssignmentServiceInterface.php`
- Create: `tests/Unit/Services/User/Program/ProgramAssignmentServiceTest.php`
- Modify: `app/Services/User/UserProgramService.php`, `app/Providers/AppServiceProvider.php`

**Interfaces:**
- Consumes: nothing
- Produces:
  - `ProgramAssignmentServiceInterface::getProgramList(int|string|null $userId, ?string $language): array`
  - `ProgramAssignmentServiceInterface::updateProgram(int $userId, ?array $postData, string $accessToken): array`
  - `ProgramAssignmentServiceInterface::updateLanguage(int $userId, ?array $postData): array`

- [ ] **Step 1: Write the interface** with all three signatures verbatim — note `getProgramList` takes `int|string|null $userId`, not `int`.

- [ ] **Step 2: Create the service and move four methods verbatim**

Promoted `private readonly` dependencies: `UserUtilityService`, `UserQuery`, `UserProgramQuery`, `UserConfigQuery`, `ProgramQuery`, `PartnerQuery`, `LanguageQuery`, `OnBoardingQuery`, `UserAppActivityQuery`, `ActiveDayService`, `UserDayQuery`, `UserChapterQuery`, `UserSubHistoryQuery`.

Move verbatim: `getProgramList` (`:270-280`), `updateProgram` (`:293-403`), `updateLanguage` (`:767-889`), `resolveOnBoardLocation` (`:1373-1379`, keep `private`).

- [ ] **Step 3: Run the Tasks 4 and 5 characterization tests plus the real-service program-list test**

Run:
```bash
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest \
  tests/Feature/Api/User/Program/UpdateProgramCharacterizationTest.php \
  tests/Feature/Api/User/Program/UpdateLanguageCharacterizationTest.php \
  tests/Feature/Api/Public/GetProgramListApiTest.php
```
Expected: PASS. Prove the pure move first.

- [ ] **Step 4: Phase-split `updateLanguage` (123) and `updateProgram` (111) below 100 lines**

Both re-enrol a user into a program, so look for the same phases in each and name them identically where the work matches — `resolveTargetProgram()`, `reassignUserProgram()`, `rebuildUserDays()`, `syncPartnerHistory()`. Shared shape between the two is a strong hint the split is right.

- [ ] **Step 5: Re-run the three tests from Step 3.** Expected: PASS, unchanged.

- [ ] **Step 6: Bind and delegate.** Add the binding; reduce all three facade methods to one-line delegations.

- [ ] **Step 7: Reduce the facade to its final form**

`UserProgramService` should now hold only: a constructor taking the **six** interfaces, and 11 one-line delegating public methods. Delete every remaining query-class dependency from its constructor. Give the class a docblock stating it exists to preserve the two controllers' constructor contracts, matching the wording style of `app/Services/ChapterService.php`.

**Add `declare(strict_types=1)` to this file now** — this task owns it, per the exception in Global Constraints. It was deliberately deferred from Task 1 because auditing 1,410 lines of legacy code for weak-mode coercion was not worth doing on code Tasks 7–12 delete. The file is now ~130 lines of delegations whose parameters are already typed, so the audit is: check each of the 11 delegating methods passes its typed parameters straight through with no implicit coercion, then add the declaration. If any method still coerces, fix it with an explicit cast and say so in the report.

Verify:
```bash
wc -l app/Services/User/UserProgramService.php
```
Expected: roughly 130 lines, down from 1,410.

- [ ] **Step 8: Add** `tests/Unit/Services/User/Program/ProgramAssignmentServiceTest.php` covering each phase method and the branches from Tasks 4 and 5.

- [ ] **Step 9: Run all the gates** (as Task 7, Step 7).

- [ ] **Step 10: Ready to commit** — `refactor(user-program): extract ProgramAssignmentService; finalize UserProgramService as a facade`

---

### Task 13: Re-home the PHPStan baseline and verify the whole change

**Files:**
- Modify: `phpstan-baseline.neon`
- Verify only: both `UserProgramController`s, `routes/api.php`, `routes/web.php`

**Interfaces:**
- Consumes: everything from Tasks 7–12
- Produces: a green tree

- [ ] **Step 1: Confirm the controllers and routes were never touched**

Run:
```bash
git diff --name-only HEAD -- app/Http/Controllers/Api/UserProgramController.php \
  app/Http/Controllers/UserProgramController.php routes/api.php routes/web.php
```
Expected: **empty output.** If any of these appear, the facade contract was broken somewhere — find and undo it. That output being empty is the strongest single proof that the 11 routes are unaffected.

- [ ] **Step 2: Re-home stale baseline entries**

Run:
```bash
XDEBUG_MODE=off vendor/bin/phpstan analyse --memory-limit=2G --no-progress
```
For each `ignore.unmatched` reported against `app/Services/User/UserProgramService.php`, find the corresponding block in `phpstan-baseline.neon` and change its `path:` to whichever new file the code moved to; delete the entry outright if the new typed code no longer triggers it. Do **not** run `--generate-baseline` — that would re-grandfather unrelated drift elsewhere.

- [ ] **Step 3: Confirm PHPStan is green**

Run:
```bash
XDEBUG_MODE=off vendor/bin/phpstan analyse --memory-limit=2G --no-progress
```
Expected: `[OK] No errors`.

- [ ] **Step 4: Full suite, both ways**

Run:
```bash
XDEBUG_MODE=off vendor/bin/pest --parallel
XDEBUG_MODE=off php -d memory_limit=1G vendor/bin/pest
```
Expected: both green, count ≥ 2,683 plus every test added in Tasks 2–12.

Note: three pre-existing timing-flaky tests can fail spuriously under load:

- `tests/Unit/Helpers/CommonHelperTest.php:233`
- `tests/Unit/Services/MetaCapiServiceTest.php:155`
- `tests/Unit/Services/User/UserUtilityServiceTest.php:170`

Each asserts that two IDs generated across a `usleep(1000)` differ, against millisecond wall-clock resolution. If one of these is the only failure, re-run before investigating — they are unrelated to this work.

- [ ] **Step 5: Style the whole change**

Run:
```bash
XDEBUG_MODE=off vendor/bin/pint --test $(git diff --name-only HEAD -- '*.php'; git ls-files --others --exclude-standard -- '*.php')
```
Expected: `"result": "passed"`. This is exactly what the CI Pint job checks, since it scopes to changed files.

- [ ] **Step 6: Record the outcome**

Measure and report:
```bash
wc -l app/Services/User/UserProgramService.php app/Services/User/Program/*.php
grep -c 'readonly' app/Services/User/UserProgramService.php
```
Expected: facade ~130 lines with 6 dependencies; six services none over ~330 lines.

- [ ] **Step 7: Ready to commit** — `refactor(user-program): re-home phpstan baseline after decomposition`

---

## Self-Review

**Spec coverage.** All 11 public methods are assigned to exactly one target service: `resetProgress` → Task 7; `sendFBEvent`/`sendRedditEvent` → Task 8; `UpdateUserPayments`/`paymentFailed` → Task 9; `getSubDetails`/`skipIntroSubScreen` → Task 10; `updateSubscription` → Task 11; `getProgramList`/`updateProgram`/`updateLanguage` → Task 12. All 13 private helpers travel with their single owner per the ownership table. Every method lacking real test coverage gets a characterization task first (Tasks 2–6); the four with real coverage (`UpdateUserPayments`, `paymentFailed`, `skipIntroSubScreen`, `getProgramList`) reuse their existing tests, named explicitly in Tasks 9 and 10. The dead `dayQuery` is Task 1. Baseline re-homing is Task 13.

**Placeholder scan.** The characterization tests in Tasks 2–6 deliberately contain a skeleton plus an instruction to fill expectations from the real method body, because the expectations *are* the current behaviour and cannot be written without reading it — Step 1 of each of those tasks is that read, and Step 4 proves the result actually bites. Every other step names exact files, line ranges, signatures and commands.

**Type consistency.** All six interface signatures are copied verbatim from the current implementation, including `getProgramList`'s `int|string|null $userId`, the `?User $authenticatedUser = null` defaults on `updateSubscription`/`getSubDetails`/`skipIntroSubScreen`, and the capitalised `UpdateUserPayments`. Task 11 consumes `ProgramEventDispatcherInterface` produced by Task 8, and Task 8 Step 4 pre-emptively rewires the two internal call sites so that dependency is real by the time Task 11 runs.

**Known risk left open.** The characterization tests are mock-interaction tests, not database-state tests — they pin *which collaborators are called with what*, which is exactly what a move-code refactor can break, but they would not catch a change in what the query classes themselves do. Since no query class is modified by this plan, that gap is acceptable. It would not be acceptable for a plan that touched `app/Http/Queries/`.
