# Numeración de créditos por empresa (código 001) — 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:** Que cada crédito tenga un **código secuencial por empresa** (001, 002, 003…) en vez de mostrar el `id` global; asignado automáticamente al crear (todos los paths), con backfill cronológico de los existentes, y mostrado en la PWA + Filament + recibo.

**Architecture:** Columna `company_credit_number` en `credits` + índice único `(company_id, company_credit_number)`. La asignación va en un **hook `creating` del modelo `Credit`** (cubre TODOS los `Credit::create` — nuevo/refinanciar/renovar/reestructurar/clonar — igual que `MultiTenantScope` autosetea `company_id`), con `lockForUpdate()` para serializar y el índice único como red de seguridad. Una migración de datos hace el backfill por empresa en orden cronológico. El display cambia de `credit.id` al código formateado en ~6 vistas PWA + Filament; el routing/FKs siguen por `id`.

**Tech Stack:** Laravel 13 / PHP 8.3, PHPUnit 11, Vue PWA, Filament 5. **Verificación:** phpunit (TDD backend) + PHPStan level 5 + Pint; `npm run build` (front). Sin tests JS de la PWA → build + revisión + validación en dispositivo. Ver [[credify-test-db]], [[credify-ci-phpstan-preflight]].

**Spec:** `docs/superpowers/specs/2026-08-01-credit-numbering-design.md`

**Contexto:**
- Créditos creados en múltiples sitios (`CreditOperationService.php:67/174/262`, `Services/Credits/Operations/BaseCreditOperation.php:60`, `Actions/RestructureCreditWithCustomInstallmentsAction.php:215`, `FinancialOperationService.php:68`) → por eso la asignación va en el **modelo**, no en un servicio.
- `credit.id` se muestra como "Crédito #{id}" en: `views/CreditDetailView.vue:5`, `views/PaymentView.vue:20`, `views/ClientDetailView.vue:96`, `components/payments/PaymentReceipt.vue:56,202`, y fallbacks offline `db/index.js:233`, `views/PaymentsHistoryView.vue:154,229`. Panel Filament de créditos también.
- `lockForUpdate` es patrón establecido (CreditOperationService, CollectorCreditOrderService, PlanLimitService).

**Gotcha:** commits desde WSL, no `git add .claude/`. Deploy full + migración (backfill). Tests comparten BD dev (`DatabaseTransactions`).

---

## File Structure
```
database/migrations/xxxx_add_company_credit_number_to_credits.php   CREATE  columna + índice único
app/Models/Credit.php                                              MODIFY  fillable + accessor code + boot creating hook
database/migrations/yyyy_backfill_company_credit_number.php         CREATE  backfill cronológico por empresa
app/Http/Controllers/Api/Pwa/CreditController.php (+ serializers)   MODIFY  exponer company_credit_number + code
resources/js/pwa/db/index.js                                       MODIFY  guardar el campo en IndexedDB + fallback
resources/js/pwa/{views,components}/... (6 spots)                  MODIFY  mostrar el código en vez de id
app/Filament/Resources/Credits/...                                 MODIFY  columna código en la tabla/vista
tests/Feature/Credits/CreditNumberingTest.php                      CREATE  asignación + independencia + backfill
CHANGELOG.md                                                       MODIFY
```

---

### Task 1: Columna + índice + asignación en el modelo (TDD)

**Files:**
- Create: `database/migrations/xxxx_add_company_credit_number_to_credits.php`
- Modify: `app/Models/Credit.php`
- Test: `tests/Feature/Credits/CreditNumberingTest.php`

- [ ] **Step 1: Test primero (asignación correlativa + independencia + único)**

