> ## Documentation Index
> Fetch the complete documentation index at: https://apidocs.restauranteropro.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Integración RC POS con Restaurantero Pro

> Prompt completo para Cursor — conecta Restaurant Controller POS con la API de Restaurantero Pro al cerrar el Corte Z.

# CURSOR PROMPT — Integración Restaurant Controller POS con Restaurantero Pro

> Este prompt va en el repo de Restaurant Controller POS (express\_pos / restaurant\_pos),
> NO en el repo de Restaurantero Pro.
> Lee este documento completo antes de escribir cualquier línea de código.

***

## CONTEXT

You are working on **Restaurant Controller POS** — specifically the `express_pos` codebase
(Laravel 12, PHP 8.2). The same integration applies to the Full Dining version (`restaurant_pos`).

**What you are building:** A service that automatically sends data to the **Restaurantero Pro API**
every time a Corte Z (cash register closing) is completed. The operator does nothing extra —
it happens automatically in the background.

**Restaurantero Pro API base URL:** `https://my.restauranteropro.com/api/v1`

**Authentication:** Header `X-API-Key: rpro_live_xxx` (configured per branch in `.env`)

**The integration point:** The `CashRegisterOpeningObserver` already exists and fires on every
status change. We hook into it when `status = completed` AND type contains `zout`.

**What the service sends (in this exact order):**

1. `POST /api/v1/ingest/orders` — productos vendidos (from OrderTicketLine)
2. `POST /api/v1/ingest/discounts` — descuentos aplicados
3. `POST /api/v1/ingest/cancellations` — cancelaciones y voids
4. `POST /api/v1/ingest/payments` — pagos por método
5. `POST /api/v1/ingest/sales` — resumen de ventas
6. `POST /api/v1/ingest/cash-register` — el Corte Z (este dispara el procesamiento en RP)

***

## CRITICAL RULES

1. **This runs in the background** — use `dispatch()->onQueue('default')` for the main job.
   The POS cannot wait for HTTP requests to Restaurantero Pro.
2. **Idempotency is mandatory** — use `CashRegisterOpening->identifier` as the base key.
3. **Never block the POS** — all failures must be caught and logged, never thrown to the user.
4. **Feature flag** — only run if `RESTAURANTERO_ENABLED=true` in `.env`. Default: false.
5. **Money conversion** — RC stores amounts with mutators that divide by 100. When reading
   `$payment->amount` or `$line->unit_price`, the value is already in pesos. Multiply by 100
   to get centavos for Restaurantero Pro.
6. **Quantity conversion** — `$line->quantity` also uses a mutator (divides by 100). A qty of
   1 unit is stored as 100, returned as 1.0. Send as integer: `(int) round($line->quantity)`.
7. **Laravel 12** — register everything in `bootstrap/app.php`, not `Kernel.php`.
8. Do NOT modify any existing working code — only ADD new files.
9. No BOM in PHP files.

***

## WHAT TO BUILD — Step by step

### STEP 1 — Environment configuration

Add these variables to `.env.example` (do NOT add values — just the keys):

```env theme={null}
# Restaurantero Pro Integration
RESTAURANTERO_ENABLED=false
RESTAURANTERO_BASE_URL=https://my.restauranteropro.com/api/v1
RESTAURANTERO_API_KEY=
RESTAURANTERO_BRANCH_IDENTIFIER=
```

Create config file `config/restaurantero.php`:

