Forráskód Böngészése

feat: implement student installment synchronization with Asaas, including encryption for API keys and database schema updates.

ebagabee 1 hete
szülő
commit
7fa0739063

+ 6 - 0
app/Exceptions/AsaasException.php

@@ -8,14 +8,20 @@
 class AsaasException extends Exception
 {
     public array $errors;
+    public $rawBody;
 
     public function __construct(Response $response)
     {
         $status = $response->status();
+        $this->rawBody = $response->json() ?? $response->body();
         $body = $response->json();
 
         $this->errors = $body['errors'] ?? [];
         $message = "Asaas API Error ($status): " . collect($this->errors)->pluck('description')->join(', ');
+        
+        if (empty($this->errors) && is_string($this->rawBody)) {
+            $message .= " | Response: " . $this->rawBody;
+        }
 
         parent::__construct($message, $status);
     }

+ 42 - 10
app/Jobs/ProcessAsaasWebhookJob.php

@@ -4,6 +4,7 @@
 
 use App\Enums\ReceivableStatus;
 use App\Models\FranchiseeAccountReceive;
+use App\Models\StudentContractInstallment;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
@@ -51,17 +52,44 @@ public function handle(): void
         $asaasId           = $payment['id'] ?? null;
 
         $receive = null;
+        $type = 'unknown';
 
         if ($externalReference) {
-            $receive = FranchiseeAccountReceive::find($externalReference);
+            // Se for um recebível de Franquia
+            if (str_starts_with($externalReference, 'TBR_')) {
+                $id = str_replace('TBR_', '', $externalReference);
+                $receive = FranchiseeAccountReceive::find($id);
+                $type = 'FranchiseeAccountReceive';
+            } 
+            // Se for parcela de aluno
+            elseif (str_starts_with($externalReference, 'STU_')) {
+                $id = str_replace('STU_', '', $externalReference);
+                $receive = StudentContractInstallment::find($id);
+                $type = 'StudentContractInstallment';
+            }
+            // Backward compatibility para faturas de teste geradas antes do prefixo
+            elseif (is_numeric($externalReference)) {
+                $receive = FranchiseeAccountReceive::find($externalReference);
+                $type = 'FranchiseeAccountReceive (legacy)';
+            }
         }
 
+        // Fallback por asaas_id caso externalReference venha nulo
         if (!$receive && $asaasId) {
             $receive = FranchiseeAccountReceive::where('asaas_id', $asaasId)->first();
+            
+            if ($receive) {
+                $type = 'FranchiseeAccountReceive (by asaas_id)';
+            } else {
+                $receive = StudentContractInstallment::where('asaas_id', $asaasId)->first();
+                if ($receive) {
+                    $type = 'StudentContractInstallment (by asaas_id)';
+                }
+            }
         }
 
         if (!$receive) {
-            Log::warning("Asaas Webhook Job: recebível não encontrado", [
+            Log::warning("Asaas Webhook Job: registro não encontrado", [
                 'externalReference' => $externalReference,
                 'asaas_id' => $asaasId,
                 'event' => $event,
@@ -69,27 +97,31 @@ public function handle(): void
             return;
         }
 
-        // Se já está pago, não processa de novo
-        if ($receive->status === ReceivableStatus::PAID && $newStatus === ReceivableStatus::PAID) {
-            Log::info("Asaas Webhook Job: recebível #{$receive->id} já está pago. Ignorando duplicata.");
+        // Conversão de status: se a model usa string (como StudentContractInstallment)
+        // Precisamos comparar de forma segura
+        $currentStatus = $receive->status instanceof ReceivableStatus ? $receive->status->value : $receive->status;
+        $newStatusValue = $newStatus instanceof ReceivableStatus ? $newStatus->value : $newStatus;
+
+        // Idempotência
+        if ($currentStatus === ReceivableStatus::PAID->value && $newStatusValue === ReceivableStatus::PAID->value) {
+            Log::info("Asaas Webhook Job: registro {$type} #{$receive->id} já está pago. Ignorando duplicata.");
             return;
         }
 
-        // Atualizar o status
         $updateData = [
-            'status'      => $newStatus,
+            'status'      => $newStatusValue,
             'asaas_status' => $payment['status'] ?? $event,
         ];
 
-        // Se foi pago, registrar a data de pagamento e o valor pago
-        if ($newStatus === ReceivableStatus::PAID) {
+        if ($newStatusValue === ReceivableStatus::PAID->value) {
             $updateData['payment_date'] = $payment['paymentDate'] ?? $payment['confirmedDate'] ?? now();
+            // A tabela do Aluno usa 'paid_value', a tabela da Franquia também.
             $updateData['paid_value']   = $payment['value'] ?? $receive->value;
         }
 
         $receive->update($updateData);
 
-        Log::info("Asaas Webhook Job: recebível #{$receive->id} atualizado para '{$newStatus->value}'", [
+        Log::info("Asaas Webhook Job: {$type} #{$receive->id} atualizado para '{$newStatusValue}'", [
             'event' => $event,
             'asaas_id' => $asaasId,
         ]);

+ 4 - 4
app/Jobs/SyncFranchiseeChargeJob.php

@@ -33,7 +33,7 @@ public function __construct(FranchiseeAccountReceive $receive)
      */
     public function handle(AsaasClient $client, AsaasCustomerService $customerService): void
     {
-        // 1. Garantir que a Unidade é um Customer na conta-mãe do Asaas
+        // Garantir que a Unidade é um Customer na conta-mãe do Asaas
         $unit = $this->receive->unit;
         if (!$unit) {
             Log::error("Recebível #{$this->receive->id} sem Unidade vinculada. Abortando integração.");
@@ -44,19 +44,19 @@ public function handle(AsaasClient $client, AsaasCustomerService $customerServic
             $asaasCustomerId = $customerService->ensureFranchiseeCustomer($unit);
             $this->receive->update(['asaas_customer_id' => $asaasCustomerId]);
             
-            // 2. Gerar a cobrança no Asaas
+            // Gerar a cobrança no Asaas
             $payload = [
                 'customer' => $asaasCustomerId,
                 'billingType' => 'UNDEFINED', // Deixa o pagador escolher PIX/Boleto
                 'value' => (float) $this->receive->value,
                 'dueDate' => $this->receive->due_date->format('Y-m-d'),
                 'description' => $this->receive->history ?? 'Cobrança Ginástica do Cérebro (TBR)',
-                'externalReference' => (string) $this->receive->id, // Para conciliarmos no webhook depois
+                'externalReference' => "TBR_{$this->receive->id}",
             ];
 
             $response = $client->post('/payments', $payload);
 
-            // 3. Atualiza o banco com o link do boleto
+            // Atualiza o banco com o link do boleto
             $this->receive->update([
                 'asaas_id' => $response['id'],
                 'invoice_url' => $response['invoiceUrl'],

+ 102 - 0
app/Jobs/SyncStudentChargeJob.php

@@ -0,0 +1,102 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Models\StudentContractInstallment;
+use App\Models\UnitPaymentAccount;
+use App\Services\Integrations\Asaas\AsaasClient;
+use App\Services\Integrations\Asaas\AsaasCustomerService;
+use Exception;
+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\Crypt;
+use Illuminate\Support\Facades\Log;
+
+class SyncStudentChargeJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public int $installmentId;
+    public int $tries = 3;
+
+    public function __construct(int $installmentId)
+    {
+        $this->installmentId = $installmentId;
+    }
+
+    public function handle(AsaasCustomerService $customerService): void
+    {
+        $installment = StudentContractInstallment::with(['student', 'unit', 'studentContract'])->find($this->installmentId);
+
+        if (!$installment) {
+            Log::warning("SyncStudentChargeJob: Parcela {$this->installmentId} não encontrada.");
+            return;
+        } 
+
+        if ($installment->asaas_id) {
+            Log::info("SyncStudentChargeJob: Parcela {$installment->id} já possui asaas_id ({$installment->asaas_id}). Ignorando.");
+            return;
+        }
+
+        if ($installment->value <= 0) {
+            Log::info("SyncStudentChargeJob: Parcela {$installment->id} possui valor zerado. Ignorando Asaas.");
+            return;
+        }
+
+        // Pegar a API Key da Subconta da Franquia
+        $paymentAccount = UnitPaymentAccount::where('unit_id', $installment->unit_id)->first();
+        
+        if (!$paymentAccount || empty($paymentAccount->asaas_api_key)) {
+            throw new Exception("Unidade {$installment->unit_id} não possui subconta Asaas ou API Key configurada.");
+        }
+
+        $apiKey = Crypt::decryptString($paymentAccount->asaas_api_key);
+
+        // Garantir que o Aluno seja um Customer na subconta
+        $asaasCustomerId = $customerService->ensureStudentCustomer($installment->student, $apiKey);
+
+        $installment->update(['asaas_customer_id' => $asaasCustomerId]);
+
+        // Criar a Cobrança no Asaas usando a apiKey da franquia
+        $client = new AsaasClient($apiKey);
+
+        $payload = [
+            'customer' => $asaasCustomerId,
+            'billingType' => 'UNDEFINED', // Deixa o aluno escolher PIX ou BOLETO
+            'value' => $installment->value,
+            'dueDate' => \Carbon\Carbon::parse($installment->due_date)->format('Y-m-d'),
+            'description' => "Mensalidade / {$installment->history} - {$installment->student->name}",
+            // Prefixo crucial para o webhook identificar qual tabela procurar!
+            'externalReference' => "STU_{$installment->id}",
+            'postalService' => false,
+        ];
+
+        // Lidar com juros e multas padrão do Asaas ou do contrato se houver:
+        // $payload['interest'] = ['value' => 1];
+        // $payload['fine'] = ['value' => 2];
+
+        try {
+            $response = $client->post('/payments', $payload);
+
+            // 4. Salvar as referências no nosso banco local
+            $installment->update([
+                'asaas_id' => $response['id'],
+                'invoice_url' => $response['invoiceUrl'] ?? null,
+                'asaas_status' => $response['status'] ?? 'PENDING',
+                'billing_type' => $response['billingType'] ?? null,
+            ]);
+
+            Log::info("SyncStudentChargeJob: Cobrança criada no Asaas para a parcela {$installment->id} (Subconta da Unidade {$installment->unit_id}).");
+
+        } catch (Exception $e) {
+            Log::error("SyncStudentChargeJob: Erro ao criar cobrança no Asaas. " . $e->getMessage(), [
+                'installment_id' => $installment->id,
+                'payload' => $payload
+            ]);
+            throw $e;
+        }
+    }
+}

+ 2 - 1
app/Services/Integrations/Asaas/AsaasAccountService.php

@@ -37,6 +37,7 @@ public function ensureSubaccount(Unit $unit): UnitPaymentAccount
             'province' => $unit->neighborhood,
             'postalCode' => preg_replace('/[^0-9]/', '', $unit->postal_code),
             'companyType' => 'LIMITED',
+            'incomeValue' => 10000.00, // Renda/Faturamento exigido pelo Asaas
         ];
 
         $response = $this->client->post('/accounts', $payload);
@@ -46,7 +47,7 @@ public function ensureSubaccount(Unit $unit): UnitPaymentAccount
             [
                 'asaas_account_id' => $response['id'],
                 'asaas_wallet_id' => $response['walletId'],
-                'asaas_api_key' => $response['apiKey'],
+                'asaas_api_key' => \Illuminate\Support\Facades\Crypt::encryptString($response['apiKey']),
                 'status' => 'ACTIVE',
                 'onboarded_at' => now(),
             ]

+ 45 - 4
app/Services/Integrations/Asaas/AsaasCustomerService.php

@@ -26,14 +26,14 @@ public function ensureFranchiseeCustomer(Unit $unit): string
             throw new Exception("Unidade {$unit->fantasy_name} não possui CNPJ para ser cobrada.");
         }
 
-        // 1. Tenta buscar no Asaas se já existe o customer com esse CNPJ
+        // Tenta buscar no Asaas se já existe o customer com esse CNPJ
         $existing = $this->client->get('/customers', ['cpfCnpj' => $cpfCnpj]);
 
         if (isset($existing['data']) && count($existing['data']) > 0) {
             return $existing['data'][0]['id'];
         }
 
-        // 2. Se não existe, cria um novo
+        // Se não existe, cria um novo
         $payload = [
             'name' => $unit->social_reason ?? $unit->fantasy_name ?? 'Franquia',
             'cpfCnpj' => $cpfCnpj,
@@ -44,9 +44,8 @@ public function ensureFranchiseeCustomer(Unit $unit): string
             'province' => $unit->neighborhood,
         ];
 
-        // Só envia telefone se for um número que pareça válido (10 a 11 dígitos), 
-        // para não travar a API do Asaas com lixo do cadastro.
         $mobilePhone = preg_replace('/[^0-9]/', '', $unit->cell_number ?? $unit->phone_number ?? '');
+        
         if (strlen($mobilePhone) >= 10 && strlen($mobilePhone) <= 11) {
             $payload['mobilePhone'] = $mobilePhone;
         }
@@ -55,4 +54,46 @@ public function ensureFranchiseeCustomer(Unit $unit): string
 
         return $response['id'];
     }
+
+    /**
+     * Garante que o Aluno exista como Customer na Subconta da Franquia.
+     * Retorna o ID do cliente no Asaas (cus_xxxxxx).
+     */
+    public function ensureStudentCustomer(\App\Models\Student $student, string $apiKey): string
+    {
+        $client = new AsaasClient($apiKey);
+        $cpf = preg_replace('/[^0-9]/', '', $student->document_number);
+
+        if (empty($cpf)) {
+            throw new Exception("Aluno {$student->name} não possui CPF cadastrado para cobrança.");
+        }
+
+        // Tenta buscar no Asaas se já existe
+        $existing = $client->get('/customers', ['cpfCnpj' => $cpf]);
+
+        if (isset($existing['data']) && count($existing['data']) > 0) {
+            return $existing['data'][0]['id'];
+        }
+
+        // Se não existe, cria um novo
+        $payload = [
+            'name' => $student->name,
+            'cpfCnpj' => $cpf,
+            'email' => $student->email,
+            'postalCode' => preg_replace('/[^0-9]/', '', $student->postal_code ?? ''),
+            'address' => $student->street,
+            'addressNumber' => $student->address_number ?? 'S/N',
+            'province' => $student->neighborhood,
+        ];
+
+        $mobilePhone = preg_replace('/[^0-9]/', '', $student->phone ?? '');
+        
+        if (strlen($mobilePhone) >= 10 && strlen($mobilePhone) <= 11) {
+            $payload['mobilePhone'] = $mobilePhone;
+        }
+
+        $response = $client->post('/customers', $payload);
+
+        return $response['id'];
+    }
 }

+ 31 - 0
database/migrations/2026_06_05_130202_add_asaas_columns_to_student_contract_installments_table.php

@@ -0,0 +1,31 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('student_contract_installments', function (Blueprint $table) {
+            $table->string('billing_type')->nullable()->after('status')->comment('PIX, BOLETO, CREDIT_CARD');
+            $table->string('asaas_status')->nullable()->after('billing_type');
+            $table->string('invoice_url')->nullable()->after('asaas_status');
+            $table->string('asaas_customer_id')->nullable()->after('invoice_url');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('student_contract_installments', function (Blueprint $table) {
+            $table->dropColumn(['billing_type', 'asaas_status', 'invoice_url', 'asaas_customer_id']);
+        });
+    }
+};