`tests/Feature/Credits/CreditNumberingTest.php`:
```php
<?php

namespace Tests\Feature\Credits;

use App\Models\Client;
use App\Models\Company;
use App\Models\Credit;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;

class CreditNumberingTest extends TestCase
{
    use DatabaseTransactions;

    private function makeCredit(Company $company): Credit
    {
        $client = Client::factory()->create(['company_id' => $company->id]);
        return Credit::factory()->create([
            'company_id' => $company->id,
            'client_id' => $client->id,
        ]);
    }

    public function test_company_credit_number_is_sequential_per_company(): void
    {
        $a = Company::factory()->create();
        $b = Company::factory()->create();

        $a1 = $this->makeCredit($a);
        $a2 = $this->makeCredit($a);
        $b1 = $this->makeCredit($b);
        $a3 = $this->makeCredit($a);

        $this->assertSame(1, $a1->company_credit_number);
        $this->assertSame(2, $a2->company_credit_number);
        $this->assertSame(3, $a3->company_credit_number);
        // Empresa B numera independiente, desde 1
        $this->assertSame(1, $b1->company_credit_number);
    }

    public function test_formatted_code_is_zero_padded(): void
    {
        $a = Company::factory()->create();
        $c = $this->makeCredit($a);
        $this->assertSame('001', $c->code);
    }
}
```

- [ ] **Step 2: Correr → falla** (columna/atributo no existen).
```bash
wsl bash -lc "cd /var/www/html/credify && php artisan test --filter=CreditNumberingTest 2>&1 | grep -iE 'Tests:|FAIL|Unknown column|Undefined' | head"
```

- [ ] **Step 3: Migración de la columna**

`php artisan make:migration add_company_credit_number_to_credits --table=credits`, luego edita:
```php
    public function up(): void
    {
        Schema::table('credits', function (Blueprint $table) {
            $table->unsignedInteger('company_credit_number')->nullable()->after('company_id');
            $table->unique(['company_id', 'company_credit_number'], 'credits_company_number_unique');
        });
    }

    public function down(): void
    {
        Schema::table('credits', function (Blueprint $table) {
            $table->dropUnique('credits_company_number_unique');
            $table->dropColumn('company_credit_number');
        });
    }
```
(Nullable + índice único: MySQL permite múltiples NULL en un índice único, así que los créditos existentes —aún sin número— no colisionan hasta el backfill.)

- [ ] **Step 4: Modelo `Credit` — fillable + accessor + hook `creating`**

En `app/Models/Credit.php`:
1. Añadir `'company_credit_number'` al `$fillable`.
2. Añadir un accessor del código formateado:
```php
    /** Código por empresa formateado (mín. 3 dígitos): 001, 002, …, 1000. */
    public function getCodeAttribute(): string
    {
        return $this->company_credit_number !== null
            ? str_pad((string) $this->company_credit_number, 3, '0', STR_PAD_LEFT)
            : (string) $this->id; // degradación defensiva (no debería ocurrir tras el backfill)
    }
```
3. Asignación automática en un `booted()` (cubre TODOS los `Credit::create`):
```php
    protected static function booted(): void
    {
        static::creating(function (Credit $credit): void {
            if (! empty($credit->company_id) && empty($credit->company_credit_number)) {
                $max = static::withoutGlobalScopes()
                    ->where('company_id', $credit->company_id)
                    ->lockForUpdate()
                    ->max('company_credit_number');
                $credit->company_credit_number = ((int) $max) + 1;
            }
        });
    }
```
(Si `Credit` ya define `booted()`, **añade** el `creating` dentro del existente en vez de duplicar el método. El `lockForUpdate` serializa concurrentes dentro de la transacción que envuelve la creación; el índice único es la red final.)

- [ ] **Step 5: Migrar + correr test → pasa + PHPStan + Pint + commit**
```bash
wsl bash -lc "cd /var/www/html/credify && php artisan migrate 2>&1 | tail -2 && php artisan test --filter=CreditNumberingTest 2>&1 | grep -iE 'Tests:|FAIL'"
wsl bash -lc "cd /var/www/html/credify && vendor/bin/phpstan analyse --level=5 app/Models/Credit.php 2>&1 | tail -3 && vendor/bin/pint app/Models/Credit.php 2>&1 | tail -2"
wsl bash -lc "cd /var/www/html/credify && git add database/migrations app/Models/Credit.php tests/Feature/Credits/CreditNumberingTest.php && git commit -m 'feat(credits): company_credit_number + asignacion por empresa (hook creating)'"
```

---

### Task 2: Backfill cronológico de los existentes (TDD)

**Files:**
- Create: `database/migrations/yyyy_backfill_company_credit_number.php`
- Test: añadir a `CreditNumberingTest.php`

