# Kazakhstan Loan Update Handler — Design

**Date:** 2026-05-28
**Status:** Approved
**Scope:** Add a new `LoanUpdateHandler` for the Kazakhstan instance, by analogy with `MexicoLoanUpdateHandler`. Payments handler is out of scope and will be done separately.

## Goal

Periodic full actualization of loans pulled from the Kazakhstan CRM HTTP API into the legacy `archivo_creditos` / `9_cobranzas` schema. The handler runs under `APP_INSTANCE=kazakhstan` (a new instance, not yet deployed but provisioned soon). All inter-instance branching points need a `kazakhstan` arm.

## External contract

**Loans endpoint:** `GET https://pl2.smsfinanceit.ru/service/gateway-api/collection-provider/api/v1/loans`
- Returns a JSON array of all loans in one response. Pagination may be added later; the initial implementation must not depend on it.
- No `by-ids` endpoint exists yet → partial actualization by id list is unsupported (stubbed with explicit exception).

**Auth — OAuth2 client_credentials via Keycloak:**
- Token URL: `https://pl2-auth.smsfinanceit.ru/realms/sms-finance/protocol/openid-connect/token`
- Form params: `grant_type=client_credentials`, `client_id=polaris`, `client_secret=<secret>`
- Response shape: `{ access_token, expires_in, refresh_expires_in: 0, token_type: "Bearer", ... }`
- `refresh_expires_in: 0` → no refresh token, re-request on expiry.
- Typical `expires_in` ≈ 18000 sec (5h). **TTL must be taken from the response field, not hardcoded.**

**Sample loan payload (lowercase keys, matches `BaseLoanUpdateHandler::getMap()` directly):**
```json
{
  "loan_id": "d3de2a29-...", "agreement_id": "863a94ab-...", "document": "222",
  "names": "Еркеназ Нұсқабаева Қуанышқызы", "principal_outstanding": "0",
  "disbursement_date": "2026-05-22T10:27:52.921616Z", "due_date": "2026-05-22T11:41:30.166302Z",
  "dpd": "0", "total_actual": "0", "status": "CLOSED", "order_id": "d3de...",
  "sex": "female", "phone": "7020431594", "email": "...", "client_type": null,
  "address": null, "municipality_name": "Караганда", "department_name": "",
  "year": "2026", "month": "MAY", "week": "21", "amount_repay": "0",
  "status_change_date": "2026-05-22T11:41:30.132757Z", "client_id": "da7b...",
  "brand": "ТОО МФО Вивус", "first_name": "Еркеназ ", "first_surname": "Нұсқабаева ",
  "second_surname": "Қуанышқызы", "amount_dd": "0", "birthday": "514425600000",
  "term": "21", "due_date_inicial": "2026-06-11", "product_type": "PDL"
}
```

KZ does NOT return: `payment_schedule`, `interests`, `comissions`, `total`, `gac`, `second_name`, `contact_number`, `company_name`, `postal_code`, `account_number`, `bank`, `oxxo_ref`, `stp_ref`, `ext_agency`, `additional_numbers`, `state`, `payments_plan`.

## Architecture

### 1. Instance plumbing

