ReceivableStatus::PAID, 'PAYMENT_CONFIRMED' => ReceivableStatus::PAID, 'PAYMENT_OVERDUE' => ReceivableStatus::OVERDUE, 'PAYMENT_DELETED' => ReceivableStatus::CANCELLED, 'PAYMENT_REFUNDED' => ReceivableStatus::CANCELLED, ]; public array $payload; public function __construct(array $payload) { $this->payload = $payload; } public function handle(NotificationService $notifications): void { $event = $this->payload['event'] ?? null; $payment = $this->payload['payment'] ?? []; if (! $event || empty($payment)) { Log::warning('Asaas Webhook Job: payload incompleto', $this->payload); return; } $newStatus = self::EVENT_STATUS_MAP[$event] ?? null; if (! $newStatus) { Log::info("Asaas Webhook Job: evento '{$event}' ignorado (não mapeado)."); return; } $externalReference = $payment['externalReference'] ?? null; $asaasId = $payment['id'] ?? null; // Cobrança de aluno (parcela) cobrada pela conta Asaas PRÓPRIA da unidade. if ($externalReference && str_starts_with($externalReference, 'STU_')) { $this->handleStudentInstallment($externalReference, $payment, $event, $newStatus); return; } $receive = null; $type = 'unknown'; if ($externalReference) { // Recebível de Franquia (TBR) cobrado pela conta da Matriz. if (str_starts_with($externalReference, 'TBR_')) { $id = str_replace('TBR_', '', $externalReference); $receive = FranchiseeAccountReceive::find($id); $type = 'FranchiseeAccountReceive'; } // 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)'; } } if (! $receive) { Log::warning('Asaas Webhook Job: registro não encontrado', [ 'externalReference' => $externalReference, 'asaas_id' => $asaasId, 'event' => $event, ]); return; } // Comparação segura de status (a model pode expor enum ou string). $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; } $updateData = [ 'status' => $newStatusValue, 'asaas_status' => $payment['status'] ?? $event, ]; if ($newStatusValue === ReceivableStatus::PAID->value) { $updateData['payment_date'] = $payment['paymentDate'] ?? $payment['confirmedDate'] ?? now(); $updateData['paid_value'] = $payment['value'] ?? $receive->value; } $receive->update($updateData); // Reflete a baixa na Conta a Pagar da unidade quando o TBR é confirmado // pago via Asaas (mantém o espelho em dia sem baixa manual). A baixa manual // de cada lado continua independente para os demais casos. if ($newStatusValue === ReceivableStatus::PAID->value) { $payable = \App\Models\UnitAccountPayable::where('franchisee_account_receive_id', $receive->id) ->whereNotIn('status', ['paid', 'cancelled']) ->first(); if ($payable) { $payable->update([ 'status' => 'paid', 'paid_value' => $payable->value, 'payment_date' => $updateData['payment_date'] ?? now(), ]); Log::info("Asaas Webhook Job: Conta a Pagar da unidade #{$payable->id} baixada automaticamente (TBR pago)."); } if ($receive->unit_id) { $notifications->dispatch([ 'origin_table' => 'franchisee_account_receives', 'origin_id' => $receive->id, 'unit_id' => $receive->unit_id, 'type' => NotificationType::PAYMENT_CREDITED->value, 'title' => 'Pagamento confirmado', 'message' => "Sua cobrança {$receive->history} foi devidamente creditada e consta como paga.", 'url' => '/financial/accounts-payable', 'action_text' => 'Ver Contas a Pagar', 'priority' => 'normal', 'recipient_user_ids' => $notifications->unitUserIds($receive->unit_id), ]); } } Log::info("Asaas Webhook Job: {$type} #{$receive->id} atualizado para '{$newStatusValue}'", [ 'event' => $event, 'asaas_id' => $asaasId, ]); } /** * Baixa/atualiza a parcela do aluno cobrada pela conta Asaas da unidade. */ private function handleStudentInstallment(string $externalReference, array $payment, string $event, ReceivableStatus $newStatus): void { $id = str_replace('STU_', '', $externalReference); $installment = \App\Models\StudentContractInstallment::find($id); if (! $installment) { Log::warning("Asaas Webhook Job: parcela {$externalReference} não encontrada.", [ 'event' => $event, 'asaas_id' => $payment['id'] ?? null, ]); return; } // Mapeia o status do recebível para o vocabulário das parcelas. $statusMap = [ ReceivableStatus::PAID->value => 'paid', ReceivableStatus::OVERDUE->value => 'overdue', ReceivableStatus::CANCELLED->value => 'cancelled', ]; $installmentStatus = $statusMap[$newStatus->value] ?? $installment->status; // Idempotência: já pago e continua pago → ignora duplicata. if ($installment->status === 'paid' && $installmentStatus === 'paid') { Log::info("Asaas Webhook Job: parcela #{$installment->id} já paga. Ignorando duplicata."); return; } $update = [ 'status' => $installmentStatus, 'asaas_status' => $payment['status'] ?? $event, ]; if ($installmentStatus === 'paid') { $update['payment_date'] = $payment['paymentDate'] ?? $payment['confirmedDate'] ?? now(); $update['paid_value'] = $payment['value'] ?? $installment->value; } $installment->update($update); Log::info("Asaas Webhook Job: parcela #{$installment->id} atualizada para '{$installmentStatus}'.", [ 'event' => $event, 'asaas_id' => $payment['id'] ?? null, ]); } }