```php theme={null}
<?php

return [
    /*
     * Activar/desactivar la integración con Restaurantero Pro.
     * Por defecto desactivada — activar por sucursal cuando el cliente contrate.
     */
    'enabled'            => env('RESTAURANTERO_ENABLED', false),

    /*
     * URL base de la API de Restaurantero Pro.
     * Cambiar a https://sandbox.restauranteropro.com/api/v1 para pruebas.
     */
    'base_url'           => env('RESTAURANTERO_BASE_URL', 'https://my.restauranteropro.com/api/v1'),

    /*
     * API Key de la sucursal — formato rpro_live_xxx (producción) o rpro_test_xxx (sandbox).
     * Se genera en el dashboard de Restaurantero Pro en Ajustes → Integraciones → API Keys.
     */
    'api_key'            => env('RESTAURANTERO_API_KEY', ''),

    /*
     * UUID de la sucursal en Restaurantero Pro.
     * Se obtiene al crear la sucursal en el dashboard de Restaurantero Pro.
     */
    'branch_identifier'  => env('RESTAURANTERO_BRANCH_IDENTIFIER', ''),

    /*
     * Nombre de este POS — aparece en los logs de Restaurantero Pro.
     */
    'pos_name'           => 'restaurant_controller',

    /*
     * Variante del POS: full_dining o express.
     */
    'pos_variant'        => env('RESTAURANTERO_POS_VARIANT', 'express'),

    /*
     * Versión del POS — se envía en cada request para debugging.
     */
    'pos_version'        => env('APP_VERSION', '1.0.0'),

    /*
     * Timeout en segundos para cada request HTTP a Restaurantero Pro.
     */
    'timeout'            => 15,

    /*
     * Número de reintentos si falla el request.
     */
    'max_retries'        => 3,
];
```

***

### STEP 2 — Main service: `RestauranteroProService`

```
File: app/Services/RestauranteroProService.php
```

