# Kazakhstan LoanUpdateHandler 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:** Add periodic full actualization of loans from the Kazakhstan CRM HTTP API into the legacy `archivo_creditos` / `9_cobranzas` schema, by analogy with `MexicoLoanUpdateHandler`.

**Architecture:** New `KazakhstanLoanUpdateHandler` + Keycloak OAuth2 client_credentials auth service. Reuses the existing 6-step `BaseLoanUpdateHandler` pipeline (refreshLoanArchives → updateClients → updateLoans → updatePhones → updateEmails → setEstadoCreditoCobro). A new nullable `agreement_id` column is added to `archivo_creditos` and `9_cobranzas` to preserve the KZ-specific identifier. The instance `kazakhstan` is provisioned at deploy time (`APP_INSTANCE=kazakhstan`).

**Tech Stack:** PHP 8.3 · Laravel 10 · PHPUnit 10 · Pint · Postgres (`collection` connection) · Laravel HTTP client · Laravel Cache · Carbon. Runtime is inside the `polaris-service-new` Docker container — all `php artisan` / `composer` / `pint` / `phpunit` commands run via `docker exec -i polaris-service-new <cmd>`.

## File Map

**Create:**
- `app/Services/Actualization/Auth/KazakhstanCrmAuthService.php` — OAuth2 client_credentials token retrieval + cache.
- `app/Services/Actualization/Handlers/Loans/KazakhstanLoanUpdateHandler.php` — KZ-specific download + mapping; reuses base SQL pipeline.
- `database/migrations/2026_05_28_120000_add_agreement_id_to_archivo_creditos_and_cobranzas.php` — adds nullable `agreement_id` column to both tables.
- `tests/Unit/Services/Actualization/Handlers/Loans/KazakhstanLoanUpdateHandlerMapperTest.php` — unit tests for the pure mapping logic (dates, month, status, birthday).

**Modify:**
- `app/Helpers/Autoload/constants.php` — add `INSTANCE_KAZAKHSTAN`.
- `app/Helpers/Autoload/functions.php` — add `isKazakhstan()`, extend `getInstanceLogoImage()`.
- `config/polaris.php` — add `actualization.kazakhstan_crm_api` section.
- `app/Services/Actualization/Handlers/Loans/Abstract/BaseLoanUpdateHandler.php` — add `agreement_id` to `getMap()` and the `updateLoans()` SQL; replace `utf8_encode` blacklist with allowlist.
- `app/Providers/AppServiceProvider.php` — bind `AbstractDatabaseLoanUpdateHandler` to `KazakhstanLoanUpdateHandler` for KZ; register `KazakhstanCrmAuthService` singleton.
- `app/Console/Kernel.php` — add `INSTANCE_KAZAKHSTAN` entry to `LEAD_TIME` and `isKazakhstan()` arm in `schedule()`.

---

## Task 1: Add `INSTANCE_KAZAKHSTAN` constant and `isKazakhstan()` helper

**Files:**
- Modify: `app/Helpers/Autoload/constants.php`
- Modify: `app/Helpers/Autoload/functions.php`

- [ ] **Step 1: Add the constant**

Edit `app/Helpers/Autoload/constants.php` — add line after `const INSTANCE_MEXICO`:

```php
const INSTANCE_MEXICO = 'mexico';
const INSTANCE_KAZAKHSTAN = 'kazakhstan';
```

- [ ] **Step 2: Add the `isKazakhstan()` predicate**

Edit `app/Helpers/Autoload/functions.php` — add a function block immediately after the existing `isMexico()` block:

```php
if (! function_exists('isKazakhstan')) {
    function isKazakhstan(): string
    {
        return config('polaris.instance') == INSTANCE_KAZAKHSTAN;
    }
}
```

- [ ] **Step 3: Extend `getInstanceLogoImage()` to accept KZ**

In `app/Helpers/Autoload/functions.php`, update the existing `getInstanceLogoImage()` function. Two changes:

1. Include `INSTANCE_KAZAKHSTAN` in the `in_array` allowed list.
2. Add it to the `match` arm that returns `/img/cobranzas.png`.

Final function body:

```php
function getInstanceLogoImage(): string
{
    $instance = config('polaris.instance');

    if (! in_array($instance, [INSTANCE_PERU, INSTANCE_COLOMBIA, INSTANCE_MEXICO, INSTANCE_KAZAKHSTAN])) {
        throw new \Exception('Instance not configured');
    }

    return match ($instance) {
        INSTANCE_PERU => asset('img/mainPeru.svg'),
        INSTANCE_COLOMBIA, INSTANCE_MEXICO, INSTANCE_KAZAKHSTAN => asset('/img/cobranzas.png'),
    };
}
```

- [ ] **Step 4: Verify the autoloaded constant and helper resolve**

Composer dump-autoload is needed because `composer.json` registers these files as `autoload.files`.

Run:
```bash
docker exec -i polaris-service-new composer dump-autoload
docker exec -i polaris-service-new php artisan tinker --execute="echo INSTANCE_KAZAKHSTAN;"
```
Expected output: `kazakhstan`

```bash
docker exec -i polaris-service-new php artisan tinker --execute="config(['polaris.instance' => 'kazakhstan']); var_dump(isKazakhstan());"
```
Expected output: `string(1) "1"` (the predicate returns `string` per the file's existing convention, truthy).

- [ ] **Step 5: Commit**

```bash
git add app/Helpers/Autoload/constants.php app/Helpers/Autoload/functions.php
git commit -m "Add INSTANCE_KAZAKHSTAN constant and isKazakhstan helper"
```

---

## Task 2: Add `kazakhstan_crm_api` config section

**Files:**
- Modify: `config/polaris.php` (around line 107 — the existing `mexico_crm_api` block)

- [ ] **Step 1: Add the config block**

In `config/polaris.php`, inside the `'actualization' => [ ... ]` array, immediately after the closing `]` of `'mexico_crm_api'`, insert:

```php
'kazakhstan_crm_api' => [
    'host'          => env('KZ_CRM_API_HOST', 'https://pl2.smsfinanceit.ru/service/gateway-api/collection-provider/api/v1'),
    'auth_url'      => env('KZ_CRM_AUTH_URL', 'https://pl2-auth.smsfinanceit.ru/realms/sms-finance/protocol/openid-connect/token'),
    'client_id'     => env('KZ_CRM_CLIENT_ID', 'polaris'),
    'client_secret' => env('KZ_CRM_CLIENT_SECRET'),
],
```

`client_secret` deliberately has no default — must be set in the deployment env.

- [ ] **Step 2: Verify the config is loaded**

```bash
docker exec -i polaris-service-new php artisan config:clear
docker exec -i polaris-service-new php artisan tinker --execute="echo config('polaris.actualization.kazakhstan_crm_api.host');"
```
Expected output: `https://pl2.smsfinanceit.ru/service/gateway-api/collection-provider/api/v1`

- [ ] **Step 3: Commit**

```bash
git add config/polaris.php
git commit -m "Add kazakhstan_crm_api config section"
```

---

## Task 3: Migration — add `agreement_id` to `archivo_creditos` and `9_cobranzas`

**Files:**
- Create: `database/migrations/2026_05_28_120000_add_agreement_id_to_archivo_creditos_and_cobranzas.php`

- [ ] **Step 1: Generate the migration file**

```bash
docker exec -i polaris-service-new php artisan make:migration add_agreement_id_to_archivo_creditos_and_cobranzas --no-interaction
```

This creates a file like `database/migrations/2026_05_28_HHMMSS_add_agreement_id_to_archivo_creditos_and_cobranzas.php`. Note the actual generated filename; the rest of this task references it.

- [ ] **Step 2: Write the migration body**

Replace the file's contents with:

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function getConnection()
    {
        return 'collection';
    }

    public function up(): void
    {
        Schema::table('archivo_creditos', function (Blueprint $table) {
            $table->string('agreement_id', 255)->nullable();
        });

        Schema::table('9_cobranzas', function (Blueprint $table) {
            $table->string('agreement_id', 255)->nullable();
        });
    }

    public function down(): void
    {
        Schema::table('archivo_creditos', function (Blueprint $table) {
            $table->dropColumn('agreement_id');
        });

        Schema::table('9_cobranzas', function (Blueprint $table) {
            $table->dropColumn('agreement_id');
        });
    }
};
```

- [ ] **Step 3: Run the migration**

```bash
docker exec -i polaris-service-new php artisan migrate --no-interaction
```
Expected output ends with `DONE` for this migration.

- [ ] **Step 4: Verify both columns exist**

Use the laravel-boost MCP `database-query` tool (preferred over `psql` shell):
```sql
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name IN ('archivo_creditos', '9_cobranzas') AND column_name = 'agreement_id';
```
Expected: 2 rows, both `character varying`, both `YES` nullable.

- [ ] **Step 5: Commit**

```bash
git add database/migrations/2026_05_28_*_add_agreement_id_to_archivo_creditos_and_cobranzas.php
git commit -m "Add agreement_id column to archivo_creditos and 9_cobranzas"
```

---

## Task 4: Wire `agreement_id` through `BaseLoanUpdateHandler::getMap()` and `updateLoans()` SQL

**Files:**
- Modify: `app/Services/Actualization/Handlers/Loans/Abstract/BaseLoanUpdateHandler.php`

- [ ] **Step 1: Add `agreement_id` to `getMap()`**

In `BaseLoanUpdateHandler::getMap()`, add the entry right after `'loan_id' => 'orden'`:

```php
'loan_id' => 'orden',
'agreement_id' => 'agreement_id',
```

- [ ] **Step 2: Add `agreement_id` to the `updateLoans()` SQL — INSERT column list**

In the `INSERT INTO "9_cobranzas" (` column list, add `agreement_id` right after `obligacion`:

```sql
INSERT INTO "9_cobranzas" (
    documento,
    obligacion,
    agreement_id,
    credito,
    ...
```

- [ ] **Step 3: Add `agreement_id` to the SELECT clause**

In the matching `SELECT` clause from `archivo_creditos`, add `agreement_id` in the same position (after the `NULLIF(orden, '')::bigint` value, before `NULLIF(capital_vigente, '')::numeric`):

```sql
SELECT
    cedula,
    NULLIF(orden, '')::bigint,
    agreement_id,
    NULLIF(capital_vigente, '')::numeric,
    ...
```

- [ ] **Step 4: Add `agreement_id` to the `ON CONFLICT DO UPDATE SET`**

In the `ON CONFLICT (obligacion) DO UPDATE SET` clause, add a line:

```sql
agreement_id = EXCLUDED.agreement_id,
```
Place it next to `oxxo_ref = EXCLUDED.oxxo_ref` for readability — order within `SET` doesn't matter semantically.

- [ ] **Step 5: Run Pint on the changed file**

```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
```
Expected: no errors; one file formatted.

- [ ] **Step 6: Smoke-check the SQL parses (no execution yet)**

```bash
docker exec -i polaris-service-new php artisan tinker --execute="echo (new \App\Services\Actualization\Handlers\Loans\PeruDatabaseLoanUpdateHandler())->getMap()['agreement_id'];"
```
Expected output: `agreement_id`

- [ ] **Step 7: Commit**

```bash
git add app/Services/Actualization/Handlers/Loans/Abstract/BaseLoanUpdateHandler.php
git commit -m "Plumb agreement_id through base loan update handler"
```

---

## Task 5: Fix `utf8_encode` from blacklist to allowlist

**Files:**
- Modify: `app/Services/Actualization/Handlers/Loans/Abstract/AbstractDatabaseLoanUpdateHandler.php`

Reasoning: the current `if (!isMexico())` mangles UTF-8 Cyrillic data (KZ payload is UTF-8 from Keycloak/Vivus). Switch to explicit allowlist matching the only two instances that actually need it (Peru/Colombia CSV ingestion uses latin1).

- [ ] **Step 1: Replace the conditional**

In `AbstractDatabaseLoanUpdateHandler::refreshLoanArchives()`, find:

```php
if (!isMexico()) {
    $value = utf8_encode($value);
}
```

Replace with:

```php
if (isPeru() || isColombia()) {
    $value = utf8_encode($value);
}
```

- [ ] **Step 2: Run Pint**

```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
```

- [ ] **Step 3: Commit**

```bash
git add app/Services/Actualization/Handlers/Loans/Abstract/AbstractDatabaseLoanUpdateHandler.php
git commit -m "Make utf8_encode allowlist explicit in loan archive refresh"
```

---

## Task 6: `KazakhstanCrmAuthService` — Keycloak token + cache

**Files:**
- Create: `app/Services/Actualization/Auth/KazakhstanCrmAuthService.php`

- [ ] **Step 1: Create the directory and file**

```bash
docker exec -i polaris-service-new php artisan make:class Services/Actualization/Auth/KazakhstanCrmAuthService --no-interaction
```

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

Replace the file's contents with:

```php
<?php

namespace App\Services\Actualization\Auth;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

class KazakhstanCrmAuthService
{
    private const CACHE_KEY = 'kazakhstan_crm_access_token';

    private const TTL_SAFETY_MARGIN_SECONDS = 30;

    public function getAccessToken(): string
    {
        $cached = Cache::get(self::CACHE_KEY);
        if (is_string($cached) && $cached !== '') {
            return $cached;
        }

        $response = Http::asForm()
            ->timeout(30)
            ->throw()
            ->post(config('polaris.actualization.kazakhstan_crm_api.auth_url'), [
                'grant_type'    => 'client_credentials',
                'client_id'     => config('polaris.actualization.kazakhstan_crm_api.client_id'),
                'client_secret' => config('polaris.actualization.kazakhstan_crm_api.client_secret'),
            ])
            ->json();

        $accessToken = $response['access_token'] ?? null;
        $expiresIn = (int) ($response['expires_in'] ?? 0);

        if (! is_string($accessToken) || $accessToken === '' || $expiresIn <= 0) {
            throw new \RuntimeException('Kazakhstan CRM auth response did not include access_token / expires_in');
        }

        Cache::put(self::CACHE_KEY, $accessToken, max(1, $expiresIn - self::TTL_SAFETY_MARGIN_SECONDS));

        return $accessToken;
    }

    public function forgetAccessToken(): void
    {
        Cache::forget(self::CACHE_KEY);
    }
}
```

- [ ] **Step 3: Run Pint**

```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
```

- [ ] **Step 4: Verify against the real Keycloak (smoke test)**

```bash
docker exec -i polaris-service-new php artisan tinker --execute="\$svc = resolve(\App\Services\Actualization\Auth\KazakhstanCrmAuthService::class); \$token = \$svc->getAccessToken(); echo 'len='.strlen(\$token).' starts='.substr(\$token, 0, 20);"
```
Expected: `len=<N> starts=eyJ...` (a JWT). Run twice — second call should be ≈instant (cache hit).

If the call fails because `KZ_CRM_CLIENT_SECRET` is not set in this environment, set it temporarily in `.env` (or pass via `KZ_CRM_CLIENT_SECRET=... docker exec -e KZ_CRM_CLIENT_SECRET=...`). Do **not** commit the secret.

- [ ] **Step 5: Commit**

```bash
git add app/Services/Actualization/Auth/KazakhstanCrmAuthService.php
git commit -m "Add KazakhstanCrmAuthService for Keycloak client_credentials auth"
```

---

## Task 7: Mapper unit tests — write the failing tests

**Files:**
- Create: `tests/Unit/Services/Actualization/Handlers/Loans/KazakhstanLoanUpdateHandlerMapperTest.php`

This task TDDs the **pure logic** of `mapLoanToDatabaseFormat` (no HTTP, no DB). The handler class doesn't exist yet — the next task creates it.

- [ ] **Step 1: Generate the test file**

```bash
docker exec -i polaris-service-new php artisan make:test Unit/Services/Actualization/Handlers/Loans/KazakhstanLoanUpdateHandlerMapperTest --unit --no-interaction
```

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

Replace the generated file with:

```php
<?php

namespace Tests\Unit\Services\Actualization\Handlers\Loans;

use App\Services\Actualization\Auth\KazakhstanCrmAuthService;
use App\Services\Actualization\Handlers\Loans\KazakhstanLoanUpdateHandler;
use PHPUnit\Framework\TestCase;

class KazakhstanLoanUpdateHandlerMapperTest extends TestCase
{
    private function makeHandler(): KazakhstanLoanUpdateHandler
    {
        return new KazakhstanLoanUpdateHandler(
            $this->createMock(KazakhstanCrmAuthService::class)
        );
    }

    private function sampleRow(array $overrides = []): array
    {
        return array_merge([
            'loan_id'            => 'd3de2a29-810a-456e-9daa-9ba1dd4f9f70',
            'agreement_id'       => '863a94ab-6cf8-4464-9233-498711dc4132',
            'document'           => '222',
            'names'              => 'Еркеназ Нұсқабаева Қуанышқызы',
            'principal_outstanding' => '0',
            'disbursement_date'  => '2026-05-22T10:27:52.921616Z',
            'due_date'           => '2026-05-22T11:41:30.166302Z',
            'status_change_date' => '2026-05-22T11:41:30.132757Z',
            'due_date_inicial'   => '2026-06-11',
            'birthday'           => '514425600000',
            'month'              => 'MAY',
            'status'             => 'CLOSED',
            'product_type'       => 'PDL',
        ], $overrides);
    }

    public function test_disbursement_date_converted_to_dmy(): void
    {
        $out = $this->makeHandler()->mapLoanToDatabaseFormat([$this->sampleRow()]);

        $this->assertSame('22/05/2026', $out[0]['disbursement_date']);
    }

    public function test_due_date_converted_to_dmy(): void
    {
        $out = $this->makeHandler()->mapLoanToDatabaseFormat([$this->sampleRow()]);

        $this->assertSame('22/05/2026', $out[0]['due_date']);
    }

    public function test_status_change_date_converted_to_dmy(): void
    {
        $out = $this->makeHandler()->mapLoanToDatabaseFormat([$this->sampleRow()]);

        $this->assertSame('22/05/2026', $out[0]['status_change_date']);
    }

    public function test_due_date_inicial_converted_to_dmy(): void
    {
        $out = $this->makeHandler()->mapLoanToDatabaseFormat([$this->sampleRow()]);

        $this->assertSame('11/06/2026', $out[0]['due_date_inicial']);
    }

    public function test_birthday_ms_timestamp_converted_to_ymd(): void
    {
        // 514425600000 ms = 1986-04-22 UTC
        $out = $this->makeHandler()->mapLoanToDatabaseFormat([$this->sampleRow()]);

        $this->assertSame('1986-04-22', $out[0]['birthday']);
    }

    public function test_month_text_converted_to_integer(): void
    {
        $out = $this->makeHandler()->mapLoanToDatabaseFormat([
            $this->sampleRow(['month' => 'MAY']),
            $this->sampleRow(['month' => 'JANUARY']),
            $this->sampleRow(['month' => 'DECEMBER']),
        ]);

        $this->assertSame(5,  $out[0]['month']);
        $this->assertSame(1,  $out[1]['month']);
        $this->assertSame(12, $out[2]['month']);
    }

    public function test_month_unknown_value_is_null(): void
    {
        $out = $this->makeHandler()->mapLoanToDatabaseFormat([
            $this->sampleRow(['month' => 'NOT_A_MONTH']),
        ]);

        $this->assertNull($out[0]['month']);
    }

    public function test_status_lowercased(): void
    {
        $out = $this->makeHandler()->mapLoanToDatabaseFormat([
            $this->sampleRow(['status' => 'CLOSED']),
            $this->sampleRow(['status' => 'OVERDUE']),
        ]);

        $this->assertSame('closed',  $out[0]['status']);
        $this->assertSame('overdue', $out[1]['status']);
    }

    public function test_missing_interest_fields_forced_to_zero_string(): void
    {
        $out = $this->makeHandler()->mapLoanToDatabaseFormat([$this->sampleRow()]);

        $this->assertSame('0', $out[0]['interests']);
        $this->assertSame('0', $out[0]['comissions']);
        $this->assertSame('0', $out[0]['total']);
        $this->assertSame('0', $out[0]['gac']);
    }

    public function test_agreement_id_passed_through(): void
    {
        $out = $this->makeHandler()->mapLoanToDatabaseFormat([$this->sampleRow()]);

        $this->assertSame('863a94ab-6cf8-4464-9233-498711dc4132', $out[0]['agreement_id']);
    }

    public function test_cyrillic_names_passed_through_untouched(): void
    {
        $out = $this->makeHandler()->mapLoanToDatabaseFormat([$this->sampleRow()]);

        $this->assertSame('Еркеназ Нұсқабаева Қуанышқызы', $out[0]['names']);
    }
}
```

- [ ] **Step 3: Run the test — confirm it fails because handler class is missing**

```bash
docker exec -i polaris-service-new php artisan test --compact --filter=KazakhstanLoanUpdateHandlerMapperTest
```
Expected: Error — class `App\Services\Actualization\Handlers\Loans\KazakhstanLoanUpdateHandler` not found. This is the expected red state.

- [ ] **Step 4: Commit the failing tests**

```bash
git add tests/Unit/Services/Actualization/Handlers/Loans/KazakhstanLoanUpdateHandlerMapperTest.php
git commit -m "Add failing mapper tests for KazakhstanLoanUpdateHandler"
```

---

## Task 8: Implement `KazakhstanLoanUpdateHandler`

**Files:**
- Create: `app/Services/Actualization/Handlers/Loans/KazakhstanLoanUpdateHandler.php`

- [ ] **Step 1: Create the file with the mapper-only implementation first**

Create `app/Services/Actualization/Handlers/Loans/KazakhstanLoanUpdateHandler.php` with:

```php
<?php

namespace App\Services\Actualization\Handlers\Loans;

use App\Services\Actualization\Auth\KazakhstanCrmAuthService;
use App\Services\Actualization\Handlers\Loans\Abstract\AbstractDatabaseLoanUpdateHandler;
use Carbon\Carbon;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;

class KazakhstanLoanUpdateHandler extends AbstractDatabaseLoanUpdateHandler
{
    private const MONTH_MAP = [
        'JANUARY'   => 1,
        'FEBRUARY'  => 2,
        'MARCH'     => 3,
        'APRIL'     => 4,
        'MAY'       => 5,
        'JUNE'      => 6,
        'JULY'      => 7,
        'AUGUST'    => 8,
        'SEPTEMBER' => 9,
        'OCTOBER'   => 10,
        'NOVEMBER'  => 11,
        'DECEMBER'  => 12,
    ];

    public function __construct(private KazakhstanCrmAuthService $authService)
    {
    }

    public function downloadLoans(): array
    {
        if (! empty($this->getLoanIds())) {
            throw new \LogicException('Partial actualization by ids is not supported for Kazakhstan yet');
        }

        try {
            $data = $this->getPendingRequest()->get('/loans')->json();
        } catch (RequestException $exception) {
            if ($exception->response?->status() === 401) {
                $this->authService->forgetAccessToken();
                $data = $this->getPendingRequest()->get('/loans')->json();
            } else {
                throw $exception;
            }
        }

        return $this->mapLoanToDatabaseFormat($data ?? []);
    }

    public function mapLoanToDatabaseFormat(array $data): array
    {
        foreach ($data as $index => $loan) {
            if (! empty($loan['disbursement_date'])) {
                $data[$index]['disbursement_date'] = Carbon::parse($loan['disbursement_date'])->format('d/m/Y');
            }
            if (! empty($loan['due_date'])) {
                $data[$index]['due_date'] = Carbon::parse($loan['due_date'])->format('d/m/Y');
            }
            if (! empty($loan['status_change_date'])) {
                $data[$index]['status_change_date'] = Carbon::parse($loan['status_change_date'])->format('d/m/Y');
            }
            if (! empty($loan['due_date_inicial'])) {
                $data[$index]['due_date_inicial'] = Carbon::parse($loan['due_date_inicial'])->format('d/m/Y');
            }
            if (! empty($loan['birthday'])) {
                $data[$index]['birthday'] = Carbon::createFromTimestampMs((int) $loan['birthday'])->format('Y-m-d');
            }

            $data[$index]['month']  = self::MONTH_MAP[strtoupper((string) ($loan['month'] ?? ''))] ?? null;
            $data[$index]['status'] = isset($loan['status']) ? strtolower((string) $loan['status']) : null;

            // KZ CRM does not provide interest/commission/total/gac fields; the base updateLoans()
            // SQL filters rows by `WHERE porc_tasa <> ''` so we inject "0" to let KZ rows through.
            $data[$index]['interests']  = '0';
            $data[$index]['comissions'] = '0';
            $data[$index]['total']      = '0';
            $data[$index]['gac']        = '0';
        }

        return $data;
    }

    protected function getPendingRequest(): PendingRequest
    {
        return Http::baseUrl(config('polaris.actualization.kazakhstan_crm_api.host'))
            ->timeout(600)
            ->withToken($this->authService->getAccessToken())
            ->throw();
    }
}
```

- [ ] **Step 2: Run the mapper tests — confirm green**

```bash
docker exec -i polaris-service-new php artisan test --compact --filter=KazakhstanLoanUpdateHandlerMapperTest
```
Expected: 11 passing tests.

- [ ] **Step 3: Run Pint**

```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
```

- [ ] **Step 4: Smoke test downloadLoans against the real API**

```bash
docker exec -i polaris-service-new php artisan tinker --execute="
\$h = new \App\Services\Actualization\Handlers\Loans\KazakhstanLoanUpdateHandler(
    resolve(\App\Services\Actualization\Auth\KazakhstanCrmAuthService::class)
);
\$loans = \$h->downloadLoans();
echo 'count='.count(\$loans).PHP_EOL;
if (!empty(\$loans)) { print_r(array_intersect_key(\$loans[0], array_flip(['loan_id','agreement_id','disbursement_date','month','status','birthday','interests']))); }
"
```
Expected: prints `count=<N>` and a sample row with transformed values (e.g. `disbursement_date => 22/05/2026`, `month => 5`, `status => closed`, `interests => 0`).

- [ ] **Step 5: Commit**

```bash
git add app/Services/Actualization/Handlers/Loans/KazakhstanLoanUpdateHandler.php
git commit -m "Implement KazakhstanLoanUpdateHandler"
```

---

## Task 9: Wire `KazakhstanLoanUpdateHandler` into the service container

**Files:**
- Modify: `app/Providers/AppServiceProvider.php`

- [ ] **Step 1: Add the use statement**

In the `use` block at the top of `AppServiceProvider.php`, add (alphabetised next to the existing `MexicoLoanUpdateHandler` import):

```php
use App\Services\Actualization\Handlers\Loans\KazakhstanLoanUpdateHandler;
```

- [ ] **Step 2: Add the KZ arm to the `AbstractDatabaseLoanUpdateHandler` match**

In `AppServiceProvider::register()`, locate the `$this->app->singleton(AbstractDatabaseLoanUpdateHandler::class, ...)` block (around line 94). Inside the `match`, add a line:

```php
$migratorClass = match (config('polaris.instance')) {
    INSTANCE_PERU => PeruDatabaseLoanUpdateHandler::class,
    INSTANCE_COLOMBIA => ColombiaDatabaseLoanUpdateHandler::class,
    INSTANCE_MEXICO => MexicoLoanUpdateHandler::class,
    INSTANCE_KAZAKHSTAN => KazakhstanLoanUpdateHandler::class,
    default => throw new \Exception('AbstractDatabaseLoanUpdateHandler is not configured'),
};
```

Do **not** add a KZ arm to `AbstractDatabasePaymentUpdateHandler` — payments are out of scope.

- [ ] **Step 3: Run Pint**

```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
```

- [ ] **Step 4: Verify binding resolves under APP_INSTANCE=kazakhstan**

```bash
docker exec -i polaris-service-new php artisan tinker --execute="
config(['polaris.instance' => 'kazakhstan']);
echo get_class(resolve(\App\Services\Actualization\Handlers\Loans\Abstract\AbstractDatabaseLoanUpdateHandler::class));
"
```
Expected: `App\Services\Actualization\Handlers\Loans\KazakhstanLoanUpdateHandler`

- [ ] **Step 5: Commit**

```bash
git add app/Providers/AppServiceProvider.php
git commit -m "Bind AbstractDatabaseLoanUpdateHandler to KazakhstanLoanUpdateHandler for KZ instance"
```

---

## Task 10: Add Kazakhstan to `Kernel::LEAD_TIME` and `schedule()`

**Files:**
- Modify: `app/Console/Kernel.php`

- [ ] **Step 1: Add the `INSTANCE_KAZAKHSTAN` entry to `LEAD_TIME`**

In `app/Console/Kernel.php`, inside the `const array LEAD_TIME = [ ... ]` array, after the `INSTANCE_PERU => [ ... ]` block, add:

```php
INSTANCE_KAZAKHSTAN => [
    EntireActualizationCommand::class => 'everyFourHours',
    PartialActualizationCommand::class => 'everyFiveMinutes',
    PartialActualizationSummaryNotificationCommand::class => 'everyTwoHours',
],
```

- [ ] **Step 2: Wire the KZ arm into `schedule()`**

In `Kernel::schedule()`, after the existing `if (isPeru() && ...)` block, add:

```php
if (isKazakhstan() && isset(self::LEAD_TIME[INSTANCE_KAZAKHSTAN])) {
    $this->scheduleCommands($schedule, self::LEAD_TIME[INSTANCE_KAZAKHSTAN]);
}
```

- [ ] **Step 3: Run Pint**

```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
```

- [ ] **Step 4: Verify schedule is registered under APP_INSTANCE=kazakhstan**

```bash
docker exec -i polaris-service-new php artisan tinker --execute="
config(['polaris.instance' => 'kazakhstan']);
\$kernel = app(\App\Console\Kernel::class);
\$schedule = app(\Illuminate\Console\Scheduling\Schedule::class);
\$method = new \ReflectionMethod(\$kernel, 'schedule');
\$method->setAccessible(true);
\$method->invoke(\$kernel, \$schedule);
foreach (\$schedule->events() as \$e) { echo \$e->command.PHP_EOL; }
"
```
Expected: lines include `EntireActualizationCommand`, `PartialActualizationCommand`, `PartialActualizationSummaryNotificationCommand`, plus the `ALL`-block commands.

- [ ] **Step 5: Commit**

```bash
git add app/Console/Kernel.php
git commit -m "Schedule actualization commands for KZ instance"
```

---

## Task 11: End-to-end actualization smoke test

**Files:** (no edits — verification only)

This task is gated on running with a real `APP_INSTANCE=kazakhstan` environment (env vars set, `KZ_CRM_CLIENT_SECRET` populated). Skip on local dev where the KZ env isn't provisioned; run on the KZ staging deployment.

- [ ] **Step 1: Confirm env**

```bash
docker exec -i polaris-service-new php artisan tinker --execute="echo config('polaris.instance');"
```
Expected: `kazakhstan`

- [ ] **Step 2: Run the full actualization**

```bash
docker exec -i polaris-service-new php artisan actualization:entire
```

Expected: command exits 0; Telegram notifications report each of the 6 steps completed.

- [ ] **Step 3: Verify `archivo_creditos`**

Via laravel-boost `database-query`:
```sql
SELECT count(*) AS total, count(agreement_id) AS with_agreement
FROM archivo_creditos;
```
Expected: `with_agreement` > 0 (every KZ row has `agreement_id` per the API contract).

```sql
SELECT cedula, nombre, sex, birthday, municipalityname
FROM archivo_creditos
WHERE nombre ~ '[А-Яа-яЁёҚқҰұІіҢңӘәҮүӨөҺһ]'
LIMIT 5;
```
Expected: 5 rows with intact Cyrillic / KZ-specific characters (validates the `utf8_encode` fix).

- [ ] **Step 4: Verify `9_cobranzas`**

```sql
SELECT count(*) AS total, count(agreement_id) AS with_agreement
FROM "9_cobranzas";
```
Expected: `total > 0` AND `with_agreement > 0`. If `total` is 0 but `archivo_creditos` has rows, the `WHERE porc_tasa <> ''` filter is rejecting KZ rows — re-check that `interests = "0"` is being set in the mapper.

- [ ] **Step 5: Verify clients / phones / emails**

```sql
SELECT count(*) FROM "10_clientes";
SELECT count(*) FROM "14_telefonos" WHERE parentesco = 'Principal Titular';
SELECT count(*) FROM mails;
```
Expected: all three > 0.

- [ ] **Step 6: Re-run actualization — idempotency check**

Run `EntireActualizationCommand` again. Repeat queries from Step 4 — counts should match (no duplicates; ON CONFLICT updates).

- [ ] **Step 7: Run the unit tests one more time**

```bash
docker exec -i polaris-service-new php artisan test --compact --filter=KazakhstanLoanUpdateHandlerMapperTest
```
Expected: 11 passing.

- [ ] **Step 8: Final Pint pass and commit any drift**

```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
git status
# If pint changed anything:
git add -u
git commit -m "Pint fixes"
```