- [ ] **Step 1: Test del backfill**

Añade a `CreditNumberingTest.php` un test que: crea 2 empresas con varios créditos (con `company_credit_number` forzado a null vía `updateQuietly` para simular datos viejos), corre la lógica de backfill, y verifica que cada empresa quedó 1..N en orden de `created_at`.
```php
    public function test_backfill_assigns_chronological_numbers_per_company(): void
    {
        $a = Company::factory()->create();
        $c1 = $this->makeCredit($a); $c1->updateQuietly(['company_credit_number' => null, 'created_at' => now()->subDays(3)]);
        $c2 = $this->makeCredit($a); $c2->updateQuietly(['company_credit_number' => null, 'created_at' => now()->subDays(1)]);
        $c3 = $this->makeCredit($a); $c3->updateQuietly(['company_credit_number' => null, 'created_at' => now()->subDays(2)]);

        \App\Support\CreditNumberBackfill::run(); // helper invocado por la migración

        $this->assertSame(1, $c1->fresh()->company_credit_number); // más antiguo
        $this->assertSame(2, $c3->fresh()->company_credit_number);
        $this->assertSame(3, $c2->fresh()->company_credit_number); // más reciente
    }
```

- [ ] **Step 2: Helper de backfill** `app/Support/CreditNumberBackfill.php` (para poder testearlo y reusarlo desde la migración):
```php
<?php

namespace App\Support;

use App\Models\Credit;
use Illuminate\Support\Facades\DB;

final class CreditNumberBackfill
{
    /** Asigna 1..N por empresa, en orden cronológico, solo donde falta. */
    public static function run(): void
    {
        $companyIds = Credit::withoutGlobalScopes()
            ->whereNull('company_credit_number')
            ->distinct()->pluck('company_id');

        foreach ($companyIds as $companyId) {
            // arranca en el máximo ya asignado de esa empresa (por si el backfill corre en 2 pasos)
            $n = (int) Credit::withoutGlobalScopes()->where('company_id', $companyId)->max('company_credit_number');
            Credit::withoutGlobalScopes()
                ->where('company_id', $companyId)
                ->whereNull('company_credit_number')
                ->orderBy('created_at')->orderBy('id')
                ->each(function (Credit $credit) use (&$n): void {
                    $n++;
                    $credit->updateQuietly(['company_credit_number' => $n]);
                });
        }
    }
}
```

- [ ] **Step 3: Migración de datos** `database/migrations/yyyy_backfill_company_credit_number.php` (timestamp POSTERIOR a la de Task 1):
```php
<?php

use App\Support\CreditNumberBackfill;
use Illuminate\Database\Migrations\Migration;

return new class extends Migration
{
    public function up(): void
    {
        CreditNumberBackfill::run();
    }

    public function down(): void
    {
        // No revertimos números ya asignados (irreversible por diseño).
    }
};
```

- [ ] **Step 4: Migrar + test → pasa + PHPStan + Pint + commit**
```bash
wsl bash -lc "cd /var/www/html/credify && php artisan migrate 2>&1 | tail -2 && php artisan test --filter=CreditNumberingTest 2>&1 | grep -iE 'Tests:|FAIL'"
wsl bash -lc "cd /var/www/html/credify && vendor/bin/phpstan analyse --level=5 app/Support/CreditNumberBackfill.php 2>&1 | tail -3 && vendor/bin/pint app/Support/CreditNumberBackfill.php 2>&1 | tail -2"
wsl bash -lc "cd /var/www/html/credify && git add database/migrations app/Support/CreditNumberBackfill.php tests/Feature/Credits/CreditNumberingTest.php && git commit -m 'feat(credits): backfill cronologico de company_credit_number'"
```

---

### Task 3: Exponer el código en la API + IndexedDB

**Files:**
- Modify: los serializers/respuestas de crédito de la PWA (`app/Http/Controllers/Api/Pwa/CreditController.php` + cualquier Resource/transformer que arme el objeto crédito para show/index/pay).
- Modify: `resources/js/pwa/db/index.js`