```php theme={null}
<?php

namespace App\Services;

use App\Models\CashRegisterOpening;
use App\Models\Discount;
use App\Models\Cancellation;
use App\Models\OrderTicketLine;
use App\Models\Payment;
use App\Models\Reservation;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class RestauranteroProService
{
    private string $baseUrl;
    private string $apiKey;
    private string $branchIdentifier;
    private string $posName;
    private string $posVariant;
    private string $posVersion;
    private int    $timeout;
    private int    $maxRetries;

    public function __construct()
    {
        $this->baseUrl          = config('restaurantero.base_url');
        $this->apiKey           = config('restaurantero.api_key');
        $this->branchIdentifier = config('restaurantero.branch_identifier');
        $this->posName          = config('restaurantero.pos_name');
        $this->posVariant       = config('restaurantero.pos_variant');
        $this->posVersion       = config('restaurantero.pos_version');
        $this->timeout          = config('restaurantero.timeout', 15);
        $this->maxRetries       = config('restaurantero.max_retries', 3);
    }

    /**
     * Verifica si la integración está habilitada y configurada.
     */
    public static function isEnabled(): bool
    {
        return config('restaurantero.enabled', false)
            && ! empty(config('restaurantero.api_key'))
            && ! empty(config('restaurantero.branch_identifier'));
    }

    /**
     * Punto de entrada principal.
     * Llama a este método cuando se completa un Corte Z.
     * Envía todos los datos del día a Restaurantero Pro en el orden correcto.
     */
    public function sendZOut(CashRegisterOpening $closing): void
    {
        if (! self::isEnabled()) {
            return;
        }

        $closingId  = $closing->identifier;
        $date       = $closing->start_at->toDateString();

        Log::info('RestauranteroPro: iniciando envío de Corte Z', [
            'closing_id' => $closingId,
            'date'       => $date,
        ]);

        try {
            // 1. Líneas de orden (primero — para que RP tenga el product mix)
            $this->sendOrders($closing, $date, $closingId);

            // 2. Descuentos
            $this->sendDiscounts($closing, $date, $closingId);

            // 3. Cancelaciones
            $this->sendCancellations($closing, $date, $closingId);

            // 4. Pagos por método
            $this->sendPayments($closing, $date, $closingId);

            // 5. Resumen de ventas
            $this->sendSales($closing, $date, $closingId);

            // 6. El Corte Z — este dispara el procesamiento en Restaurantero Pro
            $this->sendCashRegister($closing, $date, $closingId);

            Log::info('RestauranteroPro: Corte Z enviado correctamente', [
                'closing_id' => $closingId,
            ]);

        } catch (\Exception $e) {
            // NUNCA dejar que un error de RP afecte al POS
            Log::error('RestauranteroPro: error al enviar Corte Z', [
                'closing_id' => $closingId,
                'error'      => $e->getMessage(),
            ]);
        }
    }

    // ─── BUILDERS ─────────────────────────────────────────────────────────────

    private function sendCashRegister(CashRegisterOpening $closing, string $date, string $closingId): void
    {
        // Construir summaries desde los summaries del CashRegisterOpening
        $summaries = $closing->summaries->map(fn($s) => [
            'type'         => $this->mapSummaryType($s->type ?? $s->name ?? 'other'),
            'name'         => $s->name ?? 'Concepto',
            'amount_cents' => (int) round(($s->amount ?? 0) * 100),
        ])->toArray();

        // Calcular cash_variance desde los metadatos de caché si están disponibles
        $meta = cache()->get('cash_register_opening_meta', []);

        $payload = [
            'data' => [
                'type'       => 'cash-register',
                'attributes' => [
                    'branch_identifier' => $this->branchIdentifier,
                    'closing_type'      => 'zout',
                    'start_at'          => $closing->start_at->toIso8601String(),
                    'end_at'            => $closing->end_at?->toIso8601String() ?? now()->toIso8601String(),
                    'status'            => 'completed',
                    'summaries'         => $summaries,
                ],
                'meta' => [
                    'pos_name'    => $this->posName,
                    'pos_version' => $this->posVersion,
                    'pos_variant' => $this->posVariant,
                    'sent_at'     => now()->toIso8601String(),
                    'cashier_name'=> $closing->user?->name,
                ],
            ],
        ];

        $this->post('/ingest/cash-register', $payload, $closingId);
    }

    private function sendSales(CashRegisterOpening $closing, string $date, string $closingId): void
    {
        // Obtener las reservaciones cerradas del día para calcular ventas
        $openableIds = $closing->openables()
            ->where('openable_type', Reservation::class)
            ->pluck('openable_id');

        $reservations = Reservation::whereIn('id', $openableIds)->get();

        $totalSales   = $reservations->sum('subtotal');     // en pesos
        $covers       = $reservations->sum(fn($r) => $r->orders()->count() > 0 ? 1 : 0);

        // Si no hay reservaciones en openables, intentar calcular desde summaries
        if ($totalSales == 0) {
            $summaryMap = $closing->summaries->keyBy(fn($s) => strtolower($s->type ?? $s->name ?? ''));
            $cashSales  = $summaryMap->first(fn($s) => str_contains(strtolower($s->name ?? ''), 'efectivo'))?->amount ?? 0;
            $cardSales  = $summaryMap->first(fn($s) => str_contains(strtolower($s->name ?? ''), 'tarjeta'))?->amount ?? 0;
            $totalSales = $cashSales + $cardSales;
        }

        $totalSalesCents = (int) round($totalSales * 100);
        $foodSalesCents  = (int) round($totalSalesCents * 0.686);
        $bevSalesCents   = $totalSalesCents - $foodSalesCents;

        $payload = [
            'data' => [
                'type'       => 'sales',
                'attributes' => [
                    'branch_identifier'        => $this->branchIdentifier,
                    'date'                     => $date,
                    'shift'                    => 'all_day',
                    'closing_type'             => 'zout',
                    'total_sales_cents'        => $totalSalesCents,
                    'total_sales_no_iva_cents' => (int) round($totalSalesCents / 1.16),
                    'food_sales_cents'         => $foodSalesCents,
                    'beverage_sales_cents'     => $bevSalesCents,
                    'covers'                   => max($covers, $reservations->count()),
                    'tables_served'            => $reservations->count(),
                ],
                'meta' => [
                    'pos_name'    => $this->posName,
                    'pos_version' => $this->posVersion,
                    'sent_at'     => now()->toIso8601String(),
                ],
            ],
        ];

        $this->post('/ingest/sales', $payload, $closingId . '-sales');
    }

    private function sendPayments(CashRegisterOpening $closing, string $date, string $closingId): void
    {
        // Obtener pagos de las reservaciones del día
        $openableIds = $closing->openables()
            ->where('openable_type', Reservation::class)
            ->pluck('openable_id');

        $payments = Payment::query()
            ->whereHasMorph('paymentable', Reservation::class, fn($q) => $q->whereIn('id', $openableIds))
            ->whereIn('status', ['active', 'paid'])
            ->with('type')
            ->get()
            ->groupBy(fn($p) => $p->type?->name ?? 'other');

        if ($payments->isEmpty()) {
            // Fallback: construir pagos desde summaries del corte
            $paymentsArray = $closing->summaries->map(fn($s) => [
                'payment_type'      => $this->mapSummaryType($s->type ?? $s->name ?? 'other'),
                'payment_type_name' => $s->name ?? 'Otro',
                'amount_cents'      => (int) round(($s->amount ?? 0) * 100),
                'tips_cents'        => 0,
                'change_cents'      => 0,
                'transaction_count' => 1,
            ])->toArray();
        } else {
            $paymentsArray = $payments->map(fn($group, $typeName) => [
                'payment_type'      => $this->mapPaymentType($typeName),
                'payment_type_name' => $typeName,
                'amount_cents'      => (int) round($group->sum('amount') * 100),
                'tips_cents'        => 0,
                'change_cents'      => (int) round($group->sum('change') * 100),
                'transaction_count' => $group->count(),
            ])->values()->toArray();
        }

        if (empty($paymentsArray)) return;

        $payload = [
            'data' => [
                'type'       => 'payments',
                'attributes' => [
                    'branch_identifier' => $this->branchIdentifier,
                    'date'              => $date,
                    'closing_type'      => 'zout',
                    'payments'          => $paymentsArray,
                ],
                'meta' => ['pos_name' => $this->posName, 'sent_at' => now()->toIso8601String()],
            ],
        ];

        $this->post('/ingest/payments', $payload, $closingId . '-payments');
    }

    private function sendOrders(CashRegisterOpening $closing, string $date, string $closingId): void
    {
        // Obtener líneas de orden de las reservaciones del día
        $openableIds = $closing->openables()
            ->where('openable_type', Reservation::class)
            ->pluck('openable_id');

        $lines = OrderTicketLine::query()
            ->whereHas('ticket.order', fn($q) =>
                $q->whereHasMorph('orderable', Reservation::class, fn($r) => $r->whereIn('id', $openableIds))
            )
            ->whereIn('status', ['closed', 'to_pay', 'sent', 'open'])
            ->with(['ticket.order'])
            ->get()
            ->map(fn($line) => [
                'item_identifier'  => null,
                'item_sku'         => $line->identification ?? null,
                'item_name'        => (string) $line->description,
                'category'         => null,
                'category_type'    => 'food',
                'quantity'         => (int) round($line->quantity),   // mutator already divides by 100
                'unit_price_cents' => (int) round($line->unit_price * 100), // mutator divides by 100, we multiply back
                'subtotal_cents'   => (int) round($line->subtotal * 100),
                'discount_cents'   => (int) round($line->discount * 100),
                'net_total_cents'  => (int) round($line->netSubtotal * 100),
            ])->toArray();

        if (empty($lines)) return;

        $payload = [
            'data' => [
                'type'       => 'orders',
                'attributes' => [
                    'branch_identifier' => $this->branchIdentifier,
                    'date'              => $date,
                    'closing_type'      => 'zout',
                    'lines'             => $lines,
                ],
                'meta' => ['pos_name' => $this->posName, 'sent_at' => now()->toIso8601String()],
            ],
        ];

        $this->post('/ingest/orders', $payload, $closingId . '-orders');
    }

    private function sendDiscounts(CashRegisterOpening $closing, string $date, string $closingId): void
    {
        $openableIds = $closing->openables()
            ->where('openable_type', Reservation::class)
            ->pluck('openable_id');

        $discounts = Discount::query()
            ->whereHasMorph('discountable', Reservation::class, fn($q) => $q->whereIn('id', $openableIds))
            ->whereIn('status', ['active'])
            ->with('user')
            ->get()
            ->map(fn($d) => [
                'discount_identifier' => $d->identifier,
                'type'                => $d->type ?? 'fixed',
                'factor_type'         => $d->factor_type ?? 'fixed',
                'name'                => $d->name ?? 'Descuento',
                'rate'                => (float) $d->rate_or_fee,
                'amount_cents'        => (int) round($d->rate_or_fee * 100),
                'applied_by'          => $d->user?->name,
                'authorized_by'       => $d->user?->name,
                'reason'              => $d->description ?? null,
            ])->toArray();

        if (empty($discounts)) return;

        $payload = [
            'data' => [
                'type'       => 'discounts',
                'attributes' => [
                    'branch_identifier' => $this->branchIdentifier,
                    'date'              => $date,
                    'closing_type'      => 'zout',
                    'discounts'         => $discounts,
                ],
                'meta' => ['pos_name' => $this->posName, 'sent_at' => now()->toIso8601String()],
            ],
        ];

        $this->post('/ingest/discounts', $payload, $closingId . '-discounts');
    }

    private function sendCancellations(CashRegisterOpening $closing, string $date, string $closingId): void
    {
        $openableIds = $closing->openables()
            ->where('openable_type', Reservation::class)
            ->pluck('openable_id');

        $cancellations = Cancellation::query()
            ->whereHasMorph('cancellationable', OrderTicketLine::class, fn($q) =>
                $q->whereHas('ticket.order', fn($r) =>
                    $r->whereHasMorph('orderable', Reservation::class, fn($s) => $s->whereIn('id', $openableIds))
                )
            )
            ->with(['cancelledBy', 'authorizedBy', 'cancellationable'])
            ->get()
            ->map(fn($c) => [
                'cancellation_identifier' => $c->identifier,
                'item_name'               => (string) ($c->cancellationable?->description ?? 'Producto cancelado'),
                'item_sku'                => $c->cancellationable?->identification ?? null,
                'quantity'                => 1,
                'amount_cents'            => (int) round(($c->cancellationable?->subtotal ?? 0) * 100),
                'reason'                  => $c->reason ?? null,
                'cancelled_by'            => $c->cancelledBy?->name,
                'authorized_by'           => $c->authorizedBy?->name,
                'cancelled_at'            => $c->created_at->toIso8601String(),
            ])->toArray();

        if (empty($cancellations)) return;

        $payload = [
            'data' => [
                'type'       => 'cancellations',
                'attributes' => [
                    'branch_identifier' => $this->branchIdentifier,
                    'date'              => $date,
                    'closing_type'      => 'zout',
                    'cancellations'     => $cancellations,
                ],
                'meta' => ['pos_name' => $this->posName, 'sent_at' => now()->toIso8601String()],
            ],
        ];

        $this->post('/ingest/cancellations', $payload, $closingId . '-cancellations');
    }

    // ─── MAPPERS ──────────────────────────────────────────────────────────────

    /**
     * Mapea el nombre del summary del corte RC al tipo de Restaurantero Pro.
     */
    private function mapSummaryType(string $type): string
    {
        $type = strtolower($type);

        return match(true) {
            str_contains($type, 'efectivo') || str_contains($type, 'cash')           => 'cash_sales',
            str_contains($type, 'tarjeta') || str_contains($type, 'card')            => 'card_sales',
            str_contains($type, 'credito') || str_contains($type, 'credit')          => 'card_credit_sales',
            str_contains($type, 'debito') || str_contains($type, 'debit')            => 'card_debit_sales',
            str_contains($type, 'transferencia') || str_contains($type, 'transfer')  => 'transfer_sales',
            str_contains($type, 'propina') && str_contains($type, 'pagada')          => 'tips_paid_to_waiter',
            str_contains($type, 'propina') || str_contains($type, 'tip')             => 'tips_received',
            str_contains($type, 'retencion') || str_contains($type, 'moche')         => 'withholding',
            str_contains($type, 'deposito') || str_contains($type, 'deposit')        => 'deposits',
            str_contains($type, 'diferencia') || str_contains($type, 'faltante')
                || str_contains($type, 'sobrante') || str_contains($type, 'variance') => 'cash_variance',
            default                                                                   => 'other',
        };
    }

    /**
     * Mapea el nombre del tipo de pago de RC al tipo de Restaurantero Pro.
     */
    private function mapPaymentType(string $typeName): string
    {
        $type = strtolower($typeName);

        return match(true) {
            str_contains($type, 'efectivo') || str_contains($type, 'cash')   => 'cash',
            str_contains($type, 'credito') || str_contains($type, 'credit')  => 'card_credit',
            str_contains($type, 'debito') || str_contains($type, 'debit')    => 'card_debit',
            str_contains($type, 'tarjeta') || str_contains($type, 'card')    => 'card',
            str_contains($type, 'transfer')                                   => 'transfer',
            str_contains($type, 'rappi')                                      => 'rappi',
            str_contains($type, 'uber')                                       => 'uber_eats',
            str_contains($type, 'didi')                                       => 'didi_food',
            str_contains($type, 'vale')                                       => 'voucher',
            default                                                           => 'other',
        };
    }

    // ─── HTTP HELPER ──────────────────────────────────────────────────────────

    /**
     * Envía un POST a Restaurantero Pro con reintentos automáticos.
     * NUNCA lanza excepciones — solo loggea los errores.
     */
    private function post(string $endpoint, array $payload, string $idempotencyKey): void
    {
        $attempts = 0;
        $delays   = [0, 5, 30]; // segundos entre reintentos

        while ($attempts < $this->maxRetries) {
            try {
                $response = Http::withHeaders([
                    'X-API-Key'         => $this->apiKey,
                    'X-Idempotency-Key' => $idempotencyKey,
                    'Content-Type'      => 'application/json',
                    'Accept'            => 'application/json',
                ])->timeout($this->timeout)->post($this->baseUrl . $endpoint, $payload);

                if ($response->successful()) {
                    Log::info("RestauranteroPro: {$endpoint} enviado", [
                        'idempotency_key' => $idempotencyKey,
                        'status'          => $response->status(),
                    ]);
                    return;
                }

                // 409 = ya procesado (idempotencia) — no es un error
                if ($response->status() === 409) {
                    Log::info("RestauranteroPro: {$endpoint} ya procesado (idempotencia)", [
                        'idempotency_key' => $idempotencyKey,
                    ]);
                    return;
                }

                Log::warning("RestauranteroPro: {$endpoint} HTTP {$response->status()}", [
                    'idempotency_key' => $idempotencyKey,
                    'response'        => $response->json(),
                ]);

            } catch (\Exception $e) {
                Log::error("RestauranteroPro: {$endpoint} excepción (intento " . ($attempts + 1) . ")", [
                    'error'           => $e->getMessage(),
                    'idempotency_key' => $idempotencyKey,
                ]);
            }

            $attempts++;
            if ($attempts < $this->maxRetries && isset($delays[$attempts])) {
                sleep($delays[$attempts]);
            }
        }

        Log::critical("RestauranteroPro: {$endpoint} falló {$this->maxRetries} intentos", [
            'idempotency_key' => $idempotencyKey,
        ]);
    }
}
```

