false, 'show_contacts' => true, 'show_observations' => true, 'show_payment_instructions' => true, 'contact_text' => '', 'observations' => '']; public function __construct(private CompanyContext $context, private Permissions $permissions, private DocumentPreparation $preparation, private ServiceWorkflow $workflow) {} private function consistent(callable $read): mixed { if (DB::transactionLevel() > 0) { // RefreshDatabase fixtures already use a transaction. Production composition must start its own snapshot. if (! app()->environment('testing')) { throw new HttpException(409, 'Reabra a consulta numa operação independente.'); } return $read(); } return DB::transaction(function () use ($read) { DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); return $read(); }); } private function model(): array { $ids = $this->context->identifiers(); $row = DB::table('document_preview_models')->where($ids)->first(); return ['version' => (int) ($row->version ?? 0), 'data' => $row ? json_decode($row->data, true, 32, JSON_THROW_ON_ERROR) : self::DEFAULT_MODEL]; } public function configuration(int $actor): array { return $this->consistent(function () use ($actor) { $this->preparation->scope('issuer', $actor, 'view'); return $this->model() + ['local_logo_available' => $this->logo() !== null]; }); } public function configure(int $actor, array $input): array { $scope = $this->preparation->scope('issuer', $actor, 'view'); $unit = $this->context->unitId(); $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], self::CONFIGURE); try { return DB::transaction(function () use ($scope, $unit, $actor, $input) { DB::table('companies')->where('id', $scope['company_id'])->lockForUpdate()->firstOrFail(); $this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $unit); $this->preparation->scope('issuer', $actor, 'view'); $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], self::CONFIGURE); $ids = ['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id']]; $old = DB::table('document_preview_models')->where($ids)->first(); abort_unless((int) ($old->version ?? 0) === $input['version'], 409, 'A configuração mudou. Reabra-a antes de guardar.'); $data = array_intersect_key($input, self::DEFAULT_MODEL); $version = (int) ($old->version ?? 0) + 1; $values = ['version' => $version, 'data' => json_encode($data, JSON_THROW_ON_ERROR), 'updated_at' => now()]; if ($old) { DB::table('document_preview_models')->where('id', $old->id)->update($values); $id = $old->id; } else { $id = DB::table('document_preview_models')->insertGetId($ids + $values + ['created_at' => now()]); } DB::table('document_preview_model_history')->insert(['model_id' => $id, 'version' => $version, 'actor_id' => $actor, 'snapshot' => $values['data'], 'created_at' => now()]); Audit::record('document_preview.configured', $actor, $scope['tenant_id'], $scope['company_id'], details: ['record_id' => $id, 'version' => $version]); return ['version' => $version]; }); } catch (QueryException) { throw new HttpException(500, 'Não foi possível guardar a configuração; nenhum conteúdo foi incluído no erro.'); } } public function read(int $draft, int $actor, bool $historical = false): array { return $this->consistent(function () use ($draft, $actor, $historical) { $scope = $this->workflow->scope('billing_drafts', $actor, 'view'); $this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']); if ($historical) { $payload = $this->historical($draft, $actor, $scope); } else { $payload = $this->current($draft, $actor, $scope); } $payload['notice'] = self::NOTICE; // Fingerprint is keyed, so a change token cannot be used to guess bank identifiers. $revision = hash_hmac('sha256', json_encode($payload, JSON_THROW_ON_ERROR), (string) config('app.key')); return $payload + ['revision' => $revision, 'consulted_at' => now()->toIso8601String()]; }); } private function current(int $draft, int $actor, array $scope): array { foreach (['issuer','client','billing_drafts'] as $kind) { $this->preparation->scope($kind, $actor, 'view'); } $billing = $this->workflow->show('billing_drafts', $draft, $actor); abort_if($billing['internal_confirmation'] !== null, 409, 'Este registo tem confirmação interna. Abra a vista histórica separada.'); $issuer = $this->preparation->show('issuer', $scope['company_id'], $actor); $client = $this->preparation->show('client', $billing['client_id'], $actor); $prepared = $this->preparation->show('billing_drafts', $draft, $actor); $model = $this->model(); $payments = []; $paymentVersions = []; foreach ($prepared['data']['payment_instruction_ids'] ?? [] as $id) { $payment = $this->preparation->show('payment', (int) $id, $actor); $paymentVersions[] = [$payment['id'], $payment['version']]; if ($model['data']['show_payment_instructions']) { $payments[] = ['id' => $payment['id'], 'version' => $payment['version'], 'data' => $payment['data']]; } } $pending = $billing['pending']; foreach (['Emitente' => $issuer, 'Cliente' => $client, 'Preparação' => $prepared] as $section => $row) { foreach ($row['review'] as $check) { if ($check['state'] !== 'filled_not_validated') { $pending[$section.'.'.$check['field']] = $section.' — '.$check['label'].': '.$check['next']; } } } if (! $paymentVersions) { $pending['instructions_missing'] = 'Instruções de pagamento não seleccionadas; preparar apenas as aplicáveis.'; } if (! $model['data']['contact_text']) { $pending['contacts_missing'] = 'Contactos de apresentação não configurados; completar se aplicável, independentemente da visibilidade do bloco.'; } $localLogo = $this->logo(); $logo = $model['data']['show_logo'] ? $localLogo : null; if (! $localLogo) { $pending['logo_missing'] = 'Logótipo opcional não disponível: falta recurso local autorizado da empresa. A escolha de visibilidade não valida este recurso.'; } return ['mode' => 'draft', 'title' => 'Pré-visualização do rascunho de factura de serviço', 'reference' => 'Rascunho interno #'.$draft, 'scope' => $scope, 'source_id' => $billing['source_id'], 'draft_version' => $billing['version'], 'preparation_version' => $prepared['version'], 'model_version' => $model['version'], 'issuer' => ['name' => $issuer['name'], 'data' => $issuer['data']], 'client' => ['name' => $client['name'], 'data' => $client['data']], 'data' => $billing['data'], 'prepared' => $prepared['data'], 'base_amount' => $billing['base_amount'], 'pending' => $pending, 'calculation' => app(ServiceCalculation::class)->read($draft, $actor), 'fiscal_history' => $this->permissions->has($actor,$scope['tenant_id'],$scope['company_id'],'service_fiscal.view') ? app(ServiceFiscal::class)->history($draft,$actor) : [], 'payments' => $payments, 'logo' => $logo, 'contacts' => $model['data']['show_contacts'] ? $model['data']['contact_text'] : null, 'observations' => $model['data']['show_observations'] ? $model['data']['observations'] : null, 'versions' => ['issuer' => $issuer['version'], 'client' => $client['version'], 'company_name' => $issuer['name'], 'client_name' => $client['name'], 'instructions' => $paymentVersions, 'model' => $model['version']]]; } private function historical(int $draft, int $actor, array $scope): array { $confirmation = app(InternalConfirmation::class)->show($draft, $actor); abort_unless($confirmation, 404, 'Não há confirmação interna histórica deste registo.'); $snapshot = $confirmation['snapshot']; $prepared = []; $issuer = []; $client = []; $payments = []; $pending = $snapshot['pending']; $document = $snapshot['document_preparation'] ?? []; foreach ($document['records'] ?? [] as $record) { if ($record['kind'] === 'issuer') { $issuer = $record['data']; } if ($record['kind'] === 'client') { $client = $record['data']; } if ($record['kind'] === 'billing_drafts') { $prepared = $record['data']; } } foreach ($document['payment_instructions'] ?? [] as $payment) { $payments[] = ['id' => $payment['id'], 'version' => $payment['version'], 'data' => $payment['data']]; } foreach ($document['completeness'] ?? [] as $kind => $checks) { foreach ($checks as $check) { if ($check['state'] !== 'filled_not_validated') { $pending[$kind.'.'.$check['field']] = $check['label'].': '.$check['next']; } } } $pending['historical_limits'] = 'Logótipo, contactos, observações do modelo e denominação do emitente não foram capturados nesta fotografia. Não foram reconstruídos.'; return ['mode' => 'historical', 'title' => 'Confirmação interna histórica — sem validade fiscal', 'reference' => $confirmation['internal_reference'], 'scope' => $scope, 'confirmed_at' => $snapshot['confirmed_at'], 'actor_id' => $snapshot['actor_id'], 'snapshot_schema' => $snapshot['schema_version'] ?? 1, 'source_id' => $snapshot['service_draft']['id'], 'draft_version' => $snapshot['billing_draft']['version'], 'preparation_version' => null, 'model_version' => null, 'issuer' => ['name' => null, 'data' => $issuer], 'client' => ['name' => $snapshot['client']['name'], 'data' => $client], 'data' => $snapshot['data'], 'prepared' => $prepared, 'base_amount' => $snapshot['base_amount'], 'pending' => $pending, 'payments' => $payments, 'logo' => null, 'contacts' => null, 'observations' => null]; } private function logo(): ?string { $company = $this->context->identifiers()['company_id']; $disk = Storage::disk('local'); $path = 'document-logos/company-'.$company.'.png'; if (! $disk->exists($path) || $disk->size($path) > 524288) { return null; } $bytes = $disk->get($path); if (! str_starts_with($bytes, "\x89PNG\r\n\x1a\n") || (new \finfo(FILEINFO_MIME_TYPE))->buffer($bytes) !== 'image/png') { return null; } return 'data:image/png;base64,'.base64_encode($bytes); } }