- [ ] **Step 1: API** — En cada lugar donde el backend serializa un crédito para la PWA (show, index, el objeto `credit` del recibo, el payload tras crear/pagar), incluir **`company_credit_number`** y **`code`** (el accessor formateado). Buscar cómo se arma hoy (array explícito, `CreditResource`, `$credit->toArray()`, etc.) y añadir los 2 campos. Verificar que `code` (accessor) se serializa — si usan arrays explícitos, agregar `'code' => $credit->code`; si usan `toArray()`/Resource, añadir `code` a `$appends` del modelo o al Resource.

- [ ] **Step 2: IndexedDB** — En `resources/js/pwa/db/index.js`, donde se guardan/normalizan créditos, persistir `company_credit_number` (y/o `code`) para que el display offline lo tenga. Añadir un helper de formato JS reutilizable, p. ej. en un util:
```js
export function creditCode(credit) {
    if (credit?.code) return credit.code
    if (credit?.company_credit_number != null) return String(credit.company_credit_number).padStart(3, '0')
    return String(credit?.id ?? '') // degradación
}
```
(Ubícalo donde vivan los helpers de formato de la PWA; el fallback offline `db/index.js:233` que hoy hace `Crédito #${p.credit_id}` usa este helper cuando el dato esté, o degrada.)

- [ ] **Step 3: Build + commit**
```bash
wsl bash -lc "cd /var/www/html/credify && npm run build 2>&1 | grep -iE 'error|built in' | tail -2"
wsl bash -lc "cd /var/www/html/credify && vendor/bin/phpstan analyse --level=5 app/Http/Controllers/Api/Pwa/CreditController.php 2>&1 | tail -3"
wsl bash -lc "cd /var/www/html/credify && git add app/Http/Controllers/Api/Pwa/CreditController.php resources/js/pwa/db/index.js app/Models/Credit.php && git commit -m 'feat(credits): exponer code/company_credit_number en API + IndexedDB'"
```

---

### Task 4: Display en la PWA (código en vez de id)

**Files:** `views/CreditDetailView.vue`, `views/PaymentView.vue`, `views/ClientDetailView.vue`, `components/payments/PaymentReceipt.vue`, `views/PaymentsHistoryView.vue` (y `db/index.js` ya cubierto en T3).

- [ ] **Step 1: Reemplazar `Crédito #{{ credit.id }}` por el código**

En cada spot, usar el helper `creditCode(credit)` (o `credit.code` cuando el objeto lo trae del API):
- `CreditDetailView.vue:5` — `:subtitle="credit ? 'Crédito ' + creditCode(credit) : ''"` (o `#${creditCode(credit)}` si prefieres conservar el `#`).
- `PaymentView.vue:20` — `Crédito {{ creditCode(credit) }}`.
- `ClientDetailView.vue:96` — `Crédito {{ creditCode(credit) }}`.
- `PaymentReceipt.vue:56` y el texto de compartir (`:202`) — usar `creditCode(receipt.credit)`.
- `PaymentsHistoryView.vue:154,229` — el fallback `Crédito #${p.credit_id}` mantiene el `credit_id` si no hay dato enriquecido, pero si el pago trae el código del crédito, mostrarlo.
Importar `creditCode` donde se use. Mantener el routing por `credit.id` sin cambios.

- [ ] **Step 2: Build + verificación visual + commit**
```bash
wsl bash -lc "cd /var/www/html/credify && npm run build 2>&1 | grep -iE 'error|built in' | tail -2"
wsl bash -lc "cd /var/www/html/credify && grep -rn 'credit.id' resources/js/pwa/views/CreditDetailView.vue resources/js/pwa/views/PaymentView.vue | grep -i 'Crédito' || echo 'OK: display por código'"
wsl bash -lc "cd /var/www/html/credify && git add resources/js/pwa && git commit -m 'feat(credits): mostrar codigo por empresa en la PWA (no el id)'"
```

---

### Task 5: Filament — columna código

**Files:** `app/Filament/Resources/Credits/...` (tabla y/o vista de créditos).

- [ ] **Step 1:** Añadir una columna/campo que muestre el código por empresa (`code` o `company_credit_number` formateado) donde hoy se ve el `id`. Buscar la tabla de créditos de Filament (`CreditsTable`/`ListCredits`/`CreditResource`) y añadir `TextColumn::make('code')->label('Código')` (o `company_credit_number`). No quitar el id si se usa para acciones/links.