***

### STEP 3 — Job: `SendZOutToRestauranteroProJob`

```
File: app/Jobs/SendZOutToRestauranteroProJob.php
```

```php theme={null}
<?php

namespace App\Jobs;

use App\Models\CashRegisterOpening;
use App\Services\RestauranteroProService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class SendZOutToRestauranteroProJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * Reintentos del job si falla completamente.
     * El servicio ya tiene sus propios reintentos internos.
     */
    public int $tries = 2;

    /**
     * Timeout del job en segundos.
     * 6 requests × 15 seg timeout × 3 reintentos = máximo ~270 seg en el peor caso.
     */
    public int $timeout = 300;

    public function __construct(
        private readonly string $cashRegisterOpeningIdentifier
    ) {}

    public function handle(RestauranteroProService $service): void
    {
        if (! RestauranteroProService::isEnabled()) {
            return;
        }

        $closing = CashRegisterOpening::with([
            'summaries',
            'user',
            'type',
            'openables.openable',
        ])->where('identifier', $this->cashRegisterOpeningIdentifier)->first();

        if (! $closing) {
            Log::warning('SendZOutToRestauranteroProJob: CashRegisterOpening no encontrado', [
                'identifier' => $this->cashRegisterOpeningIdentifier,
            ]);
            return;
        }

        $service->sendZOut($closing);
    }

    public function failed(\Throwable $exception): void
    {
        Log::critical('SendZOutToRestauranteroProJob: falló definitivamente', [
            'identifier' => $this->cashRegisterOpeningIdentifier,
            'error'      => $exception->getMessage(),
        ]);
    }
}
```

