diff --git a/AGENTS.md b/AGENTS.md index 1ced4e1..8295bc8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,3 +20,5 @@ - As novas concessões de A foram especificamente confirmadas para as contas existentes ID 1 e ID 2, na empresa ID 1 / unidade ID 1, após conferência. Não inferir direitos dos emails, nomes ou concessões de identidade; qualquer nova aplicação inicial exige confirmação específica. - Em A, quantidades/preços são cadeias decimais exactas com até 16 algarismos inteiros e 8 decimais. Não usar float, moeda por omissão, imposto desconhecido como zero ou arredondamentos silenciosos. Histórico/eventos/outbox/pendências são transaccionais; não processar contabilidade nesta entrega. - Em 25 de Outubro de 2026, o utilizador aceitou a entrega A no âmbito demonstrado e com os limites de docs/SERVICES_A_DELIVERY.md e autorizou um checkpoint Git exclusivamente local, sem credenciais ou dados locais. Preservar esta base aceite. Não avançar para B/C nem publicar. +- Autorização posterior delimitada: B1, apenas confirmação interna dos rascunhos de facturação, fotografia imutável e captura transaccional de eventos/pendências. Mostrar «Confirmado internamente — sem validade fiscal». Não emitir documentos comerciais/fiscais, reconhecer receita, produzir lançamentos, receber, reconciliar, fechar ou publicar. A nova concessão billing_drafts.confirm_internal foi especificamente confirmada para a conta existente ID 1, empresa 1/unidade 1; não implica gestão de novas concessões nem altera a consulta da conta ID 2. B1 não conclui B integralmente; ver docs/SERVICES_B1_DELIVERY.md. +- Em 25 de Outubro de 2026, o utilizador aceitou B1 no âmbito demonstrado de confirmação interna, sem validade fiscal nem lançamentos contabilísticos, e autorizou um checkpoint Git local sem credenciais ou dados locais. Preservar B1 e os checkpoints anteriores. Não avançar para os restantes incrementos nem publicar. diff --git a/README.md b/README.md index b5a6301..a846973 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Primeiro percurso funcional local, sobre a fundação existente e o roteiro v0.9 ## Usar a entrega A +**Incremento posterior B1:** disponível a confirmação interna do rascunho de facturação para quem possua `billing_drafts.confirm_internal`. Abrir o rascunho, escolher **Rever confirmação interna**, conferir versão/contexto e confirmar. Quantidades/preços e referências devem estar completos e actuais; moeda ou regras desconhecidas permanecem pendentes. A fotografia é imutável e o rascunho confirmado deixa de poder ser editado ou eliminado. A referência `INT-B1-…` não é numeração fiscal. Não há emissão, impressão de factura, reconhecimento ou lançamento contabilístico. [Relatório B1](docs/SERVICES_B1_DELIVERY.md). A mantém-se aceite no checkpoint `ca13acb`; B1 foi aceite em 25 de Outubro de 2026 no âmbito demonstrado, sem validade fiscal nem lançamentos contabilísticos. Não avançar para os restantes incrementos nem publicar. + Actualizar o navegador e entrar normalmente. Seleccionar a empresa e a unidade autorizadas. Os novos menus são **Clientes**, **Serviços**, **Prestações em rascunho** e **Rascunhos de facturação**. 1. Criar o cliente (apenas nome obrigatório) e o serviço. São cadastros da empresa, reutilizados entre unidades. diff --git a/app/Modules/Services/Http/ServiceController.php b/app/Modules/Services/Http/ServiceController.php index ca18f25..6174e6a 100644 --- a/app/Modules/Services/Http/ServiceController.php +++ b/app/Modules/Services/Http/ServiceController.php @@ -11,7 +11,18 @@ use Illuminate\Http\Request; final class ServiceController extends Controller { public function index(Request $request, string $area, ServiceWorkflow $workflow) { return response()->json(['records' => $workflow->list($area, $request->user()->id)]); } - public function show(Request $request, string $area, int $id, ServiceWorkflow $workflow) { return response()->json($workflow->show($area, $id, $request->user()->id)); } + public function show(Request $request, string $area, int $id, ServiceWorkflow $workflow) + { + $record = $workflow->show($area, $id, $request->user()->id); + if ($area === 'billing_drafts') { $record['confirmation'] = app(\App\Modules\Services\InternalConfirmation::class)->show($id, $request->user()->id); } + return response()->json($record); + } + public function confirmInternal(Request $request, int $id, \App\Modules\Services\InternalConfirmation $confirmation) + { + abort_if(array_diff(array_keys($request->all()), ['version']) !== [], 422, 'Campo não suportado ou identificador protegido.'); + $data = $request->validate(['version' => 'required|integer|min:1']); + return response()->json($confirmation->confirm($id, $request->user()->id, (int) $data['version'], (string) $request->header('Idempotency-Key', ''))); + } public function create(Request $request, string $area, ServiceWorkflow $workflow) { return $this->save($request, $area, null, $workflow); } public function update(Request $request, string $area, int $id, ServiceWorkflow $workflow) { return $this->save($request, $area, $id, $workflow); } diff --git a/app/Modules/Services/InternalConfirmation.php b/app/Modules/Services/InternalConfirmation.php new file mode 100644 index 0000000..8fee70d --- /dev/null +++ b/app/Modules/Services/InternalConfirmation.php @@ -0,0 +1,86 @@ +workflow->scope('billing_drafts', $actor, 'view'); + $row = DB::table('b_internal_confirmations')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->where('billing_draft_id', $draft)->first(); + if (! $row) { return null; } + $history = DB::table('a_history')->where('area', 'internal_confirmations')->where('record_id', $row->id)->value('id'); + $event = DB::table('a_events as e')->join('a_outbox as o', 'e.id', '=', 'o.event_id')->where('history_id', $history)->first(['e.id','e.type','o.state']); + return ['id' => $row->id, 'internal_reference' => 'INT-B1-'.$row->id, 'label' => self::LABEL, 'snapshot' => json_decode($row->snapshot, true, 64, JSON_THROW_ON_ERROR), 'event' => $event]; + } + + public function confirm(int $draft, int $actor, int $version, string $key): array + { + $scope = $this->workflow->scope('billing_drafts', $actor, 'view'); + $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], self::PERMISSION, $scope['unit_id']); + abort_unless((bool) preg_match('/^[a-zA-Z0-9-]{8,100}$/D', $key), 422, 'É necessária uma chave de repetição válida.'); + $hash = hash('sha256', json_encode(['b1.confirm_internal', $draft, $scope, $version], JSON_THROW_ON_ERROR)); + return DB::transaction(function () use ($scope, $draft, $actor, $version, $key, $hash): array { + DB::table('companies')->where('id', $scope['company_id'])->where('tenant_id', $scope['tenant_id'])->lockForUpdate()->firstOrFail(); + $this->context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']); + $this->workflow->scope('billing_drafts', $actor, 'view'); + $this->permissions->require($actor, $scope['tenant_id'], $scope['company_id'], self::PERMISSION, $scope['unit_id']); + $old = DB::table('a_commands')->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('command_key', $key)->first(); + if ($old) { + abort_unless((int) $old->actor_id === $actor && hash_equals($old->request_hash, $hash), 409, 'Chave repetida com conteúdo ou autor diferente.'); + return json_decode($old->result, true, 32, JSON_THROW_ON_ERROR) + ['replayed' => true]; + } + $billing = DB::table('a_billing_drafts')->where('id', $draft)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->lockForUpdate()->first(); + abort_unless($billing, 403, 'Rascunho fora do âmbito autorizado.'); + abort_unless((int) $billing->version === $version, 409, 'A versão mudou; reabra o rascunho.'); + $existing = DB::table('b_internal_confirmations')->where('billing_draft_id', $draft)->first(); + if ($existing) { + $history = DB::table('a_history')->where('area', 'internal_confirmations')->where('record_id', $existing->id)->value('id'); + $event = DB::table('a_events')->where('history_id', $history)->value('id'); + $result = ['id' => $existing->id, 'billing_draft_id' => $draft, 'version' => $version, 'internal_reference' => 'INT-B1-'.$existing->id, 'event_id' => $event]; + } else { + $data = json_decode($billing->data, true, 32, JSON_THROW_ON_ERROR); + $source = DB::table('a_service_drafts')->where('id', $billing->source_id)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->first(); + abort_unless($source && (int) $source->version === (int) $data['source_version'], 409, 'A prestação mudou; reveja e actualize explicitamente o rascunho antes de confirmar.'); + $client = DB::table('a_clients')->where('id', $billing->client_id)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->first(); + abort_unless($client && trim($client->name) !== '' && trim($billing->name) !== '', 422, 'Complete o cliente e a designação do percurso.'); + abort_unless((int) $source->client_id === (int) $billing->client_id, 422, 'O cliente diverge da prestação de origem.'); + $data['lines'] = DraftValues::lines($data['lines'], $scope); + foreach ($data['lines'] as $line) { abort_if($line['quantity'] === null || $line['unit_price'] === null || trim($line['service_name']) === '', 422, 'Complete quantidade, preço e serviço de todas as linhas.'); } + $pending = DraftValues::pending($data) + ['accounts_pending' => 'Contas e classificação por validar; contabilização bloqueada.', 'recognition_pending' => 'Momento e regras de reconhecimento por validar; contabilização bloqueada.']; + $catalogue = []; + foreach (array_unique(array_column($data['lines'], 'service_id')) as $id) { $service = DB::table('a_services')->where('id', $id)->first(); $catalogue[] = $this->record($service); } + $data['client_name'] = $client->name; + $snapshot = ['label' => self::LABEL, 'scope' => $scope, 'billing_draft' => $this->record($billing), 'service_draft' => $this->record($source), + 'client' => $this->record($client), 'services' => $catalogue, 'data' => $data, 'base_amount' => DraftValues::base($data), + 'pending' => $pending, 'actor_id' => $actor, 'confirmed_at' => now()->toIso8601String(), 'accounting_status' => 'blocked_pending', 'fiscal_status' => 'not_issued']; + $id = DB::table('b_internal_confirmations')->insertGetId($scope + ['billing_draft_id' => $draft, 'billing_version' => $version, 'version' => 1, 'actor_id' => $actor, 'snapshot' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'created_at' => now()]); + $history = DB::table('a_history')->insertGetId($scope + ['area' => 'internal_confirmations', 'record_id' => $id, 'version' => 1, 'actor_id' => $actor, 'snapshot' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'created_at' => now()]); + foreach ($pending as $code => $message) { DB::table('a_pending')->insert(['history_id' => $history, 'code' => $code, 'message' => $message, 'created_at' => now()]); } + $event = DB::table('a_events')->insertGetId(['history_id' => $history, 'type' => 'b1.billing_draft.confirmed_internal', 'schema_version' => 1, 'payload' => json_encode(['confirmation_id' => $id, 'snapshot' => $snapshot], JSON_THROW_ON_ERROR), 'created_at' => now()]); + DB::table('a_outbox')->insert(['event_id' => $event, 'state' => 'captured', 'created_at' => now()]); + Audit::record('b1.billing_draft.confirmed_internal', $actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id'], details: ['area' => 'internal_confirmations', 'record_id' => $id, 'version' => 1, 'event_id' => $event]); + $result = ['id' => $id, 'billing_draft_id' => $draft, 'version' => $version, 'internal_reference' => 'INT-B1-'.$id, 'event_id' => $event]; + } + DB::table('a_commands')->insert(['tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id'], 'actor_id' => $actor, 'command_key' => $key, 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]); + return $result + ['replayed' => isset($existing) && $existing !== null]; + }); + } + + private function record(object $record): array + { + $data = (array) $record; + if (isset($data['data'])) { $data['data'] = json_decode($data['data'], true, 32, JSON_THROW_ON_ERROR); } + return $data; + } +} diff --git a/app/Modules/Services/ServiceWorkflow.php b/app/Modules/Services/ServiceWorkflow.php index d70ac81..8031acc 100644 --- a/app/Modules/Services/ServiceWorkflow.php +++ b/app/Modules/Services/ServiceWorkflow.php @@ -55,6 +55,10 @@ final class ServiceWorkflow $result = (array) $row; $result['data'] = json_decode($row->data, true, 32, JSON_THROW_ON_ERROR); $result['version'] = (int) $row->version; + if ($area === 'billing_drafts') { + $confirmationId = DB::table('b_internal_confirmations')->where('billing_draft_id', $row->id)->value('id'); + $result['internal_confirmation'] = $confirmationId ? ['reference' => 'INT-B1-'.$confirmationId, 'label' => InternalConfirmation::LABEL] : null; + } $result['base_amount'] = ServicePermissions::unitScoped($area) ? DraftValues::base($result['data']) : null; $history = DB::table('a_history')->where('area', $area)->where('record_id', $row->id)->where('version', $row->version)->value('id'); $result['pending'] = $history ? DB::table('a_pending')->where('history_id', $history)->pluck('message', 'code')->all() : []; @@ -80,6 +84,7 @@ final class ServiceWorkflow $old = $id ? $this->query($area, $scope)->where('id', $id)->first() : null; if ($id) { abort_unless($old, 403, 'Registo fora do âmbito autorizado.'); + abort_if($area === 'billing_drafts' && DB::table('b_internal_confirmations')->where('billing_draft_id', $id)->exists(), 409, 'Confirmado internamente — sem validade fiscal. O registo está bloqueado para edição.'); abort_unless((int) $old->version === (int) ($input['version'] ?? 0), 409, 'O registo mudou. Reabra-o antes de guardar.'); } $version = $old ? (int) $old->version + 1 : 1; @@ -102,6 +107,7 @@ final class ServiceWorkflow $affected = []; if ($area === 'service_drafts' && $old) { foreach (DB::table('a_billing_drafts')->where('source_id', $id)->where('tenant_id', $scope['tenant_id'])->where('company_id', $scope['company_id'])->where('unit_id', $scope['unit_id'])->get(['id','version']) as $billing) { + if (DB::table('b_internal_confirmations')->where('billing_draft_id', $billing->id)->exists()) { continue; } $billingHistory = DB::table('a_history')->where('area', 'billing_drafts')->where('record_id', $billing->id)->where('version', $billing->version)->value('id'); DB::table('a_pending')->insertOrIgnore(['history_id' => $billingHistory, 'code' => 'source_changed', 'message' => 'A prestação de origem foi alterada; os dados deste rascunho não foram reescritos.', 'created_at' => now()]); $affected[] = $billing->id; diff --git a/database/migrations/2026_10_25_040000_create_internal_confirmations.php b/database/migrations/2026_10_25_040000_create_internal_confirmations.php new file mode 100644 index 0000000..108fe40 --- /dev/null +++ b/database/migrations/2026_10_25_040000_create_internal_confirmations.php @@ -0,0 +1,61 @@ +id(); + $table->unsignedBigInteger('tenant_id'); $table->unsignedBigInteger('company_id'); $table->unsignedBigInteger('unit_id'); + $table->foreignId('billing_draft_id')->unique()->constrained('a_billing_drafts')->restrictOnDelete(); + $table->unsignedInteger('billing_version'); $table->unsignedInteger('version')->default(1); + $table->foreignId('actor_id')->constrained('users')->restrictOnDelete(); + $table->jsonb('snapshot'); $table->timestampTz('created_at'); + $table->foreign(['tenant_id','company_id','unit_id','billing_draft_id'])->references(['tenant_id','company_id','unit_id','id'])->on('a_billing_drafts')->restrictOnDelete(); + }); + DB::statement('ALTER TABLE b_internal_confirmations ADD CHECK (version = 1 AND billing_version > 0 AND jsonb_typeof(snapshot) = \'object\')'); + DB::unprepared(<<<'SQL' + CREATE OR REPLACE FUNCTION sgf_b_confirmation_guard() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + PERFORM 1 FROM a_billing_drafts WHERE id = NEW.billing_draft_id AND version = NEW.billing_version FOR UPDATE; + IF NOT FOUND THEN RAISE EXCEPTION 'SGF_B_STALE_DRAFT'; END IF; + RETURN NEW; + END; $$; + CREATE OR REPLACE FUNCTION sgf_b_draft_locked() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF EXISTS (SELECT 1 FROM b_internal_confirmations WHERE billing_draft_id = OLD.id) THEN RAISE EXCEPTION 'SGF_B_CONFIRMED_IMMUTABLE'; END IF; + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RETURN NEW; + END; $$; + CREATE OR REPLACE FUNCTION sgf_a_history_guard() RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE item record; target text; + BEGIN + target := CASE NEW.area WHEN 'clients' THEN 'a_clients' WHEN 'services' THEN 'a_services' WHEN 'service_drafts' THEN 'a_service_drafts' WHEN 'billing_drafts' THEN 'a_billing_drafts' WHEN 'internal_confirmations' THEN 'b_internal_confirmations' ELSE NULL END; + IF target IS NULL THEN RAISE EXCEPTION 'SGF_A_AREA'; END IF; + EXECUTE format('SELECT tenant_id,company_id,unit_id,version FROM %I WHERE id=$1', target) INTO item USING NEW.record_id; + IF item IS NULL OR item.tenant_id IS DISTINCT FROM NEW.tenant_id OR item.company_id IS DISTINCT FROM NEW.company_id OR item.unit_id IS DISTINCT FROM NEW.unit_id OR item.version IS DISTINCT FROM NEW.version THEN RAISE EXCEPTION 'SGF_A_HISTORY_SCOPE'; END IF; + RETURN NEW; + END; $$; + CREATE TRIGGER b_confirmation_valid BEFORE INSERT ON b_internal_confirmations FOR EACH ROW EXECUTE FUNCTION sgf_b_confirmation_guard(); + CREATE TRIGGER b_confirmation_immutable BEFORE UPDATE OR DELETE ON b_internal_confirmations FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit(); + CREATE TRIGGER b_billing_locked BEFORE UPDATE OR DELETE ON a_billing_drafts FOR EACH ROW EXECUTE FUNCTION sgf_b_draft_locked(); + SQL); + $company = ['company.view','company.edit','unit.create','access.manage']; $unit = ['unit.view','unit.edit','billing_drafts.confirm_internal']; + foreach (['clients','services','service_drafts','billing_drafts'] as $area) { + foreach (['view','create','edit','manage'] as $action) { if (in_array($area, ['clients','services'], true)) { $company[] = "$area.$action"; } else { $unit[] = "$area.$action"; } } + } + $companySql = "'".implode("','", $company)."'"; $unitSql = "'".implode("','", $unit)."'"; + DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check'); + DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((permission IN ($companySql) AND unit_id IS NULL) OR (permission IN ($unitSql) AND unit_id IS NOT NULL))"); + } + + public function down(): void + { + throw new RuntimeException('Internal confirmations are immutable; rollback requires a separately reviewed preservation procedure.'); + } +}; diff --git a/docs/SERVICES_B1_DELIVERY.md b/docs/SERVICES_B1_DELIVERY.md new file mode 100644 index 0000000..52ec73d --- /dev/null +++ b/docs/SERVICES_B1_DELIVERY.md @@ -0,0 +1,78 @@ +# B1 — confirmação interna e fotografia histórica + +Data: 25 de Outubro de 2026 (Africa/Johannesburg). Estado: B1 aceite pelo utilizador no âmbito demonstrado de confirmação interna, sem validade fiscal nem lançamentos contabilísticos. Não constitui conclusão integral de B. A permanece aceite no checkpoint `ca13acb`, com antecessor `26b80df`; nenhum foi substituído ou reaberto. + +## Aceitação + +Em 25 de Outubro de 2026, o utilizador declarou: «Aceito B1 no âmbito demonstrado de confirmação interna, sem validade fiscal nem lançamentos contabilísticos. Regista a aceitação e preserva um checkpoint Git local, sem credenciais ou dados locais. Não avances para os restantes incrementos nem publiques.» + +O checkpoint preserva código, migração, documentação e provas sintéticas. Exclui credenciais, configurações privadas, bases, sessões e logs. As contagens de desenvolvimento neste relatório são evidência histórica da entrega, não uma cópia dos dados locais. A aceitação encerra apenas B1 e mantém os limites abaixo. + +## O que ficou funcional + +Um utilizador com `billing_drafts.view` e a nova concessão explícita `billing_drafts.confirm_internal`, na empresa/unidade seleccionadas, pode abrir o rascunho, rever a operação e confirmar uma versão actual. A interface apresenta **«Confirmado internamente — sem validade fiscal»** e referência técnica `INT-B1-`, distinta de numeração fiscal. + +A confirmação exige cliente/serviços válidos na empresa, prestação na mesma unidade, versão da prestação correspondente à origem guardada, designação e linhas com quantidade positiva/preço não negativo completos. Reutiliza as validações decimais de A. Campos e identificadores protegidos enviados pelo navegador são recusados. Empresa/unidade, pertenças e permissão são revalidadas no servidor dentro da transacção, após bloquear a empresa e antes da operação. A sessão e o token de contexto existentes continuam em uso. + +Moeda desconhecida é conservada como `null`, com pendência explícita. Imposto permanece pendente e não é convertido em zero. Mesmo uma moeda preenchida continua sujeita a validação. A base aritmética usa decimais exactos e não se apresenta como total monetário definitivo ou fiscal; não há agregação de moedas distintas. + +A fotografia conserva o rascunho e a prestação com versões/dados originais, cliente e serviços com versões/dados observados, linhas e valores usados, moeda conhecida ou desconhecida, pendências, âmbito, autor e instante. Os nomes nas linhas e identificação do cliente usados na confirmação são conferidos no cadastro actual, que também fica fotografado; a versão original do rascunho continua preservada separadamente. Alterações posteriores dos cadastros ou da prestação não reescrevem a fotografia. + +A confirmação cria uma identidade própria, sem transformar a prestação em serviço confirmado. O rascunho de origem mantém a versão fixada e fica bloqueado para edição/eliminação. API e triggers SQL impedem actualizações em massa ou eliminação do rascunho confirmado; a fotografia tem protecção append-only. Não há reabertura, correcção ou anulação. A prestação e os cadastros continuam com o comportamento de A, preservando as suas versões. + +## Captura e repetição + +Confirmação, fotografia, histórico, evento `b1.billing_draft.confirmed_internal`, pendências, auditoria e resultado do comando são persistidos na mesma transacção. A captura reutiliza `a_history`, `a_events`, `a_outbox`, `a_pending` e `a_commands`; os eventos de A não são reconstruídos ou alterados. + +O outbox permanece `captured`, sem consumidor contabilístico. A fotografia mostra `not_issued` para fiscalidade e `blocked_pending` para contabilidade. Além das pendências de A, contas/classificação e reconhecimento ficam explicitamente pendentes. Não há lançamentos, receita reconhecida ou estado contabilizado. + +Repetição com mesma chave/autor/conteúdo recupera o resultado. Conteúdo diferente com a mesma chave produz 409. Outra chave para o mesmo rascunho/versão recupera a confirmação existente, sem novo evento. A unicidade por rascunho e bloqueios transaccionais impedem confirmações concorrentes duplicadas. Como não há reabertura, não se confirmam versões posteriores do rascunho já fixado. Resolução futura de pendências exigirá registos/versionamento próprios, sem editar a fotografia. + +## Migração e acesso aplicados + +O delta foi apresentado antes da aplicação. Migração aditiva `2026_10_25_040000_create_internal_confirmations`, aplicada em `sgf_tempo_test` pelas provas e depois em `sgf_tempo_dev` pelo wrapper, com proprietário separado: + +- Cria apenas `b_internal_confirmations`, com referência única ao rascunho, versão, âmbito composto, fotografia, autor e instante. +- Acrescenta triggers de validade/imutabilidade e bloqueio do rascunho confirmado; amplia a validação de `a_history` para a nova origem sem remover as origens de A. +- Acrescenta `billing_drafts.confirm_internal` ao catálogo SQL permitido, sempre com unidade explícita. Não concede esse direito pela migração, por nome/email ou por direitos de A. +- Não modifica dados históricos de A nem cria tabelas de lançamentos, numeração fiscal ou processamento contabilístico. O rollback automático desta migração é recusado para não eliminar confirmações históricas; qualquer reversão requer procedimento de preservação revisto. + +A conta existente foi conferida e apresentada: ID 1, Osmane Nala, na empresa Tempo Lda ID 1 / SEGI ID 1. O utilizador confirmou exactamente a nova concessão antes da aplicação. Foi aplicada **uma** concessão `billing_drafts.confirm_internal`, com auditoria `b1.access.initial_grant`. Samira mantém apenas consulta; os restantes direitos de A foram preservados. Total de concessões em desenvolvimento: 29. Não foram criadas contas, nem poderes de gestão da nova concessão, emissão, contabilização, recebimento ou fecho. + +O procedimento local `scripts/b1-access.php user_id company_id unit_id` permite pré-visualizar a conta/âmbito. `--apply` só deve ser usado depois de confirmação específica da operação apresentada. Não o executar como seed ou criação automática de direitos. A interface de gestão de A não permite delegar `confirm_internal`; alterações futuras desta concessão continuam a exigir procedimento local autorizado e auditado. `scripts/b1-status.php` verifica contagens e a concessão sem mostrar credenciais. + +Depois da aplicação: duas contas, um tenant, uma empresa e uma unidade preservados; clientes, serviços, prestações, rascunhos e confirmações em desenvolvimento continuavam vazios. Não foi criado qualquer documento interno real ou de demonstração na Tempo Lda/SEGI. A aplicação normal conserva `sgf_dev_app`, sem administração PostgreSQL, e a ligação à base de desenvolvimento. + +## Provas realizadas + +Suite completa: **58 testes / 442 asserções aprovados**, incluindo regressões de identidade e A, sem reabrir a sua aceitação. Compilação Vite aprovada, sem alteração de versões, manifests ou lockfiles. + +Os 14 testes de B1 cobrem confirmação autorizada; consulta e gestor de A sem nova permissão recusados; revogação efectiva no pedido seguinte da mesma sessão; impossibilidade de delegar o novo direito pela gestão de A; âmbito manipulado; acesso cruzado entre unidades e entre empresas do mesmo tenant; versão/contexto antigos; fotografia preservada após alterações de cliente, serviço e prestação; moeda/regra desconhecidas; linhas incompletas; origem desactualizada; repetição/conflito; bloqueio API e SQL de edição/eliminação; fotografia append-only; falha injectada no outbox com rollback integral, incluindo auditoria e comando. + +Prova de concorrência com **dois processos PHP reais**, sobre PostgreSQL e com sobreposição demonstrada de bloqueios: chaves iguais e chaves diferentes produziram apenas uma confirmação e um evento por rascunho. [Resultado](proofs/b1-concurrency.json). + +Prova HTTP com autenticação real de contas sintéticas, CSRF activo e sessões distintas: confirmação autorizada, leitor recusado 403, contexto/versão antigos 409, repetição recuperada, conteúdo diferente 409, alteração de rascunho confirmado 409 e fotografia inalterada após edição dos cadastros. [Resultado](proofs/b1-http-results.json). + +No navegador isolado em http://127.0.0.1:8001, ligado exclusivamente a `sgf_tempo_test`, o gestor entrou pelo formulário, preparou um rascunho e confirmou-o pela revisão explícita. A interface mostrou moeda desconhecida, referência interna, fotografia e pendências. Após saída, a conta de consulta entrou pelo formulário e consultou a fotografia sem botões de criação, edição, gestão ou confirmação. Não foi apenas uma prova com `actingAs`. + +Provas visuais: [confirmação interna e pendências](proofs/b1-01-internal-confirmation.png) e [consulta com contexto sintético](proofs/b1-02-read-only-context.png). [Métricas de captura](proofs/b1-capture.json) verificam correspondência entre confirmações, históricos, eventos e outbox. + +A instância de prova foi parada; a suite final limpa os dados sintéticos apenas na base de testes. Não se alterou a ligação da aplicação normal, não se terminaram processos de outros projectos e não se publicaram conteúdos. + +## Ficheiros e comandos + +Novo workflow `app/Modules/Services/InternalConfirmation.php`, integração em `ServiceController.php`, `ServiceWorkflow.php`, `routes/web.php`, `ServicesWorkspace.jsx` e rodapé de `app.jsx`; migração indicada acima. A integração acrescenta estado de confirmação à lista, protege o rascunho e evita pendências posteriores de origem reescreverem a captura já confirmada. + +Testes em `tests/Feature/InternalConfirmationTest.php`, fixture `tests/Support/InternalConfirmationFixture.php`; preparação de navegador adaptada e worker de concorrência existente reutilizado. Scripts próprios: `b1-access.php`, `b1-status.php`, `b1-concurrency-proof.php`, `b1-http-proof.mjs` e `b1-proof-metrics.php`. Documentação actualizada em AGENTS, README e especificações, respeitando as alterações posteriores a A. + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 test +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 npm run build +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 php scripts/b1-status.php +``` + +Os scripts de concorrência/métricas verificam explicitamente o destino `sgf_tempo_test`. Parar a instância de navegador antes de executar testes que recriam essa base. Recuperação de acesso mantém o procedimento assistido de IDENTITY_WORKFLOW.md, sem fornecedor ou recuperação por email. + +## Limites remanescentes + +B1 fixa dados internos; não emite documento comercial/fiscal, não imprime/exporta uma factura, não confirma execução do serviço, não reconhece receita nem produz lançamentos. Não foi criado consumidor contabilístico. Contas, reconhecimento, moeda funcional, regras fiscais e arredondamento fiscal continuam por validar. Não foram implementados recebimentos, reconciliação, fecho, reabertura, correcção ou anulação. O restante B e C dependem de âmbito e autorização próprios. B1 foi aceite com estes limites; A permanece encerrada. Não avançar para os restantes incrementos nem publicar. diff --git a/docs/SERVICES_B_SCOPE.md b/docs/SERVICES_B_SCOPE.md new file mode 100644 index 0000000..16bdc36 --- /dev/null +++ b/docs/SERVICES_B_SCOPE.md @@ -0,0 +1,83 @@ +# Entrega B — delimitação para revisão + +Data: 25 de Outubro de 2026 (Africa/Johannesburg). + +Estado: proposta de âmbito; não autoriza implementação, migrações, novas concessões, emissão fiscal ou publicação. Base: SERVICES_WORKFLOW_SPEC.md e entrega A aceite em SERVICES_A_DELIVERY.md, checkpoint local `ca13acb`, antecedido por `26b80df`. A permanece encerrada e não é reaberta nesta revisão. Este documento não constitui validação jurídica, fiscal ou contabilística. + +Actualização posterior: o utilizador autorizou exclusivamente B1 — confirmação interna, fotografia imutável e captura das pendências — pelo prompt anexado em 25 de Outubro de 2026. A confirmação interna não é emissão comercial/fiscal, reconhecimento ou contabilização. Moeda/tratamento fiscal desconhecidos ficam preservados e não bloqueiam, por si só, esta confirmação interna. A permissão específica foi conferida e confirmada separadamente antes da concessão. Ver SERVICES_B1_DELIVERY.md. As restantes propostas e pendências de B abaixo continuam sem autorização de implementação. + +## Regras já validadas que B deve preservar + +| Regra | Consequência para B | +| --- | --- | +| Tenant, empresa e unidade distintos; contexto e permissões explícitos | Confirmar ou processar exige autorização própria; os direitos de A não bastam. A pertença ao tenant não concede acesso às empresas. | +| Clientes e serviços da empresa; rascunhos da unidade | Reutilizar as origens existentes, sem duplicar cadastros ou transferir registos entre âmbitos. | +| Prestação, documento e recebimento são acontecimentos distintos | Confirmar um documento não confirma automaticamente a execução do serviço, não recebe dinheiro nem liquida saldo. | +| Histórico e captura durável transaccionais desde A | Consumir acontecimentos preservados, sem reconstruir o passado a partir do último estado do cadastro ou rascunho. | +| Decimais exactos e ausência de pressupostos fiscais | Não presumir moeda, imposto, contas ou arredondamento. A precisão de entrada de A não aprova uma política monetária/fiscal para B. | +| Idempotência, contexto/versionamento, auditoria e protecção SQL | Repetições e concorrência não podem duplicar confirmações, eventos ou efeitos contabilísticos. | +| Provas sintéticas apenas em sgf_tempo_test | Nenhuma demonstração na Tempo Lda/SEGI; manter a aplicação normal e os seus dados. | + +A arquitectura de contabilidade permanente e o processamento independente da contratação da interface comercial constam da especificação geral. Os mecanismos concretos abaixo ainda são propostas; não estão aprovadas regras de lançamento ou conformidade fiscal. + +## Âmbito mínimo proposto + +1. Pré-validar uma versão exacta do rascunho: origens, dados, contexto, permissões, regras aplicáveis e pendências. Mostrar separadamente bloqueios documentais e bloqueios contabilísticos. +2. Confirmar documentalmente apenas quando os requisitos dessa operação estiverem aprovados e satisfeitos. Criar identidade própria e fotografia histórica imutável; não apresentar uma confirmação interna como factura fiscal sem validação específica. +3. Capturar confirmação, fotografia, auditoria, evento e trabalho durável atomicamente, reutilizando a base de A. Uma confirmação repetida da mesma origem deve recuperar o resultado; alteração de conteúdo/versão deve gerar conflito. +4. Processar eventos por consumidor repetível, com classificação explícita: sem efeito contabilístico justificado, pendente de regra/informação, falha técnica recuperável ou contabilizado segundo perfil aprovado. +5. Quando elegível, produzir lançamentos rastreáveis ao evento, documento e versão das regras, com débitos/créditos equilibrados segundo as regras aprovadas. Persistir o efeito e a marca de processamento na mesma transacção. +6. Permitir consulta do documento histórico, origens, pendências, tentativas e efeitos. Nenhum lançamento definitivo deve ser editável pelo formulário de rascunhos. + +Não incluir recebimentos, afectações, correcções financeiras, anulações, reconciliação, fecho ou reabertura de períodos. Estas operações permanecem em C ou exigem delimitação própria. B não deve usar uma rotina de reversão improvisada para compensar decisões ainda pendentes. + +## Preservação histórica proposta + +- A fotografia conserva os dados efectivamente utilizados: identificação das partes exigida pelo tipo documental aprovado, empresa/unidade, origem e versão, linhas, quantidades/preços, moeda, datas aplicáveis, cálculos e versões das regras. Só incorporar campos fiscais depois de definida a sua necessidade. +- Alterações posteriores do nome do cliente, serviço ou rascunho não alteram o documento confirmado. Ligações ao cadastro permitem navegação; não substituem a fotografia. +- Guardar separadamente datas de prestação, documento, registo e reconhecimento quando aprovadas; não inferir uma da outra. Guardar autor e instante de confirmação. +- Fotografias e eventos são imutáveis, com protecção também contra SQL em massa. O estado do consumidor fica em registos próprios, preservando os eventos/outbox append-only de A. +- Consumir eventos antigos de A com classificação explícita. Uma edição de rascunho não se torna receita por existir no outbox. Não alterar nem regenerar esses eventos para simular acontecimentos financeiros. + +## Pendências que impedem fechar o âmbito de implementação + +| Decisão necessária | Quem deve validar / efeito enquanto faltar | +| --- | --- | +| Significado de confirmação: interna, documento comercial ou emissão fiscal; tipos abrangidos | Utilizador, com validação fiscal competente quando aplicável. Sem definição, não implementar um botão ambíguo de confirmar/emitir. | +| Campos obrigatórios, identificação fiscal, datas, séries, numeração, unicidade, momento de atribuição e tratamento de falhas/lacunas | Validação fiscal/documental. Um identificador técnico não substitui numeração legal. | +| Regime, impostos, isenções, evidências e cálculo por linha/documento | Validação fiscal. Ausência permanece pendência, nunca taxa zero. | +| Moeda documental e funcional, eventual câmbio, escalas e política de arredondamento | Validação contabilística/fiscal. A aritmética exacta de A mantém-se; não converter automaticamente em totais fiscais. | +| Momento de reconhecimento e sua relação com prestação e documento; datas de competência | Validação contabilística. Confirmar documento não implica reconhecer receita automaticamente. | +| Plano de contas, classificações, dimensões, regras de débito/crédito e vigência das versões | Validação contabilística. Não inventar contas transitórias ou mapear nomes para contas. | +| Pode haver documento confirmado com contabilização pendente? Quais bloqueios impedem cada transição? | Utilizador e responsáveis contabilístico/fiscal. Proposta: separar estados e não chamar contabilizado ao que está apenas confirmado. | +| Período admissível e tratamento de eventos tardios ou datas não autorizadas | Validação contabilística. B deve respeitar restrições aplicáveis sem implementar o fecho de C. | +| Quem confirma prestação/documento, consulta contabilidade, processa/reprocessa ou gere estes poderes; eventual separação de funções | Utilizador. Novos acessos negados por omissão, sem herança dos direitos de A; aplicação efectiva exige confirmação específica. | +| Perfil sintético exclusivo de testes para demonstrar lançamentos e recuperação | Utilizador. Deve ser identificado como artificial, isolado de desenvolvimento e incapaz de activar regras reais. Não valida conformidade. | + +Não é necessário resolver agora as políticas de recebimento, reconciliação ou correcção de C. É necessário definir como impedir que uma confirmação de B seja posteriormente alterada por caminhos não autorizados. + +## Ecrãs mínimos propostos + +- Pré-validação/confirmacão: versão e contexto explícitos, dados históricos a fixar, bloqueios e efeito exacto da operação. +- Documento confirmado: fotografia preservada, origem, identidade, datas e estado documental, sem edição directa. +- Acompanhamento contabilístico: evento, estado, motivos, tentativas e ligação aos lançamentos; reprocessamento apenas autorizado. +- Detalhe de lançamento: regra/versão, origens, moeda, débitos/créditos e evidência de equilíbrio, quando essas regras estiverem aprovadas. + +Reutilizar navegação e contexto de A. Não disponibilizar botões financeiros simulados nem expandir o sistema de parâmetros para além dos dados necessários às regras aprovadas. + +## Provas de aceitação a exigir à implementação futura + +1. Documento confirmado conserva a fotografia após alterações de cadastros e rascunhos; SQL em massa não a transfere nem reescreve. +2. Falta de configuração gera recusa ou pendência conforme a matriz aprovada, sem moeda/imposto/conta inventados. +3. Dois comandos concorrentes não confirmam duas vezes a mesma operação. Definir antes a relação permitida entre prestação e documentos; não presumir facturação parcial ou múltipla. +4. Consumidor repetido, concorrente ou interrompido antes/depois do commit não duplica efeitos. Falha técnica distingue-se de pendência de negócio e deixa evidência recuperável. +5. Cada lançamento liga-se ao evento e perfil versionado, é equilibrado nas unidades monetárias aprovadas e não se apresenta como definitivo com configuração insuficiente. +6. Eventos históricos de rascunho conservam o conteúdo e recebem classificação rastreável, sem receita artificial. Activar posteriormente a interface comercial não repete o processamento. +7. Direitos de A não autorizam confirmação/processamento; revogação produz efeito no pedido seguinte. Testar acesso cruzado entre empresas do mesmo tenant, unidades, URLs e tarefas. +8. Prova no navegador com login real sintético em instância isolada; suite e operações destrutivas apenas em sgf_tempo_test. Dados reais e checkpoint A preservados. + +## Próximo passo + +Rever as decisões da tabela antes de redigir o prompt de execução de B. A primeira decisão é o significado da confirmação e se se pretende um ensaio exclusivamente sintético ou uma capacidade documental real com requisitos fiscais validados. A existência deste documento não concede autorização de implementação ou de novos acessos. + +Aceitação posterior: em 25 de Outubro de 2026, B1 foi aceite no âmbito demonstrado de confirmação interna, sem validade fiscal nem lançamentos contabilísticos, com checkpoint Git exclusivamente local. O restante B/C continua sem autorização; não avançar nem publicar. diff --git a/docs/SERVICES_WORKFLOW_SPEC.md b/docs/SERVICES_WORKFLOW_SPEC.md index 649644c..34021a6 100644 --- a/docs/SERVICES_WORKFLOW_SPEC.md +++ b/docs/SERVICES_WORKFLOW_SPEC.md @@ -190,6 +190,10 @@ A não emite facturas nem produz lançamentos contabilísticos. Documentos confi Após validar campos/regras aplicáveis: confirmação documental, fotografia histórica, eventos/outbox, consumidor repetível, regras de contabilização e pendências. Demonstrar contabilidade permanente mesmo sem interface comercial de Contabilidade contratada. +Delimitação para revisão em [SERVICES_B_SCOPE.md](SERVICES_B_SCOPE.md): separa a base aceite de A, propostas técnicas e decisões documentais/fiscais/contabilísticas pendentes. Apenas especificação, sem autorização de implementação. + +Autorização posterior exclusiva de B1: confirmação interna do rascunho, fotografia imutável e captura de pendências, sem emissão comercial/fiscal, reconhecimento ou lançamentos. Implementação delimitada em [SERVICES_B1_DELIVERY.md](SERVICES_B1_DELIVERY.md). Não conclui B integralmente, não reabre A e não autoriza C. + ### C Recebimentos correcção reconciliação e fecho Completar o cenário sintético, incluindo os dois recebimentos, correcção ligada ao original, reconciliação explicável e tentativas de fecho com e sem pendências. Demonstrar concorrência, interrupções e recuperação. Utilização real fica dependente de regras e responsáveis aprovados. @@ -224,4 +228,6 @@ Não seleccionar fornecedor, API bancária ou integração de pagamento. Não co ## Estado desta etapa -Entrega A implementada após autorização concreta e aceite pelo utilizador em 25 de Outubro de 2026, no âmbito demonstrado e com os limites do relatório SERVICES_A_DELIVERY.md. A base de identidade aceite foi preservada. B/C permanecem apenas em especificação: não foram implementados documentos confirmados, emissão fiscal, lançamentos, recebimentos, correcções, reconciliação ou fecho. Não há autorização para avançar para B/C ou publicar. +Entrega A implementada e aceite pelo utilizador em 25 de Outubro de 2026, com os limites do relatório SERVICES_A_DELIVERY.md. A e a base de identidade foram preservadas. Posteriormente foi autorizado apenas B1, de confirmação interna, fotografia histórica e captura de pendências. O restante B e C permanecem em especificação: sem emissão comercial/fiscal, lançamentos, recebimentos, correcções, reconciliação ou fecho. Não avançar além de B1 nem publicar. + +B1 foi aceite pelo utilizador em 25 de Outubro de 2026 no âmbito demonstrado de confirmação interna, sem validade fiscal nem lançamentos contabilísticos. Preservar o checkpoint local desta entrega e os antecessores; não avançar para os restantes incrementos nem publicar. diff --git a/docs/proofs/b1-01-internal-confirmation.png b/docs/proofs/b1-01-internal-confirmation.png new file mode 100644 index 0000000..b9e1251 Binary files /dev/null and b/docs/proofs/b1-01-internal-confirmation.png differ diff --git a/docs/proofs/b1-02-read-only-context.png b/docs/proofs/b1-02-read-only-context.png new file mode 100644 index 0000000..8660498 Binary files /dev/null and b/docs/proofs/b1-02-read-only-context.png differ diff --git a/docs/proofs/b1-capture.json b/docs/proofs/b1-capture.json new file mode 100644 index 0000000..1f1848d --- /dev/null +++ b/docs/proofs/b1-capture.json @@ -0,0 +1,14 @@ +{ + "database": "sgf_tempo_test", + "confirmations": 4, + "histories": 4, + "confirmation_events": 4, + "captured_outbox": 4, + "missing_history": 0, + "fiscal_states": [ + "not_issued" + ], + "accounting_states": [ + "blocked_pending" + ] +} \ No newline at end of file diff --git a/docs/proofs/b1-concurrency.json b/docs/proofs/b1-concurrency.json new file mode 100644 index 0000000..2e509fd --- /dev/null +++ b/docs/proofs/b1-concurrency.json @@ -0,0 +1,21 @@ +{ + "database": "sgf_tempo_test", + "results": { + "same_key": { + "parallel_processes": true, + "company_lock_overlap": true, + "same_confirmation": true, + "new_confirmations": 1, + "new_events": 1, + "second_replayed": true + }, + "different_keys": { + "parallel_processes": true, + "company_lock_overlap": true, + "same_confirmation": true, + "new_confirmations": 1, + "new_events": 1, + "second_replayed": true + } + } +} \ No newline at end of file diff --git a/docs/proofs/b1-http-results.json b/docs/proofs/b1-http-results.json new file mode 100644 index 0000000..110a359 --- /dev/null +++ b/docs/proofs/b1-http-results.json @@ -0,0 +1,12 @@ +{ + "database": "sgf_tempo_test", + "real_synthetic_login": true, + "results": [ + "Confirmation authorized; reader denied 403.", + "Old context and version denied 409.", + "Identical command recovers confirmation; different content conflicts 409.", + "Snapshot unchanged after client/service edits.", + "Confirmed draft edit denied 409; reader consultation 200.", + "Currency remains unknown, tax/accounts/recognition pending; captured, not issued or accounted." + ] +} \ No newline at end of file diff --git a/resources/js/ServicesWorkspace.jsx b/resources/js/ServicesWorkspace.jsx index dc20e9f..abecc0a 100644 --- a/resources/js/ServicesWorkspace.jsx +++ b/resources/js/ServicesWorkspace.jsx @@ -15,10 +15,11 @@ export default function ServicesWorkspace({ area, context, can, api, perform, bu const [error, setError] = useState(''); const [key, setKey] = useState(() => crypto.randomUUID()); const [access, setAccess] = useState(false); + const [reviewConfirmation, setReviewConfirmation] = useState(false); const path = `/api/a/${area}`; async function load() { const result = await api(path); setRecords(result.records); } - async function open(id) { setError(''); try { setSelected(await api(`${path}/${id}`)); setForm(null); } catch (e) { setError(e.message); } } + async function open(id) { setError(''); setReviewConfirmation(false); try { setSelected(await api(`${path}/${id}`)); setForm(null); } catch (e) { setError(e.message); } } useEffect(() => { load().catch(e => setError(e.message)); if (initialId) open(initialId); }, [area, context.token]); async function start(record = null) { @@ -67,11 +68,11 @@ export default function ServicesWorkspace({ area, context, can, api, perform, bu if (!allowed('view')) return