- [ ] **Step 2: Verificar + commit**
```bash
wsl bash -lc "cd /var/www/html/credify && vendor/bin/phpstan analyse --level=5 app/Filament/Resources/Credits 2>&1 | tail -3 && php artisan filament:optimize 2>&1 | tail -2"
wsl bash -lc "cd /var/www/html/credify && git add app/Filament/Resources/Credits && git commit -m 'feat(credits): columna codigo por empresa en Filament'"
```

---

### Task 6: Verificación holística + CHANGELOG

**Files:** `CHANGELOG.md`

- [ ] **Step 1: Suite + PHPStan + Pint + build**
```bash
wsl bash -lc "cd /var/www/html/credify && php artisan test 2>&1 | tail -5"
wsl bash -lc "cd /var/www/html/credify && vendor/bin/phpstan analyse --level=5 app/ 2>&1 | tail -5"
wsl bash -lc "cd /var/www/html/credify && vendor/bin/pint --test 2>&1 | tail -5 && npm run build 2>&1 | grep -iE 'error|built in' | tail -3"
```
Expected: verde (los flaky por fecha/BD compartida son aparte).

- [ ] **Step 2: CHANGELOG** — bajo `## [Sin publicar]` → `### Cambiado`: los créditos ahora tienen un **código secuencial por empresa** (001, 002, …) en vez del id global; asignado al crear (todos los paths, hook del modelo con `lockForUpdate` + índice único), con **backfill cronológico** de los existentes, mostrado en la PWA (detalle, pago, cliente, recibo, offline) y en Filament. El `id` interno no cambia. Ref al spec.

- [ ] **Step 3: Commit**
```bash
wsl bash -lc "cd /var/www/html/credify && git add CHANGELOG.md && git commit -m 'docs(credits): CHANGELOG numeracion por empresa'"
```

---

## Deploy (post-merge, flujo — **full** + migración con backfill)
Tras merge a `main` + realineo `dev`: `npm run build` → ship assets + swap → `git pull` → **`php artisan migrate --force`** (columna + **backfill**; en prod, con datos reales, verificar que corre en tiempo razonable) → `optimize` + `filament:optimize` → reload php-fpm → validación en dispositivo (crear crédito → siguiente número; recibo/vistas muestran el código). Ver [[credify-prod-vm]].

**Secuencia con el demo:** conviene desplegar esta numeración **antes o junto con** el demo, para que los créditos del `DemoDataSeeder` se vean 001+. Coordinar con el usuario (mergear numeración → realinear/rebasar la rama demo → desplegar).

---

## Self-Review (cobertura vs. spec)
- **Campo `company_credit_number` + índice único (red de seguridad):** T1. ✅
- **Asignación por empresa concurrency-safe cubriendo TODOS los paths:** T1 (hook `creating` del modelo + `lockForUpdate`, no solo un servicio). ✅
- **Backfill cronológico por empresa:** T2 (helper testeable + migración). ✅
- **Formato 001 (accessor) + exponer en API + IndexedDB + degradación offline:** T1 (accessor) + T3. ✅
- **Display en las 6+ ubicaciones PWA + Filament + recibo:** T4 + T5. ✅
- **id/routing/FKs sin cambios; no renumerar; no tocar receipt_number:** ningún task los toca. ✅
- **TDD (correlativo, independencia entre empresas, backfill) + índice único:** T1 + T2. ✅
- **Verificación + deploy full con migración:** T6 + Deploy. ✅

**Placeholder scan:** T3/T5 dicen "buscar cómo se serializa hoy / la tabla Filament y añadir el campo" — legítimo (adaptación a estructura existente que el implementer lee); el núcleo (migración, hook, backfill, accessor, tests) está con código completo. Sin `TBD`.

**Consistencia de nombres:** `company_credit_number` (columna/fillable/hook/backfill/tests), accessor `code`, helper JS `creditCode`, `CreditNumberBackfill::run()` (usado por migración y test) — consistentes en todas las tasks. ✅