***

### STEP 4 — Hook into the existing Observer

**IMPORTANT:** Do NOT rewrite `CashRegisterOpeningObserver.php`. Only ADD the dispatch call
inside the existing `handleStatusChange` method, after the existing logic.

Find the existing file `app/Observers/CashRegisterOpeningObserver.php`.

In the `handleStatusChange` method, add this block at the END of the method,
after the existing `if` statements:

```php theme={null}
// Restaurantero Pro: enviar Corte Z en background cuando se completa
if ($cashRegisterOpening->isCompleted()
    && $category
    && str($category->type)->contains('cash_register.closings.zout')
    && \App\Services\RestauranteroProService::isEnabled()
) {
    \App\Jobs\SendZOutToRestauranteroProJob::dispatch(
        $cashRegisterOpening->identifier
    )->onQueue('default');
}
```

The final `handleStatusChange` method should look like this:

```php theme={null}
private function handleStatusChange(CashRegisterOpening $cashRegisterOpening): void
{
    $category = $cashRegisterOpening->type;

    if (! $category) {
        return;
    }

    $typeString = str($category->type);

    // Existing logic — DO NOT MODIFY
    if ($cashRegisterOpening->isPrinted() and $typeString->contains(['cash_register.closings.zout'])) {
        $this->archiveRelatedReservations($cashRegisterOpening);
    }

    if ($cashRegisterOpening->isProcessing() and $typeString->contains(['cash_register.closings.xout', 'cash_register.closings.zout'])) {
        CashRegisterOpeningCopied::dispatch($cashRegisterOpening);
    }

    if ($cashRegisterOpening->isPending() and $typeString->contains(['cash_register.closings.xout', 'cash_register.closings.zout'])) {
        $cashRegisterOpening->markAs('processing');
    }

    // NEW: Restaurantero Pro integration
    if ($cashRegisterOpening->isCompleted()
        && $typeString->contains(['cash_register.closings.zout'])
        && \App\Services\RestauranteroProService::isEnabled()
    ) {
        \App\Jobs\SendZOutToRestauranteroProJob::dispatch(
            $cashRegisterOpening->identifier
        )->onQueue('default');
    }
}
```