{serviceAreas[area]}

Sem consulta autorizada neste contexto.

; return <> -

ENTREGA A · PREPARAÇÃO

{serviceAreas[area]}

{isDraft(area) ? `Unidade: ${context.unit?.name || 'não seleccionada'}` : 'Cadastro partilhado pelas unidades da empresa'}

{allowed('create') && }{allowed('manage') && }
+

SERVIÇOS · PREPARAÇÃO E CONFIRMAÇÃO INTERNA

{serviceAreas[area]}

{isDraft(area) ? `Unidade: ${context.unit?.name || 'não seleccionada'}` : 'Cadastro partilhado pelas unidades da empresa'}

{allowed('create') && }{allowed('manage') && }
{error &&
{error}
} - {isDraft(area) &&
Apenas rascunhos. Sem emissão fiscal, confirmação de serviço ou lançamentos contabilísticos.
} + {isDraft(area) &&
{area === 'billing_drafts' ? 'Rascunhos e confirmação interna — sem validade fiscal. ' : 'Apenas rascunhos. '}Sem emissão fiscal, confirmação de serviço ou lançamentos contabilísticos.
} {access ? : <> -

Registos

{!records.length &&

Ainda não há registos neste âmbito.

}{records.map(record => )}
+

Registos

{!records.length &&

Ainda não há registos neste âmbito.

}{records.map(record => )}
{form ?

{selected ? 'Editar' : 'Criar'} {isDraft(area) ? 'rascunho' : 'cadastro'}

{area !== 'clients' &&