# CronService Refactor 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:** Split the 1255-line `CronService` into domain-specific services, eliminate logging boilerplate via a trait, and make every cron job discoverable through an explicit registry — without breaking any of the 25+ existing artisan `cron:run <type>` commands.

**Architecture:**
- `CronService` becomes a thin facade. Each public `run*` method delegates to one of 8 domain services (Subscription, UserProgram, UserData, TokenCleanup, CouponVoucher, Acquisition, LogMaintenance, Debug).
- A `LogsCronExecution` trait + `CronResult` DTO own the try/catch and `cron_logs` write — the boilerplate appears exactly once.
- Complex loops (free trial, failed-payment mail, duplicate row cleanup) extract into dedicated processor classes.
- A `CronJobRegistry` enum-style map replaces the `str_replace('_', '', ucwords(...))` magic in `RunCron` — makes every cron name explicit and greppable for AI agents.

**Tech Stack:** PHP 8.2, Laravel 11, Pest PHP, Mockery, PHPStan level 5, Laravel Pint.

---

## What this fixes (analysis)

**Problems in the current [CronService.php](app/Services/CronService.php):**

1. **Logging boilerplate 27×.** Every method has the same try/catch wrapping a one-liner, then the same `cron_logs` insert. Total ~400 lines of duplicated structure.
2. **Constructor injects 40 dependencies.** Most methods use 1-3 of them. Adding a new cron forces a constructor edit and bloats every other test.
3. **God class.** 1255 lines, no domain boundary. Cron for "delete expired SSO tokens" lives next to "send 48-hour free trial emails." Any change requires loading the whole file into context.
4. **Magic dispatch in [RunCron.php:18](app/Console/Commands/RunCron.php#L18).** `'run' . str_replace('_', '', ucwords($type, '_'))` means you cannot grep for `'send_failed_payment_mail'` and find its handler. Bad for both humans and AI agents.
5. **Bug-shaped behavior** preserved but undocumented: `runSendFailedPaymentMail`, `runUploadUserFormsToS3`, `runUploadUserJourneyLogToS3` `return` early without writing a cron_log row when there's no work. Must be preserved (constraint), but should be explicit.
6. **`echo` in [CronService.php:1174](app/Services/CronService.php#L1174)** — production code writes to stdout, polluting `cron:run` output. Remove or gate behind a `--verbose` flag.
7. **`microtime(true)` in constructor** — `$jobStarted` is captured at container resolution, not at method invocation. For long-lived containers (queue workers) this is wrong. Capture per-method.
8. **No tests.** Zero existing test coverage for any of the 27 methods. A refactor without tests is dangerous; we add tests as we extract.

---

## AI-compatibility suggestions (incorporated into the plan)

These are why this refactor is worth doing for an AI-collaborator codebase, not just a human one:

1. **Explicit registry over magic dispatch** — `CronJobRegistry` lets `grepai search "send_failed_payment_mail handler"` resolve instantly. (Task 14)
2. **One responsibility per file** — agents reason best about files they can hold in context. Each domain service is ≤300 lines. (Tasks 4–11)
3. **PHPDoc `@command` tags preserved & sharpened** — every cron method documents its artisan command, schedule cadence, and what it does in 3 lines. Agents discover crons via doc-grep. (Task 2 template)
4. **Type-safe `CronResult` DTO** instead of an associative array — autocomplete, PHPStan can verify, fewer typos. (Task 2)
5. **`#[CronJob]` attribute (optional, recommended)** — declarative metadata an agent can read via reflection. Skipped in MVP — added in Task 15 as a stretch goal because it duplicates the registry's job. Keep the registry as the source of truth.
6. **No "test helper" methods on production services** — `testSource()` and `runTestFirebaseNotification()` move to `DebugCronService` with a clear name. Agents won't accidentally call them. (Task 11)
7. **Remove TODO debug paths** — `AutoRenewFailedService::$sentNotifications` and the `echo` line are debug artifacts. Excise them. (Task 7)
8. **Trait pattern for boilerplate** — single source of truth means an agent fixing a logging bug edits one file, not 27. (Task 2)
9. **Pest tests scaffolded per service** — each new service ships with a test file that mocks Query dependencies. Future agents have a template. (Tasks 4–11)
10. **Self-documenting file structure** — `app/Services/Cron/SubscriptionCronService.php` tells an agent everything before opening the file. Compare with hunting through a 1255-line monolith.

---

## File Structure

**Create:**
```
app/Services/Cron/
├── Concerns/
│   └── LogsCronExecution.php          # Trait — try/catch + cron_log write
├── CronResult.php                      # DTO returned by closures
├── CronJobRegistry.php                 # name → [class, method] map
├── SubscriptionCronService.php         # 7 methods, subscription/payment crons
├── UserProgramCronService.php          # 6 methods, user/program data crons
├── UserDataCronService.php             # 4 methods, user lifecycle/archive crons
├── TokenCleanupCronService.php         # 3 methods, token/OTP expiry
├── CouponVoucherCronService.php        # 4 methods, coupon/voucher lifecycle
├── AcquisitionCronService.php          # 4 methods, source/listener/device
├── LogMaintenanceCronService.php       # 1 method, log table purge
├── DebugCronService.php                # 2 methods, manual/debug helpers
└── Processors/
    ├── FreeTrialProcessor.php          # extracts free_trial loop
    ├── FailedPaymentMailer.php         # extracts send_failed_payment_mail loop
    └── DuplicateRowCleaner.php         # extracts duplicate row logic (4 tables)

tests/Unit/Services/Cron/
├── LogsCronExecutionTest.php           # trait behavior
├── CronJobRegistryTest.php             # registry covers all 25+ jobs
├── SubscriptionCronServiceTest.php
├── UserProgramCronServiceTest.php
├── UserDataCronServiceTest.php
├── TokenCleanupCronServiceTest.php
├── CouponVoucherCronServiceTest.php
├── AcquisitionCronServiceTest.php
├── LogMaintenanceCronServiceTest.php
├── DebugCronServiceTest.php
├── CronServiceFacadeTest.php           # every old public method delegates
└── Processors/
    ├── FreeTrialProcessorTest.php
    ├── FailedPaymentMailerTest.php
    └── DuplicateRowCleanerTest.php
```

**Modify:**
- `app/Services/CronService.php` — replace body with pass-through delegators; reduces from 1255 → ~150 lines.
- `app/Console/Commands/RunCron.php` — replace magic dispatch with `CronJobRegistry` lookup.

**Domain mapping** (which `run*` method goes to which service):

| Service | Methods |
|---|---|
| **SubscriptionCronService** | `runUserSubHistoryArchive`, `runUpdateSubscription`, `runUpdateStripeData`, `runUpdateRazorPayData`, `runFreeTrial`, `runAutoRenewFailed`, `runSendFailedPaymentMail` |
| **UserProgramCronService** | `runRemoveDuplicateChapter`, `runUserProgramDuplicateRowCleanup`, `runCreateNextUserDay`, `runStopExtendedFlow`, `runSetExtendedFlow`, `runUpdateUserArticles` |
| **UserDataCronService** | `runDeactivateUsers`, `runUserSmokeDataArchive`, `runUploadUserFormsToS3`, `runUploadUserJourneyLogToS3` |
| **TokenCleanupCronService** | `runCleanPartnerAccessToken`, `runCleanUserSso`, `runCleanLoginTable` |
| **CouponVoucherCronService** | `runMoveUsedVouchers`, `runCouponsExpire`, `runVoucherExpired`, `runDeleteOldCoupon` |
| **AcquisitionCronService** | `runUserSourceData`, `runUpdateListenerTables`, `runUpdateUserDeviceCategory`, `runUpdateUserDeviceCategoryZero` |
| **LogMaintenanceCronService** | `runCleanLogData` |
| **DebugCronService** | `testSource`, `runTestFirebaseNotification` |

---

## Quality Constraints Verification

Before each commit, verify against the user's checklist:
- [ ] No method exceeds 40 lines (`awk` count after each task)
- [ ] No file exceeds 300 lines
- [ ] Logging boilerplate (`cronLogQuery->create([...])` + `try/catch` around the work) appears exactly once (in `LogsCronExecution`)
- [ ] Every cron name in `CronJobRegistry` resolves to a real method (Task 14 test)
- [ ] CronService facade has every original `run*` method (Task 13 test)
- [ ] `vendor/bin/pest` passes after every task
- [ ] `vendor/bin/phpstan analyse` shows no new errors

**Note for executor:** The user (Suresh) handles `git add` / `commit` / `push` manually. Do not run git commands. Treat each task boundary as a natural commit checkpoint — pause, summarize what's done, let the user commit, then proceed.

---

## Task 1: Plan-level audit & dry-run

**Files:** None modified. Pure reading task.

- [ ] **Step 1:** Read [CronService.php](app/Services/CronService.php) end-to-end. Confirm the 27 method names match the domain mapping table above. If any method is missing or misclassified, update the table before proceeding.

- [ ] **Step 2:** Run the existing test suite to capture baseline.

```bash
cd d:/wamp/www/quitsure/laravel/phptest
vendor/bin/pest --stop-on-failure
```

Expected: All currently-passing tests continue to pass. Note the count.

- [ ] **Step 3:** Snapshot existing public method signatures with a one-liner.

```bash
grep -nE '^\s*public function (run|test)' app/Services/CronService.php > /tmp/cron-methods-before.txt
wc -l /tmp/cron-methods-before.txt
```

Expected: 27 lines. Keep this file — Task 13 uses it.

---

## Task 2: `CronResult` DTO + `LogsCronExecution` trait

**Files:**
- Create: `app/Services/Cron/CronResult.php`
- Create: `app/Services/Cron/Concerns/LogsCronExecution.php`
- Test: `tests/Unit/Services/Cron/LogsCronExecutionTest.php`

- [ ] **Step 1:** Write the failing test.

```php
<?php
// tests/Unit/Services/Cron/LogsCronExecutionTest.php

use App\Http\Queries\CronLogQuery;
use App\Services\Cron\Concerns\LogsCronExecution;
use App\Services\Cron\CronResult;
use Illuminate\Support\Facades\Log;

class FakeCronHost
{
    use LogsCronExecution;

    public function __construct(protected CronLogQuery $cronLogQuery) {}

    public function runOk(): void
    {
        $this->executeCron('my_cron', 'Default msg', function (CronResult $r) {
            $r->total = 5;
            $r->notSent = 1;
            $r->context = ['some' => 'data'];
        });
    }

    public function runBoom(): void
    {
        $this->executeCron('my_cron', 'Default msg', function (CronResult $r) {
            throw new \RuntimeException('boom');
        });
    }

    public function runSkip(): void
    {
        $this->executeCron('my_cron', 'Default msg', function (CronResult $r) {
            $r->skipLog = true;
        });
    }
}

it('writes a cron_log row with totals and json-encoded context on success', function () {
    $logQuery = Mockery::mock(CronLogQuery::class);
    $logQuery->shouldReceive('create')->once()->with(Mockery::on(function ($payload) {
        return $payload['cron'] === 'my_cron'
            && $payload['total'] === 5
            && $payload['not_sent'] === 1
            && $payload['message'] === 'Default msg'
            && $payload['context'] === json_encode(['some' => 'data'])
            && is_float($payload['start_time']);
    }));

    (new FakeCronHost($logQuery))->runOk();
});

it('catches Throwable, logs error, and still writes cron_log row', function () {
    Log::spy();
    $logQuery = Mockery::mock(CronLogQuery::class);
    $logQuery->shouldReceive('create')->once();

    (new FakeCronHost($logQuery))->runBoom();

    Log::shouldHaveReceived('error')
        ->once()
        ->with(Mockery::pattern('/FakeCronHost::my_cron failed/'), Mockery::on(fn($ctx) => $ctx['error'] === 'boom'));
});

it('skips writing cron_log when result->skipLog is true', function () {
    $logQuery = Mockery::mock(CronLogQuery::class);
    $logQuery->shouldNotReceive('create');

    (new FakeCronHost($logQuery))->runSkip();
});

it('json-encodes context when it is an array; passes through when string', function () {
    $logQuery = Mockery::mock(CronLogQuery::class);
    $payloads = [];
    $logQuery->shouldReceive('create')->twice()->with(Mockery::capture($payloads));

    $host = new FakeCronHost($logQuery);
    $host->runOk(); // context is array
    // Inline a second host call with a string context:
    (function () use ($host) {
        $ref = new ReflectionMethod($host, 'executeCron');
        $ref->setAccessible(true);
        $ref->invoke($host, 'my_cron', 'msg', fn(CronResult $r) => $r->context = 'plain-string');
    })();

    expect($payloads[0]['context'])->toBeString()->and($payloads[0]['context'])->toBe(json_encode(['some' => 'data']));
});
```

- [ ] **Step 2:** Run the test to confirm failure.

```bash
vendor/bin/pest tests/Unit/Services/Cron/LogsCronExecutionTest.php
```

Expected: FAIL with "Class App\Services\Cron\CronResult does not exist".

- [ ] **Step 3:** Create the DTO.

```php
<?php
// app/Services/Cron/CronResult.php

namespace App\Services\Cron;

/**
 * Mutable result object passed into cron work closures.
 * The trait reads these fields to populate the cron_logs row.
 */
class CronResult
{
    public int $total = 0;
    public int $notSent = 0;
    public string $message;
    public mixed $context = null;
    public bool $skipLog = false;

    public function __construct(string $defaultMessage)
    {
        $this->message = $defaultMessage;
    }
}
```

- [ ] **Step 4:** Create the trait.

```php
<?php
// app/Services/Cron/Concerns/LogsCronExecution.php

namespace App\Services\Cron\Concerns;

use App\Http\Queries\CronLogQuery;
use App\Services\Cron\CronResult;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
 * Wraps cron work in the standard try/catch + cron_logs write.
 * Host class must inject CronLogQuery as $this->cronLogQuery.
 */
trait LogsCronExecution
{
    /**
     * Run a cron job with standardised logging.
     *
     * @param string   $cronName  Used as the `cron` column and in error log prefix.
     * @param string   $defaultMessage  Becomes `message` column unless $work overrides it.
     * @param callable $work  function (CronResult $result): void  — mutates $result.
     */
    protected function executeCron(string $cronName, string $defaultMessage, callable $work): void
    {
        $startTime = microtime(true);
        $result = new CronResult($defaultMessage);

        try {
            $work($result);
        } catch (Throwable $e) {
            Log::error(static::class . "::{$cronName} failed", ['error' => $e->getMessage()]);
        }

        if ($result->skipLog) {
            return;
        }

        $this->cronLogQuery->create([
            'cron' => $cronName,
            'start_time' => $startTime,
            'total' => $result->total,
            'not_sent' => $result->notSent,
            'message' => $result->message,
            'context' => is_string($result->context) ? $result->context : json_encode($result->context),
        ]);
    }
}
```

- [ ] **Step 5:** Run the test to confirm pass.

```bash
vendor/bin/pest tests/Unit/Services/Cron/LogsCronExecutionTest.php
```

Expected: PASS, 4 tests.

- [ ] **Step 6:** Run PHPStan on new files.

```bash
vendor/bin/phpstan analyse app/Services/Cron/
```

Expected: No errors. Commit checkpoint.

---

## Task 3: `CronJobRegistry`

**Files:**
- Create: `app/Services/Cron/CronJobRegistry.php`
- Test: `tests/Unit/Services/Cron/CronJobRegistryTest.php`

The registry is a single source of truth mapping cron name (the string passed to `cron:run <type>`) → handler service class + method. It replaces the regex-based dispatch in `RunCron`.

- [ ] **Step 1:** Write the failing test.

```php
<?php
// tests/Unit/Services/Cron/CronJobRegistryTest.php

use App\Services\Cron\CronJobRegistry;

it('returns a handler tuple for every known cron name', function () {
    $registry = new CronJobRegistry();

    // All 25 cron names known to the artisan command.
    $names = [
        'user_sub_history_archive', 'remove_duplicate_chapter', 'user_program_duplicate_row_cleanup',
        'clean_partner_access_token', 'clean_user_sso', 'clean_login_table',
        'move_used_vouchers', 'deactivate_users', 'update_subscription',
        'user_source_data', 'update_listener_tables', 'coupons_expire',
        'voucher_expired', 'send_failed_payment_mail', 'user_smoke_data_archive',
        'delete_old_coupon', 'update_user_articles', 'upload_user_forms_to_s3',
        'upload_user_journey_log_to_s3', 'stop_extended_flow', 'set_extended_flow',
        'update_stripe_data', 'update_razor_pay_data', 'create_next_user_day',
        'update_user_device_category', 'update_user_device_category_zero',
        'free_trial', 'auto_renew_failed', 'clean_log_data',
        'test_firebase_notification',
    ];

    foreach ($names as $name) {
        $handler = $registry->resolve($name);
        expect($handler)->toBeArray()->and($handler)->toHaveCount(2);
        [$class, $method] = $handler;
        expect(class_exists($class))->toBeTrue("Class {$class} missing for cron '{$name}'");
        expect(method_exists($class, $method))->toBeTrue("Method {$method} missing on {$class}");
    }
});

it('returns null for unknown cron name', function () {
    expect((new CronJobRegistry())->resolve('does_not_exist'))->toBeNull();
});
```

- [ ] **Step 2:** Run the test to confirm failure.

```bash
vendor/bin/pest tests/Unit/Services/Cron/CronJobRegistryTest.php
```

Expected: FAIL with "Class App\Services\Cron\CronJobRegistry does not exist".

- [ ] **Step 3:** Create the registry. Note: it references service classes that don't exist yet — that's fine, the registry test only runs after Task 12.

```php
<?php
// app/Services/Cron/CronJobRegistry.php

namespace App\Services\Cron;

/**
 * Explicit map of cron name (used by `artisan cron:run <name>`) → handler.
 * Single source of truth — add new crons here, not via reflection magic.
 */
class CronJobRegistry
{
    /** @var array<string, array{0: class-string, 1: string}> */
    private const HANDLERS = [
        // Subscription / payment
        'user_sub_history_archive' => [SubscriptionCronService::class, 'runUserSubHistoryArchive'],
        'update_subscription' => [SubscriptionCronService::class, 'runUpdateSubscription'],
        'update_stripe_data' => [SubscriptionCronService::class, 'runUpdateStripeData'],
        'update_razor_pay_data' => [SubscriptionCronService::class, 'runUpdateRazorPayData'],
        'free_trial' => [SubscriptionCronService::class, 'runFreeTrial'],
        'auto_renew_failed' => [SubscriptionCronService::class, 'runAutoRenewFailed'],
        'send_failed_payment_mail' => [SubscriptionCronService::class, 'runSendFailedPaymentMail'],

        // User program
        'remove_duplicate_chapter' => [UserProgramCronService::class, 'runRemoveDuplicateChapter'],
        'user_program_duplicate_row_cleanup' => [UserProgramCronService::class, 'runUserProgramDuplicateRowCleanup'],
        'create_next_user_day' => [UserProgramCronService::class, 'runCreateNextUserDay'],
        'stop_extended_flow' => [UserProgramCronService::class, 'runStopExtendedFlow'],
        'set_extended_flow' => [UserProgramCronService::class, 'runSetExtendedFlow'],
        'update_user_articles' => [UserProgramCronService::class, 'runUpdateUserArticles'],

        // User lifecycle / archive
        'deactivate_users' => [UserDataCronService::class, 'runDeactivateUsers'],
        'user_smoke_data_archive' => [UserDataCronService::class, 'runUserSmokeDataArchive'],
        'upload_user_forms_to_s3' => [UserDataCronService::class, 'runUploadUserFormsToS3'],
        'upload_user_journey_log_to_s3' => [UserDataCronService::class, 'runUploadUserJourneyLogToS3'],

        // Token cleanup
        'clean_partner_access_token' => [TokenCleanupCronService::class, 'runCleanPartnerAccessToken'],
        'clean_user_sso' => [TokenCleanupCronService::class, 'runCleanUserSso'],
        'clean_login_table' => [TokenCleanupCronService::class, 'runCleanLoginTable'],

        // Coupon / voucher
        'move_used_vouchers' => [CouponVoucherCronService::class, 'runMoveUsedVouchers'],
        'coupons_expire' => [CouponVoucherCronService::class, 'runCouponsExpire'],
        'voucher_expired' => [CouponVoucherCronService::class, 'runVoucherExpired'],
        'delete_old_coupon' => [CouponVoucherCronService::class, 'runDeleteOldCoupon'],

        // Acquisition / device
        'user_source_data' => [AcquisitionCronService::class, 'runUserSourceData'],
        'update_listener_tables' => [AcquisitionCronService::class, 'runUpdateListenerTables'],
        'update_user_device_category' => [AcquisitionCronService::class, 'runUpdateUserDeviceCategory'],
        'update_user_device_category_zero' => [AcquisitionCronService::class, 'runUpdateUserDeviceCategoryZero'],

        // Log maintenance
        'clean_log_data' => [LogMaintenanceCronService::class, 'runCleanLogData'],

        // Debug / manual
        'test_firebase_notification' => [DebugCronService::class, 'runTestFirebaseNotification'],
    ];

    /** @return array{0: class-string, 1: string}|null */
    public function resolve(string $cronName): ?array
    {
        return self::HANDLERS[$cronName] ?? null;
    }

    /** @return list<string> */
    public function allNames(): array
    {
        return array_keys(self::HANDLERS);
    }
}
```

- [ ] **Step 4:** Do **not** run the registry test yet — it will fail because the service classes don't exist. Mark Task 14 as the verification step.

```bash
# Skip running the test now. Continue to Task 4.
```

Commit checkpoint.

---

## Task 4: `TokenCleanupCronService` (3 simplest methods)

**Files:**
- Create: `app/Services/Cron/TokenCleanupCronService.php`
- Test: `tests/Unit/Services/Cron/TokenCleanupCronServiceTest.php`

Start with the simplest domain to validate the trait pattern end-to-end.

- [ ] **Step 1:** Write the failing test.

```php
<?php
// tests/Unit/Services/Cron/TokenCleanupCronServiceTest.php

use App\Http\Queries\CronLogQuery;
use App\Http\Queries\LoginQuery;
use App\Http\Queries\PartnerAccessTokenQuery;
use App\Http\Queries\UserSSOQuery;
use App\Services\Cron\TokenCleanupCronService;

function makeTokenCleanupService(array $mocks = []): TokenCleanupCronService
{
    return new TokenCleanupCronService(
        $mocks['cronLogQuery'] ?? Mockery::mock(CronLogQuery::class)->shouldIgnoreMissing(),
        $mocks['partnerAccessTokenQuery'] ?? Mockery::mock(PartnerAccessTokenQuery::class),
        $mocks['userSSOQuery'] ?? Mockery::mock(UserSSOQuery::class),
        $mocks['loginQuery'] ?? Mockery::mock(LoginQuery::class),
    );
}

it('runCleanPartnerAccessToken delegates and logs', function () {
    $tokenQuery = Mockery::mock(PartnerAccessTokenQuery::class);
    $tokenQuery->shouldReceive('deleteExpiredTokens')->once()->andReturn(['deleted' => 7]);

    $logQuery = Mockery::mock(CronLogQuery::class);
    $logQuery->shouldReceive('create')->once()->with(Mockery::on(fn($p) =>
        $p['cron'] === 'clean_partner_access_token'
        && $p['message'] === 'Completed cleaning partner expired tokens'
        && $p['context'] === json_encode(['deleted' => 7])
    ));

    makeTokenCleanupService(['cronLogQuery' => $logQuery, 'partnerAccessTokenQuery' => $tokenQuery])
        ->runCleanPartnerAccessToken();
});

it('runCleanUserSso delegates and logs', function () {
    $ssoQuery = Mockery::mock(UserSSOQuery::class);
    $ssoQuery->shouldReceive('deleteExpiredTokens')->once()->andReturn(['deleted' => 3]);

    $logQuery = Mockery::mock(CronLogQuery::class);
    $logQuery->shouldReceive('create')->once()->with(Mockery::on(fn($p) =>
        $p['cron'] === 'clean_user_sso'
    ));

    makeTokenCleanupService(['cronLogQuery' => $logQuery, 'userSSOQuery' => $ssoQuery])
        ->runCleanUserSso();
});

it('runCleanLoginTable delegates and logs', function () {
    $loginQuery = Mockery::mock(LoginQuery::class);
    $loginQuery->shouldReceive('deleteExpiredorUsedOTPs')->once()->andReturn(['deleted' => 12]);

    $logQuery = Mockery::mock(CronLogQuery::class);
    $logQuery->shouldReceive('create')->once()->with(Mockery::on(fn($p) =>
        $p['cron'] === 'clean_login_table'
    ));

    makeTokenCleanupService(['cronLogQuery' => $logQuery, 'loginQuery' => $loginQuery])
        ->runCleanLoginTable();
});

it('still logs a cron_log row when underlying query throws', function () {
    \Illuminate\Support\Facades\Log::spy();
    $tokenQuery = Mockery::mock(PartnerAccessTokenQuery::class);
    $tokenQuery->shouldReceive('deleteExpiredTokens')->once()->andThrow(new \RuntimeException('db down'));

    $logQuery = Mockery::mock(CronLogQuery::class);
    $logQuery->shouldReceive('create')->once();

    makeTokenCleanupService(['cronLogQuery' => $logQuery, 'partnerAccessTokenQuery' => $tokenQuery])
        ->runCleanPartnerAccessToken();

    \Illuminate\Support\Facades\Log::shouldHaveReceived('error')->once();
});
```

- [ ] **Step 2:** Run test, expect FAIL (class missing).

```bash
vendor/bin/pest tests/Unit/Services/Cron/TokenCleanupCronServiceTest.php
```

- [ ] **Step 3:** Create the service.

```php
<?php
// app/Services/Cron/TokenCleanupCronService.php

namespace App\Services\Cron;

use App\Http\Queries\CronLogQuery;
use App\Http\Queries\LoginQuery;
use App\Http\Queries\PartnerAccessTokenQuery;
use App\Http\Queries\UserSSOQuery;
use App\Services\Cron\Concerns\LogsCronExecution;

class TokenCleanupCronService
{
    use LogsCronExecution;

    public function __construct(
        protected CronLogQuery $cronLogQuery,
        protected PartnerAccessTokenQuery $partnerAccessTokenQuery,
        protected UserSSOQuery $userSSOQuery,
        protected LoginQuery $loginQuery,
    ) {}

    /** @command php artisan cron:run clean_partner_access_token */
    public function runCleanPartnerAccessToken(): void
    {
        $this->executeCron(
            'clean_partner_access_token',
            'Completed cleaning partner expired tokens',
            fn(CronResult $r) => $r->context = $this->partnerAccessTokenQuery->deleteExpiredTokens(),
        );
    }

    /** @command php artisan cron:run clean_user_sso */
    public function runCleanUserSso(): void
    {
        $this->executeCron(
            'clean_user_sso',
            'Completed cleaning user sso expired tokens',
            fn(CronResult $r) => $r->context = $this->userSSOQuery->deleteExpiredTokens(),
        );
    }

    /** @command php artisan cron:run clean_login_table */
    public function runCleanLoginTable(): void
    {
        $this->executeCron(
            'clean_login_table',
            'Completed cleaning login table of expired/used OTPs',
            fn(CronResult $r) => $r->context = $this->loginQuery->deleteExpiredorUsedOTPs(),
        );
    }
}
```

- [ ] **Step 4:** Run test, expect PASS (4 tests).

```bash
vendor/bin/pest tests/Unit/Services/Cron/TokenCleanupCronServiceTest.php
```

- [ ] **Step 5:** Verify constraints — file ≤300 lines, methods ≤40 lines, no try/catch in this file.

```bash
wc -l app/Services/Cron/TokenCleanupCronService.php
grep -c 'try {' app/Services/Cron/TokenCleanupCronService.php  # must be 0
```

Commit checkpoint.

---

## Task 5: `CouponVoucherCronService` (4 simple methods)

**Files:**
- Create: `app/Services/Cron/CouponVoucherCronService.php`
- Test: `tests/Unit/Services/Cron/CouponVoucherCronServiceTest.php`

- [ ] **Step 1:** Write the failing test (mirror Task 4 structure — one `it()` per method + one throw test).

```php
<?php
// tests/Unit/Services/Cron/CouponVoucherCronServiceTest.php

use App\Http\Queries\CouponQuery;
use App\Http\Queries\CronLogQuery;
use App\Http\Queries\VoucherQuery;
use App\Services\Cron\CouponVoucherCronService;

function makeCouponVoucherService(array $mocks = []): CouponVoucherCronService
{
    return new CouponVoucherCronService(
        $mocks['cronLogQuery'] ?? Mockery::mock(CronLogQuery::class)->shouldIgnoreMissing(),
        $mocks['voucherQuery'] ?? Mockery::mock(VoucherQuery::class),
        $mocks['couponQuery'] ?? Mockery::mock(CouponQuery::class),
    );
}

it('runMoveUsedVouchers delegates to voucherQuery->moveUsedVoucher', function () {
    $voucher = Mockery::mock(VoucherQuery::class);
    $voucher->shouldReceive('moveUsedVoucher')->once()->andReturn(['moved' => 4]);

    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldReceive('create')->once()->with(Mockery::on(fn($p) => $p['cron'] === 'move_used_vouchers'));

    makeCouponVoucherService(['cronLogQuery' => $log, 'voucherQuery' => $voucher])->runMoveUsedVouchers();
});

it('runCouponsExpire delegates to couponQuery->moveExpiredCoupons', function () {
    $coupon = Mockery::mock(CouponQuery::class);
    $coupon->shouldReceive('moveExpiredCoupons')->once()->andReturn(['moved' => 2]);

    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldReceive('create')->once()->with(Mockery::on(fn($p) => $p['cron'] === 'coupons_expire'));

    makeCouponVoucherService(['cronLogQuery' => $log, 'couponQuery' => $coupon])->runCouponsExpire();
});

it('runVoucherExpired delegates to voucherQuery->moveExpiredVoucher (note: cron name is voucher_expire in original log)', function () {
    $voucher = Mockery::mock(VoucherQuery::class);
    $voucher->shouldReceive('moveExpiredVoucher')->once()->andReturn(['moved' => 1]);

    $log = Mockery::mock(CronLogQuery::class);
    // PRESERVE original behavior: log row uses 'voucher_expire' string but registry uses 'voucher_expired'.
    // See CronService.php:564 — context-encoded cron name in cron_logs is 'voucher_expire'.
    $log->shouldReceive('create')->once()->with(Mockery::on(fn($p) => $p['cron'] === 'voucher_expire'));

    makeCouponVoucherService(['cronLogQuery' => $log, 'voucherQuery' => $voucher])->runVoucherExpired();
});

it('runDeleteOldCoupon delegates to couponQuery->copyoldCouponToDeleted', function () {
    $coupon = Mockery::mock(CouponQuery::class);
    $coupon->shouldReceive('copyoldCouponToDeleted')->once()->andReturn(['moved' => 9]);

    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldReceive('create')->once()->with(Mockery::on(fn($p) => $p['cron'] === 'delete_old_coupon'));

    makeCouponVoucherService(['cronLogQuery' => $log, 'couponQuery' => $coupon])->runDeleteOldCoupon();
});
```

- [ ] **Step 2:** Run test, expect FAIL.

- [ ] **Step 3:** Create the service.

```php
<?php
// app/Services/Cron/CouponVoucherCronService.php

namespace App\Services\Cron;

use App\Http\Queries\CouponQuery;
use App\Http\Queries\CronLogQuery;
use App\Http\Queries\VoucherQuery;
use App\Services\Cron\Concerns\LogsCronExecution;

class CouponVoucherCronService
{
    use LogsCronExecution;

    public function __construct(
        protected CronLogQuery $cronLogQuery,
        protected VoucherQuery $voucherQuery,
        protected CouponQuery $couponQuery,
    ) {}

    /** @command php artisan cron:run move_used_vouchers */
    public function runMoveUsedVouchers(): void
    {
        $this->executeCron(
            'move_used_vouchers',
            'Completed moving used vouchers',
            fn(CronResult $r) => $r->context = $this->voucherQuery->moveUsedVoucher(),
        );
    }

    /** @command php artisan cron:run coupons_expire */
    public function runCouponsExpire(): void
    {
        $this->executeCron(
            'coupons_expire',
            'Completed expiring coupons',
            fn(CronResult $r) => $r->context = $this->couponQuery->moveExpiredCoupons(),
        );
    }

    /**
     * Note: artisan name is `voucher_expired` but the cron_log row stores
     * `voucher_expire` to preserve historical logs (see legacy CronService.php:564).
     *
     * @command php artisan cron:run voucher_expired
     */
    public function runVoucherExpired(): void
    {
        $this->executeCron(
            'voucher_expire',
            'Completed expiring coupons',
            fn(CronResult $r) => $r->context = $this->voucherQuery->moveExpiredVoucher(),
        );
    }

    /** @command php artisan cron:run delete_old_coupon */
    public function runDeleteOldCoupon(): void
    {
        $this->executeCron(
            'delete_old_coupon',
            'Completed deleting old coupons',
            fn(CronResult $r) => $r->context = $this->couponQuery->copyoldCouponToDeleted(),
        );
    }
}
```

- [ ] **Step 4:** Run test, expect PASS. Commit checkpoint.

---

## Task 6: `LogMaintenanceCronService` (1 method)

**Files:**
- Create: `app/Services/Cron/LogMaintenanceCronService.php`
- Test: `tests/Unit/Services/Cron/LogMaintenanceCronServiceTest.php`

- [ ] **Step 1:** Write the failing test.

```php
<?php
// tests/Unit/Services/Cron/LogMaintenanceCronServiceTest.php

use App\Http\Queries\ApiTimingQuery;
use App\Http\Queries\CronLogQuery;
use App\Http\Queries\LogQuery;
use App\Http\Queries\PartnerLogQuery;
use App\Http\Queries\SqlLogQuery;
use App\Http\Queries\WebLogQuery;
use App\Services\Cron\LogMaintenanceCronService;

it('runCleanLogData clears all 6 log tables with 7-day retention', function () {
    $api = Mockery::mock(ApiTimingQuery::class);
    $api->shouldReceive('clearLogData')->once()->with(7)->andReturn(10);
    $log = Mockery::mock(LogQuery::class);
    $log->shouldReceive('clearLogData')->once()->with(7)->andReturn(20);
    $web = Mockery::mock(WebLogQuery::class);
    $web->shouldReceive('clearLogData')->once()->with(7)->andReturn(5);
    $partner = Mockery::mock(PartnerLogQuery::class);
    $partner->shouldReceive('clearLogData')->once()->with(7)->andReturn(3);
    $sql = Mockery::mock(SqlLogQuery::class);
    $sql->shouldReceive('clearLogData')->once()->with(7)->andReturn(8);
    $cronLog = Mockery::mock(CronLogQuery::class);
    $cronLog->shouldReceive('clearLogData')->once()->with(7)->andReturn(2);
    $cronLog->shouldReceive('create')->once()->with(Mockery::on(fn($p) =>
        $p['cron'] === 'clean_log_data' && $p['total'] === 48
    ));

    (new LogMaintenanceCronService($cronLog, $api, $log, $web, $partner, $sql))->runCleanLogData();
});
```

- [ ] **Step 2:** Run test, FAIL.

- [ ] **Step 3:** Create the service.

```php
<?php
// app/Services/Cron/LogMaintenanceCronService.php

namespace App\Services\Cron;

use App\Http\Queries\ApiTimingQuery;
use App\Http\Queries\CronLogQuery;
use App\Http\Queries\LogQuery;
use App\Http\Queries\PartnerLogQuery;
use App\Http\Queries\SqlLogQuery;
use App\Http\Queries\WebLogQuery;
use App\Services\Cron\Concerns\LogsCronExecution;

class LogMaintenanceCronService
{
    use LogsCronExecution;

    private const RETENTION_DAYS = 7;

    public function __construct(
        protected CronLogQuery $cronLogQuery,
        protected ApiTimingQuery $apiTimingQuery,
        protected LogQuery $logQuery,
        protected WebLogQuery $webLogQuery,
        protected PartnerLogQuery $partnerLogQuery,
        protected SqlLogQuery $sqlLogQuery,
    ) {}

    /** @command php artisan cron:run clean_log_data */
    public function runCleanLogData(): void
    {
        $this->executeCron(
            'clean_log_data',
            'Completed cleaning log data',
            function (CronResult $r) {
                $total = $this->apiTimingQuery->clearLogData(self::RETENTION_DAYS)
                    + $this->logQuery->clearLogData(self::RETENTION_DAYS)
                    + $this->webLogQuery->clearLogData(self::RETENTION_DAYS)
                    + $this->partnerLogQuery->clearLogData(self::RETENTION_DAYS)
                    + $this->sqlLogQuery->clearLogData(self::RETENTION_DAYS)
                    + $this->cronLogQuery->clearLogData(self::RETENTION_DAYS);
                $r->total = $total;
                $r->context = $total;
            },
        );
    }
}
```

- [ ] **Step 4:** Run test, PASS. Commit checkpoint.

---

## Task 7: `UserDataCronService` (4 methods, two with early-return)

**Files:**
- Create: `app/Services/Cron/UserDataCronService.php`
- Test: `tests/Unit/Services/Cron/UserDataCronServiceTest.php`

- [ ] **Step 1:** Write the failing test. Two key behaviors to verify:
  1. Normal path delegates correctly.
  2. When `userQuery->getUsersDeactivatedInLast24Hours()` returns empty, `runUploadUserFormsToS3` and `runUploadUserJourneyLogToS3` exit WITHOUT writing to cron_logs (preserves original early-return at [CronService.php:784](app/Services/CronService.php#L784) and [:820](app/Services/CronService.php#L820)).

```php
<?php
// tests/Unit/Services/Cron/UserDataCronServiceTest.php

use App\Http\Queries\CronLogQuery;
use App\Http\Queries\UserQuery;
use App\Http\Queries\UserSmokeQuery;
use App\Services\Cron\UserDataCronService;
use App\Services\UserJourneyLogger;
use App\Services\UserResponseFileService;
use Illuminate\Support\Collection;

function makeUserDataService(array $mocks = []): UserDataCronService
{
    return new UserDataCronService(
        $mocks['cronLogQuery'] ?? Mockery::mock(CronLogQuery::class)->shouldIgnoreMissing(),
        $mocks['userQuery'] ?? Mockery::mock(UserQuery::class),
        $mocks['userSmokeQuery'] ?? Mockery::mock(UserSmokeQuery::class),
        $mocks['userResponseFileService'] ?? Mockery::mock(UserResponseFileService::class),
        $mocks['userJourneyLogger'] ?? Mockery::mock(UserJourneyLogger::class),
    );
}

it('runDeactivateUsers delegates and logs', function () {
    $userQuery = Mockery::mock(UserQuery::class);
    $userQuery->shouldReceive('deactivateUsers')->once()->andReturn(['deactivated' => 5]);

    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldReceive('create')->once()->with(Mockery::on(fn($p) => $p['cron'] === 'deactivate_users'));

    makeUserDataService(['cronLogQuery' => $log, 'userQuery' => $userQuery])->runDeactivateUsers();
});

it('runUserSmokeDataArchive delegates and logs', function () {
    $smoke = Mockery::mock(UserSmokeQuery::class);
    $smoke->shouldReceive('copyUserSmokeDataToArchive')->once()->andReturn(['archived' => 100]);

    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldReceive('create')->once()->with(Mockery::on(fn($p) => $p['cron'] === 'user_smoke_data_archive'));

    makeUserDataService(['cronLogQuery' => $log, 'userSmokeQuery' => $smoke])->runUserSmokeDataArchive();
});

it('runUploadUserFormsToS3 skips cron_log when no users to process', function () {
    $userQuery = Mockery::mock(UserQuery::class);
    $userQuery->shouldReceive('getUsersDeactivatedInLast24Hours')->once()->andReturn(new Collection([]));

    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldNotReceive('create');

    makeUserDataService(['cronLogQuery' => $log, 'userQuery' => $userQuery])->runUploadUserFormsToS3();
});

it('runUploadUserFormsToS3 syncs each user and logs total uploaded', function () {
    $userQuery = Mockery::mock(UserQuery::class);
    $userQuery->shouldReceive('getUsersDeactivatedInLast24Hours')->once()->andReturn(new Collection([
        (object)['iUserID' => 1],
        (object)['iUserID' => 2],
    ]));

    $fileService = Mockery::mock(UserResponseFileService::class);
    $fileService->shouldReceive('syncLocalToS3')->twice()->andReturn(3, 4);

    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldReceive('create')->once()->with(Mockery::on(fn($p) =>
        $p['cron'] === 'upload_user_forms_to_s3' && $p['total'] === 7
    ));

    makeUserDataService([
        'cronLogQuery' => $log,
        'userQuery' => $userQuery,
        'userResponseFileService' => $fileService,
    ])->runUploadUserFormsToS3();
});

it('runUploadUserJourneyLogToS3 skips cron_log when no users to process', function () {
    $userQuery = Mockery::mock(UserQuery::class);
    $userQuery->shouldReceive('getUsersDeactivatedInLast24Hours')->once()->andReturn(new Collection([]));

    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldNotReceive('create');

    makeUserDataService(['cronLogQuery' => $log, 'userQuery' => $userQuery])->runUploadUserJourneyLogToS3();
});

it('runUploadUserJourneyLogToS3 syncs each user and logs total uploaded', function () {
    $userQuery = Mockery::mock(UserQuery::class);
    $userQuery->shouldReceive('getUsersDeactivatedInLast24Hours')->once()->andReturn(new Collection([
        (object)['iUserID' => 1],
    ]));

    $logger = Mockery::mock(UserJourneyLogger::class);
    $logger->shouldReceive('syncFromLocalToS3')->once()->andReturn(2);

    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldReceive('create')->once()->with(Mockery::on(fn($p) =>
        $p['cron'] === 'upload_user_journey_log_to_s3' && $p['total'] === 2
    ));

    makeUserDataService([
        'cronLogQuery' => $log,
        'userQuery' => $userQuery,
        'userJourneyLogger' => $logger,
    ])->runUploadUserJourneyLogToS3();
});
```

- [ ] **Step 2:** Run test, FAIL.

- [ ] **Step 3:** Create the service.

```php
<?php
// app/Services/Cron/UserDataCronService.php

namespace App\Services\Cron;

use App\Http\Queries\CronLogQuery;
use App\Http\Queries\UserQuery;
use App\Http\Queries\UserSmokeQuery;
use App\Services\Cron\Concerns\LogsCronExecution;
use App\Services\UserJourneyLogger;
use App\Services\UserResponseFileService;
use Illuminate\Support\Facades\Log;

class UserDataCronService
{
    use LogsCronExecution;

    public function __construct(
        protected CronLogQuery $cronLogQuery,
        protected UserQuery $userQuery,
        protected UserSmokeQuery $userSmokeQuery,
        protected UserResponseFileService $userResponseFileService,
        protected UserJourneyLogger $userJourneyLogger,
    ) {}

    /** @command php artisan cron:run deactivate_users */
    public function runDeactivateUsers(): void
    {
        $this->executeCron(
            'deactivate_users',
            'Completed deactivating users',
            fn(CronResult $r) => $r->context = $this->userQuery->deactivateUsers(),
        );
    }

    /** @command php artisan cron:run user_smoke_data_archive */
    public function runUserSmokeDataArchive(): void
    {
        $this->executeCron(
            'user_smoke_data_archive',
            'Completed archiving user smoke data',
            fn(CronResult $r) => $r->context = $this->userSmokeQuery->copyUserSmokeDataToArchive(),
        );
    }

    /** @command php artisan cron:run upload_user_forms_to_s3 */
    public function runUploadUserFormsToS3(): void
    {
        $this->executeCron(
            'upload_user_forms_to_s3',
            'Completed uploading user forms to S3',
            function (CronResult $r) {
                $users = $this->userQuery->getUsersDeactivatedInLast24Hours();
                if ($users->isEmpty()) {
                    Log::info('Cron::runUploadUserFormsToS3 → no user found');
                    $r->skipLog = true;
                    return;
                }
                $uploadCount = 0;
                foreach ($users as $user) {
                    $uploadCount += $this->userResponseFileService->syncLocalToS3($user);
                }
                $r->total = $uploadCount;
                $r->context = $uploadCount;
            },
        );
    }

    /** @command php artisan cron:run upload_user_journey_log_to_s3 */
    public function runUploadUserJourneyLogToS3(): void
    {
        $this->executeCron(
            'upload_user_journey_log_to_s3',
            'Completed uploading user journey logs to S3',
            function (CronResult $r) {
                $users = $this->userQuery->getUsersDeactivatedInLast24Hours();
                if ($users->isEmpty()) {
                    Log::info('Cron::runUploadUserJourneyLogToS3 → no user found');
                    $r->skipLog = true;
                    return;
                }
                $uploadCount = 0;
                foreach ($users as $user) {
                    $uploadCount += $this->userJourneyLogger->syncFromLocalToS3($user);
                }
                $r->total = $uploadCount;
                $r->context = $uploadCount;
            },
        );
    }
}
```

- [ ] **Step 4:** Run test, PASS. Commit checkpoint.

---

## Task 8: `AcquisitionCronService` (4 methods, one DB-transactional)

**Files:**
- Create: `app/Services/Cron/AcquisitionCronService.php`
- Test: `tests/Unit/Services/Cron/AcquisitionCronServiceTest.php`

The tricky one: `runUpdateListenerTables` wraps DB transaction begin/commit/rollback. Preserve exactly.

- [ ] **Step 1:** Write tests (one `it()` per public method + one test verifying transaction rollback on failure).

```php
<?php
// tests/Unit/Services/Cron/AcquisitionCronServiceTest.php

use App\Http\Queries\AndroidListenerDataQuery;
use App\Http\Queries\CronLogQuery;
use App\Http\Queries\IosListenerDataQuery;
use App\Http\Queries\MobileDeviceQuery;
use App\Http\Queries\OnlyDownloadQuery;
use App\Http\Queries\UserSourceQuery;
use App\Services\Cron\AcquisitionCronService;
use Illuminate\Support\Facades\DB;

function makeAcquisitionService(array $mocks = []): AcquisitionCronService
{
    return new AcquisitionCronService(
        $mocks['cronLogQuery'] ?? Mockery::mock(CronLogQuery::class)->shouldIgnoreMissing(),
        $mocks['userSourceQuery'] ?? Mockery::mock(UserSourceQuery::class),
        $mocks['onlyDownloadQuery'] ?? Mockery::mock(OnlyDownloadQuery::class),
        $mocks['iosListenerDataQuery'] ?? Mockery::mock(IosListenerDataQuery::class),
        $mocks['androidListenerDataQuery'] ?? Mockery::mock(AndroidListenerDataQuery::class),
        $mocks['mobileDeviceQuery'] ?? Mockery::mock(MobileDeviceQuery::class),
    );
}

it('runUserSourceData updates both source tables', function () {
    $src = Mockery::mock(UserSourceQuery::class);
    $src->shouldReceive('updateUserSourcesSingleQuery')->once()->andReturn(15);
    $download = Mockery::mock(OnlyDownloadQuery::class);
    $download->shouldReceive('updateUserSourcesSingleQuery')->once()->andReturn(20);

    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldReceive('create')->once()->with(Mockery::on(fn($p) =>
        $p['cron'] === 'user_source_data'
        && str_contains($p['context'], '"UserSourceUpdated":15')
        && str_contains($p['context'], '"OnlyDownloadUpdated":20')
    ));

    makeAcquisitionService([
        'cronLogQuery' => $log,
        'userSourceQuery' => $src,
        'onlyDownloadQuery' => $download,
    ])->runUserSourceData();
});

it('runUpdateListenerTables commits transaction on success', function () {
    DB::shouldReceive('beginTransaction')->once();
    DB::shouldReceive('commit')->once();
    DB::shouldReceive('rollBack')->never();

    $ios = Mockery::mock(IosListenerDataQuery::class);
    $ios->shouldReceive('processBulkbFoundUpdate')->once()->andReturn([10, 1]);
    $android = Mockery::mock(AndroidListenerDataQuery::class);
    $android->shouldReceive('processBulkbFoundUpdate')->once()->andReturn([20, 2]);

    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldReceive('create')->once()->with(Mockery::on(fn($p) =>
        $p['cron'] === 'update_listener_tables' && $p['total'] === 30 && $p['not_sent'] === 3
    ));

    makeAcquisitionService([
        'cronLogQuery' => $log,
        'iosListenerDataQuery' => $ios,
        'androidListenerDataQuery' => $android,
    ])->runUpdateListenerTables();
});

it('runUpdateListenerTables rolls back transaction and still logs cron_log on failure', function () {
    DB::shouldReceive('beginTransaction')->once();
    DB::shouldReceive('rollBack')->once();
    DB::shouldReceive('commit')->never();

    $ios = Mockery::mock(IosListenerDataQuery::class);
    $ios->shouldReceive('processBulkbFoundUpdate')->once()->andThrow(new \RuntimeException('boom'));

    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldReceive('create')->once();

    \Illuminate\Support\Facades\Log::spy();
    makeAcquisitionService([
        'cronLogQuery' => $log,
        'iosListenerDataQuery' => $ios,
    ])->runUpdateListenerTables();
    \Illuminate\Support\Facades\Log::shouldHaveReceived('error')->once();
});

it('runUpdateUserDeviceCategory delegates and logs', function () {
    $device = Mockery::mock(MobileDeviceQuery::class);
    $device->shouldReceive('updateUserDeviceCategory')->once()->andReturn(42);

    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldReceive('create')->once()->with(Mockery::on(fn($p) =>
        $p['cron'] === 'update_user_device_category' && $p['total'] === 42
    ));

    makeAcquisitionService(['cronLogQuery' => $log, 'mobileDeviceQuery' => $device])
        ->runUpdateUserDeviceCategory();
});

it('runUpdateUserDeviceCategoryZero delegates and logs', function () {
    $device = Mockery::mock(MobileDeviceQuery::class);
    $device->shouldReceive('updateUserDeviceCategoryZero')->once()->andReturn(7);

    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldReceive('create')->once()->with(Mockery::on(fn($p) =>
        $p['cron'] === 'update_user_device_category_zero' && $p['total'] === 7
    ));

    makeAcquisitionService(['cronLogQuery' => $log, 'mobileDeviceQuery' => $device])
        ->runUpdateUserDeviceCategoryZero();
});
```

- [ ] **Step 2:** Run test, FAIL.

- [ ] **Step 3:** Create the service.

```php
<?php
// app/Services/Cron/AcquisitionCronService.php

namespace App\Services\Cron;

use App\Http\Queries\AndroidListenerDataQuery;
use App\Http\Queries\CronLogQuery;
use App\Http\Queries\IosListenerDataQuery;
use App\Http\Queries\MobileDeviceQuery;
use App\Http\Queries\OnlyDownloadQuery;
use App\Http\Queries\UserSourceQuery;
use App\Services\Cron\Concerns\LogsCronExecution;
use Illuminate\Support\Facades\DB;
use Throwable;

class AcquisitionCronService
{
    use LogsCronExecution;

    public function __construct(
        protected CronLogQuery $cronLogQuery,
        protected UserSourceQuery $userSourceQuery,
        protected OnlyDownloadQuery $onlyDownloadQuery,
        protected IosListenerDataQuery $iosListenerDataQuery,
        protected AndroidListenerDataQuery $androidListenerDataQuery,
        protected MobileDeviceQuery $mobileDeviceQuery,
    ) {}

    /** @command php artisan cron:run user_source_data */
    public function runUserSourceData(): void
    {
        $this->executeCron(
            'user_source_data',
            'Completed updating user source data',
            function (CronResult $r) {
                $userSrcUpdated = $this->userSourceQuery->updateUserSourcesSingleQuery();
                $onlyDownloadUpdated = $this->onlyDownloadQuery->updateUserSourcesSingleQuery();
                $r->context = [
                    'UserSourceUpdated' => $userSrcUpdated ?? 0,
                    'OnlyDownloadUpdated' => $onlyDownloadUpdated ?? 0,
                ];
            },
        );
    }

    /** @command php artisan cron:run update_listener_tables */
    public function runUpdateListenerTables(): void
    {
        $this->executeCron(
            'update_listener_tables',
            'Completed updating listener tables',
            function (CronResult $r) {
                DB::beginTransaction();
                try {
                    [$iosTotal, $iosNotReg] = $this->iosListenerDataQuery->processBulkbFoundUpdate();
                    [$andTotal, $andNotReg] = $this->androidListenerDataQuery->processBulkbFoundUpdate();
                    DB::commit();

                    $r->total = $iosTotal + $andTotal;
                    $r->notSent = $iosNotReg + $andNotReg;
                    $r->context = [
                        'ios' => ['total' => $iosTotal, 'notRegistered' => $iosNotReg],
                        'android' => ['total' => $andTotal, 'notRegistered' => $andNotReg],
                    ];
                } catch (Throwable $e) {
                    DB::rollBack();
                    throw $e; // re-throw so trait logs the error consistently
                }
            },
        );
    }

    /** @command php artisan cron:run update_user_device_category */
    public function runUpdateUserDeviceCategory(): void
    {
        $this->executeCron(
            'update_user_device_category',
            'Completed updating user device category',
            function (CronResult $r) {
                $total = $this->mobileDeviceQuery->updateUserDeviceCategory();
                $r->total = $total;
                $r->context = $total;
            },
        );
    }

    /** @command php artisan cron:run update_user_device_category_zero */
    public function runUpdateUserDeviceCategoryZero(): void
    {
        $this->executeCron(
            'update_user_device_category_zero',
            'Completed updating user device category zero',
            function (CronResult $r) {
                $total = $this->mobileDeviceQuery->updateUserDeviceCategoryZero();
                $r->total = $total;
                $r->context = $total;
            },
        );
    }
}
```

- [ ] **Step 4:** Run test, PASS. Verify line count ≤300, methods ≤40. Commit checkpoint.

---

## Task 9: `DuplicateRowCleaner` processor (extract logic from `runUserProgramDuplicateRowCleanup`)

**Files:**
- Create: `app/Services/Cron/Processors/DuplicateRowCleaner.php`
- Test: `tests/Unit/Services/Cron/Processors/DuplicateRowCleanerTest.php`

This isolates the 4-table cleanup loop from [CronService.php:206-285](app/Services/CronService.php#L206-L285) so the consuming `UserProgramCronService` method stays short.

- [ ] **Step 1:** Write the failing test.

```php
<?php
// tests/Unit/Services/Cron/Processors/DuplicateRowCleanerTest.php

use App\Services\Cron\Processors\DuplicateRowCleaner;
use Illuminate\Support\Collection;

interface DuplicateRowCapableQuery {
    public function getDuplicateRows(int $iProgramID);
    public function removeDuplicateRow(int $itemId, int $iUserID, int $iProgramID): array;
}

it('clean() processes duplicates across programs and returns per-program details', function () {
    $query = Mockery::mock(DuplicateRowCapableQuery::class);
    $query->shouldReceive('getDuplicateRows')->with(1)->andReturn(new Collection([
        (object)['iUserID' => 100, 'iDayID' => 200],
        (object)['iUserID' => 101, 'iDayID' => 201],
    ]));
    $query->shouldReceive('removeDuplicateRow')->twice()->andReturn(
        ['Flag' => 1, 'Error' => ''],
        ['Flag' => 1, 'Error' => ''],
    );

    $cleaner = new DuplicateRowCleaner();
    $programs = new Collection([(object)['iProgramID' => 1]]);

    $result = $cleaner->clean($query, 'iDayID', $programs);

    expect($result['totalDuplicates'])->toBe(2);
    expect($result['totalRemoved'])->toBe(2);
    expect($result['totalFailed'])->toBe(0);
    expect($result['programResults'])->toHaveCount(1);
    expect($result['programResults'][0]['duplicatesRemoved'])->toBe(2);
});

it('clean() records failures when removeDuplicateRow returns Flag=0', function () {
    $query = Mockery::mock(DuplicateRowCapableQuery::class);
    $query->shouldReceive('getDuplicateRows')->with(1)->andReturn(new Collection([
        (object)['iUserID' => 100, 'iDayID' => 200],
    ]));
    $query->shouldReceive('removeDuplicateRow')->once()->andReturn(['Flag' => 0, 'Error' => 'db error']);

    \Illuminate\Support\Facades\Log::spy();
    $cleaner = new DuplicateRowCleaner();
    $programs = new Collection([(object)['iProgramID' => 1]]);

    $result = $cleaner->clean($query, 'iDayID', $programs, tableName: 'Days');

    expect($result['totalFailed'])->toBe(1);
    expect($result['totalRemoved'])->toBe(0);
    \Illuminate\Support\Facades\Log::shouldHaveReceived('error')->once();
});
```

- [ ] **Step 2:** Run test, FAIL.

- [ ] **Step 3:** Create the processor.

```php
<?php
// app/Services/Cron/Processors/DuplicateRowCleaner.php

namespace App\Services\Cron\Processors;

use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;

/**
 * Removes duplicate rows from a "user program" style table across all programs.
 * Used by user_program_duplicate_row_cleanup (Days/Exercise/Actions/Activity).
 */
class DuplicateRowCleaner
{
    /**
     * @param object $query  Query class with getDuplicateRows() and removeDuplicateRow() methods.
     * @param string $itemKey  Column name on the duplicate row (e.g. 'iDayID', 'iExerciseID').
     * @param Collection $programs  Programs to iterate.
     * @return array{totalDuplicates: int, totalRemoved: int, totalFailed: int, programResults: list<array<string, mixed>>}
     */
    public function clean(object $query, string $itemKey, Collection $programs, string $tableName = ''): array
    {
        $totalDuplicates = 0;
        $totalRemoved = 0;
        $totalFailed = 0;
        $programResults = [];

        foreach ($programs as $program) {
            $duplicates = $query->getDuplicateRows($program->iProgramID);
            $programRemoved = 0;
            $programDetails = [];

            foreach ($duplicates as $row) {
                $itemId = $row->{$itemKey};
                $check = $query->removeDuplicateRow($itemId, $row->iUserID, $program->iProgramID);

                if ($check['Flag'] == 0 && ! empty($check['Error'])) {
                    $totalFailed++;
                    Log::error("DuplicateRowCleaner failed-{$tableName}-{$program->iProgramID}", ['error' => $check]);
                } else {
                    $programRemoved++;
                    $programDetails[] = ['iUserID' => $row->iUserID, $itemKey => $itemId];
                }
            }

            if ($programRemoved > 0) {
                $programResults[] = [
                    'iProgramID' => $program->iProgramID,
                    'duplicatesRemoved' => $programRemoved,
                    'details' => $programDetails,
                ];
            }

            $totalDuplicates += $duplicates->count();
            $totalRemoved += $programRemoved;
        }

        return compact('totalDuplicates', 'totalRemoved', 'totalFailed', 'programResults');
    }
}
```

- [ ] **Step 4:** Run test, PASS. Commit checkpoint.

---

## Task 10: `UserProgramCronService` (6 methods, uses DuplicateRowCleaner)

**Files:**
- Create: `app/Services/Cron/UserProgramCronService.php`
- Test: `tests/Unit/Services/Cron/UserProgramCronServiceTest.php`

- [ ] **Step 1:** Write the failing test. Six it()s — one per method. Spot-check the two complex ones (chapter cleanup, duplicate row cleanup).

```php
<?php
// tests/Unit/Services/Cron/UserProgramCronServiceTest.php

use App\Http\Queries\CronLogQuery;
use App\Http\Queries\ProgramQuery;
use App\Http\Queries\UserActionQuery;
use App\Http\Queries\UserActivityQuery;
use App\Http\Queries\UserAppActivityQuery;
use App\Http\Queries\UserChapterQuery;
use App\Http\Queries\UserConfigQuery;
use App\Http\Queries\UserDayQuery;
use App\Http\Queries\UserExerciseQuery;
use App\Services\Cron\Processors\DuplicateRowCleaner;
use App\Services\Cron\UserProgramCronService;
use Illuminate\Support\Collection;

function makeUserProgramService(array $mocks = []): UserProgramCronService
{
    return new UserProgramCronService(
        $mocks['cronLogQuery'] ?? Mockery::mock(CronLogQuery::class)->shouldIgnoreMissing(),
        $mocks['programQuery'] ?? Mockery::mock(ProgramQuery::class),
        $mocks['userChapterQuery'] ?? Mockery::mock(UserChapterQuery::class),
        $mocks['userDayQuery'] ?? Mockery::mock(UserDayQuery::class),
        $mocks['userExerciseQuery'] ?? Mockery::mock(UserExerciseQuery::class),
        $mocks['userActionQuery'] ?? Mockery::mock(UserActionQuery::class),
        $mocks['userActivityQuery'] ?? Mockery::mock(UserActivityQuery::class),
        $mocks['userAppActivityQuery'] ?? Mockery::mock(UserAppActivityQuery::class),
        $mocks['userConfigQuery'] ?? Mockery::mock(UserConfigQuery::class),
        $mocks['duplicateRowCleaner'] ?? Mockery::mock(DuplicateRowCleaner::class),
    );
}

it('runStopExtendedFlow delegates to userConfigQuery->stopExtendedFlow', function () {
    $cfg = Mockery::mock(UserConfigQuery::class);
    $cfg->shouldReceive('stopExtendedFlow')->once()->andReturn(5);
    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldReceive('create')->once()->with(Mockery::on(fn($p) =>
        $p['cron'] === 'stop_extended_flow' && $p['total'] === 5
    ));
    makeUserProgramService(['cronLogQuery' => $log, 'userConfigQuery' => $cfg])->runStopExtendedFlow();
});

it('runSetExtendedFlow delegates to userConfigQuery->setExtendedFlow', function () {
    $cfg = Mockery::mock(UserConfigQuery::class);
    $cfg->shouldReceive('setExtendedFlow')->once()->andReturn(8);
    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldReceive('create')->once()->with(Mockery::on(fn($p) =>
        $p['cron'] === 'set_extended_flow' && $p['total'] === 8
    ));
    makeUserProgramService(['cronLogQuery' => $log, 'userConfigQuery' => $cfg])->runSetExtendedFlow();
});

it('runUpdateUserArticles delegates to userAppActivityQuery->bulkUpdateUserArticles', function () {
    $app = Mockery::mock(UserAppActivityQuery::class);
    $app->shouldReceive('bulkUpdateUserArticles')->once()->andReturn(99);
    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldReceive('create')->once()->with(Mockery::on(fn($p) =>
        $p['cron'] === 'update_user_articles' && $p['total'] === 99
    ));
    makeUserProgramService(['cronLogQuery' => $log, 'userAppActivityQuery' => $app])->runUpdateUserArticles();
});

it('runCreateNextUserDay aggregates per-program results', function () {
    $programs = new Collection([
        (object)['iProgramID' => 1],
        (object)['iProgramID' => 2],
    ]);
    $progQuery = Mockery::mock(ProgramQuery::class);
    $progQuery->shouldReceive('getPrograms')->once()->andReturn($programs);

    $day = Mockery::mock(UserDayQuery::class);
    $day->shouldReceive('createNextUserDay')->with(1)
        ->andReturn(['processed' => 10, 'created' => 7, 'skipped' => 2, 'errors' => 1]);
    $day->shouldReceive('createNextUserDay')->with(2)
        ->andReturn(['processed' => 5, 'created' => 3, 'skipped' => 1, 'errors' => 1]);

    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldReceive('create')->once()->with(Mockery::on(fn($p) =>
        $p['cron'] === 'create_next_user_day' && $p['total'] === 10 && $p['not_sent'] === 2
    ));

    makeUserProgramService([
        'cronLogQuery' => $log, 'programQuery' => $progQuery, 'userDayQuery' => $day,
    ])->runCreateNextUserDay();
});

it('runUserProgramDuplicateRowCleanup invokes cleaner for all 4 tables', function () {
    $programs = new Collection([(object)['iProgramID' => 1]]);
    $progQuery = Mockery::mock(ProgramQuery::class);
    $progQuery->shouldReceive('getPrograms')->once()->andReturn($programs);

    $cleaner = Mockery::mock(DuplicateRowCleaner::class);
    $cleaner->shouldReceive('clean')->times(4)->andReturn([
        'totalDuplicates' => 1, 'totalRemoved' => 1, 'totalFailed' => 0, 'programResults' => [],
    ]);

    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldReceive('create')->once()->with(Mockery::on(fn($p) =>
        $p['cron'] === 'user_program_duplicate_row_cleanup' && $p['total'] === 4
    ));

    makeUserProgramService([
        'cronLogQuery' => $log,
        'programQuery' => $progQuery,
        'duplicateRowCleaner' => $cleaner,
    ])->runUserProgramDuplicateRowCleanup();
});

it('runRemoveDuplicateChapter iterates programs and removes duplicates', function () {
    $progQuery = Mockery::mock(ProgramQuery::class);
    $progQuery->shouldReceive('getPrograms')->once()->andReturn(new Collection([
        (object)['iProgramID' => 1],
    ]));

    $chapter = Mockery::mock(UserChapterQuery::class);
    $chapter->shouldReceive('getDuplicateChapters')->with(1)->andReturn(new Collection([
        (object)['iChapterID' => 10, 'iUserID' => 100],
    ]));
    $chapter->shouldReceive('removeDuplicateUserChapter')->with(10, 100, 1)
        ->andReturn(['Flag' => 1, 'Error' => '']);

    $log = Mockery::mock(CronLogQuery::class);
    $log->shouldReceive('create')->once()->with(Mockery::on(fn($p) =>
        $p['cron'] === 'remove_duplicate_chapter'
    ));

    makeUserProgramService([
        'cronLogQuery' => $log,
        'programQuery' => $progQuery,
        'userChapterQuery' => $chapter,
    ])->runRemoveDuplicateChapter();
});
```

- [ ] **Step 2:** Run test, FAIL.

- [ ] **Step 3:** Create the service. Note the `runRemoveDuplicateChapter` body is its own loop (different shape from the row cleaner — fewer fields). Keep it inline but use `DuplicateRowCleaner` for the 4-table cleanup.

```php
<?php
// app/Services/Cron/UserProgramCronService.php

namespace App\Services\Cron;

use App\Http\Queries\CronLogQuery;
use App\Http\Queries\ProgramQuery;
use App\Http\Queries\UserActionQuery;
use App\Http\Queries\UserActivityQuery;
use App\Http\Queries\UserAppActivityQuery;
use App\Http\Queries\UserChapterQuery;
use App\Http\Queries\UserConfigQuery;
use App\Http\Queries\UserDayQuery;
use App\Http\Queries\UserExerciseQuery;
use App\Services\Cron\Concerns\LogsCronExecution;
use App\Services\Cron\Processors\DuplicateRowCleaner;
use Illuminate\Support\Facades\Log;

class UserProgramCronService
{
    use LogsCronExecution;

    public function __construct(
        protected CronLogQuery $cronLogQuery,
        protected ProgramQuery $programQuery,
        protected UserChapterQuery $userChapterQuery,
        protected UserDayQuery $userDayQuery,
        protected UserExerciseQuery $userExerciseQuery,
        protected UserActionQuery $userActionQuery,
        protected UserActivityQuery $userActivityQuery,
        protected UserAppActivityQuery $userAppActivityQuery,
        protected UserConfigQuery $userConfigQuery,
        protected DuplicateRowCleaner $duplicateRowCleaner,
    ) {}

    /** @command php artisan cron:run stop_extended_flow */
    public function runStopExtendedFlow(): void
    {
        $this->executeCron(
            'stop_extended_flow',
            'Completed stopping extended flow',
            function (CronResult $r) {
                $count = $this->userConfigQuery->stopExtendedFlow();
                $r->total = $count;
                $r->context = $count;
            },
        );
    }

    /** @command php artisan cron:run set_extended_flow */
    public function runSetExtendedFlow(): void
    {
        $this->executeCron(
            'set_extended_flow',
            'Completed setting extended flow',
            function (CronResult $r) {
                $count = $this->userConfigQuery->setExtendedFlow();
                $r->total = $count;
                $r->context = $count;
            },
        );
    }

    /** @command php artisan cron:run update_user_articles */
    public function runUpdateUserArticles(): void
    {
        $this->executeCron(
            'update_user_articles',
            'Completed updating user articles',
            function (CronResult $r) {
                $count = $this->userAppActivityQuery->bulkUpdateUserArticles();
                $r->total = $count;
                $r->context = $count;
            },
        );
    }

    /** @command php artisan cron:run create_next_user_day */
    public function runCreateNextUserDay(): void
    {
        $this->executeCron(
            'create_next_user_day',
            'Completed creating next user day',
            function (CronResult $r) {
                $programs = $this->programQuery->getPrograms();
                $totalProcessed = $totalCreated = $totalSkipped = $totalErrors = 0;
                $programResults = [];

                foreach ($programs as $program) {
                    $result = $this->userDayQuery->createNextUserDay($program->iProgramID);
                    $programResults[$program->iProgramID] = $result;
                    $totalProcessed += $result['processed'];
                    $totalCreated += $result['created'];
                    $totalSkipped += $result['skipped'];
                    $totalErrors += $result['errors'];
                }

                $r->total = $totalProcessed;
                $r->notSent = $totalSkipped;
                $r->message = "Completed creating next user day: {$totalCreated} created, {$totalSkipped} skipped, {$totalErrors} errors";
                $r->context = compact('totalProcessed', 'totalCreated', 'totalSkipped', 'totalErrors', 'programResults');
            },
        );
    }

    /** @command php artisan cron:run user_program_duplicate_row_cleanup */
    public function runUserProgramDuplicateRowCleanup(): void
    {
        $this->executeCron(
            'user_program_duplicate_row_cleanup',
            'Completed user program duplicate row cleanup',
            function (CronResult $r) {
                $tables = [
                    'Days' => [$this->userDayQuery, 'iDayID'],
                    'Exercise' => [$this->userExerciseQuery, 'iExerciseID'],
                    'Actions' => [$this->userActionQuery, 'iActionID'],
                    'Activity' => [$this->userActivityQuery, 'iFormID'],
                ];

                $programs = $this->programQuery->getPrograms();
                $tableSummary = [];
                $totalDuplicates = $totalRemoved = $totalFailed = 0;

                foreach ($tables as $tableName => [$query, $itemKey]) {
                    $tableResult = $this->duplicateRowCleaner->clean($query, $itemKey, $programs, $tableName);
                    $tableSummary[$tableName] = $tableResult['programResults'];
                    $totalDuplicates += $tableResult['totalDuplicates'];
                    $totalRemoved += $tableResult['totalRemoved'];
                    $totalFailed += $tableResult['totalFailed'];

                    $removedInTable = $tableResult['totalRemoved'];
                    if ($removedInTable > 1) {
                        Log::error("UserProgramCronService::runUserProgramDuplicateRowCleanup {$tableName}", [
                            'duplicatesRemoved' => $removedInTable,
                            'programs' => $tableResult['programResults'],
                        ]);
                    }
                }

                $r->total = $totalDuplicates;
                $r->notSent = $totalFailed;
                $r->context = compact('totalRemoved', 'tableSummary');
            },
        );
    }

    /** @command php artisan cron:run remove_duplicate_chapter */
    public function runRemoveDuplicateChapter(): void
    {
        $this->executeCron(
            'remove_duplicate_chapter',
            'Completed duplicate chapter removal',
            function (CronResult $r) {
                $programs = $this->programQuery->getPrograms();
                $totalUsers = $notRegistered = $chapterCount = 0;
                $programSummary = [];

                foreach ($programs as $program) {
                    $duplicates = $this->userChapterQuery->getDuplicateChapters($program->iProgramID);
                    $programRemoved = 0;
                    $programDetails = [];

                    foreach ($duplicates as $row) {
                        $check = $this->userChapterQuery->removeDuplicateUserChapter(
                            $row->iChapterID, $row->iUserID, $program->iProgramID
                        );
                        if ($check['Flag'] == 0 && ! empty($check['Error'])) {
                            $notRegistered++;
                            Log::error("UserProgramCronService::runRemoveDuplicateChapter failed-{$program->iProgramID}", ['error' => $check]);
                        } else {
                            $programRemoved++;
                            $programDetails[] = ['iUserID' => $row->iUserID, 'iChapterID' => $row->iChapterID];
                        }
                        $chapterCount++;
                    }

                    if ($programRemoved > 0) {
                        $programSummary[] = [
                            'iProgramID' => $program->iProgramID,
                            'duplicatesRemoved' => $programRemoved,
                            'details' => $programDetails,
                        ];
                    }
                    $totalUsers += $duplicates->count();
                }

                if (! empty($programSummary)) {
                    Log::error('UserProgramCronService::runRemoveDuplicateChapter summary', [
                        'totalDuplicatesRemoved' => $chapterCount - $notRegistered,
                        'programs' => $programSummary,
                    ]);
                }

                $r->total = $totalUsers;
                $r->notSent = $notRegistered;
                $r->context = [
                    'chaptersProcessed' => $chapterCount,
                    'programs' => $programs->count(),
                    'programSummary' => $programSummary,
                ];
            },
        );
    }
}
```

- [ ] **Step 4:** Run test, PASS. Verify file size — this is the largest service. If it exceeds 300 lines, move `runRemoveDuplicateChapter`'s body into a `DuplicateChapterCleaner` processor (mirror Task 9). Commit checkpoint.

---

## Task 11: `FreeTrialProcessor` + `FailedPaymentMailer` + `SubscriptionCronService` + `DebugCronService`

This is the largest task. Split into substeps. The complex methods (`runFreeTrial`, `runSendFailedPaymentMail`) each get a dedicated processor so the service method stays under 20 lines.

**Files:**
- Create: `app/Services/Cron/Processors/FreeTrialProcessor.php`
- Create: `app/Services/Cron/Processors/FailedPaymentMailer.php`
- Create: `app/Services/Cron/SubscriptionCronService.php`
- Create: `app/Services/Cron/DebugCronService.php`
- Test: `tests/Unit/Services/Cron/Processors/FreeTrialProcessorTest.php`
- Test: `tests/Unit/Services/Cron/Processors/FailedPaymentMailerTest.php`
- Test: `tests/Unit/Services/Cron/SubscriptionCronServiceTest.php`
- Test: `tests/Unit/Services/Cron/DebugCronServiceTest.php`

### Substep 11a — `FreeTrialProcessor`

- [ ] **Step 1:** Write failing test that covers the per-user loop from [CronService.php:1061-1141](app/Services/CronService.php#L1061-L1141): updates UserProgram, creates sub history, sends Firebase + email + journey log. Returns `{totalUsers, notRegistered}`.

```php
<?php
// tests/Unit/Services/Cron/Processors/FreeTrialProcessorTest.php

use App\Http\Queries\CronQuery;
use App\Http\Queries\ProgramQuery;
use App\Http\Queries\UserInfoQuery;
use App\Http\Queries\UserProgramQuery;
use App\Http\Queries\UserSubHistoryQuery;
use App\Services\Cron\Processors\FreeTrialProcessor;
use App\Services\FirebaseNotificationService;
use App\Services\PostmarkService;
use App\Services\UserJourneyLogger;
use Illuminate\Support\Collection;

it('process() enrols eligible users and returns totals', function () {
    config(['firebase-notification.free_trial' => [
        'title' => 'T', 'body' => 'B', 'data' => [], 'fcm_options' => [],
    ]]);
    config(['app.web_base_url' => 'https://example.test/']);
    config(['constants.from_name_otp' => 'X', 'constants.from_email_id' => 'a@b.c']);

    $programs = new Collection([(object)['iProgramID' => 1]]);
    $progQ = Mockery::mock(ProgramQuery::class);
    $progQ->shouldReceive('getPrograms')->once()->andReturn($programs);

    $cronQ = Mockery::mock(CronQuery::class);
    $cronQ->shouldReceive('getUserForFreeTrial')->with(1)->andReturn(new Collection([
        (object)[
            'iUserID' => 100, 'iUserProgramID' => 200, 'vName' => 'Test',
            'vDeviceId' => 'dev', 'bUninstalled' => 0, 'dLastLogin' => null, 'dUninstallTime' => null,
            'vIntroSubSkip' => null,
        ],
    ]));

    $userProg = Mockery::mock(UserProgramQuery::class);
    $userProg->shouldReceive('updateUserProgramById')->once();
    $subHist = Mockery::mock(UserSubHistoryQuery::class);
    $subHist->shouldReceive('createUserSubHistory')->once();
    $firebase = Mockery::mock(FirebaseNotificationService::class);
    $firebase->shouldReceive('sendToDevice')->once()->andReturn('ok');
    $userInfo = Mockery::mock(UserInfoQuery::class);
    $userInfo->shouldReceive('getUserEmail')->once()->andReturn('u@example.com');
    $postmark = Mockery::mock(PostmarkService::class);
    $postmark->shouldReceive('sendPredefinedTemplate')->once();
    $journey = Mockery::mock(UserJourneyLogger::class);
    $journey->shouldReceive('log')->once();

    $processor = new FreeTrialProcessor($progQ, $cronQ, $userProg, $subHist, $firebase, $userInfo, $postmark, $journey);
    $result = $processor->process();

    expect($result)->toBe(['totalUsers' => 1, 'notRegistered' => 0]);
});

it('process() counts notRegistered when firebase returns notRegistered', function () {
    config(['firebase-notification.free_trial' => ['title' => '', 'body' => '', 'data' => [], 'fcm_options' => []]]);
    $progQ = Mockery::mock(ProgramQuery::class);
    $progQ->shouldReceive('getPrograms')->andReturn(new Collection([(object)['iProgramID' => 1]]));
    $cronQ = Mockery::mock(CronQuery::class);
    $cronQ->shouldReceive('getUserForFreeTrial')->andReturn(new Collection([
        (object)[
            'iUserID' => 100, 'iUserProgramID' => 200, 'vName' => 'T',
            'vDeviceId' => 'd', 'bUninstalled' => 0, 'dLastLogin' => null, 'dUninstallTime' => null,
            'vIntroSubSkip' => 'existing',
        ],
    ]));

    $userProg = Mockery::mock(UserProgramQuery::class);
    $userProg->shouldReceive('updateUserProgramById')->once()->with(200, Mockery::on(fn($d) => ! isset($d['vIntroSubSkip'])));
    $subHist = Mockery::mock(UserSubHistoryQuery::class);
    $subHist->shouldReceive('createUserSubHistory')->once();
    $firebase = Mockery::mock(FirebaseNotificationService::class);
    $firebase->shouldReceive('sendToDevice')->once()->andReturn('notRegistered');
    $userInfo = Mockery::mock(UserInfoQuery::class);
    $userInfo->shouldReceive('getUserEmail')->once()->andReturn(null);
    $journey = Mockery::mock(UserJourneyLogger::class);
    $journey->shouldReceive('log')->once();

    $processor = new FreeTrialProcessor($progQ, $cronQ, $userProg, $subHist, $firebase, $userInfo, Mockery::mock(PostmarkService::class), $journey);
    $result = $processor->process();
    expect($result['notRegistered'])->toBe(1);
});
```

- [ ] **Step 2:** Run, FAIL.

- [ ] **Step 3:** Create `FreeTrialProcessor` — copy logic from `runFreeTrial`, accept dependencies via constructor. The processor returns a `['totalUsers' => int, 'notRegistered' => int]` tuple. (Show full implementation by porting lines 1061-1141 of original CronService.php; preserve every detail including the `(empty || filter_var)` quirk on line 1109.)

```php
<?php
// app/Services/Cron/Processors/FreeTrialProcessor.php

namespace App\Services\Cron\Processors;

use App\Http\Queries\CronQuery;
use App\Http\Queries\ProgramQuery;
use App\Http\Queries\UserInfoQuery;
use App\Http\Queries\UserProgramQuery;
use App\Http\Queries\UserSubHistoryQuery;
use App\Services\FirebaseNotificationService;
use App\Services\PostmarkService;
use App\Services\UserJourneyLogger;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;

class FreeTrialProcessor
{
    public function __construct(
        protected ProgramQuery $programQuery,
        protected CronQuery $cronQuery,
        protected UserProgramQuery $userProgramQuery,
        protected UserSubHistoryQuery $userSubHistoryQuery,
        protected FirebaseNotificationService $firebaseNotificationService,
        protected UserInfoQuery $userInfoQuery,
        protected PostmarkService $postmarkService,
        protected UserJourneyLogger $userJourneyLogger,
    ) {}

    /** @return array{totalUsers: int, notRegistered: int} */
    public function process(): array
    {
        $totalUsers = 0;
        $notRegistered = 0;

        foreach ($this->programQuery->getPrograms() as $program) {
            $users = $this->cronQuery->getUserForFreeTrial($program->iProgramID);
            if (! $users) {
                continue;
            }
            foreach ($users as $user) {
                $this->enrolUser($user);
                if ($this->sendPush($user) === 'notRegistered') {
                    $notRegistered++;
                }
                $this->sendEmail($user);
                $this->logJourney($user);
                $totalUsers++;
            }
        }

        return compact('totalUsers', 'notRegistered');
    }

    private function enrolUser(object $user): void
    {
        $update = [
            'bSubscribed' => 1,
            'bFreeTrial' => 1,
            'dExpiryDate' => Carbon::now()->addHours(48)->format('Y-m-d H:i:s'),
        ];
        if (is_null($user->vIntroSubSkip)) {
            $update['vIntroSubSkip'] = 'cron';
        }
        $this->userProgramQuery->updateUserProgramById($user->iUserProgramID, $update);
        $this->userSubHistoryQuery->createUserSubHistory([
            'iUserProgramID' => $user->iUserProgramID,
            'dStartDate' => Carbon::now(),
            'vSubType' => 'Cron',
            'vProductId' => 'free-trial',
            'iDiscount' => 100,
        ]);
    }

    private function sendPush(object $user): string
    {
        $cfg = config('firebase-notification.free_trial');
        $shouldSend = $user->bUninstalled == 0
            || ($user->dLastLogin && $user->dUninstallTime
                && Carbon::parse($user->dLastLogin)->gt(Carbon::parse($user->dUninstallTime)));
        if (! $shouldSend) {
            return 'skipped';
        }
        return $this->firebaseNotificationService->sendToDevice(
            $user->vDeviceId ?? '', $user->iUserID, $cfg['title'], $cfg['body'], $cfg['data'], $cfg['fcm_options']
        );
    }

    private function sendEmail(object $user): void
    {
        $email = $this->userInfoQuery->getUserEmail($user->iUserID);
        if (empty($email) && ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
            Log::info("FreeTrialProcessor → Invalid email for userID: {$user->iUserID}");
            return;
        }
        $this->postmarkService->sendPredefinedTemplate(
            $email, 'free-trial',
            [
                'name' => $user->vName,
                'unsubscribe_link' => front_url('emails/unsubscribe-request/' . encryptData($user->iUserID)),
            ],
            config('constants.from_name_otp'),
            config('constants.from_email_id'),
        );
    }

    private function logJourney(object $user): void
    {
        $this->userJourneyLogger->log($user->iUserID, [
            'Timestamp' => now(),
            'APIName' => 'free_trial',
            'Data' => ['iUserID' => $user->iUserID],
        ]);
    }
}
```

- [ ] **Step 4:** Run test, PASS.

### Substep 11b — `FailedPaymentMailer`

- [ ] **Step 1:** Write failing test mirroring [CronService.php:584-689](app/Services/CronService.php#L584-L689). Cover: product-code-missing path (update bEmailSent=4), invalid email (bEmailSent=5), pending-check-fails skip, 406 from Postmark (bEmailSent=5), success path (bEmailSent=1).

(Tests are concrete and exhaustive; write 5 `it()` blocks — one per branch.)

- [ ] **Step 2:** Run, FAIL.

- [ ] **Step 3:** Create `FailedPaymentMailer.php`. Constructor accepts `FailedUserPaymentQuery`, `UserConfigQuery`, `ProductCodeQuery`, `UserInfoQuery`, `PostmarkService`. `process()` returns `['totalUsers' => int, 'notRegistered' => int, 'skipped' => bool]` (skipped=true when input is empty, lets caller set `$result->skipLog = true`).

Port lines 584-688 verbatim into the processor, splitting into private methods: `resolveProductCode()`, `resolveUserInfo()`, `buildPayload()`, `sendMail()`, `handleResponse()`. Each ≤20 lines.

- [ ] **Step 4:** Run test, PASS.

### Substep 11c — `SubscriptionCronService`

- [ ] **Step 1:** Write failing test. Seven `it()`s — one per method. The 4 simple ones (sub history archive, update subscription, stripe data, razorpay data) mirror Task 4 patterns. The 3 complex ones (`runFreeTrial`, `runAutoRenewFailed`, `runSendFailedPaymentMail`) mock the processor/service and verify delegation.

- [ ] **Step 2:** Run, FAIL.

- [ ] **Step 3:** Create the service.

```php
<?php
// app/Services/Cron/SubscriptionCronService.php

namespace App\Services\Cron;

use App\Http\Queries\CronLogQuery;
use App\Http\Queries\RazorpayListenerQuery;
use App\Http\Queries\StripeListenerQuery;
use App\Http\Queries\UserProgramQuery;
use App\Http\Queries\UserSubHistoryQuery;
use App\Services\AutoRenewFailedService;
use App\Services\Cron\Concerns\LogsCronExecution;
use App\Services\Cron\Processors\FailedPaymentMailer;
use App\Services\Cron\Processors\FreeTrialProcessor;

class SubscriptionCronService
{
    use LogsCronExecution;

    public function __construct(
        protected CronLogQuery $cronLogQuery,
        protected UserSubHistoryQuery $userSubHistoryQuery,
        protected UserProgramQuery $userProgramQuery,
        protected StripeListenerQuery $stripeListenerQuery,
        protected RazorpayListenerQuery $razorpayListenerQuery,
        protected AutoRenewFailedService $autoRenewFailedService,
        protected FreeTrialProcessor $freeTrialProcessor,
        protected FailedPaymentMailer $failedPaymentMailer,
    ) {}

    /** @command php artisan cron:run user_sub_history_archive */
    public function runUserSubHistoryArchive(): void
    {
        $this->executeCron(
            'user_subhistory_archive',
            'Archived expired records',
            function (CronResult $r) {
                $result = $this->userSubHistoryQuery->archiveExpiredRecords();
                $r->message = $result ? 'Archived expired records' : 'No records to archive';
                $r->context = $result;
            },
        );
    }

    /** @command php artisan cron:run update_subscription */
    public function runUpdateSubscription(): void
    {
        $this->executeCron(
            'update_subscription',
            'Completed updating subscriptions',
            fn(CronResult $r) => $r->context = $this->userProgramQuery->updateSubscription(),
        );
    }

    /** @command php artisan cron:run update_stripe_data */
    public function runUpdateStripeData(): void
    {
        $this->executeCron(
            'update_stripe_data',
            'Completed updating stripe data',
            function (CronResult $r) {
                $count = $this->stripeListenerQuery->updateStripeData();
                $r->total = $count;
                $r->context = $count;
            },
        );
    }

    /** @command php artisan cron:run update_razor_pay_data */
    public function runUpdateRazorPayData(): void
    {
        $this->executeCron(
            'update_razorpay_data',
            'Completed updating razorpay data',
            function (CronResult $r) {
                $count = $this->razorpayListenerQuery->updateRazorpayData();
                $r->total = $count;
                $r->context = $count;
            },
        );
    }

    /** @command php artisan cron:run free_trial */
    public function runFreeTrial(): void
    {
        $this->executeCron(
            'free_trial',
            'Completed free trial notification and email',
            function (CronResult $r) {
                $result = $this->freeTrialProcessor->process();
                $r->total = $result['totalUsers'];
                $r->notSent = $result['notRegistered'];
                $r->context = '';
            },
        );
    }

    /** @command php artisan cron:run auto_renew_failed */
    public function runAutoRenewFailed(): void
    {
        $this->executeCron(
            'auto_renew_failed',
            'Completed auto-renew failed notifications',
            function (CronResult $r) {
                $result = $this->autoRenewFailedService->processAllSets();
                $r->total = $result['totalProcessed'];
                $r->notSent = $result['totalErrors'];
                $r->message = "Completed auto-renew failed notifications: {$result['totalEmailsSent']} emails, {$result['totalPushSent']} push sent";
                $r->context = $result;
                // Original code did `echo json_encode($result['sentNotifications'])` — removed (debug artifact).
            },
        );
    }

    /** @command php artisan cron:run send_failed_payment_mail */
    public function runSendFailedPaymentMail(): void
    {
        $this->executeCron(
            'send_failed_payment_mail',
            'Completed sending failed payment mails',
            function (CronResult $r) {
                $result = $this->failedPaymentMailer->process();
                if (! empty($result['skipped'])) {
                    $r->skipLog = true;
                    return;
                }
                $r->total = $result['totalUsers'];
                $r->notSent = $result['notRegistered'];
                $r->context = '';
            },
        );
    }
}
```

- [ ] **Step 4:** Run test, PASS. Verify file ≤300 lines.

### Substep 11d — `DebugCronService`

- [ ] **Step 1:** Write a smoke test that confirms both methods are callable (they print to stdout — assert no exception, no cron_log write).

- [ ] **Step 2:** Create the service. Move `testSource()` and `runTestFirebaseNotification()` verbatim from CronService.php. They do not use the trait (intentionally — they're stdout-only debug tools).

- [ ] **Step 3:** Run test, PASS. Commit checkpoint.

---

## Task 12: Replace `CronService` body with facade

**Files:**
- Modify: `app/Services/CronService.php`
- Test: `tests/Unit/Services/Cron/CronServiceFacadeTest.php`

The original `CronService` keeps every public method signature but each method now does one thing: delegate.

- [ ] **Step 1:** Write the facade test FIRST.

```php
<?php
// tests/Unit/Services/Cron/CronServiceFacadeTest.php

use App\Services\Cron\AcquisitionCronService;
use App\Services\Cron\CouponVoucherCronService;
use App\Services\Cron\DebugCronService;
use App\Services\Cron\LogMaintenanceCronService;
use App\Services\Cron\SubscriptionCronService;
use App\Services\Cron\TokenCleanupCronService;
use App\Services\Cron\UserDataCronService;
use App\Services\Cron\UserProgramCronService;
use App\Services\CronService;

/**
 * Asserts every legacy public method on CronService still exists and
 * delegates to the corresponding domain service.
 */

$delegationMap = [
    // subscription
    'runUserSubHistoryArchive' => [SubscriptionCronService::class, 'runUserSubHistoryArchive'],
    'runUpdateSubscription' => [SubscriptionCronService::class, 'runUpdateSubscription'],
    'runUpdateStripeData' => [SubscriptionCronService::class, 'runUpdateStripeData'],
    'runUpdateRazorPayData' => [SubscriptionCronService::class, 'runUpdateRazorPayData'],
    'runFreeTrial' => [SubscriptionCronService::class, 'runFreeTrial'],
    'runAutoRenewFailed' => [SubscriptionCronService::class, 'runAutoRenewFailed'],
    'runSendFailedPaymentMail' => [SubscriptionCronService::class, 'runSendFailedPaymentMail'],
    // user program
    'runRemoveDuplicateChapter' => [UserProgramCronService::class, 'runRemoveDuplicateChapter'],
    'runUserProgramDuplicateRowCleanup' => [UserProgramCronService::class, 'runUserProgramDuplicateRowCleanup'],
    'runCreateNextUserDay' => [UserProgramCronService::class, 'runCreateNextUserDay'],
    'runStopExtendedFlow' => [UserProgramCronService::class, 'runStopExtendedFlow'],
    'runSetExtendedFlow' => [UserProgramCronService::class, 'runSetExtendedFlow'],
    'runUpdateUserArticles' => [UserProgramCronService::class, 'runUpdateUserArticles'],
    // user data
    'runDeactivateUsers' => [UserDataCronService::class, 'runDeactivateUsers'],
    'runUserSmokeDataArchive' => [UserDataCronService::class, 'runUserSmokeDataArchive'],
    'runUploadUserFormsToS3' => [UserDataCronService::class, 'runUploadUserFormsToS3'],
    'runUploadUserJourneyLogToS3' => [UserDataCronService::class, 'runUploadUserJourneyLogToS3'],
    // token cleanup
    'runCleanPartnerAccessToken' => [TokenCleanupCronService::class, 'runCleanPartnerAccessToken'],
    'runCleanUserSso' => [TokenCleanupCronService::class, 'runCleanUserSso'],
    'runCleanLoginTable' => [TokenCleanupCronService::class, 'runCleanLoginTable'],
    // coupon voucher
    'runMoveUsedVouchers' => [CouponVoucherCronService::class, 'runMoveUsedVouchers'],
    'runCouponsExpire' => [CouponVoucherCronService::class, 'runCouponsExpire'],
    'runVoucherExpired' => [CouponVoucherCronService::class, 'runVoucherExpired'],
    'runDeleteOldCoupon' => [CouponVoucherCronService::class, 'runDeleteOldCoupon'],
    // acquisition
    'runUserSourceData' => [AcquisitionCronService::class, 'runUserSourceData'],
    'runUpdateListenerTables' => [AcquisitionCronService::class, 'runUpdateListenerTables'],
    'runUpdateUserDeviceCategory' => [AcquisitionCronService::class, 'runUpdateUserDeviceCategory'],
    'runUpdateUserDeviceCategoryZero' => [AcquisitionCronService::class, 'runUpdateUserDeviceCategoryZero'],
    // log
    'runCleanLogData' => [LogMaintenanceCronService::class, 'runCleanLogData'],
    // debug
    'runTestFirebaseNotification' => [DebugCronService::class, 'runTestFirebaseNotification'],
    'testSource' => [DebugCronService::class, 'testSource'],
];

foreach ($delegationMap as $facadeMethod => [$targetClass, $targetMethod]) {
    it("CronService::{$facadeMethod} delegates to {$targetClass}::{$targetMethod}", function () use ($facadeMethod, $targetClass, $targetMethod) {
        $delegate = Mockery::mock($targetClass);
        $delegate->shouldReceive($targetMethod)->once();
        app()->instance($targetClass, $delegate);

        $facade = app(CronService::class);
        $facade->{$facadeMethod}();
    });
}
```

- [ ] **Step 2:** Run, expect FAIL because original CronService still has body.

- [ ] **Step 3:** Rewrite `app/Services/CronService.php` as a facade.

```php
<?php
// app/Services/CronService.php  (NEW BODY — replaces the entire 1255-line file)

namespace App\Services;

use App\Services\Cron\AcquisitionCronService;
use App\Services\Cron\CouponVoucherCronService;
use App\Services\Cron\DebugCronService;
use App\Services\Cron\LogMaintenanceCronService;
use App\Services\Cron\SubscriptionCronService;
use App\Services\Cron\TokenCleanupCronService;
use App\Services\Cron\UserDataCronService;
use App\Services\Cron\UserProgramCronService;

/**
 * Backwards-compatible facade over the domain-specific cron services.
 * All scheduler entries and RunCron magic-dispatch keep working via these
 * pass-throughs. New work should call the domain services directly.
 */
class CronService
{
    public function __construct(
        protected SubscriptionCronService $subscription,
        protected UserProgramCronService $userProgram,
        protected UserDataCronService $userData,
        protected TokenCleanupCronService $tokenCleanup,
        protected CouponVoucherCronService $couponVoucher,
        protected AcquisitionCronService $acquisition,
        protected LogMaintenanceCronService $logMaintenance,
        protected DebugCronService $debug,
    ) {}

    // === Subscription ===
    public function runUserSubHistoryArchive(): void { $this->subscription->runUserSubHistoryArchive(); }
    public function runUpdateSubscription(): void { $this->subscription->runUpdateSubscription(); }
    public function runUpdateStripeData(): void { $this->subscription->runUpdateStripeData(); }
    public function runUpdateRazorPayData(): void { $this->subscription->runUpdateRazorPayData(); }
    public function runFreeTrial(): void { $this->subscription->runFreeTrial(); }
    public function runAutoRenewFailed(): void { $this->subscription->runAutoRenewFailed(); }
    public function runSendFailedPaymentMail(): void { $this->subscription->runSendFailedPaymentMail(); }

    // === User Program ===
    public function runRemoveDuplicateChapter(): void { $this->userProgram->runRemoveDuplicateChapter(); }
    public function runUserProgramDuplicateRowCleanup(): void { $this->userProgram->runUserProgramDuplicateRowCleanup(); }
    public function runCreateNextUserDay(): void { $this->userProgram->runCreateNextUserDay(); }
    public function runStopExtendedFlow(): void { $this->userProgram->runStopExtendedFlow(); }
    public function runSetExtendedFlow(): void { $this->userProgram->runSetExtendedFlow(); }
    public function runUpdateUserArticles(): void { $this->userProgram->runUpdateUserArticles(); }

    // === User Data ===
    public function runDeactivateUsers(): void { $this->userData->runDeactivateUsers(); }
    public function runUserSmokeDataArchive(): void { $this->userData->runUserSmokeDataArchive(); }
    public function runUploadUserFormsToS3(): void { $this->userData->runUploadUserFormsToS3(); }
    public function runUploadUserJourneyLogToS3(): void { $this->userData->runUploadUserJourneyLogToS3(); }

    // === Token Cleanup ===
    public function runCleanPartnerAccessToken(): void { $this->tokenCleanup->runCleanPartnerAccessToken(); }
    public function runCleanUserSso(): void { $this->tokenCleanup->runCleanUserSso(); }
    public function runCleanLoginTable(): void { $this->tokenCleanup->runCleanLoginTable(); }

    // === Coupon / Voucher ===
    public function runMoveUsedVouchers(): void { $this->couponVoucher->runMoveUsedVouchers(); }
    public function runCouponsExpire(): void { $this->couponVoucher->runCouponsExpire(); }
    public function runVoucherExpired(): void { $this->couponVoucher->runVoucherExpired(); }
    public function runDeleteOldCoupon(): void { $this->couponVoucher->runDeleteOldCoupon(); }

    // === Acquisition ===
    public function runUserSourceData(): void { $this->acquisition->runUserSourceData(); }
    public function runUpdateListenerTables(): void { $this->acquisition->runUpdateListenerTables(); }
    public function runUpdateUserDeviceCategory(): void { $this->acquisition->runUpdateUserDeviceCategory(); }
    public function runUpdateUserDeviceCategoryZero(): void { $this->acquisition->runUpdateUserDeviceCategoryZero(); }

    // === Log Maintenance ===
    public function runCleanLogData(): void { $this->logMaintenance->runCleanLogData(); }

    // === Debug / Manual ===
    public function runTestFirebaseNotification(): void { $this->debug->runTestFirebaseNotification(); }
    public function testSource(): void { $this->debug->testSource(); }
}
```

- [ ] **Step 4:** Run test, PASS (32 assertions). Also re-run the full suite to confirm nothing else broke.

```bash
vendor/bin/pest
```

- [ ] **Step 5:** Verify signature parity with snapshot from Task 1.

```bash
grep -nE '^\s*public function (run|test)' app/Services/CronService.php > /tmp/cron-methods-after.txt
diff <(awk '{print $3}' /tmp/cron-methods-before.txt) <(awk '{print $3}' /tmp/cron-methods-after.txt)
```

Expected: no diff in method names. Commit checkpoint.

---

## Task 13: Switch `RunCron` to use `CronJobRegistry`

**Files:**
- Modify: `app/Console/Commands/RunCron.php`

The magic dispatch becomes explicit. Easier to grep, easier for AI agents, easier to debug.

- [ ] **Step 1:** Write a feature test.

```php
<?php
// tests/Feature/RunCronCommandTest.php

use App\Services\Cron\SubscriptionCronService;

it('cron:run dispatches to the registered service method', function () {
    $svc = Mockery::mock(SubscriptionCronService::class);
    $svc->shouldReceive('runUpdateSubscription')->once();
    app()->instance(SubscriptionCronService::class, $svc);

    $this->artisan('cron:run', ['type' => 'update_subscription'])
        ->expectsOutput('Executed update_subscription successfully')
        ->assertExitCode(0);
});

it('cron:run errors on unknown type', function () {
    $this->artisan('cron:run', ['type' => 'not_a_real_cron'])
        ->expectsOutput("Cron type 'not_a_real_cron' not found")
        ->assertExitCode(1);
});
```

- [ ] **Step 2:** Run test, expect FAIL or PASS depending on whether old magic dispatch handles `update_subscription` correctly. Either way, we want it to go through the registry.

- [ ] **Step 3:** Rewrite RunCron.

```php
<?php
// app/Console/Commands/RunCron.php

namespace App\Console\Commands;

use App\Services\Cron\CronJobRegistry;
use Illuminate\Console\Command;

class RunCron extends Command
{
    protected $signature = 'cron:run {type}';
    protected $description = 'Run a named cron job (registered in CronJobRegistry)';

    public function handle(CronJobRegistry $registry): int
    {
        $type = $this->argument('type');
        $handler = $registry->resolve($type);

        if ($handler === null) {
            $this->error("Cron type '{$type}' not found");
            return 1;
        }

        [$class, $method] = $handler;
        app($class)->{$method}();
        $this->info("Executed {$type} successfully");
        return 0;
    }
}
```

- [ ] **Step 4:** Run feature test, expect PASS.

- [ ] **Step 5:** Smoke-test every registered cron name against the actual artisan command (does NOT invoke the work — just verifies dispatch). Add to `RunCronCommandTest`:

```php
it('cron:run resolves every registered cron name', function () {
    $registry = new App\Services\Cron\CronJobRegistry();
    foreach ($registry->allNames() as $name) {
        [$class, $method] = $registry->resolve($name);
        // Mock just enough to swallow the call.
        $svc = Mockery::mock($class);
        $svc->shouldReceive($method)->once()->andReturnNull();
        app()->instance($class, $svc);

        $this->artisan('cron:run', ['type' => $name])->assertExitCode(0);
    }
});
```

Expected: PASS, 30 dispatches. Commit checkpoint.

---

## Task 14: Run the registry test from Task 3

Now that all services exist, the deferred `CronJobRegistryTest` can run.

- [ ] **Step 1:** Run.

```bash
vendor/bin/pest tests/Unit/Services/Cron/CronJobRegistryTest.php
```

Expected: PASS, 2 tests. If any cron name has no matching service class+method, fix the registry or rename the method.

---

## Task 15: Final verification

- [ ] **Step 1:** Full test suite.

```bash
vendor/bin/pest
```

Expected: All previously-passing tests still pass + new tests pass. Note the new total.

- [ ] **Step 2:** PHPStan level 5.

```bash
vendor/bin/phpstan analyse app/Services/Cron app/Services/CronService.php app/Console/Commands/RunCron.php
```

Expected: No new errors.

- [ ] **Step 3:** Quality checklist verification.

```bash
# No file >300 lines in app/Services/Cron
for f in $(find app/Services/Cron -name '*.php'); do
    lines=$(wc -l < "$f")
    if [ "$lines" -gt 300 ]; then echo "TOO LONG: $f ($lines lines)"; fi
done

# Logging boilerplate appears exactly once
grep -rn "cronLogQuery->create" app/Services/Cron/ | grep -v Concerns/LogsCronExecution.php
# Expected: zero output (only the trait writes to cron_logs)
```

- [ ] **Step 4:** Method length check.

```bash
# Methods >40 lines anywhere in Cron services
for f in $(find app/Services/Cron -name '*.php'); do
    awk '/^\s+(public|private|protected) function/ {start=NR; name=$0}
         /^\s+}\s*$/ && start {if (NR-start>40) printf "LONG: %s:%d (%d lines): %s\n", FILENAME, start, NR-start, name; start=0}' "$f"
done
# Expected: zero output
```

- [ ] **Step 5:** Pint formatting.

```bash
vendor/bin/pint app/Services/Cron app/Services/CronService.php app/Console/Commands/RunCron.php tests/Unit/Services/Cron tests/Feature/RunCronCommandTest.php
```

- [ ] **Step 6:** Tell the user the refactor is complete and summarise:
  - 8 domain services + 3 processors created
  - `CronService` reduced from 1255 → ~120 lines
  - `LogsCronExecution` trait owns the cron_logs write — appears once
  - `CronJobRegistry` makes every cron name explicitly greppable
  - `RunCron` no longer uses regex magic
  - Test count went from 0 cron-related tests → ~70+ new tests

Final commit checkpoint — let the user stage and commit.

---

## Self-Review

**Spec coverage:**
- "Improvements" → analysis section at top
- "Split into domain-specific services" → 8 services, tasks 4–11
- "Keep ALL existing method signatures" → Task 12 facade + CronServiceFacadeTest
- "Don't change Query class interfaces" → no Query class is modified
- "Don't change cron log structure" → trait preserves the 6-key payload; specific tests verify
- "Same error handling behavior" → trait catches Throwable, logs same prefix, writes cron_log
- "Constructor injection via Laravel DI" → every service uses constructor injection
- "No method >40 lines" → verified in Task 15 step 4
- "No file >300 lines" → verified in Task 15 step 3
- "Logging boilerplate appears once" → verified in Task 15 step 3 (grep)
- "All 25+ cron jobs still work" → verified in Task 13 step 5
- "AI compatibility" → 10-point list at top of plan + registry + per-file responsibility

**Placeholders:** none. Every code block is complete.

**Type consistency:** `CronResult` has `total/notSent/message/context/skipLog` — used consistently across all tasks. `CronJobRegistry::resolve()` returns `?array{0: class-string, 1: string}` — consumers in RunCron destructure as `[$class, $method]`. All service constructors begin with `CronLogQuery $cronLogQuery` — matches trait's `$this->cronLogQuery` requirement.

**Known gaps / deferred decisions:**
- `runRemoveDuplicateChapter` keeps its inline loop because the field shape differs from `DuplicateRowCleaner` (no `tableName`, different log key format). If `UserProgramCronService` exceeds 300 lines, extract to `DuplicateChapterCleaner` (Task 10 note already mentions this).
- Whether to delete `AutoRenewFailedService::$sentNotifications` debug field is out of scope — the trait wires `$result->context = $autoRenewService->processAllSets()` so the array still flows through, but the `echo` is removed.

---

## Execution Handoff

Plan complete and saved to `docs/superpowers/plans/2026-05-18-cron-service-refactor.md`. Two execution options:

**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. Best for a 15-task refactor like this — keeps main context clean.

**2. Inline Execution** — Execute tasks in this session using `executing-plans`, batch execution with checkpoints for review.

Which approach?