***

### STEP 5 — Artisan command to test the connection

```
File: app/Console/Commands/TestRestauranteroProCommand.php
```

```php theme={null}
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;

class TestRestauranteroProCommand extends Command
{
    protected $signature   = 'restaurantero:test';
    protected $description = 'Verifica la conexión con Restaurantero Pro API';

    public function handle(): int
    {
        $this->info('Verificando conexión con Restaurantero Pro...');
        $this->newLine();

        if (! config('restaurantero.enabled')) {
            $this->warn('RESTAURANTERO_ENABLED=false — Integración desactivada.');
            $this->line('Para activar, agrega en .env:');
            $this->line('  RESTAURANTERO_ENABLED=true');
            $this->line('  RESTAURANTERO_API_KEY=rpro_live_xxx');
            $this->line('  RESTAURANTERO_BRANCH_IDENTIFIER=uuid-de-tu-sucursal');
            return Command::SUCCESS;
        }

        $apiKey    = config('restaurantero.api_key');
        $baseUrl   = config('restaurantero.base_url');
        $branchId  = config('restaurantero.branch_identifier');

        $this->table(['Campo', 'Valor'], [
            ['Base URL',           $baseUrl],
            ['API Key',            substr($apiKey, 0, 15) . '...'],
            ['Branch Identifier',  $branchId],
            ['POS Variant',        config('restaurantero.pos_variant')],
        ]);
        $this->newLine();

        try {
            $response = Http::withHeaders([
                'X-API-Key'    => $apiKey,
                'Accept'       => 'application/json',
            ])->timeout(10)->get($baseUrl . '/ping');

            if ($response->successful()) {
                $data = $response->json('data.attributes');
                $this->info('✓ Conexión exitosa con Restaurantero Pro');
                $this->table(['Campo', 'Valor'], [
                    ['Sucursal',    $data['branch_name'] ?? 'N/A'],
                    ['Plan',        $data['plan'] ?? 'N/A'],
                    ['Ambiente',    $data['environment'] ?? 'N/A'],
                    ['API Version', $data['api_version'] ?? 'N/A'],
                ]);
            } else {
                $this->error('✗ Error de conexión: HTTP ' . $response->status());
                $this->line($response->body());
                return Command::FAILURE;
            }
        } catch (\Exception $e) {
            $this->error('✗ No se pudo conectar: ' . $e->getMessage());
            return Command::FAILURE;
        }

        $this->newLine();
        $this->info('La integración está lista. Los Cortes Z se enviarán automáticamente.');

        return Command::SUCCESS;
    }
}
```