- **`app/Helpers/Autoload/constants.php`** — add `const INSTANCE_KAZAKHSTAN = 'kazakhstan';`
- **`app/Helpers/Autoload/functions.php`** — add predicate `isKazakhstan(): string` mirroring `isMexico()`. Extend `getInstanceLogoImage()` to include `INSTANCE_KAZAKHSTAN` (reuse `/img/cobranzas.png` so it doesn't block on an asset).
- **`config/polaris.php` → `actualization`** — add:
  ```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` has no default — must come from env per environment.
- **`app/Providers/AppServiceProvider.php`** — in the `match` for `AbstractDatabaseLoanUpdateHandler` add `INSTANCE_KAZAKHSTAN => KazakhstanLoanUpdateHandler::class`. Register `KazakhstanCrmAuthService` as a singleton in the same `register()` method. For `AbstractDatabasePaymentUpdateHandler` do NOT add a KZ arm — let the existing default throw "is not configured" if anything tries to resolve it.
- **`app/Console/Kernel.php`** — add `INSTANCE_KAZAKHSTAN` entry in `LEAD_TIME`:
  ```php
  INSTANCE_KAZAKHSTAN => [
      EntireActualizationCommand::class => 'everyFourHours',
      PartialActualizationCommand::class => 'everyFiveMinutes',
      PartialActualizationSummaryNotificationCommand::class => 'everyTwoHours',
  ],
  ```
  Plus an `if (isKazakhstan() && isset(...)) { ... }` block in `schedule()`. CSV reports / promises / vicidial are intentionally omitted — they belong to other features and will be added separately.

### 2. Database migration

`database/migrations/2026_05_28_XXXXXX_add_agreement_id_to_archivo_creditos_and_cobranzas.php`, connection = `collection`.

```php
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 { /* symmetric dropColumn */ }
```

Nullable is required — Peru/Colombia/Mexico CRMs don't emit `agreement_id`, so for them the column stays NULL.

### 3. Base class changes (`BaseLoanUpdateHandler`)

These changes affect Peru/Colombia/Mexico semantically:

- **`getMap()`** — add `'agreement_id' => 'agreement_id'`.
- **`updateLoans()` SQL** — add `agreement_id` to:
  - INSERT column list for `9_cobranzas`,
  - SELECT list from `archivo_creditos`,
  - `ON CONFLICT (obligacion) DO UPDATE SET agreement_id = EXCLUDED.agreement_id`.
- **`refreshLoanArchives()`, line ~41** — replace `if (!isMexico())` with explicit allowlist `if (isPeru() || isColombia())`. KZ data is UTF-8 Cyrillic; `utf8_encode` would corrupt it. Mexico is unaffected (still skipped); Peru/Colombia keep the existing behaviour.

### 4. New auth service

`app/Services/Actualization/Auth/KazakhstanCrmAuthService.php`:

- Constructor: nothing (config-driven).
- `getAccessToken(): string` — Laravel cache key `kazakhstan_crm_access_token`; on miss/expiry POST to `auth_url` with `Http::asForm()->post(...)->throw()`, store with TTL = `expires_in - 30` seconds (clock-drift cushion). Return `access_token`.
- `forgetAccessToken(): void` — `Cache::forget(...)`. Called when the loans endpoint returns 401 (single retry).
- Registered as `singleton` in `AppServiceProvider::register()`.

### 5. New handler

`app/Services/Actualization/Handlers/Loans/KazakhstanLoanUpdateHandler.php` extends `AbstractDatabaseLoanUpdateHandler`.

```php
public function __construct(private KazakhstanCrmAuthService $authService) {}
```

- **`getPendingRequest(): PendingRequest`** — `Http::baseUrl(config('polaris.actualization.kazakhstan_crm_api.host'))->timeout(600)->withToken($this->authService->getAccessToken())->throw()`.
- **`downloadLoans(): array`** — if `getLoanIds()` is not empty, throw `\LogicException('Partial actualization by ids is not supported for Kazakhstan yet')`. Otherwise GET `/loans` and pass through `mapLoanToDatabaseFormat`.
- **`mapLoanToDatabaseFormat(array $data): array`** — per-row transformations:

  | Field | From | To | Transform |
  |---|---|---|---|
  | `disbursement_date` | ISO 8601 | `d/m/Y` | `Carbon::parse(...)->format('d/m/Y')` |
  | `due_date` | ISO 8601 | `d/m/Y` | same |
  | `status_change_date` | ISO 8601 | `d/m/Y` | same |
  | `due_date_inicial` | `YYYY-MM-DD` | `d/m/Y` | same (SQL uses `TO_DATE(..., 'DD/MM/YYYY')`) |
  | `birthday` | ms timestamp string | `Y-m-d` | `Carbon::createFromTimestampMs((int)$value)->format('Y-m-d')` |
  | `month` | `"MAY"` | `5` (int) | lookup table; unknown → `null` |
  | `status` | `"CLOSED"` | `"closed"` | `strtolower` (then base handler does `LoanStatus::getByCode`) |

  Also force `interests = "0"` (and defensively `comissions/total/gac = "0"`) per row — without this, the `WHERE porc_tasa <> ''` filter in `updateLoans()` would reject every KZ loan. This is documented technical debt; the proper fix is a relaxed `updateLoans()` SQL but is out of scope here.
- **No `getSteps()` override** — uses the 6 default steps from `BaseLoanUpdateHandler`. `updatePhones` / `updateEmails` SQL handles NULL/empty fields correctly with existing `WHERE x != ''` clauses.
- **No `updatePaymentPeriods()`** — KZ doesn't return `payment_schedule`. The Mexico override pattern doesn't apply.

### 6. 401 handling

If `getPendingRequest()->get('/loans')` returns 401 (token revoked / drift), `Http::throw()` throws. Catch once in `downloadLoans()`, call `$this->authService->forgetAccessToken()`, retry once. Second 401 propagates as `IncomingUpdateException` via the existing `AbstractUpdateHandler::process()` try/catch.

## Data flow

```
GET /loans (Bearer token)
   ↓
mapLoanToDatabaseFormat() — date formats, month text→int, status lowercase,
                             birthday ms→Y-m-d, interests="0" fix
   ↓
refreshLoanArchives() — upsert into archivo_creditos by orden;
                         status_id/loan_type_id resolved from dictionaries
   ↓
updateClients()    →  10_clientes
updateLoans()      →  9_cobranzas (with agreement_id)
updatePhones()     →  14_telefonos (only Principal Titular for KZ; other phone sources are NULL)
updateEmails()     →  mails
setEstadoCreditoCobro() — joins Estados_default to set cobro column
```

## Error handling

- **Keycloak unreachable / 5xx** → `Http::throw()` → `IncomingUpdateException` with the step's `client_error_msg`. Existing Telegram notification machinery in `AbstractUpdateHandler::process()` reports it.
- **401 from /loans** → one retry with fresh token; on second failure → exception as above.
- **Unknown month string** → row stored with `month = null`. SQL `NULLIF(month, '')::integer` already handles NULL gracefully.
- **Unparseable date** → `Carbon::parse` throws → propagates as exception (acceptable; surfaces broken data fast).
- **Empty response body** → `?? []` guard, full actualization completes with zero rows; no exception (matches Mexico).

## Testing / verification

This project has no meaningful test suite (CLAUDE.md: only `ExampleTest.php` stubs). Verification is manual via the laravel-boost MCP tools inside `polaris-service-new` with `APP_INSTANCE=kazakhstan`:

1. `tinker`: `resolve(KazakhstanCrmAuthService::class)->getAccessToken()` → JWT; second call within TTL hits cache.
2. `tinker`: `resolve(AbstractDatabaseLoanUpdateHandler::class)->downloadLoans()` → array with transformed fields.
3. Run `EntireActualizationCommand`; verify with `database-query`:
   - `SELECT count(*), count(agreement_id) FROM archivo_creditos` — agreement_id populated.
   - `SELECT count(*) FROM "9_cobranzas" WHERE agreement_id IS NOT NULL` — loans flow through (validates the `interests = "0"` workaround).
   - Spot-check `10_clientes`, `14_telefonos`, `mails` for Cyrillic integrity (validates the `utf8_encode` allowlist change).
4. Re-run `EntireActualizationCommand` — no duplicates (ON CONFLICT works), `agreement_id` updates if source changed.
5. **Regression check on Peru/Colombia/Mexico** (the base class and SQL changed):
   - With each `APP_INSTANCE`, resolve the binding and confirm the correct concrete handler is returned.
   - Confirm `archivo_creditos.agreement_id` is NULL for existing data and the `INSERT INTO "9_cobranzas"` SQL still runs.
6. `vendor/bin/pint --dirty` before finalizing.

## Out of scope

- `KazakhstanDatabasePaymentUpdateHandler` (payments) — separate task.
- Partial actualization by loan id list (no upstream endpoint yet).
- KZ-specific scheduled commands beyond the actualization triad (CSV reports, promises, communications migration).
- DB index on `agreement_id` — add when a query needs it.
- Replacing `interests = "0"` workaround with a relaxed `updateLoans()` SQL — separate cleanup.

## Risks

- **Base SQL & utf8_encode changes affect Peru/Colombia/Mexico.** The change is semantically a no-op for them (new nullable column always NULL; allowlist matches their existing behaviour), but must be validated on staging before deploy.
- **Workaround `interests = "0"`** persists in the data — downstream reports that aggregate `porc_tasa` will see synthetic zeros for KZ. Worth a follow-up TODO.
- **Token cache key is global** (`kazakhstan_crm_access_token`) — fine since only one client_id per environment, but won't multi-tenant cleanly if that ever changes.