***

## STEP 6 — `.env` configuration for Pitic (first client)

Add these lines to the `.env` of the RC installation at Pitic:

```env theme={null}
# Restaurantero Pro — Sucursal Pitic
RESTAURANTERO_ENABLED=true
RESTAURANTERO_BASE_URL=https://my.restauranteropro.com/api/v1
RESTAURANTERO_API_KEY=rpro_test_xtYaAXCEoaoHVR2fkLgIY77lamAU8KfzNO7lWKJM
RESTAURANTERO_BRANCH_IDENTIFIER=019e0c31-f390-7185-8bff-f5046c392930
RESTAURANTERO_POS_VARIANT=full_dining
```

> **Note for Carlos:** Use `rpro_test_` key for testing. Switch to `rpro_live_` key
> when going to production. The branch\_identifier above is for Pitic (Hermosillo).

***

## VERIFICATION CHECKLIST

After completing all steps:

* [ ] `config/restaurantero.php` exists with all keys
* [ ] `app/Services/RestauranteroProService.php` exists
* [ ] `app/Jobs/SendZOutToRestauranteroProJob.php` exists
* [ ] `app/Observers/CashRegisterOpeningObserver.php` has the new block at the end of `handleStatusChange`
* [ ] `app/Console/Commands/TestRestauranteroProCommand.php` exists
* [ ] Run: `php artisan restaurantero:test` → should show "Conexión exitosa"
* [ ] With `RESTAURANTERO_ENABLED=false` → `php artisan restaurantero:test` shows the setup instructions
* [ ] Complete a test Corte Z in the POS → check Laravel logs for "RestauranteroPro: Corte Z enviado"
* [ ] Verify in Restaurantero Pro dashboard that data arrived

***

## NOTES FOR CARLOS

**Full Dining vs Express:** The integration is identical for both versions. The models
(`CashRegisterOpening`, `OrderTicketLine`, `Payment`, `Discount`, `Cancellation`) exist in
both codebases with the same structure. Apply this same integration to both repos.

**Queue:** The job runs on the `default` queue. Make sure the queue worker is running:

```bash theme={null}
php artisan queue:work --queue=default
```

**Logs:** All activity is logged with the prefix `RestauranteroPro:` — easy to grep:

```bash theme={null}
tail -f storage/logs/laravel.log | grep RestauranteroPro
```

**Testing without a real Corte Z:** You can trigger the job manually:

```bash theme={null}
php artisan tinker
>>> \App\Jobs\SendZOutToRestauranteroProJob::dispatchSync('uuid-del-cashregisteropening');
```

**Colección de Postman:** para probar los 8 endpoints de ingesta directo, sin escribir
código, descarga la colección con ejemplos reales (mismo orden de Corte Z documentado
en la sección 3 de `api-v1.mdx`):

**[📥 Descargar Restaurantero\_Pro\_API.postman\_collection.json](https://gist.githubusercontent.com/Azul6040/17bd027e3b1f5effa9706ebf6f5937f1/raw/99c8b7f6f913a2c414334d9c79142658f7eac0e2/Restaurantero_Pro_API.postman_collection.json)**

Importa el archivo en Postman (Import → File), configura las variables `base_url`,
`api_key`, y `branch_identifier` en la pestaña "Variables" de la colección, y el header
`X-Idempotency-Key` ya viene listo para generar un UUID nuevo automáticamente en cada request.

***

*Restaurant Controller POS — Restaurantero Pro Integration — Fase 4 de 4*
*Laravel 12 · PHP 8.2*
*Mayo 2026*
