Merge branch 'front' of https://git.tempo.co.mz/nala/Investidor-V2
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-11-11 11:53:57 +02:00
173 changed files with 18844 additions and 501 deletions
File diff suppressed because one or more lines are too long
@@ -0,0 +1,149 @@
# Revisão das melhorias externas do InvesteMoz
## Continuação de 10/11/2026 — contador multiterritorial
**Estado: código preparado e testado; sem disponibilização no portal.** Os aliases `/simulador/` e `/contactos/` conservam a mesma situação de entrega anterior. A revisão visual e as melhorias da **janela S08**, cuja conclusão não foi comunicada, mantêm-se no saldo. Esta referência à janela S08 não lhe atribui por inferência o conteúdo da etapa de licenciamento com o mesmo identificador. Provas e aceitações anteriores permanecem preservadas.
### Comportamento corrigido
O total usa `COUNT(DISTINCT id)` sobre a selecção pública original de `Opportunity::published()`. Uma oportunidade com dois distritos na mesma província e outro território numa segunda província acrescenta **uma oportunidade e duas províncias**. Oportunidades sem localização continuam no total. A publicação, visibilidade, data, eliminação lógica e exclusão de exemplos são revalidadas pelo scope existente.
As províncias são resolvidas a partir da referência principal e de `opportunity_locations`, seguindo a hierarquia pública existente. Ligações repetidas e nomes provinciais equivalentes após normalização não aumentam a contagem; Maputo Cidade e Maputo Província permanecem distintos. Antepassados privados, removidos, indisponíveis, ciclos ou cadeias incompletas não geram cobertura. O nome provincial legado só serve como alternativa quando não existem referências canónicas e corresponde a uma província do catálogo público. Desconhecido não se converte num território inventado.
A definição é **províncias com localização declarada nas oportunidades públicas**, apresentada junto aos indicadores. Abrangência nacional, impacto e contexto de corredor não são automaticamente convertidos em implantação. É uma definição de apresentação deste indicador, não uma alteração de localizações, relações, filtros ou decisões organizacionais.
A agregação usa uma única instrução SQL com CTEs e mantém separados o universo de oportunidades e a cobertura territorial. Não há carregamento do universo em PHP nem consultas por oportunidade. A inspecção de schema do scope existente é preservada: observaram-se duas consultas para esta projecção, sem incluir o resto da homepage, middleware ou bootstrap. Isto não é prova de carga sustentada.
A cache da homepage passa a `portal_home_territorial_v2_{locale}`, conservando o prazo de dez minutos e os invalidadores de gravação/eliminação de sectores e oportunidades. A chave nova evita apresentar a contagem antiga após a futura disponibilização. As chaves históricas não foram apagadas no ambiente activo. A frescura dos restantes consumidores e os eventos de escrita existentes não foram redesenhados.
### Ficheiros deste delta
- `app/Services/InvestmentGeography.php`: agregação territorial reutilizando publicação e referências públicas existentes.
- `app/Http/Controllers/Api/V1/HomeController.php`: consumo das duas contagens e chave de cache versionada.
- `app/Providers/AppServiceProvider.php`: invalidação das duas variantes linguísticas da nova cache.
- `resources/js/components/portal-hero.tsx`: definição legível do indicador e da contagem única.
- `tests/Feature/HomeTerritorialStatisticsTest.php` e `tests/Unit/HomeTerritorialCacheTest.php`: regressões dirigidas novas.
- `tests/Feature/PublicPortalNavigationTest.php`: somente referências à chave de cache actualizadas; o ciclo histórico completo não foi repetido.
- Matriz, backlog e manual existentes: referências e saldo actualizados, sem substituir critérios ou estados aceites.
### Provas e preservação
Passaram **nove testes novos, com 23 asserções**: sete casos SQL com tabelas temporárias e dois casos de cache sem base de dados. Cobrem múltiplos territórios, ligações repetidas, publicação e visibilidade restrita, ausência de localização, âmbito nacional, contexto sem implantação, fallback legado, ancestrais privados/removidos/incompletos/cíclicos, Maputo Cidade/Província, catálogo vazio, 120 oportunidades, versão de cache e invalidação nos dois idiomas. O caso com 120 oportunidades mantém o mesmo número de consultas que o catálogo vazio.
O cluster QA existente `main-qa-20261104-pgdata:55441` estava parado: a primeira tentativa dos sete testes foi recusada por ausência de ligação, antes de criar fixtures ou efectuar asserções. Foi iniciado o mesmo cluster, sem nova base, porta, role ou migration. Os testes só admitem identidade QA efectiva, criam tabelas temporárias na ligação e terminam com rollback. Depois de confirmar ausência de outros clientes, o cluster foi encerrado normalmente, repondo o estado inicial parado. Não foi usado `RefreshDatabase`, nem reconstruído o schema ou reutilizada uma demonstração como fixture descartável.
Também passaram PHPStan nos dois consumidores PHP alterados, Pint nos ficheiros PHP do delta, TypeScript global e formatação/lint do componente. O build QA concluiu em saída privada `storage/app/private/external-portal-review-20261110/home-territorial-build`, sem trocar o build servido ou executar Wayfinder. O build emitiu avisos sobre fontes opcionais e chunks grandes; não se instalaram dependências nem se declarou resolvida a carga do portal.
Uma consulta de diagnóstico, **sem cache e em transacção READ ONLY**, observou seis oportunidades e cinco províncias no destino `investemoz:5432`: duas consultas, 134,07 ms nesta execução, rollback final e zero comandos de negócio. O recibo privado é `home-territorial-readonly.json`. Não é um ensaio de escrita nem transforma essa observação isolada numa garantia de desempenho.
Não foram repetidos os testes aceites dos aliases, do simulador, do Balcão ou da S08. Não houve migration, concessão, escrita em dados de negócio, operação fiscal, publicação ou deploy. A compilação e os testes não substituem a revisão visual pendente.
### Saldo e reversão deste delta
O contador multiterritorial fica preparado/testado; a disponibilização no portal continua pendente, juntamente com os aliases anteriores. **Revisão visual e melhorias da janela S08 continuam pendentes**, preservando as provas já aceites. Não se declara encerrado o P0 inteiro ou a S08.
Reverter apenas este delta: repor o contador e a chave anterior em `HomeController`, remover `opportunityStatistics()` e o import associado, retirar as duas novas invalidações e a explicação no hero, e repor as referências do teste histórico. Não repor ficheiros completos sobre alterações posteriores, não remover os aliases entregues anteriormente e não executar rollback de migrations. Não há dados a desfazer. A compilação privada não está ligada ao portal.
## Relatório inicial de 10/11/2026
## Resultado e âmbito
Em 10/11/2026, a directiva fornecida pela Tempo foi confrontada com o projecto `C:\laragon\www\portal-investidor`, destino confirmado pelo utilizador nesta conversa. Foram corrigidos dois aliases de navegação ausentes. A leitura do ambiente demonstra que os números do relatório externo não descrevem o estado actualmente configurado. A revisão visual, o contador multiterritorial e as fases funcionais seguintes continuam abertos; esta entrega não declara concluído o programa de melhorias.
O texto colado é o pedido de execução. O DOCX é uma avaliação externa: exemplos de SQL, Node/Express, React, seeds, contactos, benefícios, câmbio e integrações nele contidos não constituem comandos ou decisões aprovadas. Não foi executado código do documento.
## Fontes e preservação
- Directiva de Implementação Segura, versão 1.0, 08/11/2026, recebida como texto colado.
- `correecoes ao portal.docx`, no caminho indicado pelo utilizador. SHA-256 `db630ab937cc00686480aa02ef0156a8ac20edadf97306995277a5173d890f51`; 5.968 elementos `w:p` extraídos. Os números de parágrafo abaixo são posições XML, não páginas verificadas por renderização. A indicação de 141 páginas pertence ao pedido.
- `AGENTS.md` do InvesteMoz, backlog mestre, matriz existente e manual de rastreabilidade. As fontes oficiais já aceites pela Tempo conservam essa aceitação; não foi criada uma obrigação genérica de nova validação de autenticidade.
- Commit inicial `2209b8382019b0bbc2be57b3f6e665a3c94dd032`. O repositório do portal não apresentava alterações Git antes desta tarefa. O SGF Tempo e o seu trabalho não integrado não foram modificados.
O inventário privado contém caminhos, tamanhos e hashes de 3.374 ficheiros em `app`, `routes`, `resources`, `database`, `tests`, `config`, `bootstrap`, `deploy`, `tools` e `docs`. São 412 ficheiros em `app`, 789 em `resources`, 140 em `database`, 675 em `tests`, 648 em `tools` e 609 em `docs`, além dos restantes directórios. A leitura automatizada de declarações e hashes dá cobertura do inventário; não constitui prova funcional de todos esses ficheiros. Dependências instaladas e artefactos de execução não foram substituídos.
As evidências locais estão em `storage/app/private/external-portal-review-20261110/`: `inventory.json`, extração integral do DOCX, `runtime-readonly.json` e cópia do ficheiro de rotas anterior. A pasta é privada/ignorada pelo Git e não é um pacote de publicação.
## Arquitectura e ambientes encontrados
A aplicação existente usa Laravel, PHP, Inertia, React/TypeScript, Fortify e PostgreSQL/PostGIS. `composer.json`, `package.json` e os respectivos locks permanecem intactos. React já integra a arquitectura; a presença de React no documento não exige nem justifica reconstruir a interface. Não foi instalado um backend Node/Express.
As rotas incluem catálogo público, sectores, oportunidades, carteira, clusters/corredores, repositório, simulador, Balcão, perfis, gestão de conteúdos e outros módulos existentes. O inventário identifica 502 declarações literais de rotas; ciclos e composição dinâmica impedem interpretar esse número como total de endpoints efectivos.
O README de execução local descreve `investmoz_local_test:55441`. A configuração realmente carregada nesta sessão aponta para `investemoz:5432`, ambiente Laravel `local`, URL `https://portal-investidor.test` e debug activo. Assim, a base configurada foi tratada como protegida. Estes dados não demonstram a configuração do domínio público em produção.
A consulta foi executada numa transacção PostgreSQL com `SET TRANSACTION READ ONLY`, timeout de 15 segundos e rollback final. Foram observadas 296 tabelas fora dos esquemas de sistema, 88 registos de migrations e 105 ficheiros de migrations: 17 ficheiros não constam do registo consultado. A lista nominal está no inventário privado. Não foi executada qualquer migration, reparação ou instalação automática. Divergência de schema não equivale, por si só, à perda de uma funcionalidade anteriormente aceite.
O diagnóstico `portal:dev-check` passou os dez controlos apresentados: PHP, driver, chave, configuração PostgreSQL, pastas de execução, dependências frontend, ligação, PostGIS e tabelas principais. É um diagnóstico de arranque, não uma prova de produção ou dos percursos completos.
O `phpunit.xml` aponta para QA `investmoz_test:55441`; a classe base de Feature pode reconstruir o schema. Não se executou essa suite sobre a base configurada nem se reutilizou uma demonstração com dados como base descartável. Os testes desta entrega interceptam acesso à base e transporte de email.
## Compatibilidade das recomendações
As classificações seguintes referenciam os requisitos da matriz existente, sem criar outro backlog ou alterar aceitações anteriores.
### Usabilidade e navegação
1. **Adaptável — cabeçalho** (`AUD-LEG-02`, `AUD-LEG-07`; DOCX p. XML 7–14, 352–379 e 524–551). `portal-hero.tsx` contém uma cópia acessível, uma cópia invisível que reserva o espaço final e texto animado marcado `aria-hidden`. A repetição numa extração textual não demonstra repetição visual. Não foram removidas essas protecções nem acrescentado CSS com `!important`. Falta observar o resultado em desktop, móvel e movimento reduzido.
2. **Adaptável — indicador provincial** (`AUD-GEO-03`; p. XML 445–478 e 617–650). `HomeController` conta actualmente valores distintos de `opportunities.province` nas oportunidades públicas. A leitura encontrou seis oportunidades públicas, cinco valores distintos, seis referências territoriais principais e nenhuma localização adicional. Não reproduz o valor externo «1», nem justifica fixar «11». O código não agrega `opportunity_locations`: permanece uma lacuna concreta para oportunidades multiterritoriais. Reutilizar o contrato territorial de `InvestmentCatalogue::locationPresentation`, distinguindo implantação, declaração e contexto de corredor; avaliar o custo das consultas antes de substituir a agregação SQL por leituras por registo. Não foi introduzida uma implementação com consultas por oportunidade apenas para corrigir o número.
3. **Aplicável — aliases antigos** (`AUD-LEG-06`; p. XML 380–409 e 552–581). `/simulador-fiscal` e `/apoio` já são rotas oficiais; `/Simulador` também existe. Os aliases minúsculos `/simulador` e `/contactos`, com ou sem barra final, não eram encontrados pelo router. Foram acrescentados redireccionamentos 301 para os destinos oficiais, conservando os parâmetros de consulta. Não se criou outro simulador ou formulário.
4. **Já implementada em parte — contactos** (`AUD-HUMAN-01`; p. XML 92–109). Há email institucional configurado e o rodapé contém contactos. Os campos específicos de apoio estão vazios; por isso a página de apoio ainda apresenta a indicação de contactos directos não publicados. Não é ausência total de contactos no portal. Não se copiaram os endereços de exemplo do DOCX para a configuração de apoio.
5. **Dependente de validação específica — identidade e contactos de apoio** (`AUD-HUMAN-01`). `portal.operator_name` está vazio. A propriedade da Tempo está decidida em `AGENTS.md`; os contactos dedicados, horários e designação a apresentar devem reutilizar os dados e decisões existentes que lhes correspondam. Os números e morada já no rodapé não foram certificados de novo nem alterados nesta tarefa.
6. **Aplicável, por demonstrar — móvel e teclado** (`AUD-LEG-02`, `AUD-LEG-07`). Há componentes e provas históricas; falta conferir o delta actual em navegador. Não se repetiram suites históricas ou se declarou acessibilidade integral com base nos testes de rotas.
### Oportunidades e publicação
7. **Já implementada em parte — fichas e campos** (`MASTER-9dee08d0-0074`, requisitos de oportunidades existentes; p. XML 174–211, 697–1143). O modelo já tem montante/moeda, promotor, maturidade, localização e última validação; há gestão, recursos, revisão de origem e contratos canónicos. Comparar as variantes de licenciamento, fonte/confiança e referência temporal dentro desses contratos antes de criar campos ou tabelas.
8. **Já implementada — capacidade de destaque** (gestão de oportunidades existente). `is_featured`, filtragem por publicação e apresentação de até seis destaques já existem. A leitura encontrou três oportunidades públicas assinaladas. Não foram promovidos projectos para preencher o ecrã. A alegada secção vazia depende do ambiente e da apresentação efectiva.
9. **Rejeitada — preenchimento comercial inventado** (p. XML 190–211 e modelos SQL seguintes). Não converter montante desconhecido em «A negociar», CAPEX fictício, calendário, licença ou promotor inventados. Nenhum dado foi enriquecido automaticamente.
10. **Adaptável — origem, confiança e rastreabilidade**. Reutilizar revisão de origem, recursos, versões e auditoria existentes. Um novo campo só é necessário depois de localizar uma lacuna no contrato correspondente; não foram duplicadas entidades ou relações.
### Simulador e acervo jurídico
11. **Já implementada em parte — simulador e recuperação** (`AUD-SIM-01`, `AUD-SIM-02`). Existem serviços e testes fiscais/financeiros, rascunhos e consumidores documentais. A avaliação externa também reconhece a integração. Não se declarou ausente nem se executaram novamente todos os ciclos anteriores.
12. **Adaptável — regras versionadas e resultados explicáveis** (`AUD-SIM-02`; p. XML 1183–2533). A intenção é compatível; deve ser aplicada no motor existente, com âmbito, período e citação. As duas versões repetidas da proposta não são duas funcionalidades novas.
13. **Rejeitada como implementação directa — API Node/Express, novas tabelas fiscais e seeds** (p. XML 2547–4667). Os exemplos não foram instalados. Os blocos sucessivos de seed e correcção não constituem uma única especificação aprovada. As taxas, benefícios, elegibilidade e percentagens do documento não foram importados nem tomados como legislação vigente.
14. **Dependente de conferência específica — divergências fiscais** (`AUD-SIM-02`). Confrontar cada parâmetro que se pretenda alterar com o acervo oficial já aceite, identificando diploma/artigo, período e regra afectada. Não é necessário revalidar genericamente a credibilidade das fontes oficiais. Não foi emitida conclusão jurídica ou alterado cálculo fiscal nesta entrega.
15. **Já implementada em parte — biblioteca e fontes** (`AUD-LIB-01`, `AUD-LIB-02`). Há biblioteca, controlo de publicação, classificação, scan, importação e vínculos; publicação documental não activa um cálculo. A existência de código não certifica todos os documentos ou direitos de divulgação.
### Balcão e pré-dossiê
16. **Adaptável — pré-dossiê no mesmo Balcão** (`AUD-DESK-01`, `AUD-DESK-02`; p. XML 4692–5968). Existem dossier, pedidos, editor de serviços, contexto, histórico e regras de titularidade. Reutilizar esses recursos e os rascunhos existentes; o wizard de cinco passos do DOCX não substitui o percurso adaptativo aprovado pela Tempo.
17. **Adaptável, com saldo por demonstrar — pré-preenchimento, guardar/retomar e exportação**. Conferir o consumidor e a versão do serviço concretos. Não foi criado outro cadastro, protocolo, PDF, tabela ou conta. As provas anteriores de dossier/pedido permanecem válidas no seu âmbito.
18. **Rejeitada — equivalência a acto público**. O pré-dossiê não é aprovação, submissão oficial, certificação ou benefício concedido. Não foram acrescentados logótipos, parceiros, SLAs ou integração institucional presumida.
19. **Dependente de confirmação técnica/institucional — integração directa com APIEX**. Nenhuma ligação, credencial, API ou acordo foi inferido do documento.
20. **Rejeitada como taxa actual — fallback cambial fixo** (p. XML 5039–5104). Reutilizar fontes e mecanismos já existentes, com data/natureza da taxa; valores de recurso e resultados demonstrativos não foram activados.
### Segurança, desempenho e divulgação
21. **Já implementada em parte — validação, autorização e controlo de abuso**. Há middleware, perfis/MFA, políticas, validação no servidor, throttling e controlos documentais. Os quatro testes de contacto reutilizados verificam validação, transporte simulado, erro de envio e escape de conteúdo; não certificam a segurança integral do portal.
22. **Adaptável — imagens, SEO, sitemap, redes móveis e acessibilidade**. O cabeçalho já utiliza WebP e imagens móveis. Não se aplicou conversão indiscriminada de imagens nem um schema SEO sugerido sem conferir o seu significado. Core Web Vitals, carga sustentada e navegação visual actual não foram medidos.
23. **Dependente do ambiente — HTTPS e debug**. O ambiente local anuncia HTTPS e debug activo. O HEAD HTTPS não concluiu a negociação TLS porque Schannel não conseguiu verificar a revogação do certificado (`CRYPT_E_NO_REVOCATION_CHECK`). Não se desactivou essa verificação. Isto não demonstra uma falha de certificado do domínio público. HSTS/debug em produção exigem inspecção da configuração efectiva desse destino, sem deploy nesta tarefa.
24. **Rejeitada como instalação automática — reCAPTCHA, CDN ou transmissão externa de dados pessoais**. Não se adicionou fornecedor externo, tracking, upload ou integração para satisfazer uma recomendação genérica. Conferir primeiro as protecções e necessidades existentes.
## Alteração realizada
`routes/web.php`: três linhas acrescentam os dois aliases, com 301, resolução por nome das páginas existentes e conservação de query string. Um parâmetro chamado `redirect` mantém-se um parâmetro de dados e não escolhe o destino do redireccionamento.
`tests/Unit/PortalLegacyNavigationTest.php`: quatro variantes de caminho e um caso de conservação de contexto/destino. A ligação à base é proibida pelo mock. Os testes exercitam a colecção de rotas e a execução do redireccionamento; não executam o percurso completo através do middleware ou de um navegador.
A matriz existente recebe apenas referências e evidências nos IDs relacionados. Estados de aceitação, critérios e restantes células anteriores são preservados. O backlog e o manual existentes apontam para esta entrega.
## Verificações e limites
- Reprodução antes da correcção: cinco casos novos falharam por rota inexistente.
- Após a correcção: nove testes dirigidos passaram, com 37 asserções, incluindo os quatro testes de contacto já existentes. Transporte de email simulado; nenhum email real foi enviado.
- PHP: sintaxe de `routes/web.php` válida.
- Pint: teste novo aprovado. O ficheiro de rotas apresenta seis categorias de formatação já presentes na cópia obtida do commit inicial. Não se reformatou todo o ficheiro alheio ao delta.
- Consulta PostgreSQL: `transaction_read_only=on`, rollback final. Sem alterações de negócio, migrations, concessões, seeds, contas, configurações fiscais ou dados reais.
- Navegador: o acesso CUA devolveu indisponibilidade do host. Não há captura nova, prova visual ou revisão responsiva desta entrega.
- HTTP externo ao processo: as duas tentativas HEAD HTTPS foram impedidas pela verificação de revogação local. Não se afirma uma verificação HTTP ponta a ponta dos destinos.
## Reversão
Retirar apenas o ciclo dos aliases acrescentado após a rota `/Simulador`; a cópia original está no registo privado. O teste novo e estas referências documentais podem ser removidos individualmente. Não usar `git reset --hard`, não repor integralmente um ficheiro que entretanto receba alterações de terceiros e não executar rollback de migrations: nenhuma foi realizada. A reversão não exige alterar dados ou permissões.
## Continuação delimitada
Continuar o P0 pelo contador provincial: contrato de definição visível, agregação multiterritorial com respeito pela publicação e pelas fontes territoriais existentes, testes de duplicação/sem localização/contexto e conferência só de leitura. Não substituir o número por um total nacional fixo nem aceitar uma implementação com consultas por oportunidade sem avaliar o impacto. Retomar o cabeçalho e os percursos em desktop/móvel quando o acesso ao navegador estiver disponível.
As fases estruturais, fiscais e de pré-dossiê devem partir desta reconciliação e dos IDs existentes. A directiva pede apresentação do resultado da Fase 0/P0 antes dessas alterações; esta entrega apresenta os achados e o primeiro delta seguro, com o saldo P0 explícito. Não há deploy, publicação, aceitação ou conclusão global presumida.
@@ -1,3 +1,393 @@
## 11/11/2026 — apresentação pública profissional do directório financeiro em QA
**Pedido do proprietário:** descrições profissionais e apelativas,com descrição/produtos/serviços úteis ao investidor,sem texto de fichas,importação ou documento gerador;orientação transversal registada emAGENTS. O directório e o detalhe passam a uma apresentação editorial comum:reconhece exclusivamente o resumo genérico exacto da importação e usa a natureza publicada da entidade;descrições editoriais específicas são preservadas e limpas da fórmula genérica final. Produtos identificados são apresentados pelos nomes/instrumentos existentes;os sete catálogos específicos disponíveis não são extrapolados para outras entidades. Nas restantes,apresentam-se áreas de serviço conhecidas ou contacto para conhecer soluções,sem inventar produtos/condições.
Retirados da apresentação pública nomes de ficheiros,hashes,linhas de importação,códigos de sucessora e descrições de registo interno. Ligações institucionais e datas úteis continuam acessíveis;metadados e fontes permanecem intactos no cadastro/editor. Instituições históricas/fundidas continuam identificadas como tal. Instituições sem chamada não mostram uma secção vazia de candidatura. Textos da selecção guardada e chamadas públicas dePorque investir/Serviços ajustados à linguagem do investidor. Fluxos de interesse,dossier,edição,revisão/publicação e filtros preservados.
**QA:** nove verificações afectadas aprovadas,três novas incluindo270referências fornecidas,descrições específicas preservadas,produtos não inventados e renderização dos componentes públicos reais de lista/detalhe. Sem escrita em dados activos ou nova publicação do lote;é uma projecção editorial dos dados já publicados. Evidência `outputs/funding-public-editorial-20261111/verification.json` e quatroHTMLs;isto não constitui conferência visual no navegador. Tipos/lint/compilação aprovados;pacote Git com aplicação/reversão conferidas no fecho.
**Estado:** implementado em QA,não instalado;homologação mantém `8f295f05` e270instituições publicadas. Restam instalação/visual e enriquecimento de produtos específicos quando houver informação disponível. O saldo dos outros módulos e resíduos fiscais permanece inalterado. Não é necessário seleccionar ou publicar novamente as270instituições para esta alteração de apresentação.
## 11/11/2026 — rectificação do volume PEC instalada em8f295f05
Instalação expressamente autorizada,executada pelo processo Git existente e conferida às11:03:24Africa/Johannesburg(09:03:24UTC): `8f295f05510c786afeb8ea1dfa692e08d90a0d30`. No simulador fiscal,L8,**Rectificar volume na simulação PEC** conserva o valor original e o corrigido,com escolha explícita/fonte e efeito na estimativa apresentados no relatório. A hipótese de tesouraria anterior exige reconciliação se o total mudar;nenhum comprovativo pago,dedução ou saldo oficial é duplicado. Não executa pagamentos nem submete declaração rectificativa.
Backup novo/restauro isolado conferidos:190tabelas/369ficheiros. Quinze fontes por hash;revisão,entrada,up,login,Concursos,fundos e simuladorHTTP200. Configuração intacta,zero migrações/importações. Totais/checksums idênticos ao backup nas sete tabelas protegidas:funding_programmes,funding_programme_versions,funding_programme_interests,desk_cases,simulation_form_drafts,labour_demands,training_capacities.275fichas/270instituições publicadas preservadas. Recibo `outputs/irpc-pec-correction-20261111/installation.json`;pacote `outputs/entrega-pec-rectificacao-20261111.zip`,SHA256 `559e4477059b092a193d990f7dfb6cb50007d43c24ec00e2484326f076e0c973`. Revisãoa708f079 preservada para reversão.
Este recorte29(3) deixa de ser pendência de implementação/instalação. Visual permanece separado;correcções fora do recorte têm âmbito próprio. Restam última base29(5),PPC excedente,períodos/regimes especiais e demais resíduos L1–L7/restanteL8/L9–L16 e módulos. Próximo trabalho:primeiro residual de simulação executável com as fontes aceites,sem reabrir este recorte. Portal não declarado integralmente concluído.
## 11/11/2026 — rectificação do volume PEC na simulação em QA
**Recorte29(3) avançado:** fonte documental permite rectificar o volume de negócios2025 usado na estimativa PEC2026. Valor original,valor corrigido,titular,exercício e fundamento ficam conservados;escolha explícita entre aplicar a correcção e manter o original. Não submete declaração rectificativa. A estimativa recalcula0,5%,limites e PPC anteriores uma única vez;relatório mostra origem,valor corrigido e efeito na estimativa. Mudança da base/titular/exercício exige reconferência;fonte ou valor ausente não é zero.
Cálculo→API privada→recuperação→HTML/relatório do mesmo dossier em cinco cenários:aumento40.000→90.000MZN,redução40.000→20.000,original conservado40.000,base divergente e correcção para volumezero indeterminados. Hipótese de caixa anterior40.000 deixa de reconciliar quando o total muda;não é sobrescrita. Saldo anual138MZN,comprovativos pagos e histórico anterior preservados. QA55441 existente:uma prova nativa/47asserções com rollback,cinco testes novos de cálculo/interface e40afectados aprovados;tipos/lint/compilação aprovados. Provas `outputs/irpc-pec-correction-20261111/*-verification.json`;pacote Git com aplicação/reversão conferidas no fecho. Não é conferência visual.
**Estado:** implementado em QA,ainda não instalado;homologação mantém `a708f079` e270instituições publicadas. Fonte:RIRPC,Decreto9/2008,art29(3),BR16/I16-04-2008,original já aceite SHA256 `fc373ff5637edf75ec6a9595dec8763a8503d4ee786e3b7d8f1c742eae9d80ee`. Sem migração,base,tabela ou direito novo. Restam instalação/visual,última base29(5),PPC excedente,períodos/regimes especiais e demais resíduos fiscais L1–L7/restanteL8/L9–L16. Correcções fora deste recorte conservam âmbito próprio;L8 e portal não são declarados integralmente concluídos. Próximo trabalho:primeiro residual de simulação executável pelas fontes aceites,sem reabrir este recorte.
## 11/11/2026 — finalidade de viabilidade e projecção PEC instaladas ema708f079
Instalação expressamente autorizada,executada pelo processo Git existente e conferida às10:45:50Africa/Johannesburg(08:45:50UTC): `a708f079b318a13100644c27287f1caa854ecf1d`. No simulador fiscal,L8,os ecrãs apresentam **Simular PPC/PEC**,estimativas fiscais e hipóteses de tesouraria para avaliar a viabilidade do negócio. A hipótese PEC permite distribuir valores por três meses ou concentrar a estimativa num mês escolhido,com soma conferida e prazo legal não certificado. Rascunho,recuperação,histórico e relatórios preservam escolhas;dados documentais já pagos permanecem distintos. O portal não executa pagamentos,nem submete declarações fiscais.
Backup novo/restauro isolado conferidos:190tabelas/369ficheiros. Vinte e quatro fontes por hash;revisão,entrada,up,login,Concursos,fundos e simuladorHTTP200. Configuração intacta,zero migrações/importações. Totais/checksums idênticos ao backup nas sete tabelas protegidas:funding_programmes,funding_programme_versions,funding_programme_interests,desk_cases,simulation_form_drafts,labour_demands,training_capacities.275fichas/270instituições publicadas preservadas. Recibo `outputs/irpc-pec-plan-20261111/installation.json`;pacote `outputs/entrega-simulacao-viabilidade-20261111.zip`,SHA256 `a4dbbe26ca72230e3ff7a5c2a744c187a93380a4857e522b1db5ee17acce3ca5`. Revisãobcbbe51c preservada para reversão.
Este delta deixa de ser pendência de implementação/instalação. Conferência visual permanece separada. Execução de pagamentos não é critério de conclusão deste simulador,conforme clarificação expressa registada emAGENTS. Próximo trabalho:primeiro residual de simulação executável pelas fontes aceites. Base29(5),rectificação29(3),PPC excedente,períodos/regimes e restantes resíduos L1–L7/restanteL8/L9–L16 e módulos mantêm o saldo;portal não declarado integralmente concluído.
## 11/11/2026 — finalidade do simulador e projecção de tesouraria PEC
**Clarificação expressa do proprietário:** o portal simula impostos para avaliar a viabilidade do negócio; não faz pagamentos. A apresentação anterior de «obrigação» e «plano de pagamento» criava uma expectativa errada de execução. Ecrãs PPC/PEC passam a **Simular PPC/PEC**,estimativa fiscal e projecção de tesouraria. Fontes legais e dados documentais já pagos continuam a informar o cálculo; não são operações executadas pelo portal. A execução de pagamentos não é pendência deste simulador.
**Implementado em QA:** hipótese PEC distribuída por Junho/Agosto/Outubro com montantes explícitos cuja soma corresponde exactamente ao total,ou hipótese concentrada num mês escolhido. Não inventa igualdade/arredondamento ou validação de prazo para a opção única. Valores desconhecidos,divergência da soma,total alterado e ausência de estimativa positiva invalidam a projecção dependente,sem bloquear o cálculo fiscal. Trocar hipótese conserva edições;resumo com Alterar. Rascunho privado,recuperação,histórico e relatório do mesmo dossier reutilizados. As estimativas não alimentam os comprovativos pagos nem alteram deduções anuais.
Cinco verificações novas de cálculo/interface e35afectadas aprovadas;percurso sintéticoAPI/recuperação/relatório em quatro cenários(distribuído,concentrado,divergente,total alterado),com saldo anual138MZN conservado. Provas `outputs/irpc-pec-plan-20261111/*-verification.json`. Sem migração,base,papel ou permissão nova. Tipos/lint/compilação aprovados;uma prova nativa/38asserções com rollback. Pacote Git e reversão conferidos no fecho. Isto não é conferência visual.
**Estado:** QA,ainda sem instalação;homologação permanece `bcbbe51c` e270instituições publicadas. Restam instalação/visual e recortes fiscais de base29(5),rectificação29(3),PPC excedente,períodos/regimes e outras regras de simulação;prazo legal da hipótese única não é certificado. L1–L7/restanteL8/L9–L16 e demais módulos permanecem no saldo,sem transformar o portal em sistema de execução fiscal. Próximo trabalho:primeiro residual de simulação executável com fontes aceites.
## 11/11/2026 — total e calendário PEC instalados embcbbe51c
Instalação expressamente autorizada,executada pelo processo Git existente e conferida às10:28:18Africa/Johannesburg(08:28:18UTC): `bcbbe51c2fc279d5b2d85412f7bcb6ca2423a9b6`. Utilizável no simulador fiscal,fecho anual L8: **Preparar obrigação PEC**,com total ordinário2026 baseado no volume documental2025,limites antes dos PPC pagos anteriores,mesesJunho/Agosto/Outubro e dispensa no ano de início efectivo. Guardar/recuperar pelo rascunho privado e apresentar no relatório do mesmo dossier. Não cria pagamento,dedução anual ou reembolso.
Backup novo/restauro isolado conferidos:190tabelas/369ficheiros. Catorze fontes por hash;revisão,entrada,up,login,Concursos,fundos e simuladorHTTP200. Configuração preservada,zero migrações/importações. Sete tabelas protegidas mantêm totais/checksums do backup:funding_programmes,funding_programme_versions,funding_programme_interests,desk_cases,simulation_form_drafts,labour_demands,training_capacities.275fichas/270instituições publicadas preservadas. Recibo `outputs/irpc-pec-obligation-20261111/installation.json`;pacote preservado `outputs/entrega-pec-obrigacao-revisada-20261111.zip`,SHA256 `a9d9bb089305003487695969d653c7f876a913fce44bbbffb5191c1a792a9184`. Revisãofbff2660 preservada para reversão.
Este recorte deixa de ser pendência de implementação/instalação;visual permanece separado. Restam montantes por prestação/pagamento único,última base29(5),rectificação29(3),PPC excedente,períodos/regimes especiais,guias/juros. Próximo trabalho:primeiro desses casos executável pelas fontes aceites,sem repetir o recorte instalado. L1–L7/restanteL8/L9–L16 e restantes módulos continuam no saldo;portal não declarado integralmente concluído.
## 11/11/2026 — total e calendário PEC ordinário em QA
**Novo recorte L8:** preparação do total PEC2026 pelo volume de negócios documental2025:0,5%,limites30.000/100.000MZN aplicados antes de deduzir PPC efectivamente pagos no ano anterior. MesesJunho/Agosto/Outubro,sem inventar dia limite ou importar o arredondamento PPC. Dispensa do ano de início efectivo da actividade29(4) fundamentada separadamente. Fonte:RIRPC,Decreto9/2008,art29(1–5),BR16/I16-04-2008,original SHA256 `fc373ff5637edf75ec6a9595dec8763a8503d4ee786e3b7d8f1c742eae9d80ee`, já preservado no acervo.
Formulário progressivo reutiliza titular/exercício e conserva valores ao ocultar campos sem aplicação; rascunho privado e relatório do mesmo dossier reutilizados. Total preparado não é PEC pago,nem cria dedução anual,reembolso,guia ou consumo de saldo oficial. Ausência de informação não equivale a zero. Sem volume no ano anterior exige base29(5);PPC superiores ao montante limitado ficam por reconciliar,sem obrigação negativa. Esses casos não impedem o cálculo anual independente.
**Percurso sintético:** cálculo→API privadaPOST/PUT→GET→recálculo→HTML/relatório:40.000MZN ordinário,20.000mínimo antes de PPC,90.000máximo antes de PPC,dispensa inicial0,volumezero e desconhecido indeterminados. Histórico e saldo anual138MZN preservados. QA55441 existente,uma prova nativa/56asserções com rollback,sem nova base/tabela/porta/papel/migração. Seis verificações novas de cálculo/interface e29afectadas aprovadas; tipos/lint/compilação aprovados. Provas `outputs/irpc-pec-obligation-20261111/*-verification.json`;pacote Git com aplicação/reversão conferidas no fecho.
**Saldo:** implementado em QA,ainda não instalado. Homologação mantém `fbff2660` e270instituições publicadas. Restam entrega Git e visual;montantes por prestação/opção de pagamento único,última base29(5),rectificação29(3),PPC acima do limite,períodos/regimes especiais,guias/juros permanecem abertos. L8 não é integralmente concluído;resíduos L1–L7/restanteL8/L9–L16 e restantes módulos conservados. Próximo trabalho:primeiro desses casos executável com as fontes aceites,sem reabrir os recortes entregues.
## 11/11/2026 — obrigação PPC instalada emfbff2660
Instalação expressamente autorizada e executada pelo processo Git existente: `fbff2660f280f53fe079221164bb21917032ac70`, conferida às09:04:59Africa/Johannesburg(07:04:59UTC). Utilizável no simulador fiscal,fecho anual L8: **Preparar obrigação PPC**,com imposto anterior documental líquido de retenções,80%,três prestações arredondadas individualmente por excesso ao milhar e calendárioMaio/Julho/Setembro. Guarda e recuperação pelo rascunho privado existente e inclusão no relatório do dossier. Obrigação preparada não equivale a pagamento,nem cria dedução de PPC.
Backup novo/restauro isolado conferidos:190tabelas/369ficheiros. Catorze fontes do delta por hash e revisão,entrada,up,login,Concursos,fundos e simuladorHTTP200. Configuração preservada,zero migrações/importações;sete tabelas protegidas com totais/checksums idênticos ao backup:funding_programmes,funding_programme_versions,funding_programme_interests,desk_cases,simulation_form_drafts,labour_demands,training_capacities.275fichas/270instituições publicadas preservadas. Recibo `outputs/irpc-ppc-obligation-20261111/installation.json`; pacote `outputs/entrega-ppc-obrigacao-20261111.zip`,SHA256 `ab72a11b9306e4503ab92669c47ec592b2510aa01189ebab1d5216bb40d3769a`. Revisão6805b606 preservada para reversão.
Este recorte deixa de ser pendência de implementação/instalação. Conferência visual permanece separada. Próximo residual fiscal:PEC/obrigações e outros casos executáveis com fontes aceites;anualização/períodos especiais,dispensas/guias/juros e resíduos L1–L7/restanteL8/L9–L16 mantêm-se explícitos. Restantes módulos permanecem no saldo existente;portal não declarado integralmente concluído.
## 11/11/2026 — obrigação PPC ordinária de L8 em QA
**Pendência avançada:** o fecho anual existente passa a preparar a obrigação PPC2026 com liquidação documental do exercício civil completo2025: imposto anterior21(1) líquido de retenções,80%,três prestações iguais arredondadas individualmente por excesso ao milhar de meticais. Calendário por mês:Maio,Julho,Setembro,sem inventar dia limite. Fonte:RIRPC,Decreto9/2008,arts27(1)(a)/28(1–3),BR16/I16-04-2008,original SHA256 `fc373ff5637edf75ec6a9595dec8763a8503d4ee786e3b7d8f1c742eae9d80ee`, já preservado no acervo. Não usa colecta projectada corrente como liquidação anterior.
Reutiliza titular/exercício e rascunho privado existentes; formulário progressivo conserva edições deliberadas ao trocar enquadramento. Obrigação preparada e comprovativos efectivamente pagos permanecem distintos: não cria pagamento,dedução,saldo oficial,guia ou juros. Campos vazios,fonte ausente,divergência de titular/período,base negativa e período especial ficam indeterminados,sem impedir o fecho anual independente. Zero explícito permanece zero. Anualização28(3),dispensas,PEC e restantes regimes ficam separados.
**Prova sintética:** cálculo→POST/PUT→GET privado→recálculo→HTML/relatório do mesmo dossier em cinco cenários:72.000MZN ordinário,75.000após arredondamento,zero,valor desconhecido e período especial indeterminados. Saldo anual138MZN e comprovativos inalterados; histórico anterior conservado. Uma prova nativa/47asserções com rollback no QA55441 existente; cinco verificações novas de cálculo/interface e24afectadas aprovadas. Tipos/lint/compilação aprovados; pacote Git com aplicação/reversão conferidas no fecho. Provas `outputs/irpc-ppc-obligation-20261111/*-verification.json`.
**Estado:** implementado em QA, ainda sem instalação. Homologação mantém `6805b606`,Educação entregue e270instituições publicadas. Restam entrega Git deste delta e visual; L8 não está integralmente concluído. Preservam-se os resíduos L1–L7,restanteL8/L9–L16 e restantes módulos. Próximo trabalho fiscal: PEC/obrigações e demais casos executáveis com as fontes aceites,sem reabrir PPC ordinário.
## 11/11/2026 — retoma da revisão de Educação instalada em6805b606
Instalação autorizada pelo utilizador, executada pelo processo Git existente e conferida às08:46:05Africa/Johannesburg(06:46:05UTC): `6805b606a106fbed410b191ed778c0fad7c8e913`. Guardar e recuperar a preparação privada da revisão interna de procura/capacidade fica utilizável na homologação, pelo detalhe existente em `/management/education`. A gravação privada não decide nem aprova; preserva notas, versão analisada e a chave de uma intenção incerta.
Backup novo e restauro isolado conferidos:190tabelas/369ficheiros. Catorze fontes conferidas por hash; revisão,entrada,up,login,Concursos,fundos e simuladorHTTP200. Configuração intacta, zero migrações/importações. Totais e checksums inalterados nas sete tabelas protegidas:funding_programmes,funding_programme_versions,funding_programme_interests,desk_cases,simulation_form_drafts,labour_demands,training_capacities. Permanecem275fichas/270instituições publicadas. Recibo `outputs/education-review-preparation-20261111/installation.json`; pacote preservado `outputs/entrega-educacao-retoma-20261111.zip`, SHA256 `d2b7fd5e0d24baffd7b53d534c78e6e0f4492b47a40bb1d9d146b8a8b553caf5`.
Este recorte deixa de ser pendência de implementação e instalação. Conferência visual continua pendente; jornadas externas educativas e representação/consentimento documentados permanecem em aberto. Resíduos fiscais L1–L7/restanteL8/L9–L16 e restantes módulos mantêm-se no saldo existente. Próximo trabalho: primeiro residual executável dessa ordem, sem repetir este recorte nem as provas aceites.
## 11/11/2026 — retoma durável da revisão de Educação em QA
A revisão interna de procura e capacidade pode guardar e recuperar a preparação privada, incluindo notas, versão analisada e intenção de decisão incerta. Reutiliza os eventos de auditoria existentes, com conteúdo cifrado e isolamento por conta, tipo e declaração. Guardar não decide: a decisão continua pelo escritor nativo, com revisão independente e validação de versão. A intenção fica persistida antes do envio; uma resposta perdida recupera a mesma chave, sem duplicar a decisão. Edições posteriores e preparações mais recentes noutra janela não são substituídas silenciosamente.
**QA concluído, ainda não instalado:** cinco testes nativos/105 asserções e dez testes novos de interface, com os 17 afectados preservados; análise PHP e tipos aprovados. A página nativa recuperada foi exercitada no componente real em harness: recibo reconhecido, edição posterior conservada e zero envios adicionais. Prova `outputs/education-review-preparation-20261111/verification.json`. Isto não constitui conferência visual no navegador. Sem migração, tabela, papel ou concessão nova.
**Saldo:** falta entregar este delta pelo processo Git e conferir a apresentação no uso normal. Jornadas externas de estudantes, formadoras e empregadores, representação e consentimento documentados permanecem pendentes; Educação não é declarada integralmente concluída. A homologação mantém `9c067d6a` e as 270 instituições publicadas. Permanecem explícitos os resíduos fiscais L1–L7/restante L8/L9–L16 e os restantes módulos. As provas aceites não foram reabertas.
## 11/11/2026 — combinação CBF15/créditos/PEC instalada em9c067d6a
Instalação expressamente autorizada nesta retoma e executada pelo processoGit existente: `9c067d6ae89c0cd1e9ad4f2813e84048988d3319`, conferida às07:43:38Africa/Johannesburg(05:43:38UTC). Dezficheiros do delta conferidos por hash; revisão,entrada,login,up,Concursos,fundos e simuladorHTTP200; configuração preservada, zero migrações/importações. Backup/restauro isolado conferido:190tabelas e369ficheiros. Nenhum restauro sobre dados activos.
**Utilizável na homologação:** combinação CBF15corrente/transportado com créditos económicos/internacionais e saldos anteriores, com escolha dePEC limitada à colecta residual apósCBF15; gravação/recuperação e relatórios pelo percurso existente, conforme cinco cenáriosQA aceites. Os recortes anteriores de transporte e os históricos são preservados. Nenhum saldo oficial é consumido, pagamento/reembolso executado ou nova prioridade entre exercícios inventada.
**Preservação activa comprovada:** totais e checksums idênticos ao backup nas tabelas `funding_programmes`, `funding_programme_versions`, `funding_programme_interests`, `desk_cases` e `simulation_form_drafts`.275fichas,270publicadas; o original financeiro continua disponível. Não se extrapola esta comparação para tabelas voláteis de sessão/cache. Prova `outputs/irpc-cbf15-combination-20261111/installation.json`; pacote e reversão `outputs/entrega-irpc-combinacao-20261111.zip`, SHA256 `cebcd063b06f70a33b57f6cdd710478fb7aa3d18ef64aec4489cf855064bff99`.
Saldo: este recorte deixa de ser pendência de implementação/instalação. Conferência visual mantém-se separada; continuam repartição automática/múltiplos projectos, outros períodos/moedas/regimes/EP/cumulações/benefícios/formulários e os restantes resíduos L1–L7/restanteL8/L9–L16 e módulos não fiscais. O portal não é declarado integralmente concluído. A próxima implementação seguirá o primeiro residual executável do saldo existente, sem reabrir a combinação entregue nem a publicação das270instituições.
## 11/11/2026 — combinação CBF15 com créditos económicos/internacionais e PEC em QA
Retoma da ordem de conclusão existente, sem nova auditoria ou repetição da publicação financeira:270instituições publicadas e revisão instalada4f3dbcf8 preservadas; visual financeiro/S08 permanece separado. Reconciliação delimitada: o transporte CBF15 básico(7476dc34) já integra a revisão instalada4f3dbcf8, pelo histórico Git e recibos aceites; a nota antiga «QA, não instalado» desse transporte foi superada por entregas posteriores. Não reinstalar esse recorte como se estivesse ausente.
**Implementado nesta passagem, ainda sem instalação:** retirada a recusa técnica genérica da combinação de CBF15 corrente/transportado com saldos anteriores, reutilizando as fontes e ordem de dedução existentes. Créditos económicos e internacionais/correspondentes transportes precedemCBF15; PEC corrente e anterior usam apenas a colecta residual seguinte, antes de retenções/PPC. Uma escolha explícita de PEC superior ao residual é recusada como indeterminada, sem reduzir silenciosamente parcelas ou fabricar saldo recuperável. Histórico e fontes de investimento/uso anterior continuam preservados; a chave da combinação inclui versão própria para exigir recálculo dos resultados anteriores desse ramo, sem reescrever históricos nem invalidar os recortes sem combinação.
Cinco cenários sintéticos: combinação ordinária138MZN; quota residual documental100→saldo438; PEC sobrealocado→saldo indeterminado; CBF15 esgota colecta1338 ePEC seleccionado expressamente0→saldo−500 proveniente dePPC, sem reembolso/consumo oficial; janelaCBF15 expirada→saldo538. Também provada a varianteCBF15 corrente e o limite de quota após créditos anteriores. Cálculo→POST/PUT do rascunho privado existente→GET→recálculo exacto→HTML/dossier aprovados no QA55441/proactive_investment_20261027_test, com rollback e sem novas bases/portas/migrations/direitos. Uma provaAPI/37asserções; cinco testes novos nas combinações e23casos afectados de compatibilidade aprovados, não multiplicados como funcionalidades. Tipos/lint aprovados; compilação QA e pacote registados no fecho. Provas `outputs/irpc-cbf15-combination-20261111/*-verification.json` e relatórios recuperados.
**Saldo:** implementação e percurso desta combinação concluídos emQA; instalação do novo delta e visual separados. Persistem repartição automática/múltiplos projectos, outros períodos/moedas/regimes/EP/cumulações/benefícios e documentos fora dos recortes demonstrados; L1–L7/restanteL8/L9–L16 não são encerrados. Os cinco programas anteriores privados do directório financeiro não são publicados por consequência desta entrega. Próximo trabalho: agrupar a entrega compatível deste delta e continuar o primeiro residual fiscal executável, preservando os fechos aceites.
## 10/11/2026 —270instituições publicadas e consultáveis na homologação
**Cadastro e disponibilização do documento fornecido concluídos em homologação.** Conferência de leitura às22:54:31Africa/Johannesburg(20:54:31UTC), revisão instalada `4f3dbcf86499180011fc79712d6b5be445e57e26`:270/270instituições/operadores do documento publicados, nenhuma referência do original em falta,270identidades únicas,393referências físicas e168contactos. Publicações confirmadas pelo fluxo editorial nativo do utilizador; zero escritas efectuadas pelo verificador. As275fichas totais incluem cinco referências anteriores de fundos/programas que permanecem privadas, sem serem publicadas por consequência deste lote. Fonte original/SHA/edição, incertezas e identidades preservadas.
Consulta pública comprovada porHTTP200 e dados efectivamente devolvidos: `/fundos?kind=institution&include_historical=1` contém270resultados; filtro categoria seguros/tipo seguradora/presença física/Nampula contém seis(Fidelidade,Emose,HollardMoçambique,Britam,Arko,Palma). Fichas deBCI,Absa eArko abrem no componente público e conservam a fonte SHA256 `119bb5514d2777849b70aebfec21d28a41885cbdc7d9827fbebd764fe994c108`. A opção de incluir histórico não transforma uma instituição fundida/extinta em activa. Sede/balcões continuam separados de cobertura e produtos; dados desconhecidos não se tornam condições confirmadas nem garantias de financiamento/seguro.
**Saldo ACT-20261029-12 neste recorte:** conteúdo do documento cadastrado, publicado pelo editor e disponível por localização/categoria/tipo; já não é página vazia ou apenas códigoQA. Continuidade banco/seguradora/balcão/fonte/versão no mesmo dossier, gravação/recuperação e conservação após alterações continuam cobertas pelas provasQA aceites, reutilizadas sem criar dossier sintético em produção. Conferência visual de navegador permanece separada; não se presume instalação de outros módulos, fecho integral do portal ou conclusão dos resíduos fiscais. Nenhuma nova instalação/alteração de direitos exigida para declarar este resultado. Prova `outputs/funding-directory-20261110/institutions/publication-complete.json`.
## 10/11/2026 — recusa depois do login: navegadores e sessões diferentes
URL comunicado pelo utilizador:`/dashboard`. Conferência dirigida dos registos confirma login/MFA no **Edge** às22:03:53, seguido de`/dashboard`HTTP200 às22:03:55. A abertura seguinte de`/management/fundos/referencias-assistidas`às22:04:02/22:04:17 recebeu403 em **Safari/WebKit**, não no Edge. Existiam duas sessões da mesma conta: a sessão recente do Edge com confirmaçãoMFA válida e a anterior com confirmação antiga. A autorização editorial/verificação de email/perfil existentes estavam preservados. Logo esta nova recusa não comprova expiração do MFA recém-concluído no Edge nem falta de permissão da conta; o link abriu outro contexto de navegador.
Solução indicada: copiar e colar o URL do editor na barra do Edge já autenticado, mantendo a mesma aba nas retomas. Não repetir login por rotina, alterar permissões, desactivar verificações ou reinstalar por este motivo. **Reposição de acesso confirmada:** GETdo editor noEdge às22:11:21(+02),HTTP200. A recusa de entrada ficou delimitada e resolvida por uso da sessão correcta, sem nova instalação, concessão ou alteração de segurança. A instalação4f3dbcf8 permanece; publicação/conteúdo consultável ainda não declarados concluídos. Consulta estritamente de leitura; nenhum identificador de sessão/token/cookie/palavra-passe exportado e nenhuma sessão modificada. Prova:`outputs/funding-directory-20261110/institutions/access-context-diagnosis.json`.
## 10/11/2026 — retoma editorial instalada em4f3dbcf8
Correcção instalada pelo executor Git existente em `4f3dbcf86499180011fc79712d6b5be445e57e26`, confirmada às22:01:41Africa/Johannesburg (20:01:41UTC).7ficheiros conferidos por hash; revisão/entrada/login/up/Concursos/fundos HTTP200, configuração preservada, zero migrações/importações automáticas. Novo backup/restauro190tabelas/369ficheiros conferido, sem substituir dados activos. Pacote `outputs/entrega-instituicoes-retoma-20261110.zip`, SHA256 `9bce0250d546cd87f7c60a55f0c1d47fba74e8965f7d3224a588e140f87ec3cb`; anteriores preservados.
**Saldo activo:90rascunhos, zero publicações.** A indicação de publicação não foi confirmada no servidor por expiraçãoMFA/403. A correcção torna visível o estado e conserva os recibos na mesma aba, poractor/fontes; não publica por consequência da instalação nem altera o limiteMFA900s. Os90rascunhos anteriores ao armazenamento no cliente são recuperados idempotentemente na primeira passagem actualizada; após isso as fichas conferidas são saltadas nas retomas da mesma aba. Confirmações de revisão/publicação voltam a ser exigidas na entrada, e o backend revalida guardas/hash/versão. Fechar a aba/limpar armazenamento não elimina rascunhos, mas perde a retoma local. Estado alterado por outra janela exige conferência actual; o armazenamento do cliente não é prova de publicação.
Resta a conclusão autenticada da preparação/publicação e a consulta efectiva do conteúdo no destino. ACT12 continua aberto, visual pendente, sem reabrir APIEX/SWOT/S08 ou resíduos fiscais anteriores. Prova: `outputs/funding-directory-20261110/institutions/resume-installed.json`.
## 10/11/2026 — retoma após MFA, sem repetir fichas conferidas
Verificação activa posterior:90rascunhos/zero publicações; continuação GETMomentum403 às21:49:39 ePOSTpublicar403 às21:49:54 (+02). A sessão voltou a expirar. A publicação comunicada não chegou a ser executada pelo servidor. Nenhum rascunho foi removido.
Correcção delimitada emQA: a mesma aba conserva selecção, recibos/versionamento/hash e fundamento, separados por conta e conjunto de fontes; após reautenticação salta fichas já conferidas. As confirmações de revisão/publicação são desmarcadas na nova entrada, sem publicação automática. Uma publicação de resposta incerta conserva o mesmo corpo para recuperação idempotente. Novo comando de renovação utiliza logout/login/MFA nativos; limite900s e guardas mantidos. «Seleccionar todas as instituições(270)», contagem de progresso e controlos antes da lista recolhida tornam o estado visível. Sem migração, dados novos ou alterações de permissões. Alteração ainda não instalada nesta anotação; revisão activaef24cbdc.
Prova dirigida do componente efectivo: remonta a página como após autenticação, recupera selecção/recibos, não repete preparação, não publica automaticamente, recupera confirmação incerta e isola outra conta/fontes. Prova API editorial anterior1caso/27asserções reexecutada apenas pela propriedadeeditor_id acrescentada; provas270fichas e do dossier reutilizadas. Tipos/análise/compilação da entrega são registados pelo executor existente.
## 10/11/2026 — confirmação da preparação parcial e expiração do MFA
Após execução no editor, a homologação contém **32rascunhos e zero fichas publicadas**. A preparação gravou efectivamente e avançou por diferentes instituições. Os registos HTTP mostram preparação200 atéMyBucks às21:31:42, seguida deGETxtenda403 às21:31:43 ePOSTpublicar403 às21:31:59 (Africa/Johannesburg). A entrada no editor fora confirmada às21:16:43; corresponde ao limite existente de900segundos do MFA em `PortalProfileAccess::mfa`. Não é perda da instalação nem repetição de dados. Reautenticar no portal com MFA e retomar a preparação idempotente conserva os32rascunhos, sem substituir edições; publicação continua a exigir a confirmação separada. Nenhuma guarda, sessão ou permissão foi alterada. Revisão instalada `ef24cbdc`, ACT12 ainda sem conteúdo público confirmado. Prova de leitura: `outputs/funding-directory-20261110/institutions/publication-observation.json`.
## 10/11/2026 — instalação confirmada do directório financeiro
Revisão `ef24cbdc9218e2027f7d1a39968cc60869a35410` instalada na homologação às19:15UTC, pelo executor Git existente.33ficheiros conferidos por hash com o commit; configuração preservada, zero migrações/importações automáticas. Backup/restauro confirmado de190tabelas e369ficheiros. Entradas `/fundos?kind=institution`, `/concursos`, `/login`, `/up` e revisão HTTP200. Pacote anterior preservado; nova entrega `outputs/entrega-instituicoes-editorial-20261110.zip` SHA256 `168d7ffb6d054533674f3f07799959b46bc12641c045accfc1766da5cead9077`.
**Implementação instalada; conteúdo ainda sem publicação confirmada.** Leitura activa:zero fichas/zero publicações; tabelas sem RLS, logo o resultado não decorre de falta de contexto de leitura. Entrada autenticada no editor assistido confirmada nos registos; ainda sem pedidos POST de preparação/publicação observados. A indicação do utilizador de operação concluída permanece por reconciliar com o resultado efectivo, sem repetir a importação ou simular MFA. A conta existente tem designação editorial e perfil exigidos; nenhuma concessão alterada. ACT-20261029-12 permanece aberto até existirem fichas consultáveis. Visual e restantes resíduos do portal mantidos.
## 10/11/2026 — preparação editorial assistida das instituições
A autenticação SSH foi confirmada, mas não substitui a sessão e MFA recentes do editor no portal. Acrescentada `/management/fundos/referencias-assistidas`: selecção explícita das instituições do documento, conferência das fontes, preparação sequencial idempotente e publicação confirmada numa segunda operação. Preparar nunca publica. Fichas deliberadamente alteradas são preservadas e exigem revisão individual; mudança do conjunto de fontes interrompe a operação. Uma resposta de publicação perdida conserva versão/hash/fundamento para a mesma repetição. Expiração de MFA mantém as guardas e os resultados já confirmados.
Prova adicional delimitada:1caso API/27asserções,1caso frontend de separação preparação/publicação e recuperação da confirmação. Reutilizadas as5provas API/735asserções e28frontend anteriores; não se repetiu a carga das270instituições. Instalação e publicação no destino continuam distintas e serão registadas após confirmação efectiva. Sem migrações, novos direitos ou alteração das sessões existentes.
## 10/11/2026 — instituições por localização, categoria e tipo no processo do investidor
Pedido expresso da Tempo: inserir as instituições do documento agora indicado por localização/categoria/tipo e permitir que o investidor consulte bancos, seguradoras e outros intervenientes quando a necessidade surgir, sem limitar a decisão por falta de informação. **Implementação e conteúdo demonstrados em QA; nenhuma instalação ou publicação activa executada nesta passagem. Última revisão instalada confirmada:1c75a30a, APIEX/SWOT/Concursos preservados. ACT-12 não é encerrado pela preparação técnica.**
Original read-only `Notas/Instituições financeiras em Moçambique.docx`, edição Novembro2026, SHA256 `119bb5514d2777849b70aebfec21d28a41885cbdc7d9827fbebd764fe994c108`. Texto,509linhas de tabelas e nomes/contactos presentes nos parágrafos reconciliados. **270instituições/operadores,393referências de localização física e168referências de contacto**, não270ofertas de financiamento nem393moradas independentemente confirmadas. Instituições repetidas são reunidas por identidade explícita/aliases documentados ou número de registo citado; divergências, moradas alternativas, terceiros, dados históricos e identidades por confirmar permanecem identificados, sem substituir factos. Entidades sem morada continuam consultáveis como não localizadas. Totais estatísticos/redes incompletas não criam entidades ou agências fictícias. Listas antigas não confirmam licença actual; ABC permanece fundida/extinta segundo o documento, ligada à referência Access, sem atribuir a origem dos balcões por inferência.
Reutilizado o catálogo/versionamento/editorial existente: categorias banca,crédito/microfinanças,gestão de investimento,pensões,seguros/resseguros,mediação,câmbio,pagamentos e supervisão; uma entidade pode ter mais de uma categoria/função sem duplicação. Filtros por categoria,tipo e presença física são distintos da cobertura do produto. Província do cadastro é associada apenas por correspondência inequívoca; rua/localidade e precisão limitada conservam-se como declaradas. Pesquisa por cidade/balcão/morada/alias permite localizar informação textual (por exemplo Beira), sem geocodificação inventada. Informação por confirmar não é correspondência confirmada; ausência de registo não prova ausência de oferta. Oito gestoras de pensões continuam distintas de produtos de crédito, e seguradora não é apresentada automaticamente como financiadora/garantia de crédito.
**Continuidade no mesmo dossier:** ferramentas do investimento abrem a pesquisa contextual; seleccionam-se necessidade (incluindo seguros), instituição e, opcionalmente, sede/balcão. Guarda-se identidade estável, categorias/tipos, versão pública, local exacto escolhido, fonte/documento/SHA/linha, estado de confirmação e produto quando existente. Banco e seguradora coexistem; gravação repetida não duplica escolha. Actualização posterior dos pressupostos do investimento conserva a escolha; retirada da ficha pública não apaga a fotografia consultada. Dados/etapas/autoridade de decisão anteriores não são alterados. Consulta pública livre, operações privadas e edição editorial com as guardas existentes; nenhuma candidatura, email ou contacto enviado às instituições, novo perfil/grant/porta/schema ou ligação fictícia.
Provas no QA55441 existente, com rollback: **5casos API/735asserções únicas** (2desta fonte/608 e3de compatibilidade/127),28frontend dirigidos incluindo S08/navegação e recuperação da tentativa com balcão escolhido, tipos/PHPStan/lint/Pint/compilação QA aprovados. Uma tentativa de suite paralela foi recusada pela guarda da mesma base; compatibilidade executada sequencialmente sem remover a guarda ou criar outra base. `outputs/funding-directory-20261110/institutions/{content,verification}.json` e **`catalogo-qa.html` com270fichas ligadas**; renderização estática dos componentes, não conferência visual de navegador. Provas e pacote anteriores das11fichas/BNI Capital/FDEL mantidos. As7instituições comuns são enriquecidas pela mesma identidade; não há segundo directório. Fontes/documento original não foram alterados.
**Saldo concreto:** conteúdo e percurso local/categoria/tipo → banco/seguradora → fonte/contacto → mesmo dossier → continuação/recuperação demonstrados em QA. Restam instalação e disponibilização/revisão/publicação autenticada pelo fluxo editorial existente, consulta efectiva no destino e visual (conector indisponível). Preparação documental não publica lote automaticamente, conformeAGENTS.md; não declarar informação já disponível na homologação por existir este código. Restantes módulos e resíduosL1–L7/restanteL8/L9–L16 mantêm o saldo anterior.
## 10/11/2026 — ACT-20261029-12: directório financeiro com conteúdo em QA
**Homologação mantém1c75a30a; este conjunto não está instalado nem publicado.** A leitura autenticada do destino confirmou catálogo /fundos HTTP200, schema instalado e zero fichas/versões. O requisito não fica encerrado por essa disponibilidade técnica. APIEX/SWOT mantêm a entrega instalada própria, sem reabertura.
Localizada e reutilizada a lista fornecida em `Relatorios/benchmark de melhorias investeMoz.docx`, SHA256 `065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617`, quadro de04/10/2026. Doze entradas documentais correspondem a **11fichas:7instituições e4programas/fundos** — BCI,Millennium bim,Absa,Standard Bank,Moza,BNI,Gapi,FECOP,Semeia Negócios,FCID VIII eFAE; **BNI Capital** integra os produtos do BNI, sem duplicar instituição ou presumir personalidade jurídica autónoma. Agregado genérico de linhas BNI não gera fundos fictícios. Referência FDEL anterior conservada, sem a contar novamente nesta entrega.
O cadastro/versionamento/competência editorial existentes passam a conservar natureza factual, identidade estável, contactos com fonte/data, locais físicos (sede/balcão/ponto institucional) e produtos próprios. Moradas não determinam cobertura financeira. Produto tem finalidade, instrumento, condições/montantes, disponibilidade e cobertura próprios; vazios são identificados como por confirmar. Cobertura e necessidade têm de corresponder ao **mesmo produto**, antes da paginaçãoSQL. Critério documentado pode excluir desconhecidos; consulta geral distingue resultados por confirmar de correspondência sustentada. FCID conserva seis províncias documentadas, sem confundir Maputo Província/Cidade; FAE mantém o Vale do Zambeze como âmbito textual com vínculo geográfico por confirmar, sem inventar distritos. A localização institucional do BNI não limita o financiamento a Maputo. Semeia conserva a chamada encerrada; a antiga linha BCI2018 não é reaberta. Condições e janelas históricas não se tornam ofertas actuais pela importação. Fontes indicadas consultadas para âmbito/disponibilidade; falha de recolha da Gapi não revoga o original, cuja data04/10 e disponibilidade por confirmar ficam explícitas.
Demonstrado em QA55441 existente: localização/necessidade → consulta das fichas reais fornecidas → fonte/contacto/condições → escolha privada no mesmo dossier e oportunidade → recuperação. Snapshot preserva versão pública e produto consultados mesmo após retirada/alteração da ficha. Repetição da intenção e recuperação após recarregar não duplicam a escolha/evento; outra oportunidade/actor e dossier encerrado mantêm guardas. Não envia candidatura, contactos ou dados às instituições, não concede crédito nem classifica o investidor automaticamente. Sem pontuação/IA, schema/base/porta nova, novas competências ou alteração de dados activos.
Provas dirigidas: **3casos API/127asserções e13frontend**, PHPStan nível7 sem supressões, tipos/lint/Pint/compilação QA aprovados. Testes transaccionais revertidos; sequências QA podem avançar. `outputs/funding-directory-20261110/verification.json`, `content-qa.json`, `dossier-qa.json`; **catálogo consultável de revisão** `outputs/funding-directory-20261110/catalogo-qa.html` e11fichasHTML ligadas. Renderização estática dos componentes afectados, não conferência visual de navegador nem publicação no destino. Provas anteriores preservadas e reutilizadas.
**Saldo ACT-12:** recorte funcional e conteúdo fornecido demonstrados em QA, preparado para disponibilização pelo fluxo editorial. **Restam instalação do conjunto, preparação/revisão/publicação autenticada das11fichas no destino e confirmação da consulta real pelo investidor**; visual continua pendente com conector indisponível. Não declarar ACT-12 encerrado ou o portal integral. Dados de sede/balcões, condições ou cobertura não documentados conservam o estado por confirmar; não bloqueiam os restantes dados fundamentados. Resíduos L1–L7/restanteL8/L9–L16 e outros módulos mantêm o saldo anterior.
## 10/11/2026 — APIEX, SWOT e acesso destacado a Concursos instalados
**Homologação instalada e conferida: `1c75a30a601ef00fc187ddf5a64183446d329e6f`**, https://teste-investidor.tempo.co.mz. Inclui APIEX/SWOT37854d02 e correcção do menu autorizada pela Tempo. **Concursos e anúncios** tem agora grupo próprio no menu horizontal comum, com consulta pública e acompanhamentos para utilizadores autenticados; endereço https://teste-investidor.tempo.co.mz/concursos. O módulo já estava activo e apenas a entrada estava dentro de Descoberta; não foi necessário activá-lo novamente. Um rascunho privado e zero versões publicadas preservados.
Conferência final:21hashes Git iguais à release, revisão HTTPS correcta e root/up/login/concursos HTTP200, configuração intacta, zero migrations/importações. Backup fresco restaurado com190tabelas/contagens/checksums iguais e369ficheiros/configuração conferidos;7476dc34 e pacotes anteriores preservados para reversão de código. Recibos `outputs/apiex-swot-concursos-delivery-20261110/{backup,restore,files-restore,verification,delivery}.json`; verificação UTC no recibo. Instalação executada pelo mesmo executor Git, sem alterações de permissões, dados reais, publicação de análises ou submissão externa.
**Saldo:** APIEX e SWOT abaixo deixam de ser pendência de instalação. Acesso pelas ferramentas **Preparação APIEX e SWOT** do dossier existente, conservando a oportunidade seleccionada. Conferência visual no uso normal continua pendente no mesmo registo; não é inferida das provas automáticas. Âmbitos normativos não mapeados continuam indeterminados; os restantes módulos e resíduos L1–L7/restanteL8/L9–L16 conservam o saldo anterior. O portal não é declarado integralmente concluído.
## 10/11/2026 — correcção dirigida das âncoras antes da promoção
O executor recusou c7901592 por um caso frontendS08 (1falha/1788), mantendo7476dc34 activo. Diagnóstico delimitado: a página tinha dois efeitos de âncora e o teste existente conservava uma única sequência de dependências. A abertura SWOT foi integrada no listener de hash já existente, também para mudanças posteriores de #tarefas, sem alterar a guarda/foco/expansão de S08. O teste S08 aceite conserva as suas asserções; acrescentado caso que alterna #tarefas/#S08, verifica isolamento no dossier e limpeza do listener.15casos dirigidos APIEX/SWOT/âncoras aprovados. Nenhuma alteração de dados, permissões, fontes normativas ou configuração; entrega autorizada retomada no mesmo mecanismo, sem mascarar a falha nem desactivar verificações. Recibos finais continuam a determinar a revisão instalada.
## 10/11/2026 — Concursos visíveis no menu principal; instalação agrupada autorizada
Conferência delimitada do destino7476dc34 por SSH autenticado: `procurement.enabled=true`, módulo disponível, tabelas presentes; `/concursos` e raiz HTTP200 com `procurementEnabled=true`. Preservados um concurso em rascunho e zero versões publicadas. O módulo não desapareceu nem estava desligado: a ligação estava dentro de Descoberta. Pedido Tempo «onde foram parar concurso? depois de resolver isso autorizo a instalação»: entrada promovida a grupo próprio **Concursos e anúncios** no menu horizontal comum, com **Consultar concursos e anúncios** e, após autenticação, **Os concursos que acompanho**. Retirada só a ligação duplicada de Descoberta; cinco atalhos principais e autorizações editoriais preservados. Não cria/publica concursos nem muda flags, dados ou schema.
Prova dirigida da navegação:20casos aprovados, incluindo visitante/utilizador, disponibilidade, destinos únicos e selecção correcta de catálogo/ficha/acompanhamentos. Tipos/lint/compilação QA aprovados. Observação anterior `outputs/investment-preparation-20261110/concursos-before.json`; pacote agrupado `outputs/entrega-apiex-swot-concursos-20261110.zip`, preservando o pacote APIEX/SWOT anterior. O conjunto APIEX/SWOT abaixo conserva as provas aceites, sem repetição. Instalação autorizada expressamente após esta correcção; execução e recibo final separados desta prova QA. Visual continua pendente por indisponibilidade do conector.
## 10/11/2026 — APIEX e SWOT no dossier existente: QA concluído, instalação pendente
**Instalado:** `7476dc343098f15a7982e9d7581bf4f59d43bc1a` em https://teste-investidor.tempo.co.mz, promovido às14:28:12UTC e conferido às14:29:07UTC (16:29 locais). Transporte CBF15 deixa de ser pendência de instalação. Delta14hashes Git/HTTPS, root/up/login/revision200, configuração intacta, zero migrations/importações; backup/restauro190tabelas e369ficheiros conferidos. Recibos `outputs/cbf15-carry-delivery-20261110/`; release anterior e pacotes preservados.
**Implementado em QA, ainda não instalado — APIEX:** dossier existente → requisitos fundamentados disponíveis/em falta/não aplicáveis/indeterminados → documentos existentes e complementos → revisão → prévia/ZIP com índice e anexos íntegros → registo da entrega externa, esclarecimento, resposta e decisão com data/referência/comprovativo. Dados do investidor/empresa/contexto/localização/oportunidade reutilizados. Estados em preparação/pronto para entrega/entrega registada/decisão recebida distintos; exportar não submete nem aprova. Snapshot preparado, versões, SHA e pacote privado cifrado preservados; recuperação devolve os mesmos bytes após alteração do contexto, revalidando acesso e segurança dos documentos. Ausência de recepção electrónica APIEX, segundo a orientação Tempo, não bloqueia este percurso; nenhum conector fictício ou formulário oficial inventado. Reutiliza o avaliador e modelos/fontes aceites; períodos/regimes fora do âmbito actualmente mapeado ficam indeterminados, sem conclusão normativa presumida.
**Implementado em QA, ainda não instalado — SWOT:** no mesmo dossier/oportunidade, objecto explícito investimento/território/sector/cluster, evidências do contexto/análise proactiva e Competitividade pública aprovada, quatro dimensões internas/externas, fonte/data/classificação de cada conclusão (facto documentado/hipótese/por confirmar). Sem pontuação, IA ou inferência de ausência pela falta de registos. Gravação/recuperação/histórico paginado e revisão pela equipa com permissões existentes. Alteração/retirada de fontes assinala revisão, conserva fotografia consultada e edições deliberadas. Acções ideia/oportunidade/parceiros/risco continuam no mesmo dossier; risco liga à tarefa nativa com origem própria e repetição sem duplicação. Privado por defeito; publicação exige o fluxo editorial existente, nunca automática. Contextos retirados ficam no histórico privado de leitura, sem reactivação.
Provas sintéticas no QA55441 existente, com rollback: seis casos API/211asserções;16casos frontend dirigidos (incluindo transporte e cabeçalho comum147páginas), tipos, análise estática e lint aprovados. `outputs/investment-preparation-20261110/verification.json`, ZIP/índice/anexos, recuperação SWOT e provas de isolamento/histórico/segurança. Pacote Git agrupado `outputs/entrega-apiex-swot-20261110.zip`, base exacta7476dc34, reversão de código sem apagar auditoria, pacotes privados ou tarefas posteriores. Nenhuma migration/base/porta/conta real nova, submissão real ou publicação. Compilação QA separada; provas anteriores reutilizadas.
**Saldo concreto:** APIEX e SWOT acima encerrados em implementação QA; instalação deste conjunto e conferência visual continuam pendentes. O conector mantém “The browser access host is unavailable”; SSR/testes não substituem visual, sem novo ciclo de reinícios. Os restantes módulos conservam o saldo anterior, incluindo resíduos L1–L7, restanteL8 e L9–L16. Isto não declara o portal integralmente concluído.
## 10/11/2026 — transporte documental CBF15 em QA
Homologação mantém `d216830b`; este delta ainda não está instalado. Tipos/lint/Pint/buildQA aprovados; pacote com aplicação/reversão exactas,sem migrations/dados/credenciais. Pendência encerrada no recorte normal residente2026/ano civil/MZN: crédito de investimento de origem anterior do mesmo contribuinte/projecto/local/regime, com activo/documento/parcela, exercício de origem, NUIT/projecto de origem, taxa original e qualificação históricas documentados. Exercício de origem anterior a2026,dentro da janela original e não anterior à aquisição; no projecto em funcionamento,a aquisição não precede o início aplicável. Não renova o prazo nem recalcula origem a outra taxa. Valor corrente/ausência e uso efectivo agregado exigem reconciliação explícita; histórico de simulações não equivale a utilização fiscal.
Cálculo → POST/PUT do mesmo rascunho privado → GET → recálculo → HTML/dossier demonstrados no QA55441 existente com rollback. Exemplo sintético recuperado: nominal de origem4.000,uso efectivo anterior1.000,disponível3.000,quota documental do projecto200 → dedução200,não utilizado2.800,saldo500MZN. Início2022 → quinto exercício2026; mudando o início para2021 → sexto,parcela expirada3.000,disponível/dedução0,saldo700,com activo,uso anterior e origens preservados. Seis/sete resultados históricos conservados,incluindo o resultado ordinário aceite. Provas `outputs/irpc-cbf15-carry-20261110/{carry,expired}-verification.json` e relatórios respectivos; API um caso/28asserções novas,30casos frontend nas fronteiras alteradas incluindo formulário real compilado e prevenção de duplicação. SSR/formulário simulado não é navegador/visual. FonteCBF15(4/7),APIEX2025 PDF71–72 já aceite,reutilizada.
A preparação mantém os activos correntes e exige explicitar os dados em falta; activos de origem e correntes partilham a capacidade documental e a detecção de duplicação entre mapas. Origem com outro NUIT/projecto/taxa,exercício incoerente,uso superior ao nominal ou declaração de ausência incompatível não produz dedução. Sem pagamento,reembolso,consumo oficial ou criação automática de saldo transportado. Livros antigos permanecem nos formatos1/2; só o transporte preparado usa livro3/detalhe2,para o código anterior recusar em vez de ignorar o crédito novo. Snapshots e históricos aceites não são reescritos; reversão é de código e não deve sobrescrever dados posteriores.
Saldo preservado: outros projectos/localizações/regimes/cumulações/EP/L7,combinação positiva com transportes económicos/internacionais/PEC anteriores,repartição automática,benefícios/formulários oficiais e restantes resíduos L1–L7/restanteL8/L9–L16 mantêm os seus recortes. Os módulos não fiscais mantêm o saldo existente,sem nova auditoria geral. Próximo trabalho: primeira combinação residual executável respeitando a ordem de dedução e quota do projecto,ou percurso independente do saldo existente; visual continua pendente no mesmo registo. Não é conclusão integral do portal.
## 10/11/2026 — menus horizontais instalados
**Homologação: `d216830b068af8a7a40d43993795639e73aaed78`**, promovida às13:56:27UTC e conferida às13:56:54UTC (15:56 locais), em https://teste-investidor.tempo.co.mz. Delta10hashes Git e revisão HTTPS iguais; root/up/login/revision HTTP200, configuração preservada, zero migrations/importações. Backup/restauro190tabelas com contagens/checksums iguais e369ficheiros/configuração conferidos; revisão d5cb41b1 preservada para reversão. Recibos `outputs/horizontal-menu-delivery-20261110/{backup,restore,files-restore,verification,delivery}.json`. Pacote anterior e dados/históricos preservados; sessãoSSH pronta para continuação autenticada, sem credencial em disco.
O recorte dos menus horizontais abaixo deixa de ser pendência de instalação: cabeçalho comum em páginas públicas/autenticação/definições/privadas, sem sidebar global concorrente; Descoberta sem repetição dos cinco atalhos mantidos na entrada principal. Prova estrutural dos146layouts e23casos dirigidos reutilizada; não é conferência visual. O conector continua indisponível,sem novos reinícios ou alterações ao navegador. Visual mantém-se pendente no mesmo registo. O restante saldo de funcionalidades por módulo e L1–L7/restanteL8/L9–L16 não é encerrado por esta instalação.
## 10/11/2026 — janela CBF15 instalada; menus horizontais uniformizados em QA
**Homologação: `d5cb41b111cac95c431a3072b8c4cc0141481ec2`**, promovida às13:43:40UTC e conferida às13:44:18UTC (15:44 locais). Delta12hashes Git e revisão HTTPS iguais; root/up/login/revision HTTP200, configuração preservada, zero migrations/importações. Backup/restauro190tabelas e369ficheiros/configuração conferidos,89e6d72c preservada. Recibos `outputs/cbf15-window-delivery-20261110/`. Janela de cinco exercícios CBF15 deixa de ser pendência de instalação; transporte de créditos anteriores e restantes resíduos fiscais continuam separados.
**Delta posterior em QA, ainda não instalado — pedido expresso da Tempo10/11, corrigido para menus horizontais com dropdown:** cabeçalho comum passa a ser montado uma vez pelo layout global em todas as páginas; área privada usa o mesmo cabeçalho sem navegação lateral concorrente. Autenticação, definições, gestão e páginas públicas conservam conteúdo e breadcrumbs; filtros de capacidades, direitos e destinos privados existentes preservados. Esconder cabeçalho na impressão mantém-se. Os cinco atalhos (Porquê investir,Partir de uma ideia,Consultar oportunidades,Simulador Fiscal,Sectores) ficam intactos na entrada principal. Descoberta conserva localizações/mapa,catálogos e módulos opcionais,sem repetir os atalhos; Partir de uma ideia já estava fora desse grupo. CSS responsivo e implementação comum dos dropdowns/teclado/Escape/toque reutilizados,sem novo menu alternativo.
Conferência estrutural dos layouts das146páginasTSX existentes: exactamente um cabeçalho comum por composição, incluindo páginas privadas,autenticação/definições, sem sidebar global; conteúdo/contexto/breadcrumbs e cinco atalhos preservados. Provas `tests/frontend/portal-horizontal-shell.test.mjs`, navegação e renderização dirigida (23casos aprovados). É SSR com dependências de UI substituídas,não prova visual de navegador. Tentativa única de inventárioCUA nesta continuação devolveu exactamente `Browsers: Error: The browser access host is unavailable`; nenhuma alteração/reinício de navegador. Conferência visual mantém-se pendente no registo existente. Tipos/lint/build QA aprovados, logs desta entrega; base activa/dados/permissões intocados.
O saldo integral dos restantes módulos e L1–L7/restanteL8/L9–L16 permanece no plano existente; nenhuma contagem de páginas/testes declara o portal concluído.
## 10/11/2026 — janela de cinco exercícios CBF15 em QA
Homologação mantém `89e6d72c`; o delta seguinte ainda não está instalado. Pendência encerrada em QA: contagem inclusiva dos cinco exercícios civis do CBF15(4/7) na liquidação2026, para investimento efectivo do próprio2026. Início de exploração para projecto novo; início dos procedimentos após aprovação para projecto em funcionamento. Não usa cinco anos móveis nem reinicia o prazo pela data de aquisição. Início2022, mesmo em31/12 → quinto exercício2026, dedução200 pela quota residual, saldo500MZN. Início2021 → sexto exercício2026, prazo expirado, dedução0 e saldo700; activo e documento preservados. Datas inválidas/futuras e activos antigos apresentados como investimento corrente permanecem indeterminados/recusados, não zero por omissão.
Cálculo → POST/PUT do mesmo rascunho privado → GET → recálculo → HTML/dossier demonstrados no QA55441 existente, com rollback, cinco/seis resultados históricos e todos os anteriores preservados. Provas `outputs/irpc-cbf15-window-20261110/{fifth,expired}-verification.json` e relatórios respectivos. Modelo `IRPC-CBF15/2`, livro existente versão2 sem migration; snapshots aceites não são reescritos. Fonte já aceite APIEX2025 PDF71–72,CBF15(4/7), reutilizada sem nova validação de autenticidade. Provas anteriores reutilizadas; apenas fronteiras alteradas conferidas.
Saldo: transporte de créditos de investimento de anos anteriores, repartição automática/múltiplos projectos, EP/L7/cumulações/outros benefícios/formulários oficiais continuam por implementar nos respectivos recortes. A janela corrente não declara o transporte entregue. Resíduos L1–L7, restanteL8 e L9–L16 e saldo dos módulos não fiscais preservados. Conferência visual pendente no mesmo registo; portal não integral. Próximo trabalho executável: transporte documental CBF15 dentro da mesma janela, com consumo anterior reconciliado e sem duplicar o investimento corrente.
## 10/11/2026 — CBF15 instalado na homologação
**Revisão instalada: `89e6d72c1e86df8eb86e7be74583659975585036`**, promovida às13:27:59UTC e conferida às13:28:21UTC (15:28 locais), em https://teste-investidor.tempo.co.mz. Os17hashes do delta coincidem com Git; revisão/root/up/login HTTP200, configuração preservada, zero migrations/importações. Backup fresco/restauro190tabelas com contagens/checksums iguais e369ficheiros/configuração extraídos e conferidos;7c8d4726 preservada para reversão. Recibos `outputs/cbf15-delivery-20261110/{backup,restore,files-restore,verification,delivery}.json`; pacote imutável `outputs/entrega-cbf15-20261110.zip`, SHA25613bdecef2697edcc821d6fa490586d0c2f4b171b4308c655aa60ba5632664c83.
O recorte CBF15 abaixo deixa de ser pendência de instalação: primeiro exercício2026, residente geral/MZN, activos e condições documentados, colecta integral do projecto ou quota residual documentada, cálculo/gravação/recuperação/relatórios com histórico. As notas QA anteriores ficam como histórico das provas aceites. Outros exercícios/transporte/caducidade, repartição automática/múltiplos projectos, EP/L7/cumulações/outros benefícios/formulários e restantes resíduos L1–L7/L8/L9–L16 permanecem no plano existente. Próximo trabalho: residual executável de CBF15/L8, preservando o recorte instalado e sem repetir as provas aceites. Conferência visual continua pendente no mesmo registo; portal não declarado integral.
## 10/11/2026 — acordo PT–MZ instalado; crédito CBF15 demonstrado em QA
**Homologação: `7c8d4726e9f93325480e2ee6e5e790e9adbcaf19`**, promovida às12:36:07UTC e conferida às12:37:11UTC (14:37 locais). Revisão HTTPS/10hashes iguais, quatro entradasHTTP200; configuração preservada, zero migrations/importações. Backup/restauro190tabelas e369ficheiros/configuração conferidos, b10b35f3 preservada. Recibos `outputs/cdt-agreement-delivery-20261110/`. Falha inicial de recepção Git delimitada ao directório objects/7c pertencente aroot: apenas proprietário/grupo ajustados a conta do serviço,modo0755 inalterado; pedido original validado recolocado sem alterar corpo/SHA ou controlos. Recibos git-permission-repair/request-retry; não foi falha do portal nem alteração de segurança do navegador. Sessão SSH preservada para continuação autenticada, sem credencial em disco.
**Pendência efectivamente encerrada em QA, ainda sem instalação:** CBF15, crédito fiscal por investimento efectivo no primeiro exercício2026, residente geral/ano civil/MZN, colecta inteiramente da actividade do projecto ou quota residual documentada após créditos anteriores e sem outros benefícios próprios/cumulações ou transportes positivos combinados. Reutiliza qualificação CBF de L7 e fonte APIEX já aceite (recibo irpc-l7-common-qa-20261026,compilação oficial2025,PDF66–72,art15PDF71–72), não importação como activação. Localização do investimento,aprovação/condiçõesCBF3/9/10/regime genérico13,início aplicável e activos/documentos exigem correspondência própria. Activo novo corpóreo,nacional,produtivo e fora15(6); condições negativas/exclusões documentadas produzem crédito zero nessa linha,preservando os dados. Informação em falta não é zero.
Percurso completo: preparar detalhe → calcular após económico/internacional e antesPEC/RF/PPC → gravar/actualizar pelo writer privado existente → recuperar → recalcular → HTML/dossier com fornecedor/documento/parcela/valores/qualificação/período/histórico. Maputo cidade5%: investimento10.000 → crédito500 → saldo200 MZN. Restantes províncias10%: crédito1.000 → saldo−300, recuperável simulado devido aoPPC. Cenário de limite: nominal4.000,dedução1.400,crédito não usado2.600,PEC não utilizado200,PPC500; nenhum reembolso de crédito/PEC ou saldo oficial criado/consumido. Provas `outputs/irpc-cbf15-20261110/{city,province,capped,partial}-verification.json` e relatórios respectivos. Quatro versões do mesmo rascunho API em transacção revertida QA55441,com histórico anterior e versões seguintes preservados; zero alterações activas.
Quota residual documentada: nominal4.000, limite do projecto200, dedução200, não utilizado3.800 e saldo500 MZN; recuperada pela API com quatro resultados históricos. Quota superior à colecta global remanescente, negativa ou sem fundamento é recusada. Não há repartição automática de rendimentos/custos/perdas. Tipos, lint/Pint e compilação QA aprovados; provas anteriores reutilizadas.
Livros anteriores L8 permanecem versão1,com chaves/resultados inalterados quando sem CBF15. Só o livro com este novo detalhe usa versão2: cliente anterior recusa o formato,conservando o snapshot,em vez de recalcular ignorando o novo benefício. Não é migration de base. O resumo antigo dos benefícios fica conservado e não reaplicado; ausência dos restantes benefícios exige declaração/reconciliação explícita,mesmo quando CBF15 é ausente. Fonte documental tem capacidade por factura/parcela e entre mapas da entidade; mudar id/data não duplica investimento. Repetir simulação não é uso fiscal anterior.
**Saldo preservado:** CBF15 de outros exercícios/transporte/caducidade,repartição automática/múltiplos projectos,EP/L7/cumulações,outros benefícios e obrigações/formulários oficiais continuam separados. Ausência/zeroCBF15 não bloqueia os saldos anteriores aceites. Restantes L1–L7/L8/L9–L16 e módulos não fiscais mantêm o saldo existente; o portal não está integral. Visual permanece pendente. Conjunto CBF15 preparado para entrega sobre7c8d4726,sem instalação nesta entrada.
## 10/11/2026 — b10b35f3 instalado; acordo institucional PT–MZ em QA
**Homologação: `b10b35f30830922dae00afde50ac2bdb0e8cef6f`**, promovida às12:15:13UTC e conferida às12:16:31UTC (14:16 locais). Revisão HTTPS e16hashes Git iguais, quatro entradasHTTP200; configuração preservada, zero migrations/importações. Backup/restauro190tabelas com contagens/checksums iguais e369ficheiros/configuração conferidos;60b52fc8 disponível para reversão. Recibos `outputs/government-fraction-delivery-20261110/{backup,restore,files-restore,verification}.json`. Sessão SSH mantida aberta para continuação autenticada, sem credencial em disco. Isenção por devedor público11(3)a e fracções económicas L6 deixam de ser pendências de instalação; as anotações anteriores QA são históricas nesses recortes.
**Delta posterior QA, ainda não instalado:** correspondência11(3)b para instituição/organismo beneficiário corrente que concedeu o financiamento no âmbito de acordo concluído entre os governos PT–MZ. Beneficiário, financiamento ligado aos juros e acordo/cláusula aplicável exigem suporte próprio. País, nome da entidade ou qualificação institucional isolada não activam isenção. Fundamento distinto do devedor público11(3)a; limpar/trocar a escolha invalida a decisão corrente, sem apagar resultado histórico nem reutilizar campos ocultos como nova decisão.
Cálculo → gravação/GET pela API existente no QA55441 com rollback → recuperação → HTML/dossier demonstrados. Exemplo sintético: juros brutos60, imposto pago10 e fracção nacional8, com acordo qualificado → limite convencional0/crédito0/saldo708 MZN; sem reembolso, transporte ou consumo oficial e sem alterar L6. Relatório conserva instituição, financiamento e acordo. Prova `outputs/irpc-pt-mz-agreement-20261110/verification.json`; tipos/lint/Pint/build QA aprovados e provas anteriores reutilizadas.
Pacote `outputs/entrega-cdt-acordo-20261110.zip`, base exactab10b35f3, aplicação/reversão conferidas; entrega seguinte expressamente autorizada durante esta continuação, ainda sem instalação nesta entrada. Restantes beneficiários/regimes11(3)b, EP, relações especiais e demais CDT/benefícios/obrigações conservam correspondência própria; saldo L1–L7/restanteL8/L9–L16 preservado. Conferência visual continua pendente no mesmo registo; portal não integral.
## 10/11/2026 — CDT instalada; isenção governamental e precisão económica em QA
**Homologação: `60b52fc8c7446cbbee904a4411eca8ad284aa7ad`**, promovida às11:49:26UTC e conferida às11:50:11UTC (13:50 locais). Delta13hashes Git e revisão HTTPS iguais; portal/login/up/revision HTTP200, .env preservado, zero migrations/importações. Backup fresco/restauro190tabelas com contagens/checksums iguais e369ficheiros/configuração extraídos/conferidos; ac67f9c1 disponível para reversão. Recibos `outputs/cdt-pt-delivery-20261110/{backup,restore,files-restore,verification}.json`. Sessão SSH encerrada, credencial não guardada. Royalties/dividendos/juros ordinários PT–MZ deste recorte deixam de ser pendência de instalação; não reabrir provas aceites.
**Novos fechos dirigidos apenas em QA, ainda não instalados:**
- CDT11(3)a: devedor Estado português, subdivisão política/administrativa ou autarquia, expressamente qualificado e documentado; mantém residência, fonte, definição, período e restantes condições do recorte. Limite convencional0, imposto pago10 sem crédito elegível e saldo708 MZN no exemplo. Não gera reembolso/transporte nem altera L6. Beneficiários/acordos11(3)b e outras situações permanecem separados. Prova `outputs/irpc-pt-mz-government-20261110/verification.json`, com API/gravação/recuperação/HTML/dossier e fundamento do devedor no relatório.
- L6: diferença económica conserva fracções exactas, incluindo subcêntimos e n/d, pelo mecanismo racional existente. Remove a limitação técnica de exigir cêntimos inteiros neste mapa; não cria regra de arredondamento fiscal. Montante do tópico é reconciliado por igualdade racional, evitando tratar0,006 como0 ou aceitar truncamento. Exemplo de IRPC subjacente20,01: nominal12,006, efeito112,006, base4512,006, colecta1443,84192; reconferida L8, saldo723,83592. Repetição/substituição não acumula; valores já considerados/históricos preservados. Prova `outputs/irpc-economic-fraction-20261110/verification.json`, até API/recuperação/HTML/dossier/L8. Outros mapas e arredondamento de declarações oficiais conservam âmbito próprio.
Conjunto novo agrupável sobre60b52fc8; não integra a revisão instalada. Conferência visual permanece pendente no mesmo registo S08. Resíduos L1–L7, restante L8 e L9–L16 preservados; nenhum lote ou portal declarado integral. Próximos ramos: CDT11(3)b e restantes correspondências concretas, obrigações/benefícios e demais percursos conforme saldo existente.
Conjunto posterior preparado em `outputs/entrega-isencao-precisao-20261110.zip`, base exacta60b52fc8. Tipos/lint/Pint/build QA aprovados, aplicação/reversão conferidas em índices temporários. Não instalado; provas e pacotes anteriores preservados.
## 10/11/2026 — económico instalado; CDT Portugal avançada em QA
**Homologação: `ac67f9c13a6561b66c5da788a48849beac90a905`**, promovida pelo executor Git existente às11:32:14UTC e conferida às11:33:16UTC (13:33 locais). Revisão HTTPS e27hashes Git iguais; portal/login/up/revision HTTP200; .env preservado, zero migrations/importações. Backup/restauro190tabelas com contagens/checksums iguais e369ficheiros/configuração extraídos/conferidos. Release c359364d conservada. Recibos `outputs/economic-special-delivery-20261110/{backup,restore,files-restore,verification}.json`. Credencial não guardada; sessão SSH encerrada. Inclui quota económica, inclusão/reconciliação ordinária e especial L6, partilha64(3) e associações64(4). Entradas anteriores «apenas QA» são históricas nestes recortes; não reinstalar por consequência.
**Delta posterior, ainda QA:** correspondência CDT PT–MZ para royalties ordinárias não digitais, dividendos e juros ordinários de fonte portuguesa de residente convencional em Moçambique. País não activa a convenção. Exige beneficiário efectivo, definição/natureza, fonte/pagador, ausência de EP/instalação fixa ligada, período e suporte documentados; relações especiais verificadas nos juros/royalties, isenção governamental11(3) separada. Limite10% do bruto nos ramos suportados; dividendos usam alteração do Protocolo2009 art6, evitando taxa15% do texto1992. Não usa retenção L5 em sentido inverso nem tax sparing presumido. Mantém limite manual/históricos e parcela líquida nacional.
Prova sintética por ramo: bruto60 → limiteCDT6, imposto pago10/fracção nacional8 → crédito6 → saldo702 MZN. Cálculo/gravação/recuperação/HTML/dossier por API existente no QA55441; sem novo writer, schema, direito ou escrita activa. Provas `outputs/irpc-pt-mz-{credit,dividend,interest}-20261110/verification.json`. Fracções mantêm exactidão, sem arredondamento fiscal presumido. País/condição/período inválidos deixam apenas o caso dependente indeterminado. Este delta não integra ac67f9c1.
Restam CDT de outros Estados/naturezas, EP/instalação fixa, juros governamentais, excessos/relações especiais e casos fora do recorte, além dos demais resíduos L1–L7/L8/L9–L16. Conferência visual continua pendente no mesmo registo. Não declarar L8 ou o portal integralmente concluídos.
Conjunto CDT agrupado em `outputs/entrega-cdt-pt-20261110.zip`, base exacta ac67f9c1, aplicação/reversão conferidas; tipos/lint/Pint e build QA aprovados. Instalação deste delta ainda não executada.
## 10/11/2026 — crédito económico: partilha e associações em QA
Homologação mantém `c359364d`. Avanço posterior em QA, sem instalação: art64(3) distingue a parcela elegível do rendimento da partilha e o excesso com natureza de mais-valia; art64(4) distingue associação em participação e à quota, exigindo tributação efectiva documentada na participação. Reutiliza cálculo60%, prova do imposto subjacente, limites, prioridades, isolamento e writer privado existentes. Não calcula a mais-valia como crédito nem pressupõe inclusão L6 ou reconferência da colecta. Campos antigos e históricos são preservados; fontes incompletas ficam recuperáveis e indeterminadas.
Critério demonstrado: calcular → gravar/GET pela API existente no QA55441 com rollback → recuperar exactamente → HTML e relatório do dossier. Partilha sintética: valor atribuído200, entradas100 e rendimento documentado150 → elegível100/excesso50 → quota35,84/crédito12/saldo688 MZN. Associação documentada100/imposto20 → crédito12/saldo688 MZN, sem alterar L6. Provas `outputs/irpc-economic-liquidation-20261110/verification.json` e `outputs/irpc-economic-association-20261110/verification.json`. Não são actos fiscais nem consumo de saldos oficiais.
Conjunto de fontes compatíveis com quota e inclusão ordinária agrupado em `outputs/entrega-economica-especial-20261110.zip`, base c359364d, aplicação/reversão conferidas em índices temporários. Pacotes anteriores intactos. Inclusão/correcção especial em L6 também demonstrada em QA: reconcilia rendimento total da partilha (incluindo mais-valia) uma só vez, enquanto a quota usa apenas a componente elegível; associações reutilizam a qualificação própria. Prova outputs/irpc-economic-special-inclusion-20261110/verification.json: efeito162, base4562/colecta1459,84, quota35,84/saldo739,84 recuperados na API e relatórios. Casos transitórios, fracções de cêntimo, CDT, benefícios/obrigações e restantes resíduos L1–L7/L9–L16 continuam no saldo. Conferência visual pendente. Próximo trabalho funcional: correspondência CDT automática nos casos suportados pelas fontes existentes; eventual instalação deste conjunto é acção separada. Tipos/lint/Pint/build QA conferidos no conjunto final.
## 10/11/2026 — inclusão/correcção económica L6 em QA
Instalado: `c359364d`. Quota económica e inclusão/correcção L6 ordinária demonstradas em QA, sem instalação. Pacote anterior `outputs/entrega-economica-l6-l8-20261110.zip` preservado; novo delta posterior agrupado separadamente. Conferência visual pendente no registo existente.
Escolha explícita da origem L8, reconciliação de lucros/crédito já considerados, prévia da diferença e confirmação precedem o cálculo/gravação existentes. Repetição substitui a parcela; não acumula. Prova: +112 MZN leva lucro tributável a6812, base4512 e colecta1443,84. Montantes já incluídos100/12 dão efeito0; excesso150/20 corrige−58. Origem L8 anterior fica desactualizada; reconferida a nova colecta, quota35,84 e saldo723,84 mantêm-se na API/recuperação/HTML/dossier, com históricos preservados. Provas `outputs/irpc-economic-inclusion-20261110/verification.json` e `outputs/irpc-economic-quota-20261110/verification.json`. Tipos/lint/Pint/build QA aprovados.
Âmbito ordinário residente geral2026/MZN. Fracções de cêntimo exigem tratamento próprio, sem arredondamento presumido. Casos especiais, CDT e obrigações continuam no saldo. Resíduos L1–L7 e L9–L16 preservados; L6/L8 não integrais. Registos anteriores abaixo são históricos.
## Saldo operacional reconciliado — 10/11/2026, após `ff726e57`
**Estado mais recente:** conjunto Educação + fracção nacional ordinária instalado na homologação em `c359364de3f65599f2248540a5b040604e8490cc`, confirmado por HTTPS às 10:55:21 UTC (12:55 locais). Backup/restauro190tabelas e369ficheiros/configuração conferidos,14fontes do delta idênticas à revisão Git,zero migrations/importações,.env preservado e release ff726e57 mantida para reversão. Recibo `outputs/education-l8-delivery-20261110/verification.json`. As notas anteriores de conjunto «só QA» descrevem o estado anterior à instalação e não são nova pendência.
| Recorte | Estado actual |
| --- | --- |
| Educação — continuidade ao paginar histórico | Instalado c359364d; restantes jornadas/retoma durável e conferência visual conservadas. |
| L8 — fracção nacional ordinária do crédito internacional | Instalado c359364d; não reabrir cálculo/gravação/recuperação/relatórios aceites. |
| L8 — quota proporcional económica ordinária após acréscimo53 | Novo delta QA, ainda não instalado: cálculo, gravação/GET pela API existente, recuperação e relatórios demonstrados; tipos/lint aprovados. Inclusão/correcção automática do acréscimo na base não incluída. |
Novo residual avançado após a instalação: opção explícita de derivar a quota económica da colecta L6 × (lucros incluídos + crédito nominal já acrescido) / matéria colectável L6. CIRPC64(1–2)/53 e ordem já aceites,sem novo enquadramento jurídico genérico. Exige correspondência documentada à base corrente; origem alterada,acréscimo ausente ou componentes acima da base tornam o caso dependente indeterminado. Não reescreve L6,não acrescenta o crédito duas vezes,não altera o modo manual/históricos. Prova `outputs/irpc-economic-quota-20261110/verification.json`:quota256/5, saldo3244/5 no cenário sintético. L8 continua parcial,com inclusão/correcção económica,art64(3/4),CDT automática,obrigações e restantes casos ainda por implementar conforme saldo;L1–L7 eL9–L16 preservados.
### Actualização fiscal: recorte L8 demonstrado em QA; saldo L1–L16 preservado
**Pendência encerrada nesta passagem em QA:** derivação proporcional da fracção nacional do crédito internacional no caso L6 ordinário corrente, com parcela líquida já incluída e correspondida à matéria colectável. Percurso cálculo → gravação/recuperação pela API privada existente → recálculo → HTML/relatório do dossier demonstrado. Não é fecho integral de L8, determinação automática de CDT ou repartição de custos/perdas. Delta agrupável com a continuidade de Educação, ainda não instalado na homologação, que mantém `ff726e57`.
O resumo anterior centrado em L8 era incompleto. Conservam-se explicitamente os seguintes resíduos e lotes; os recibos posteriores identificados neste manual prevalecem sobre anotações antigas de ausência/instalação pendente. Nenhuma estimativa histórica ou contagem de testes representa conclusão do módulo.
| Lote fiscal | Saldo efectivo preservado |
| --- | --- |
| L1 — enquadramento IVA | Casos residuais de P058 e P001 fora dos tratamentos já entregues, incluindo o incremento P001 `d1551dab`/`63147312`. P084/P085 e restantes fechos operacionais aceites não são reabertos. |
| L2 — dedução/regularizações IVA | Art.18(6)/afectação mista, regularizações sucessivas/plurianuais, erros/incobráveis e variantes fora dos recortes entregues. |
| L3 — saldo/Modelo A | Sequência/reversões de deduções, apuramentos próprios de fornecimentos/margem/viagens/eventos e formalidades C/D/E não cobertas; entregas posteriores de restituição preservadas. |
| L4 — ISPC | Operações ocasionais, aquisições acima do limite e formulários/instruções/regulamentação de 2026 aplicáveis; apuramento habitual entregue preservado. |
| L5 — retenções IRPC | Casos documentais/AT e variantes de dispensa/CDT, juros/rectificações/guias não encerrados pelos recibos. Taxas gerais/especiais, câmbio e retenções sofridas já entregues conservados. |
| L6 — IRPC normal | Grupos de código e documentais discriminados no manual: perdas/deduções, períodos/moedas/representação e casos fora do recorte ordinário demonstrado. Colecta ordinária utilizável não é IRPC integral. |
| L7 — tratamentos específicos IRPC | Famílias/combinações/mistos/transporte e documentos por caso ainda abertos. Cumulações e ramos específicos entregues preservados. |
| L8 — liquidação IRPC | Derivação nacional ordinária encerrada em QA nesta passagem. Restam correspondência CDT automática, inclusão/correcção económica e quota automáticas, art.64(3/4)/benefícios, obrigações PPC/PEC, juros/guias, autónomos/períodos/moedas/M20M22 e casos transitórios/especiais. Transporte ordinário de créditos/PEC já entregue em `569a1cd7` não é tratado como ausente. |
| L9 — INSS | Percurso contributivo por trabalhador/vínculo/base/período e recuperação/declaração; referências de taxas e massa salarial não são fecho integral. |
| L10 — IRPS mensal | Tabela mensal do período, condições pessoais aplicáveis, remunerações/benefícios, retenção e líquido ligados a L9; preservar o caso anteriormente demonstrado, sem o extrapolar. |
| L11 — IRPS anual/outras categorias | Categorias, englobamento/tratamento próprio, deduções, retenções, saldo e formulários; não copiar regras IRPC. |
| L12 — Selo | Verbas por operação/contrato, incidência/base/taxa/isenção/obrigação com fonte e período; não aplicar exemplo genérico a todas as facturas. |
| L13 — direitos aduaneiros | Código pautal/origem/valor/despesas/câmbio/benefícios e percurso documental; não inferir código pela descrição nem benefício pela localização. |
| L14 — ICE | Produtos/operações, base/unidade e tributação específica/ad valorem/mista, período e ligações pertinentes; produção interna não depende de calcular direitos aduaneiros. |
| L15 — SISA | Delimitação legal/autárquica, transmissão onerosa/direitos, incidência/isenções/base/período e percurso; DUAT não é propriedade da terra. |
| L16 — IPRA/encargos imobiliários | Posse/uso/valor patrimonial, autarquia/exercício/isenções e separação de impostos e taxas administrativas; preço de compra não substitui valor patrimonial. |
Não foram identificados recibos de fecho integral de L9–L16 nesta reconciliação dirigida. Isso conserva o saldo; não apaga código, fontes ou casos parciais anteriores. Dúvidas/fontes em falta afectam apenas os casos dependentes. Detalhes e prova L8 na entrada actual do manual existente e em `outputs/irpc-national-fraction-20261110/recovered-reports.json`.
Esta é a actualização da visão e da matriz existentes, não um novo inventário. A matriz conserva 3.555 linhas sobrepostas de requisitos, cláusulas e campos; a contagem não representa funcionalidades independentes nem trabalho por implementar. Preservam-se todos os fechos e provas aceites. As secções datadas de 18/10 abaixo são históricas e não reabrem implementações posteriores.
Instalação de fontes até `ff726e57` concluída na homologação, incluindo S08, contador, aliases, contactos, pré-dossier, curadoria e sitemap. Prova: `outputs/reviewed-install-20261110/verification.json`, revisão HTTPS, 44 hashes do delta, configuração preservada, zero migrations/importações. Não reinstalar esse pacote. Código instalado não comprova por si só cada flag, ledger de instalação modular, canal externo ou percurso privado em uso real; as habilitações anteriores e as suas provas mantêm-se. Um módulo não é declarado desactivado apenas por falta de nova observação.
| Módulo / percurso utilizável a completar | Implementação que falta | Instalação e conferência | Dependência concreta / critério de conclusão |
| --- | --- | --- | --- |
| Descoberta, oportunidades e conteúdos públicos → interesse/dossier | Completar cláusulas e modalidades especializadas ainda abertas; não refazer cadastro, revisão, publicação, pesquisa ou curadoria entregues. | Fontes até ff726e57 instaladas; conferir apresentação/percursos ainda sem prova visual. | Dados efectivos de cada anúncio, divulgação e representação existentes. Fecho: localizar uma ficha, preparar/publicar pelo workflow aplicável e continuar no mesmo dossier sem perder contexto. |
| Territórios, mapa, âncoras/clusters → selecção contextual | Consumidores territoriais e correspondências ainda não cobertos; preservar mapa único, filtros e observações quantitativas entregues. | Não reabrir instalação dos deltas incluídos; conferir consumidores sem prova, sem repetir percursos aceites. | Geometrias, coordenadas, associações e fontes reais em falta por item. Fecho: pesquisar/seleccionar/guardar/recuperar a referência correcta, sem inventar equivalências ou pontos. |
| Balcão, percurso adaptativo e S08 → pedido/resultado | Restantes consumidores e parametrização por âmbito, obrigações/registo/licenciamento sustentados; núcleo de dossier, pedido, histórico e fecho pelo dono preservado. | S08 instalada. Conferência restante: #S08, estado, botão, gravação/recuperação, janela estreita. | Requisitos de cada serviço e actos externos próprios; bloquear só esse serviço/caso quando faltarem. Fecho: preparar/submeter/acompanhar/recuperar resultado no dossier certo. |
| CRM, responsáveis e tarefas → acompanhamento | Restantes formulários/consumidores e preparações não confirmadas; não recriar conversão, próxima acção, edição e preparação de tarefas já demonstradas. | Fontes instaladas; restantes variantes por perfil e uso visual a conferir. | Designações/representação do caso e integrações externas apenas onde usadas. Fecho: converter uma vez, atribuir, acompanhar e retomar sem duplicar tarefa/interacção. |
| Biblioteca, documentos, scanner e suporte → consulta privada | Cobertura do acervo e consumidores/variantes ainda não individualizados; preservar importação idempotente, originais, scanner, revogação e incidentes demonstrados. | Não reinstalar consulta pública/editor aceites; conferir anexos e consumidores pendentes. Operação de scanner/worker é distinta de código. | Bytes/fontes autorizados e disponibilidade efectiva dos serviços quando necessários. Fecho: importar/consultar/partilhar/revogar com original, autoria e histórico íntegros. |
| Simulador fiscal/contributivo → cálculo/rascunho/relatório | Resíduos L1–L7, restante L8 e L9–L16 conforme tabela acima. Fracção nacional ordinária agora demonstrada em QA; transporte/crédito económico já entregues preservados. Não limitar saldo fiscal a L8. | Fontes até ff726e57 instaladas; delta de fracção nacional ainda QA. Conferir apenas novos consumidores/deltas. | Diploma/período/limite/prova/CDT concretos faltantes bloqueiam só o cálculo dependente. Fecho por ramo: cenário calculado, guardado, recuperado e exportado com fonte/versão/valores coerentes. |
| Negócios, empresas e duplicados → ficha/publicação/comparação | Consolidação rastreável de entidades inequívocas, outros formatos e variantes contratuais ainda abertos. Pesquisa de semelhantes por raio/sector/classificação e consulta dos lotes já existem. | Fontes instaladas; visual/variantes ainda não observadas separados. | Pontos/fontes/classificações e prova do mandato por caso; não fundir apenas por nome. Fecho: localizar/importar/reconciliar, publicar e comparar sem perder originais/relações. |
| Fundos e alertas → referência/avaliação informativa/acompanhamento | Vínculos restantes da referência ao cadastro e canais/fontes externas; catálogo, editor, versões, critérios e gerador local já implementados. | Código instalado; confirmar operação do agendador/canal apenas no âmbito configurado e observação visual restante. | Fontes/prazos efectivos e credencial/destinatário do canal concreto. Fecho: consultar uma chamada, responder aos critérios, guardar e receber o aviso configurado sem decisão de crédito presumida. |
| Forma societária e preparação da empresa → modelo | Casos especiais; ingestão/versionamento/vínculo dos sete modelos exactos e derivadas; actividade/licenciamento integrado e percursos de assinatura/submissão/registo ainda não cobertos. | Comparador e preparação factual têm código instalado; não confundir ficha factual com contrato. Conferência visual pendente. | Exemplares exactos dos modelos e intervenientes externos de assinatura/registo. Fecho: comparar com fontes, preparar a forma escolhida e recuperar o modelo/derivada versionados; ausência de um modelo não bloqueia o comparador. |
| Capitalização e BVM → orientação/referência no dossier | Casos especiais e procedimentos CVM ainda não codificados; os guiões base de capitalização e cinco percursos BVM já existem. | Fontes instaladas; visual/login dos recortes pendentes. | Regulamento anual de montantes, preçário/encargos e condições da contraparte aplicáveis. Fecho: seleccionar percurso, perceber requisitos/fontes/indeterminações e recuperar a preparação no dossier. |
| Checklists e formulários → versão publicada/submissão | Outros consumidores e parametrização transversal de limites, formatos/mensagens e camadas documentais por fonte/âmbito. Editor, vigência, tipos, anexos e selector sem UUID manual já existem. | Fontes instaladas; habilitação nominal quando exigida pelo instalador existente, sem a presumir ausente; visual restante. | Texto/requisito/contexto publicado por administrador autorizado. Fecho: publicar v2, novas submissões usam v2 e respostas v1 continuam recuperáveis. |
| Educação → declaração/revisão/histórico | Retoma durável da preparação de revisão e jornadas externas de estudantes/formadoras/empregadores ainda abertas. **Avançado nesta passagem:** paginação do histórico preserva a revisão em preparação e o envio incerto, mantendo comparação/actor. | Delta novo local, ainda não instalado; quatro verificações novas, sem repetir provas anteriores. Conferência visual separada. | Não existe cadastro/documento aprovado dos vínculos externos, conforme decisão já registada; bloqueia só esses vínculos. Fecho interno: preparar/submeter, outro gestor rever e consultar histórico sem perder preparação ou duplicar decisão. |
| Bolsa/parcerias → candidatura/sala/acordo/marco | Variantes e consumidores não cobertos; preparação durável dos passos ainda só em memória. Editores, candidaturas, acordos, resumos e históricos entregues não são ausência funcional. | Fontes instaladas; conferência de partilha/revogação e restantes variantes por actor pendente. | Participantes/documentos autorizados no caso; não inferir autorização de oportunidade a partir da sala. Fecho: candidatura, colaboração, acordo/marco e revogação sem perda de original/histórico. |
| Perfis, gestão, indicadores e painéis → operação por papel | Métricas/fontes/denominadores e restantes filas por perfil; contextos de país em consumidores ainda abertos. Preservar perfis, revogação, suporte e editores entregues. | Código instalado; atribuições reais não são criadas por instalação; observação por papel separada. | Designações operacionais e fontes dos indicadores concretos. Fecho: acção útil permitida/negada pelo papel, evento contado com âmbito/período explícitos e distinção zero/sem dados. |
| Acesso offline, línguas e IA → consumidores previstos | Consulta offline de fichas já existe; edição/sincronização e idiomas adicionais não concluídos; IA não substitui os percursos determinísticos. | Código entregue preservado; abertura sem rede, apresentação e novos deltas por conferir. | Línguas/traduções aprovadas e fornecedor/credencial/âmbito de IA quando necessários; só bloqueiam esses casos. Fecho por consumidor, sem prometer cobertura geral offline ou inteligência não implementada. |
**Ordem de continuação preservada:** primeiro defeitos que impeçam usar/concluir percursos existentes (estabilização); depois simulador fiscal delimitado, Imobiliário, Bens de consumo/franquias, Estatística/painéis e IA, conforme ordem já registada. A correcção da continuidade do histórico de Educação pertence à estabilização de um percurso existente, não cria uma frente paralela. Próximo trabalho nessa ordem: continuar o saldo fiscal L8 executável com as fontes aceites; faltas de prova/CDT/regulamento bloqueiam apenas os casos afectados. A conferência visual S08 não suspende essa implementação.
O portal inteiro permanece em conclusão, sem excluir requisitos por inferência. A matriz conserva os critérios originais e as provas por ID; este quadro organiza o trabalho por percursos, sem promover módulos agregados a concluídos por código instalado ou por um teste parcial.
<!-- saldo-atual-20261018 -->
## Saldo atual por requisito em 18 de outubro de 2026
+2 -1
View File
@@ -1,6 +1,7 @@
/* Presentation choice for Tempo's 21/10/2026 request: one shared navigation. */
.tempo-header {
position: relative;
position: sticky;
top: 0;
z-index: 40;
background: #090909;
color: #fff;
+41 -1
View File
@@ -16,6 +16,46 @@ export default function EducationReview(props: EducationReviewProps) {
aria-label="Revisão interna da declaração"
>
{edit.notice && <p role="status">{edit.notice}</p>}
{edit.preparationAvailable && (
<section
className="space-y-2 rounded border p-3"
aria-label="Preparação privada da revisão"
>
<p>
Guarde a preparação para retomar noutra entrada. É
privada desta conta e declaração; guardar não decide nem
aprova a declaração.
</p>
{edit.savedPreparation?.version ? (
<p>
Preparação guardada: versão{' '}
{edit.savedPreparation.version}.
</p>
) : (
<p>Ainda sem preparação privada guardada.</p>
)}
<button
type="button"
className={button}
disabled={edit.persisting}
onClick={() => void edit.persistPreparation()}
>
Guardar preparação da revisão
</button>
<button
type="button"
className={button}
disabled={edit.persisting || form.processing}
onClick={() => void edit.restorePreparation()}
>
Recuperar preparação guardada
</button>
{edit.persisting && (
<p role="status">A conferir a preparação privada…</p>
)}
</section>
)}
{(edit.stale || edit.conflict) && (
<section
className="space-y-3 rounded border p-4"
@@ -155,7 +195,7 @@ export default function EducationReview(props: EducationReviewProps) {
</h2>
{edit.uncertain && (
<p>
A recuperação repete a decisão enviada:{' '}
A recuperação mantém a mesma intenção de revisão:{' '}
{states[edit.sent?.status ?? ''] ?? 'Por confirmar'}
. Fundamento: {edit.sent?.review_reason}. As edições
posteriores ficam conservadas.
@@ -0,0 +1,157 @@
import { card, input } from '@/pages/investment/shared';
export const financialCategories: Record<string, string> = {
banking: 'Banca',
credit: 'Crédito e microfinanças',
investment: 'Gestão de investimento',
pensions: 'Fundos de pensões',
insurance: 'Seguros e resseguros',
intermediation: 'Mediação de seguros',
exchange: 'Câmbio',
payments: 'Pagamentos e serviços interbancários',
supervision: 'Supervisão',
other: 'Outras instituições',
};
export const financialTypes: Record<string, string> = {
commercial_bank: 'Banco comercial',
investment_bank: 'Banco de investimento',
bank: 'Banco (tipo não especificado)',
microbank: 'Microbanco',
cooperative: 'Cooperativa de crédito',
savings: 'Organização de poupança e empréstimo',
investment_manager: 'Gestora de fundos de investimento',
investment_company: 'Sociedade de investimento',
pension_manager: 'Gestora de fundos de pensões',
insurer: 'Seguradora',
reinsurer: 'Resseguradora',
microinsurer: 'Micro-seguradora',
broker: 'Corretora de seguros',
agent: 'Agente de seguros',
reinsurance_broker: 'Corretor de resseguro',
exchange_house: 'Casa de câmbio',
electronic_money: 'Instituição de moeda electrónica',
interbank: 'Serviços interbancários',
supervisor: 'Supervisor',
other: 'Outro tipo documentado',
};
export type Classification = {
institution_categories?: string[];
institution_types?: string[];
institution_services?: string[];
institution_status?: string;
successor_key?: string | null;
register_reference?: string | null;
source_document_name?: string | null;
source_document_sha256?: string | null;
aliases?: string[];
source_rows?: { id: string; section: string; text: string }[];
};
export const evidenceLabels: Record<string, string> = {
reported: 'Reportado no documento',
to_confirm: 'A confirmar',
historical: 'Referência histórica',
conflict: 'Informação divergente/identidade por confirmar',
unlocated: 'Localização por confirmar',
};
export function ClassificationSummary({ value }: { value: Classification }) {
return (
<section className={card}>
<h2>Categoria e tipo da instituição</h2>
<p>
Categorias:{' '}
{(value.institution_categories ?? [])
.map((k) => financialCategories[k] ?? k)
.join(' · ') || 'Por classificar'}
</p>
<p>
Tipos:{' '}
{(value.institution_types ?? [])
.map((k) => financialTypes[k] ?? k)
.join(' · ') || 'Por classificar'}
</p>
{['merged','historical','unknown'].includes(value.institution_status??'') && <p>{value.institution_status==='merged'?'Instituição integrada noutra entidade ou com actividade encerrada.':value.institution_status==='historical'?'Informação histórica.':'Situação actual por confirmar junto da instituição.'}</p>}
</section>
);
}
export function ClassificationFields({
value,
change,
needs,
}: {
value: Classification;
change: (patch: Partial<Classification>) => void;
needs: Record<string, string>;
}) {
const multiple = (
key:
| 'institution_categories'
| 'institution_types'
| 'institution_services',
label: string,
options: Record<string, string>,
) => (
<label className="my-3 block">
{label}
<select
multiple
className={input}
value={value[key] ?? []}
onChange={(e) =>
change({
[key]: Array.from(
e.target.selectedOptions,
(o) => o.value,
),
})
}
>
{Object.entries(options).map(([id, title]) => (
<option key={id} value={id}>
{title}
</option>
))}
</select>
</label>
);
return (
<details className={card}>
<summary>Categoria, tipo e serviço — Alterar</summary>
{multiple(
'institution_categories',
'Categorias documentadas',
financialCategories,
)}
{multiple(
'institution_types',
'Tipos documentados',
financialTypes,
)}
{multiple(
'institution_services',
'Serviços identificados (não é oferta garantida)',
needs,
)}
<label>
Situação
<select
className={input}
value={value.institution_status ?? 'unknown'}
onChange={(e) =>
change({ institution_status: e.target.value })
}
>
<option value="unknown">Por confirmar</option>
<option value="reported">Reportada no documento</option>
<option value="historical">Histórica</option>
<option value="merged">
Fundida/extinta segundo a fonte
</option>
</select>
</label>
<p>
As linhas originais e as classificações anteriores conservam-se
nas versões. Não complete lacunas por inferência.
</p>
</details>
);
}
@@ -127,8 +127,8 @@ export default function FundingCriteriaAssessment({
)}
{criteria.length === 0 && (
<p>
Esta ficha não contém critérios suficientes para conferir a
correspondência.
Consulte a entidade para conhecer as condições de acesso
aplicáveis ao seu investimento.
</p>
)}
{awaitingType > 0 && (
@@ -0,0 +1,243 @@
import { Link, router } from '@inertiajs/react';
import { useRef, useState } from 'react';
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
import { createOperationKey } from '@/lib/operation-key';
import { card, input, button } from '@/pages/investment/shared';
import type { Programme } from '@/pages/funding/shared';
export type FundingDossier = {
id: string;
version: number;
opportunity: string | null;
closed: boolean;
href: string;
};
export function FundingDossierChoice({
record,
dossier,
selection,
needs,
destinations,
}: {
record: Programme;
dossier: FundingDossier | null;
selection: Record<string, string>;
needs: Record<string, string>;
destinations: { id: string; opportunity: string | null; title: string }[];
}) {
const [need, setNeed] = useState(selection.need ?? '');
const [product, setProduct] = useState('');
const [location, setLocation] = useState('');
const [consent, setConsent] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<FundingDossier | null>(null);
const pending = useRef<Record<string, unknown> | null>(null);
const running = useRef(false);
async function save() {
if (!dossier || running.current) return;
if (!pending.current)
pending.current = {
context: dossier.id,
opportunity: dossier.opportunity,
version: dossier.version,
version_id: record.version_id,
product: product || null,
location_id: location || null,
location_mode: selection.location_mode ?? 'coverage',
need,
territory: selection.territory,
consent,
operation_key: createOperationKey(),
};
running.current = true;
setBusy(true);
setError('');
try {
setResult(
(await profileJson(
`/fundos/${record.id}/dossier`,
pending.current,
'POST',
)) as FundingDossier,
);
pending.current = null;
} catch (e) {
setError(
e instanceof Error
? e.message
: 'Resultado por confirmar. Repita a mesma intenção.',
);
if (
e instanceof ProfileValidationError &&
e.status !== null &&
e.status >= 400 &&
e.status < 500 &&
e.status !== 408
)
pending.current = null;
} finally {
running.current = false;
setBusy(false);
}
}
return (
<section className={card}>
<h2>Continuar a preparação no meu dossier</h2>
<p>
Conserva a referência, versão, produto, local e necessidade. Não
envia candidatura ou dados à instituição.
</p>
{!dossier ? (
<label>
Escolher dossier e oportunidade existentes
<select
className={input}
defaultValue=""
onChange={(e) => {
const chosen = destinations[Number(e.target.value)];
if (chosen)
router.get(`/fundos/${record.id}`, {
...selection,
context: chosen.id,
opportunity: chosen.opportunity ?? '',
});
}}
>
<option value="">Seleccionar</option>
{destinations.map((d, i) => (
<option key={`${d.id}:${d.opportunity}`} value={i}>
{d.title}
</option>
))}
</select>
</label>
) : result ? (
<Link className={button} href={result.href}>
Escolha guardada — continuar no mesmo dossier
</Link>
) : (
<>
<fieldset
disabled={
busy || Boolean(pending.current) || dossier.closed
}
>
<label>
Necessidade
<select
className={input}
value={need}
onChange={(e) => setNeed(e.target.value)}
>
<option value="">Seleccionar</option>
{Object.entries(needs).map(([id, label]) => (
<option key={id} value={id}>
{label}
</option>
))}
</select>
</label>
{record.payload.kind === 'institution' &&
(record.payload.products ?? []).length > 0 && (
<label>
Programa/produto
<select
className={input}
value={product}
onChange={(e) =>
setProduct(e.target.value)
}
>
<option value="">
Seleccionar e conferir
cobertura/condições
</option>
{record.payload.products?.map((p) => (
<option key={p.code} value={p.code}>
{p.title} ·{' '}
{p.availability === 'unknown'
? 'Disponibilidade por confirmar'
: p.availability ===
'closed'
? 'Encerrado'
: 'Oferta identificada'}
</option>
))}
</select>
</label>
)}
{record.payload.kind === 'institution' && (
<label>
Sede/balcão a conservar na preparação
<select
className={input}
value={location}
onChange={(e) =>
setLocation(e.target.value)
}
>
<option value="">
Sem local específico seleccionado / por
confirmar
</option>
{(record.payload.locations ?? [])
.filter((l) => l.id)
.map((l) => (
<option key={l.id} value={l.id}>
{l.label} ·{' '}
{l.location_text ||
l.territory_path
?.map((n) => n.name)
.join(' / ') ||
'Local por confirmar'}{' '}
·{' '}
{l.evidence_status ===
'to_confirm'
? 'A confirmar'
: ''}
</option>
))}
</select>
</label>
)}
<p>
Local seleccionado:{' '}
{selection.territory
? selection.territory_label ||
'Conservado da pesquisa; consulte a cobertura do produto.'
: 'Local de interesse por identificar; a referência fica guardada com essa lacuna explícita.'}
</p>
<label>
<input
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
/>
Conservar esta referência privada no dossier;
financiamento não garantido.
</label>
</fieldset>
<button
type="button"
className={button}
disabled={
busy ||
dossier.closed ||
(!pending.current && (!consent || !need))
}
onClick={() => void save()}
>
{pending.current
? 'Repetir a mesma intenção'
: 'Guardar e continuar no dossier'}
</button>
{dossier.closed && (
<p>Dossier encerrado; consulta preservada.</p>
)}
</>
)}
{error && <p role="alert">{error}</p>}
</section>
);
}
@@ -0,0 +1,686 @@
import {
ClassificationFields,
ClassificationSummary,
evidenceLabels,
type Classification,
} from '@/components/funding-classification';
import {fundingPublicPresentation} from '@/lib/funding-public-presentation';
import { card, input, button } from '@/pages/investment/shared';
export type FundingSource = {
source_url: string | null;
source_document_name?: string | null;
source_document_sha256?: string | null;
evidence_status?: string | null;
origin?: string;
source_refs?: string[];
source_reference: string;
checked_on: string;
};
export type FundingContact = FundingSource & { kind: string; value: string };
export type FundingLocation = FundingSource & {
id?: string;
location_text?: string;
hours?: string;
role: string;
label: string;
address: string;
territory_id: string;
territory_path?: { name: string }[];
};
export type FundingProduct = FundingSource & {
code: string;
title: string;
instrument: string;
needs: string[];
coverage: string;
coverage_text: string;
territory_ids: string[];
territory_paths?: { name: string }[][];
conditions: string;
amounts: string;
availability: string;
contact_url: string;
};
export type InstitutionDetails = Classification & {
institution_key: string;
institution_nature: string;
contacts: FundingContact[];
locations: FundingLocation[];
products: FundingProduct[];
};
export const emptyInstitution: InstitutionDetails = {
institution_key: '',
institution_nature: 'unknown',
contacts: [],
locations: [],
products: [],
};
const source: FundingSource = {
source_url: '',
source_reference: '',
checked_on: '',
};
function Text({
label,
value,
change,
type = 'text',
}: {
label: string;
value: string;
change: (v: string) => void;
type?: string;
}) {
return (
<label className="my-2 block">
{label}
<input
className={input}
type={type}
value={value ?? ''}
maxLength={type === 'text' ? 1500 : undefined}
onChange={(e) => change(e.target.value)}
/>
</label>
);
}
function Sources({
value,
change,
}: {
value: FundingSource;
change: (v: Partial<FundingSource>) => void;
}) {
return (
<details>
<summary>Fonte e data deste elemento — Alterar</summary>
<Text
label="Endereço da fonte"
type="url"
value={value.source_url ?? ''}
change={(source_url) => change({ source_url })}
/>
{!value.source_url && (
<>
<Text
label="Documento de origem"
value={value.source_document_name ?? ''}
change={(source_document_name) =>
change({ source_document_name })
}
/>
<Text
label="SHA-256 do original"
value={value.source_document_sha256 ?? ''}
change={(source_document_sha256) =>
change({ source_document_sha256 })
}
/>
</>
)}
{value.source_document_sha256 && (
<label>
Situação desta informação
<select
className={input}
value={value.evidence_status ?? 'to_confirm'}
onChange={(e) =>
change({ evidence_status: e.target.value })
}
>
{Object.entries(evidenceLabels).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</label>
)}
<Text
label="Referência/edição"
value={value.source_reference}
change={(source_reference) => change({ source_reference })}
/>
<Text
label="Data de consulta"
type="date"
value={value.checked_on}
change={(checked_on) => change({ checked_on })}
/>
</details>
);
}
export function InstitutionFields({
value,
change,
territories,
natures,
needs,
}: {
value: InstitutionDetails;
change: (patch: Partial<InstitutionDetails>) => void;
territories: { id: string | number; name?: string }[];
natures: Record<string, string>;
needs: Record<string, string>;
}) {
const update = <K extends 'contacts' | 'locations' | 'products'>(
key: K,
index: number,
patch: Partial<InstitutionDetails[K][number]>,
) =>
change({
[key]: value[key].map((row, i) =>
i === index ? { ...row, ...patch } : row,
),
});
return (
<section className={card}>
<h2>Instituição, presença física e produtos</h2>
<p>
A sede ou balcão não determina a cobertura do financiamento. Não
preencha condições desconhecidas por inferência.
</p>
<ClassificationFields value={value} change={change} needs={needs} />
<Text
label="Identidade estável da instituição (referência do cadastro/fonte)"
value={value.institution_key}
change={(institution_key) => change({ institution_key })}
/>
<label>
Natureza
<select
className={input}
value={value.institution_nature}
onChange={(e) =>
change({ institution_nature: e.target.value })
}
>
{Object.entries(natures).map(([id, label]) => (
<option key={id} value={id}>
{label}
</option>
))}
</select>
</label>
<details>
<summary>Contactos ({value.contacts.length}) — Alterar</summary>
{value.contacts.map((row, i) => (
<fieldset className={card} key={i}>
<legend>Contacto {i + 1}</legend>
<select
className={input}
value={row.kind}
onChange={(e) =>
update('contacts', i, { kind: e.target.value })
}
>
{Object.entries({
website: 'Página institucional',
email: 'Email',
phone: 'Telefone',
address: 'Endereço postal',
}).map(([id, label]) => (
<option key={id} value={id}>
{label}
</option>
))}
</select>
<Text
label="Contacto documentado"
value={row.value}
change={(v) => update('contacts', i, { value: v })}
/>
<Sources
value={row}
change={(v) => update('contacts', i, v)}
/>
<button
type="button"
onClick={() =>
change({
contacts: value.contacts.filter(
(_, n) => n !== i,
),
})
}
>
Remover contacto
</button>
</fieldset>
))}
<button
type="button"
className={button}
disabled={value.contacts.length >= 20}
onClick={() =>
change({
contacts: [
...value.contacts,
{ ...source, kind: 'website', value: '' },
],
})
}
>
Acrescentar contacto
</button>
</details>
<details>
<summary>
Sede e balcões ({value.locations.length}) — Alterar
</summary>
{value.locations.map((row, i) => (
<fieldset className={card} key={i}>
<legend>Local físico {i + 1}</legend>
<select
className={input}
value={row.role}
onChange={(e) =>
update('locations', i, { role: e.target.value })
}
>
<option value="headquarters">Sede</option>
<option value="branch">Balcão</option>
<option value="contact_point">
Contacto institucional — tipo por confirmar
</option>
</select>
<Text
label="Designação"
value={row.label}
change={(label) =>
update('locations', i, { label })
}
/>
<Text
label="Morada documentada"
value={row.address}
change={(address) =>
update('locations', i, { address })
}
/>
<label>
Local do cadastro
<select
className={input}
value={row.territory_id ?? ''}
onChange={(e) =>
update('locations', i, {
territory_id: e.target.value,
})
}
>
<option value="">
Correspondência por confirmar
</option>
{territories.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</label>
<Sources
value={row}
change={(v) => update('locations', i, v)}
/>
<button
type="button"
onClick={() =>
change({
locations: value.locations.filter(
(_, n) => n !== i,
),
})
}
>
Remover local
</button>
</fieldset>
))}
<button
type="button"
className={button}
disabled={value.locations.length >= 50}
onClick={() =>
change({
locations: [
...value.locations,
{
...source,
role: 'headquarters',
label: '',
address: '',
territory_id: '',
},
],
})
}
>
Acrescentar sede/balcão
</button>
</details>
<details>
<summary>
Programas/produtos ({value.products.length}) — Alterar
</summary>
{value.products.map((row, i) => (
<fieldset className={card} key={row.code}>
<legend>Produto {i + 1}</legend>
<Text
label="Nome documentado"
value={row.title}
change={(title) => update('products', i, { title })}
/>
<Text
label="Instrumento"
value={row.instrument}
change={(instrument) =>
update('products', i, { instrument })
}
/>
<label>
Necessidades documentadas
<select
multiple
className={input}
value={row.needs}
onChange={(e) =>
update('products', i, {
needs: Array.from(
e.target.selectedOptions,
(o) => o.value,
),
})
}
>
{Object.entries(needs).map(([id, label]) => (
<option key={id} value={id}>
{label}
</option>
))}
</select>
</label>
<label>
Cobertura deste produto
<select
className={input}
value={row.coverage}
onChange={(e) =>
update('products', i, {
coverage: e.target.value,
territory_ids: [],
})
}
>
<option value="unknown">Por confirmar</option>
<option value="national">
Nacional, segundo a fonte
</option>
<option value="territorial">
Locais documentados
</option>
</select>
</label>
{row.coverage === 'territorial' && (
<label>
Locais abrangidos
<select
multiple
className={input}
value={row.territory_ids}
onChange={(e) =>
update('products', i, {
territory_ids: Array.from(
e.target.selectedOptions,
(o) => o.value,
),
})
}
>
{territories.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</label>
)}
<Text
label="Explicação da cobertura"
value={row.coverage_text}
change={(coverage_text) =>
update('products', i, { coverage_text })
}
/>
<label>
Condições — vazio significa por confirmar
<textarea
className={input}
value={row.conditions}
maxLength={4000}
onChange={(e) =>
update('products', i, {
conditions: e.target.value,
})
}
/>
</label>
<Text
label="Montantes/moeda/prazos documentados — se conhecidos"
value={row.amounts}
change={(amounts) =>
update('products', i, { amounts })
}
/>
<label>
Disponibilidade
<select
className={input}
value={row.availability}
onChange={(e) =>
update('products', i, {
availability: e.target.value,
})
}
>
<option value="unknown">Por confirmar</option>
<option value="documented">
Oferta identificada na fonte (não é
concessão)
</option>
<option value="closed">
Encerrada segundo a fonte
</option>
</select>
</label>
<Text
label="Canal de contacto do produto"
type="url"
value={row.contact_url}
change={(contact_url) =>
update('products', i, { contact_url })
}
/>
<Sources
value={row}
change={(v) => update('products', i, v)}
/>
<button
type="button"
onClick={() =>
change({
products: value.products.filter(
(_, n) => n !== i,
),
})
}
>
Remover produto
</button>
</fieldset>
))}
<button
type="button"
className={button}
disabled={value.products.length >= 40}
onClick={() =>
change({
products: [
...value.products,
{
...source,
code: crypto.randomUUID(),
title: '',
instrument: '',
needs: [],
coverage: 'unknown',
coverage_text: '',
territory_ids: [],
conditions: '',
amounts: '',
availability: 'unknown',
contact_url: '',
},
],
})
}
>
Acrescentar programa/produto
</button>
</details>
</section>
);
}
function Source({value}:{value:FundingSource}){return <>{value.source_url&&<details><summary>Informação institucional</summary><a className="underline" href={value.source_url} target="_blank" rel="noopener noreferrer">Consultar informação da entidade</a><p>Consulta em {value.checked_on}.</p></details>}{value.evidence_status==='historical'&&<p>Informação histórica.</p>}{value.evidence_status==='conflict'&&<p>Informação por esclarecer junto da instituição.</p>}</>;}
export function InstitutionView({ value }: { value: InstitutionDetails }) {
return (
<section className={card}>
<h2>Instituição financeira</h2>
<ClassificationSummary value={value} />
{!value.institution_types?.length && (
<p>
Natureza:{' '}
{
(
{
bank: 'Banco',
credit: 'Instituição de crédito',
microfinance: 'Microfinanças',
investment_fund: 'Fundo de investimento',
development:
'Agência de desenvolvimento/financiamento',
other: 'Outra natureza documentada',
unknown: 'Por confirmar',
} as Record<string, string>
)[value.institution_nature]
}
</p>
)}
<h3>Contactos</h3>
{value.contacts.length === 0 && <p>Contactos por confirmar.</p>}
{value.contacts.map((c, i) => (
<article key={i}>
<p>
{c.kind === 'website' ? (
<a
className="underline"
href={c.value}
target="_blank"
rel="noopener noreferrer"
>
{c.value}
</a>
) : (
c.value
)}
</p>
<Source value={c} />
</article>
))}
<h3>Sede e balcões — presença física</h3>
{value.locations.length === 0 && (
<p>Localização física por confirmar.</p>
)}
{value.locations.map((l, i) => (
<article key={i}>
<p>
{l.role === 'headquarters'
? 'Sede'
: l.role === 'branch'
? 'Balcão'
: 'Ponto de contacto institucional'}
: {l.label && !/^(Morada|Localização).*(documento|fonte)/i.test(l.label) ? l.label+' · ' : ''}{l.address}
</p>
<p>
{l.territory_path?.map((n) => n.name).join(' / ') ||
'Localização detalhada a confirmar'}
</p>
{l.location_text && (
<p>Local indicado: {l.location_text}</p>
)}
{l.hours && (
<p>
Horário de atendimento:{' '}
{l.hours}
</p>
)}
<Source value={l} />
</article>
))}
<h3>Produtos e serviços</h3>
{value.products.length === 0 && (
<p>
{fundingPublicPresentation({...value,title:"",kind:"institution",summary:""}).services.join(" · ") || "Consulte as soluções disponíveis junto da instituição."}
</p>
)}
{value.products.map((p) => (
<article className={card} key={p.code}>
<h4>{p.title}</h4>
<p>{p.instrument}</p>
<p>
Cobertura do financiamento:{' '}
{p.coverage === 'national'
? 'Nacional, segundo a fonte'
: p.coverage === 'territorial'
? 'Locais documentados'
: 'Por confirmar'}{' '}
· {p.coverage_text}
</p>
{p.territory_paths?.map((path, i) => (
<p key={i}>{path.map((n) => n.name).join(' / ')}</p>
))}
<p>Condições: {p.conditions || 'Por confirmar'}</p>
<p>
Montantes/moeda/prazos: {p.amounts || 'Por confirmar'}
</p>
<p>
Disponibilidade:{' '}
{p.availability === 'unknown'
? 'Por confirmar'
: p.availability === 'closed'
? 'Encerrada segundo a fonte'
: 'Oferta identificada na fonte; confirmar condições actuais com a instituição'}
</p>
{p.contact_url && (
<a
href={p.contact_url}
className="underline"
target="_blank"
rel="noopener noreferrer"
>
Contactar/consultar o produto
</a>
)}
<Source value={p} />
</article>
))}
<p>
Uma referência não é elegibilidade confirmada, aprovação nem
garantia de financiamento. O portal não envia pedidos ou dados à
instituição.
</p>
</section>
);
}
@@ -131,9 +131,14 @@ export function PortalGroupedMenu() {
);
const accountGroups = groups.filter(
(group) =>
!['home', 'discovery', 'realization', 'support', 'work'].includes(
group.id,
),
![
'home',
'procurement-area',
'discovery',
'realization',
'support',
'work',
].includes(group.id),
);
const institutionalCurrent = url.split('?')[0] === '/contacto';
return (
@@ -195,6 +200,7 @@ export function PortalGroupedMenu() {
{groups
.filter((group) =>
[
'procurement-area',
'discovery',
'realization',
'support',
+4
View File
@@ -335,6 +335,10 @@ export default function PortalHero({
</div>
))}
</dl>
<p className="mt-3 max-w-lg text-xs text-white/80">
Províncias com localização declarada nas oportunidades
públicas. Cada oportunidade conta uma vez no total.
</p>
</div>
</section>
);
@@ -1,3 +1,6 @@
import {SimulationIrpcPecObligation} from './simulation-irpc-pec-obligation';
import { SimulationIrpcPpcObligation } from './simulation-irpc-ppc-obligation';
import { SimulationIrpcInvestment } from './simulation-irpc-investment';
import { SimulationIrpcEconomic } from './simulation-irpc-economic';
import { FiscalProofParcelFields } from './fiscal-proof-parcel';
import { SimulationIrpcCarry } from './simulation-irpc-carry';
@@ -339,6 +342,9 @@ export function SimulationIrpcAnnual({ draft, onChange }: Props) {
</fieldset>
<fieldset className="space-y-2 rounded border p-3">
<legend>PPC efectivamente pagos</legend>
<p>Simulação fiscal para avaliar a viabilidade do negócio e as necessidades de tesouraria. Os valores e meses previstos são estimativas; o portal não executa pagamentos nem submete declarações fiscais.</p>
<SimulationIrpcPecObligation book={b} onChange={pecObligation=>patch({pecObligation})}/>
<SimulationIrpcPpcObligation book={b} onChange={ppcObligation=>patch({ppcObligation})}/>
{group('ppc', 'PPC efectuados')}
<p>
Regista comprovativos de pagamentos por conta; não
@@ -479,6 +485,7 @@ export function SimulationIrpcAnnual({ draft, onChange }: Props) {
}
/>
<SimulationIrpcInternational book={b} save={save} />
<SimulationIrpcInvestment draft={draft} book={b} save={save} />
<SimulationIrpcPec book={b} save={save} />
<SimulationIrpcCarry book={b} save={save} />
{[
@@ -0,0 +1,150 @@
import { useState } from 'react';
import { annualBook } from '@/lib/irpc-annual-settlement';
import type { NormalBook } from '@/lib/irpc-normal';
import {
prepareEconomicInclusion,
calculateEconomicInclusions,
economicInclusionMoney,
} from '@/lib/irpc-normal-economic';
export function SimulationIrpcEconomicInclusion({
draft,
book,
save,
}: {
draft: Record<string, unknown>;
book: NormalBook;
save: (b: NormalBook) => void;
}) {
const [selected, setSelected] = useState('');
const [profit, setProfit] = useState('');
const [credit, setCredit] = useState('');
const [reference, setReference] = useState('');
const [error, setError] = useState('');
const [preview, setPreview] = useState<{
fingerprint: string;
book: NormalBook;
net: string;
} | null>(null);
const sources = annualBook(draft)?.economicCredits?.rows ?? [];
if (!sources.length) return null;
const fingerprint = JSON.stringify([
book,
sources,
selected,
profit,
credit,
reference,
]);
return (
<section
className="space-y-3 rounded border p-3"
aria-label="Incluir ou corrigir lucros e crédito económico em L6"
>
<h3 className="font-semibold">
Incluir ou corrigir lucros e crédito económico em L6
</h3>
<p>
Escolha a origem preparada em L8. Indique os montantes já
considerados na contabilidade e noutros ajustamentos, antes
deste mapa; zero deve ser indicado expressamente. A prévia
propõe apenas a diferença e conserva o resultado anterior.
Depois de confirmar, calcule e guarde L6 pelo percurso
existente; L8 exige nova conferência da colecta. Valores
derivados mantêm as fracções exactas, sem arredondamento fiscal
presumido.
</p>
<label className="block">
Origem dos lucros
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
>
<option value="">Escolha a origem</option>
{sources.map((r) => (
<option key={r.id} value={r.id}>
{r.distributionDocument} — {r.distributionParcel}
</option>
))}
</select>
</label>
<label className="block">
Rendimentos já considerados — MZN (incluindo eventual mais-valia
da partilha)
<input
value={profit}
onChange={(e) => setProfit(e.target.value)}
/>
</label>
<label className="block">
Crédito nominal já acrescido — MZN
<input
value={credit}
onChange={(e) => setCredit(e.target.value)}
/>
</label>
<label className="block">
Suporte da reconciliação
<textarea
value={reference}
onChange={(e) => setReference(e.target.value)}
/>
</label>
{error && <p role="alert">{error}</p>}
<button
type="button"
onClick={() => {
try {
const source = sources.find((r) => r.id === selected);
if (!source)
throw Error('Escolha explicitamente uma origem.');
const next = prepareEconomicInclusion(book, {
source,
profitAlreadyIncluded: profit,
creditAlreadyIncluded: credit,
reference,
});
const model = calculateEconomicInclusions(
next.economicInclusions!,
next.base!.subjectNuit,
next.year,
);
setPreview({
fingerprint,
book: next,
net: economicInclusionMoney(model.net),
});
setError('');
} catch (e) {
setPreview(null);
setError(String(e));
}
}}
>
Pré-visualizar inclusão/correcção
</button>
{preview && preview.fingerprint === fingerprint && (
<div className="space-y-2 rounded border p-3">
<p>
Efeito total deste mapa no lucro tributável:{' '}
{preview.net} MZN. Substitui as parcelas da mesma
origem, sem as acumular.
</p>
<button
type="button"
onClick={() => {
if (preview.fingerprint !== fingerprint) return;
save(preview.book);
setPreview(null);
}}
>
Confirmar preparação em L6
</button>
<button type="button" onClick={() => setPreview(null)}>
Cancelar prévia
</button>
</div>
)}
</section>
);
}
@@ -110,7 +110,10 @@ export function SimulationIrpcEconomic({
rowChange({ ...r, ...v });
const prefix = 'Crédito económico ' + (i + 1) + ' — ';
const field = (
key: Exclude<keyof EconomicPosition, 'previousUse'>,
key: Exclude<
keyof EconomicPosition,
'previousUse' | 'liquidation' | 'association'
>,
label: string,
) => (
<label key={key} className="block">
@@ -145,9 +148,7 @@ export function SimulationIrpcEconomic({
</select>
</label>
);
const outside =
(!!r.kind && r.kind !== 'ordinary_profit') ||
r.article40 === 'yes';
const outside = r.article40 === 'yes';
const detail = (
<>
{(
@@ -226,7 +227,60 @@ export function SimulationIrpcEconomic({
'Suporte da quota proporcional da colecta',
],
] as const
).map(([key, label]) => field(key, label))}
)
.filter(
([key]) =>
r.nationalMethod !== 'proportional' ||
(key !== 'nationalLimit' &&
key !== 'nationalLimitReference'),
)
.map(([key, label]) => field(key, label))}
<label className="block">
Quota proporcional
<select
aria-label={prefix + 'Método da quota'}
value={r.nationalMethod ?? 'documented'}
onChange={(e) =>
patch({
nationalMethod: e.target
.value as EconomicPosition['nationalMethod'],
})
}
>
<option value="documented">
Limite documentado
</option>
<option value="proportional">
Derivar da colecta L6 ordinária
</option>
</select>
</label>
{r.nationalMethod === 'proportional' && (
<label className="block">
Correspondência dos lucros mais acréscimo53 na
base corrente
<textarea
aria-label={
prefix + 'Correspondência da quota'
}
value={r.nationalBasisReference ?? ''}
onChange={(e) =>
patch({
nationalBasisReference:
e.target.value,
})
}
/>
<span className="block">
Quota = colecta L6 × (lucros incluídos +
crédito nominal acrescido) / matéria
colectável L6. Não altera a base nem
acrescenta o crédito novamente. O limite
manual anterior fica conservado, mas não é
usado neste modo.
</span>
</label>
)}
{choice(
'incomeIncluded',
'Lucros ilíquidos e acréscimo incluídos na base corrente',
@@ -339,6 +393,155 @@ export function SimulationIrpcEconomic({
</option>
</select>
</label>
{r.kind === 'association' && (
<fieldset className="space-y-2 rounded border p-3">
<legend>Associação — art.64(4)</legend>
<label className="block">
Modalidade
<select
aria-label={
prefix + 'Modalidade da associação'
}
value={r.association?.kind ?? ''}
onChange={(e) =>
patch({
association: {
kind: e.target.value as
| ''
| 'participation'
| 'quota',
effectivelyTaxed:
r.association
?.effectivelyTaxed ??
'',
reference:
r.association
?.reference ?? '',
},
})
}
>
<option value="">
Escolha a modalidade
</option>
<option value="participation">
Em participação
</option>
<option value="quota">À quota</option>
</select>
</label>
{r.association?.kind === 'participation' && (
<label className="block">
Rendimentos distribuídos efectivamente
tributados
<select
aria-label={
prefix +
'Tributação efectiva da participação'
}
value={
r.association.effectivelyTaxed
}
onChange={(e) =>
patch({
association: {
...r.association!,
effectivelyTaxed: e
.target.value as
| ''
| 'yes'
| 'no',
},
})
}
>
<option value="">
Informação em falta
</option>
<option value="yes">
Sim — documentado
</option>
<option value="no">
Não — documentado
</option>
</select>
</label>
)}
<label className="block">
Suporte do contrato, rendimentos e adaptação
do crédito
<input
aria-label={
prefix + 'Suporte da associação'
}
value={r.association?.reference ?? ''}
onChange={(e) =>
patch({
association: {
kind: '',
effectivelyTaxed: '',
...r.association,
reference: e.target.value,
},
})
}
/>
</label>
</fieldset>
)}
{r.kind === 'liquidation' && (
<fieldset className="space-y-2 rounded border p-3">
<legend>
Partilha em liquidação — art.64(3)
</legend>
<p>
No rendimento abaixo indique a diferença
positiva classificada documentalmente como
aplicação de capitais. O crédito limita-se
também ao valor atribuído menos entradas
efectivas para capital. O excesso mantém
tratamento próprio de mais-valia; não
integra a quota económica. Confirme a
inclusão L6 e o IRPC correspondente apenas à
parcela elegível.
</p>
{(
[
[
'attributed',
'Valor atribuído na partilha — MZN',
],
[
'paidInCapital',
'Entradas efectivas para capital na contabilidade da liquidada — MZN',
],
[
'reference',
'Suporte da partilha e classificação do rendimento',
],
] as const
).map(([key, label]) => (
<label key={key} className="block">
{label}
<input
aria-label={prefix + label}
value={r.liquidation?.[key] ?? ''}
onChange={(e) =>
patch({
liquidation: {
attributed: '',
paidInCapital: '',
reference: '',
...r.liquidation,
[key]: e.target.value,
},
})
}
/>
</label>
))}
</fieldset>
)}
{residentConfirmed && r.recipientResident === 'yes' ? (
<p>
Residência do beneficiário reutilizada da
@@ -1,3 +1,4 @@
import { SimulationIrpcPtCdt } from './simulation-irpc-pt-cdt';
import { FiscalProofParcelFields } from './fiscal-proof-parcel';
import CountrySelect, { type Country } from '@/components/country-select';
import { countryIsoCodes } from '@/lib/country-iso-codes';
@@ -116,6 +117,15 @@ export function SimulationIrpcInternational({
<fieldset className="space-y-3 rounded border p-3">
<legend>Crédito por dupla tributação internacional</legend>
<p>{internationalCreditSource}</p>
{c.positions.some((p) => p.nationalMethod === 'proportional') && (
<p>
No modo de derivação ordinária abaixo, o portal calcula a
fracção nacional a partir da colecta e matéria colectável L6
correntes e da parcela líquida correspondida. Mantêm-se as
restantes condições e limites do modo documentado; não
determina a convenção aplicável.
</p>
)}
<p>
Reúna os comprovativos de cada país numa posição por exercício.
Indique a fracção nacional e a convenção conforme o suporte do
@@ -220,6 +230,59 @@ export function SimulationIrpcInternational({
onChange={(country) => patch({ country })}
allowLegacyValue
/>
<label className="block">
Fracção nacional
<select
aria-label={
'Internacional ' +
(index + 1) +
' — Método da fracção nacional'
}
value={p.nationalMethod ?? 'documented'}
onChange={(e) =>
patch({
nationalMethod: e.target
.value as InternationalPosition['nationalMethod'],
})
}
>
<option value="documented">
Limite documentado
</option>
<option value="proportional">
Derivar da colecta L6 ordinária
</option>
</select>
</label>
{p.nationalMethod === 'proportional' && (
<label className="block">
Correspondência da parcela líquida estrangeira
<textarea
aria-label={
'Internacional ' +
(index + 1) +
' — Correspondência da parcela líquida'
}
value={p.nationalBasisReference ?? ''}
onChange={(e) =>
patch({
nationalBasisReference:
e.target.value,
})
}
/>
<span className="block">
Indique abaixo a parcela líquida já incluída
na matéria colectável, após os custos,
perdas e deduções correspondentes. A fracção
será colecta L6 × parcela líquida / matéria
colectável L6, antes de créditos. O portal
não reparte custos ou perdas nem determina
convenções. O limite manual anterior é
conservado, mas não usado neste modo.
</span>
</label>
)}
{(
[
['subjectNuit', 'NUIT titular'],
@@ -238,23 +301,33 @@ export function SimulationIrpcInternational({
'Suporte da fracção nacional e exercício',
],
] as const
).map(([key, label]) => (
<label key={key} className="block">
{label}
<input
aria-label={
'Internacional ' +
(index + 1) +
' — ' +
label
}
value={p[key]}
onChange={(e) =>
patch({ [key]: e.target.value })
}
/>
</label>
))}
)
.filter(
([key]) =>
p.nationalMethod !== 'proportional' ||
(key !== 'nationalLimit' &&
key !== 'nationalLimitReference'),
)
.map(([key, label]) => (
<label key={key} className="block">
{key === 'incomeAmount' &&
p.nationalMethod === 'proportional'
? 'Parcela líquida estrangeira incluída — MZN'
: label}
<input
aria-label={
'Internacional ' +
(index + 1) +
' — ' +
label
}
value={p[key]}
onChange={(e) =>
patch({ [key]: e.target.value })
}
/>
</label>
))}
<label className="block">
Rendimento incluído na matéria colectável
<select
@@ -315,23 +388,65 @@ export function SimulationIrpcInternational({
</option>
</select>
</label>
{(p.treaty === 'yes' || p.treatyLimit) && (
{p.treaty === 'yes' && (
<label className="block">
Imposto pago elegível nos termos da convenção —
limite MZN
<input
Determinar limite da convenção
<select
aria-label={
'Internacional ' +
(index + 1) +
' — Limite CDT'
' — Método CDT'
}
value={p.treatyLimit}
value={p.treatyMethod ?? 'documented'}
onChange={(e) =>
patch({ treatyLimit: e.target.value })
patch({
treatyMethod: e.target
.value as InternationalPosition['treatyMethod'],
})
}
/>
>
<option value="documented">
Limite documentado
</option>
<option value="ptMzRoyalty">
Portugal — royalties ordinárias,
art.12/23(3)a
</option>
<option value="ptMzDividend">
Portugal — dividendos, art.10/23(3)a
</option>
<option value="ptMzInterest">
Portugal — juros ordinários,
art.11/23(3)a
</option>
</select>
</label>
)}
<SimulationIrpcPtCdt
position={p}
patch={patch}
prefix={'Internacional ' + (index + 1) + ' — '}
/>
{(p.treatyMethod ?? 'documented') === 'documented' &&
(p.treaty === 'yes' || p.treatyLimit) && (
<label className="block">
Imposto pago elegível nos termos da
convenção — limite MZN
<input
aria-label={
'Internacional ' +
(index + 1) +
' — Limite CDT'
}
value={p.treatyLimit}
onChange={(e) =>
patch({
treatyLimit: e.target.value,
})
}
/>
</label>
)}
<label className="block">
Suporte da convenção ou da não aplicação
<input
@@ -0,0 +1,658 @@
import { QualificationFields } from './simulation-irpc-special';
import { FiscalProofParcelFields } from './fiscal-proof-parcel';
import {
prepareInvestmentCredit,
prepareInvestmentCarry,
type AnnualBook,
} from '@/lib/irpc-annual-settlement';
import {
investmentCreditSource,
newInvestmentAsset,
type InvestmentCredit,
type InvestmentAsset,
} from '@/lib/irpc-investment-credit';
export function SimulationIrpcInvestment({
draft,
book,
save,
}: {
draft: Record<string, unknown>;
book: AnnualBook;
save: (b: AnnualBook) => void;
}) {
const c = book.investmentCredit;
if (!c)
return (
<fieldset className="rounded border p-3">
<legend>Crédito fiscal por investimento — CBF15</legend>
<p>
Prepare o detalhe do investimento efectivo e da qualificação
do projecto. Substitui neste cálculo o resumo anterior dos
benefícios, que fica conservado. O recorte cobre
investimento efectivo de2026 dentro dos cinco exercícios do
prazo,com colecta integral ou quota residual documental do
projecto, sem outros benefícios próprios ou transportes
anteriores.
</p>
<button
type="button"
onClick={() => save(prepareInvestmentCredit(draft, book))}
>
Preparar crédito por investimento
</button>
</fieldset>
);
const patch = (v: Partial<InvestmentCredit>) =>
save({ ...book, investmentCredit: { ...c, ...v } });
const text = (
key:
| 'projectReference'
| 'locationReference'
| 'startDate'
| 'startReference'
| 'recognitionReference'
| 'noOtherBenefitsReference'
| 'soleProjectReference',
label: string,
) => (
<label className="block">
{label}
<input
aria-label={'CBF15 — ' + label}
value={c[key]}
onChange={(e) => patch({ [key]: e.target.value })}
/>
</label>
);
const choice = (
key:
| 'cbfRequirements'
| 'genericOnly'
| 'noOtherBenefits'
| 'soleProject',
label: string,
) => (
<label className="block">
{label}
<select
aria-label={'CBF15 — ' + label}
value={c[key]}
onChange={(e) => patch({ [key]: e.target.value })}
>
<option value="">Informação em falta</option>
<option value="yes">Sim — documentado</option>
<option value="no">Não — documentado</option>
</select>
</label>
);
return (
<fieldset className="space-y-3 rounded border p-3">
<legend>Crédito fiscal por investimento — CBF15</legend>
<p>{investmentCreditSource}</p>
<p>
Beneficiário {c.subjectNuit}, exercício {c.year}. Resumo
anterior conservado:{' '}
{book.internationalCredits?.benefits.state ||
'informação em falta'}
; montante{' '}
{book.internationalCredits?.benefits.amount || 'não preenchido'}
. O detalhe aplica apenas o benefício aqui qualificado; não
volta a deduzir o resumo.
</p>
<button
type="button"
onClick={() =>
patch({
subjectNuit: book.subjectNuit,
year: book.year,
assessmentKey: book.assessmentKey,
})
}
>
Vincular CBF15 à colecta seleccionada
</button>
<QualificationFields
label="CBF15"
value={c.qualification}
onChange={(qualification) => patch({ qualification })}
/>
{choice(
'noOtherBenefits',
'Sem outros benefícios próprios/cumulações neste recorte',
)}
{text(
'noOtherBenefitsReference',
'Reconciliação dos outros benefícios e tratamentos L6/L7',
)}
{c.qualification.state === 'known' && (
<>
{text(
'projectReference',
'Despacho/termos ou instrumento legal do projecto',
)}
{choice(
'cbfRequirements',
'Condições CBF3/9 e âmbito do projecto documentados',
)}
{choice(
'genericOnly',
'Investimento no regime genérico, fora dos benefícios específicos — art.13',
)}
<label className="block">
Reconhecimento — art.10
<select
aria-label="CBF15 — Reconhecimento"
value={c.recognition}
onChange={(e) =>
patch({
recognition: e.target
.value as InvestmentCredit['recognition'],
})
}
>
<option value="">Por corresponder</option>
<option value="documented">
Reconhecimento documentado
</option>
<option value="not_required">
Excepção com norma e suporte
</option>
</select>
</label>
{text(
'recognitionReference',
'Suporte do reconhecimento ou norma de excepção',
)}
{choice(
'soleProject',
'Toda a colecta corrente respeita à actividade do projecto',
)}
{c.soleProject === 'yes'
? text(
'soleProjectReference',
'Suporte da correspondência integral da colecta ao projecto',
)
: c.soleProject === 'no' && (
<fieldset className="rounded border p-3">
<legend>
Parte da colecta respeitante ao projecto
</legend>
<p>
Indique a colecta residual do projecto
depois dos créditos
económicos/internacionais que lhe
respeitam. Documente a atribuição entre
actividades; a quota não pode exceder a
colecta global remanescente. O portal não
reparte rendimentos, custos ou perdas por
presunção.
</p>
<label className="block">
Quota residual do projecto — MZN ou n/d
<input
aria-label="CBF15 — Quota residual do projecto"
value={c.projectQuota?.amount ?? ''}
onChange={(e) =>
patch({
projectQuota: {
amount: e.target.value,
reference:
c.projectQuota
?.reference ?? '',
},
})
}
/>
</label>
<label className="block">
Suporte da quota e atribuição dos créditos
anteriores
<input
aria-label="CBF15 — Suporte da quota do projecto"
value={
c.projectQuota?.reference ?? ''
}
onChange={(e) =>
patch({
projectQuota: {
amount:
c.projectQuota
?.amount ?? '',
reference: e.target.value,
},
})
}
/>
</label>
</fieldset>
)}
<label className="block">
Local efectivo do investimento
<select
aria-label="CBF15 — Local do investimento"
value={c.location}
onChange={(e) =>
patch({
location: e.target
.value as InvestmentCredit['location'],
})
}
>
<option value="">Por documentar</option>
<option value="maputo_city">
Cidade de Maputo —5%
</option>
<option value="other">
Restantes províncias —10%, incluindo Província
de Maputo
</option>
</select>
</label>
{text(
'locationReference',
'Suporte da localização do investimento',
)}
<label className="block">
Início do prazo do crédito
<select
aria-label="CBF15 — Início"
value={c.startKind}
onChange={(e) =>
patch({
startKind: e.target
.value as InvestmentCredit['startKind'],
})
}
>
<option value="">Por documentar</option>
<option value="new">
Projecto novo — início de exploração
</option>
<option value="existing">
Projecto em funcionamento — início dos
procedimentos após aprovação
</option>
</select>
</label>
{text('startDate', 'Data do início aplicável — AAAA-MM-DD')}
{text(
'startReference',
'Suporte do início e contagem dos cinco exercícios',
)}
<p>
Indique investimento efectivamente realizado,
documentado em MZN, adquirido novo e afecto à exploração
nacional. Art15(6) exclui edifícios, viaturas ligeiras,
mobiliário/conforto/decoração, equipamentos sociais,
tecnologia de ponta e outros bens não directa e
imprescindivelmente ligados à actividade produtiva. Não
copiar investimento projectado como realização.
</p>
<button
type="button"
onClick={() =>
patch({
assets: [
...c.assets,
newInvestmentAsset(crypto.randomUUID()),
],
})
}
>
Acrescentar activo/documento
</button>
</>
)}
{!c.carryForward ? (
<button
type="button"
onClick={() => save(prepareInvestmentCarry(draft, book))}
>
Preparar transporte de crédito de anos anteriores
</button>
) : (
<fieldset className="space-y-2 rounded border p-3">
<legend>Transporte CBF15 do mesmo projecto</legend>
<p>
Declare activos de origem anterior, taxa e qualificação
históricas. Não copie saldos de simulações como uso
fiscal. O prazo original de cinco exercícios mantém-se;
investimentos correntes ficam separados.
</p>
<label className="block">
Correspondência histórica do crédito transportado
<input
aria-label="CBF15 transporte — Correspondência"
value={c.carryForward.reference}
onChange={(e) =>
patch({
carryForward: {
...c.carryForward!,
reference: e.target.value,
},
})
}
/>
</label>
<label className="block">
Investimento efectivo de 2026
<select
aria-label="CBF15 transporte — Investimento corrente"
value={c.carryForward.currentState}
onChange={(e) =>
patch({
carryForward: {
...c.carryForward!,
currentState: e.target.value as
| 'known'
| 'absent'
| '',
},
})
}
>
<option value="">Por documentar</option>
<option value="known">
Conhecido — activos correntes discriminados
</option>
<option value="absent">
Ausência documentada — apenas origens anteriores
</option>
</select>
</label>
<label className="block">
Suporte do investimento corrente/ausência
<input
aria-label="CBF15 transporte — Suporte corrente"
value={c.carryForward.currentReference}
onChange={(e) =>
patch({
carryForward: {
...c.carryForward!,
currentReference: e.target.value,
},
})
}
/>
</label>
<label className="block">
Uso efectivo de todo o crédito reconciliado
<select
aria-label="CBF15 transporte — Reconciliação do uso"
value={c.carryForward.useReconciled}
onChange={(e) =>
patch({
carryForward: {
...c.carryForward!,
useReconciled: e.target.value as
| ''
| 'yes'
| 'no',
},
})
}
>
<option value="">Por documentar</option>
<option value="yes">
Sim — inclui as origens anteriores e o crédito
corrente
</option>
<option value="no">Não reconciliado</option>
</select>
</label>
<button
type="button"
onClick={() =>
patch({
assets: [
...c.assets,
{
...newInvestmentAsset(
crypto.randomUUID(),
),
originYear: '',
originNuit: '',
originProject: '',
originReference: '',
originRate: '',
},
],
})
}
>
Acrescentar activo de origem anterior
</button>
</fieldset>
)}
{c.assets.map((r, index) => {
const change = (v: Partial<InvestmentAsset>) =>
patch({
assets: c.assets.map((x) =>
x.id === r.id ? { ...r, ...v } : x,
),
});
return (
<fieldset
key={r.id}
className="space-y-2 rounded border p-3"
>
<legend>Investimento {index + 1}</legend>
{r.originYear !== undefined && (
<>
<p>
Origem anterior — mantém a data real de
aquisição e o prazo original do projecto.
</p>
{(
[
[
'originYear',
'Exercício que originou o crédito',
],
[
'originNuit',
'NUIT do contribuinte de origem',
],
[
'originProject',
'Referência do projecto de origem',
],
[
'originRate',
'Taxa original documentada — 5 ou 10',
],
[
'originReference',
'Suporte da qualificação e crédito de origem,mesmo projecto/local/regime',
],
] as const
).map(([key, label]) => (
<label key={key} className="block">
{label}
<input
aria-label={
'CBF15 origem ' +
(index + 1) +
' — ' +
label
}
value={r[key] ?? ''}
onChange={(e) =>
change({
[key]: e.target.value,
})
}
/>
</label>
))}
</>
)}
{(
[
['issuer', 'Fornecedor/emitente'],
['document', 'Factura/documento'],
[
'date',
'Data do investimento efectivo — AAAA-MM-DD',
],
[
'proofAmount',
'Montante total do documento — MZN',
],
[
'amount',
'Investimento atribuído a este activo/projecto — MZN',
],
['currency', 'Moeda documental (recorte MZN)'],
[
'reference',
'Suporte do activo, valor e realização',
],
] as const
).map(([key, label]) => (
<label key={key} className="block">
{label}
<input
aria-label={
'CBF15 activo ' +
(index + 1) +
' — ' +
label
}
value={r[key]}
onChange={(e) =>
change({ [key]: e.target.value })
}
/>
</label>
))}
<FiscalProofParcelFields
label={'InvestimentoCBF15 ' + (index + 1)}
value={r}
change={change}
/>
{(
[
[
'realised',
'Investimento efectivamente realizado',
],
[
'newTangible',
'Activo imobilizado corpóreo adquirido em estado novo',
],
[
'domesticUse',
'Afecto à exploração do projecto em território nacional',
],
[
'directProductive',
'Directa e imprescindivelmente ligado à actividade produtiva',
],
[
'excluded15_6',
'Abrangido por alguma exclusão15(6) (para deduzir exige Não)',
],
] as const
).map(([key, label]) => (
<label key={key} className="block">
{label}
<select
aria-label={
'CBF15 activo ' +
(index + 1) +
' — ' +
label
}
value={r[key]}
onChange={(e) =>
change({ [key]: e.target.value })
}
>
<option value="">
Informação em falta
</option>
<option value="yes">
Sim — documentado
</option>
<option value="no">
Não — documentado
</option>
</select>
</label>
))}
<button
type="button"
onClick={() =>
patch({
assets: c.assets.filter(
(x) => x.id !== r.id,
),
})
}
>
Retirar activo {index + 1}
</button>
</fieldset>
);
})}
<fieldset className="space-y-2 rounded border p-2">
<legend>Uso anterior efectivo do crédito</legend>
<select
aria-label="CBF15 — Uso anterior"
value={c.previousUse.state}
onChange={(e) =>
patch({
previousUse: {
...c.previousUse,
state: e.target
.value as InvestmentCredit['previousUse']['state'],
},
})
}
>
<option value="">Informação em falta</option>
<option value="known">Montante conhecido</option>
<option value="absent">Ausência documentada</option>
</select>
<label className="block">
Montante anteriormente utilizado em todo o crédito
discriminado
<input
aria-label="CBF15 — Montante de uso anterior"
value={c.previousUse.amount}
onChange={(e) =>
patch({
previousUse: {
...c.previousUse,
amount: e.target.value,
},
})
}
/>
</label>
<label className="block">
Suporte do uso/ausência
<input
aria-label="CBF15 — Suporte do uso anterior"
value={c.previousUse.reference}
onChange={(e) =>
patch({
previousUse: {
...c.previousUse,
reference: e.target.value,
},
})
}
/>
</label>
</fieldset>
<p>
Calcule e guarde pelo percurso L8 existente. O crédito não
utilizado fica discriminado; transporte para outros exercícios e
formalidades oficiais conservam percurso próprio.
</p>
</fieldset>
);
}
@@ -4,6 +4,7 @@ import { SimulationIrpcPeriodFx } from '@/components/simulation-irpc-period-fx';
import { SimulationIrpcDepreciation } from '@/components/simulation-irpc-depreciation';
import { SimulationIrpcCosts } from '@/components/simulation-irpc-costs';
import { SimulationIrpcSchedules } from '@/components/simulation-irpc-schedules';
import { SimulationIrpcEconomicInclusion } from '@/components/simulation-irpc-economic-inclusion';
import { SimulationIrpcAssessment } from '@/components/simulation-irpc-assessment';
import { useState } from 'react';
import { SimulationIrpcTaxableBase } from '@/components/simulation-irpc-taxable-base';
@@ -438,6 +439,16 @@ export function SimulationIrpcNormal({
)}
<SimulationIrpcPeriodFx draft={draft} book={b} save={save} />
<SimulationIrpcSchedules book={b} save={save} />
<SimulationIrpcEconomicInclusion
key={JSON.stringify([
draft.context_opportunity,
b.base?.subjectNuit,
b.year,
])}
draft={draft}
book={b}
save={save}
/>
<SimulationIrpcDepreciation
draft={draft}
book={b}
@@ -0,0 +1,85 @@
import {
newPecCorrection,
type PecCorrection,
} from '@/lib/irpc-pec-correction';
export function SimulationIrpcPecCorrection({
value,
original,
subjectNuit,
onChange,
}: {
value?: PecCorrection;
original: string;
subjectNuit: string;
onChange: (c: PecCorrection) => void;
}) {
if (!value)
return (
<button
type="button"
onClick={() =>
onChange(newPecCorrection(original, subjectNuit))
}
>
Rectificar volume na simulação PEC
</button>
);
const patch = (c: Partial<PecCorrection>) => onChange({ ...value, ...c }),
field = (
key: Exclude<keyof PecCorrection, 'version' | 'apply'>,
label: string,
) => (
<label className="block">
{label}
<input
aria-label={label}
value={value[key]}
onChange={(e) => patch({ [key]: e.target.value })}
/>
</label>
);
return (
<section
aria-label="Rectificação da estimativa PEC"
className="rounded border p-3"
>
<h6>Volume de negócios corrigido</h6>
<p>
O volume original permanece {original || 'por indicar'} MZN. A
nova fonte fundamenta a correcção da simulação2026; não submete
declaração fiscal.
</p>
<label className="block">
Aplicar rectificação PEC
<select
aria-label="Aplicar rectificação PEC"
value={value.apply}
onChange={(e) =>
patch({
apply: e.target.value as PecCorrection['apply'],
})
}
>
<option value="">Por decidir</option>
<option value="yes">
Usar volume corrigido na estimativa
</option>
<option value="no">Conservar volume original</option>
</select>
</label>
<details open={value.apply !== 'no'}>
<summary>Alterar rectificação PEC</summary>
{field('originalTurnover', 'Volume original analisado — PEC')}
{field('revisedTurnover', 'Volume corrigido — PEC')}
{field('subjectNuit', 'NUIT da rectificação PEC')}
{field('turnoverYear', 'Ano do volume corrigido — PEC')}
{field('reference', 'Fonte e fundamento da rectificação PEC')}
</details>
<p>
Trocar a opção conserva os dois valores. Uma alteração da base
original exige nova conferência; recalcule o fecho e a hipótese
de tesouraria para reflectir a estimativa corrigida.
</p>
</section>
);
}
@@ -0,0 +1,151 @@
import {SimulationIrpcPecCorrection} from './simulation-irpc-pec-correction';
import {SimulationIrpcPecPlan} from './simulation-irpc-pec-plan';
import {
newPecObligation,
calculatePecObligation,
type PecObligation,
} from '@/lib/irpc-pec-obligation';
import { fractionPresentation } from '@/lib/fiscal-rational';
import type { AnnualBook } from '@/lib/irpc-annual-settlement';
export function SimulationIrpcPecObligation({
book,
onChange,
}: {
book: AnnualBook;
onChange: (p: PecObligation) => void;
}) {
const p = book.pecObligation;
if (!p)
return (
<section className="rounded border p-3">
<h5>Estimativa PEC</h5>
<p>
Preparar o total e os meses previstos conserva os
comprovativos pagos em separado.
</p>
<button
type="button"
onClick={() =>
onChange(newPecObligation(book.subjectNuit, book.year))
}
>
Simular PEC
</button>
</section>
);
const m = calculatePecObligation(
p,
book.subjectNuit,
book.year,
book.assessment,
),
patch = (changes: Partial<PecObligation>) =>
onChange({ ...p, ...changes });
const field = (
key: Exclude<keyof PecObligation, 'version' | 'scope' | 'plan' | 'correction'>,
label: string,
) => (
<label className="block">
{label}
<input
aria-label={label}
value={p[key]}
onChange={(e) => patch({ [key]: e.target.value })}
/>
</label>
);
return (
<section
aria-label="Preparação da estimativa PEC"
className="rounded border p-3"
>
<h5>Estimativa PEC — preparação</h5>
<label className="block">
Enquadramento PEC
<select
aria-label="Enquadramento PEC"
value={p.scope}
onChange={(e) =>
patch({
scope: e.target.value as PecObligation['scope'],
})
}
>
<option value="">Por confirmar</option>
<option value="ordinary_resident">
Residente geral — comercial,industrial ou agrícola,ano
civil
</option>
<option value="other">
Outro enquadramento — separado
</option>
</select>
</label>
{p.scope === 'ordinary_resident' && (
<>
{field('subjectNuit', 'NUIT — estimativa PEC')}
{field('year', 'Exercício — estimativa PEC')}
{field(
'activityStartYear',
'Ano de início efectivo da actividade — PEC',
)}
{field(
'activityReference',
'Fonte do início de actividade — PEC',
)}
{p.activityStartYear !== book.year && (
<>
{field('previousYear', 'Exercício anterior — PEC')}
{field(
'previousTurnover',
'Volume de negócios anterior — MZN',
)}
{field(
'turnoverReference',
'Fonte do volume de negócios — PEC',
)}
<SimulationIrpcPecCorrection value={p.correction} original={p.previousTurnover} subjectNuit={p.subjectNuit} onChange={correction=>patch({correction})}/>
{field(
'previousPpcPaid',
'PPC efectivamente pagos no ano anterior — MZN',
)}
{field(
'ppcReference',
'Fonte dos PPC anteriores — PEC',
)}
</>
)}
</>
)}
{m.pending.length ? (
<p role="status">{m.pending.join(' ')}</p>
) : m.notApplicable ? (
<p>Não aplicável no ano de início da actividade29(4).</p>
) : (
<>
<p>
Total estimado:{' '}
{fractionPresentation(m.total!).decimal} MZN.
</p>
<ul>
{m.months.map((month) => (
<li key={month}>{month}</li>
))}
</ul>
</>
)}
<SimulationIrpcPecPlan plan={p.plan} total={m.total} notApplicable={m.notApplicable} year={book.year} onChange={plan=>patch({plan})}/>
<p>
Guardar o rascunho conserva esta preparação. Calcular o fecho
anual inclui-a no relatório. Não regista pagamentos,cria
deduções ou executa guias. Os comprovativos efectivos permanecem
no detalhe PEC existente.
</p>
<details>
<summary>Fonte e limites PEC</summary>
<p>{m.source}</p>
</details>
</section>
);
}
@@ -0,0 +1,135 @@
import {
newPecPlan,
calculatePecPlan,
pecPlanSource,
type PecPlan,
} from '@/lib/irpc-pec-plan';
import {
fractionPresentation,
type FiscalFraction,
} from '@/lib/fiscal-rational';
export function SimulationIrpcPecPlan({
plan,
total,
notApplicable,
year,
onChange,
}: {
plan?: PecPlan;
total: FiscalFraction | null;
notApplicable: boolean;
year: string;
onChange: (p: PecPlan) => void;
}) {
if (!plan)
return (
<div className="rounded border p-3">
<p>
Prepare os montantes por prestação ou a opção de pagamento
único,sem registar pagamento.
</p>
<button
type="button"
disabled={
!total || notApplicable || BigInt(total.numerator) <= 0n
}
onClick={() => onChange(newPecPlan())}
>
Simular tesouraria PEC
</button>
</div>
);
const m = calculatePecPlan(plan, total, notApplicable, year),
patch = (p: Partial<PecPlan>) => onChange({ ...plan, ...p });
const field = (
key: 'reference' | 'june' | 'august' | 'october',
label: string,
) => (
<label className="block">
{label}
<input
aria-label={label}
value={plan[key]}
onChange={(e) => patch({ [key]: e.target.value })}
/>
</label>
);
return (
<section
aria-label="Projecção de tesouraria PEC"
className="rounded border p-3"
>
<h6>Projecção PEC</h6>
{m.pending.length ? (
<p role="status">{m.pending.join(' ')}</p>
) : (
<p>
Soma reconciliada:{' '}
{m.rows
.map(
(r) =>
r.month +
': ' +
fractionPresentation(r.amount).decimal +
' MZN',
)
.join('; ')}
. Prazo legal não validado.
</p>
)}
<details open={m.pending.length > 0}>
<summary>Alterar projecção PEC</summary>
<label className="block">
Opção de preparação PEC
<select
aria-label="Opção de preparação PEC"
value={plan.mode}
onChange={(e) =>
patch({ mode: e.target.value as PecPlan['mode'] })
}
>
<option value="">Por escolher</option>
<option value="three">
Distribuir a estimativa por três meses
</option>
<option value="single">
Concentrar a estimativa num mês
</option>
</select>
</label>
{field('reference', 'Fundamento do projecção PEC')}
{plan.mode === 'three' && (
<>
{field('june', 'PEC Junho — MZN')}
{field('august', 'PEC Agosto — MZN')}
{field('october', 'PEC Outubro — MZN')}
</>
)}
{plan.mode === 'single' && (
<label className="block">
Mês previsto do PEC único
<input
aria-label="Mês previsto do PEC único"
type="month"
value={plan.singleMonth}
onChange={(e) =>
patch({ singleMonth: e.target.value })
}
/>
</label>
)}
</details>
<p>
O mês escolhido é uma hipótese de tesouraria para avaliar a
viabilidade. A reconciliação da soma não valida os prazos
legais,emite guia ou regista pagamento. Alterar a estimativa
exige reconferir o plano. Guarde pelo rascunho e recalcule o
fecho para actualizar o relatório.
</p>
<details>
<summary>Fonte do projecção PEC</summary>
<p>{pecPlanSource}</p>
</details>
</section>
);
}
@@ -0,0 +1,169 @@
import {
newPpcObligation,
calculatePpcObligation,
ppcObligationSource,
type PpcObligation,
} from '@/lib/irpc-ppc-obligation';
import { fractionPresentation } from '@/lib/fiscal-rational';
import type { AnnualBook } from '@/lib/irpc-annual-settlement';
export function SimulationIrpcPpcObligation({
book,
onChange,
}: {
book: AnnualBook;
onChange: (value: PpcObligation) => void;
}) {
const p = book.ppcObligation;
if (!p)
return (
<section className="rounded border p-3">
<h5>Estimativa PPC</h5>
<p>
Preparar a obrigação e os meses previstos não regista
pagamentos nem cria deduções na liquidação.
</p>
<button
type="button"
onClick={() =>
onChange(newPpcObligation(book.subjectNuit, book.year))
}
>
Simular PPC
</button>
</section>
);
const m = calculatePpcObligation(
p,
book.subjectNuit,
book.year,
book.assessment,
);
const patch = (changes: Partial<PpcObligation>) =>
onChange({ ...p, ...changes });
const field = (
key:
| 'subjectNuit'
| 'year'
| 'previousYear'
| 'previousTax'
| 'previousWithholdings'
| 'assessmentReference'
| 'withholdingReference',
label: string,
) => (
<label className="block">
{label}
<input
aria-label={label}
value={p[key]}
onChange={(e) => patch({ [key]: e.target.value })}
/>
</label>
);
return (
<section
aria-label="Preparação da estimativa PPC"
className="rounded border p-3"
>
<h5>Estimativa PPC — exercício anterior</h5>
<p>
Reutiliza o titular e exercício ao iniciar. Complete a
liquidação anterior e as retenções documentadas; campos vazios
não equivalem a zero.
</p>
<label className="block">
Enquadramento PPC
<select
aria-label="Enquadramento PPC"
value={p.scope}
onChange={(e) =>
patch({
scope: e.target.value as PpcObligation['scope'],
})
}
>
<option value="">Por confirmar</option>
<option value="ordinary_resident">
Residente geral — actividade comercial, industrial ou
agrícola, ano civil
</option>
<option value="other">
Outro enquadramento — cálculo separado
</option>
</select>
</label>
{p.scope === 'ordinary_resident' && (
<>
{field('subjectNuit', 'NUIT — estimativa PPC')}
{field('year', 'Exercício — estimativa PPC')}
{field('previousYear', 'Exercício anterior — PPC')}
<label className="block">
Exercício anterior civil completo
<select
aria-label="Exercício anterior civil completo"
value={p.previousFullCalendarYear}
onChange={(e) =>
patch({
previousFullCalendarYear: e.target
.value as PpcObligation['previousFullCalendarYear'],
})
}
>
<option value="">Por confirmar</option>
<option value="yes">Sim</option>
<option value="no">
Não — anualização/período especial separado
</option>
</select>
</label>
{field(
'previousTax',
'Imposto liquidado anterior21(1) — MZN',
)}
{field(
'assessmentReference',
'Fonte da liquidação anterior — PPC',
)}
{field(
'previousWithholdings',
'Retenções na fonte anteriores — MZN',
)}
{field(
'withholdingReference',
'Fonte das retenções anteriores — PPC',
)}
</>
)}
{m.pending.length ? (
<p role="status">{m.pending.join(' ')}</p>
) : (
<>
<p>
80% antes de arredondar:{' '}
{fractionPresentation(m.annualBeforeRounding!).decimal}{' '}
MZN. Total das três prestações arredondadas:{' '}
{fractionPresentation(m.total!).decimal} MZN.
</p>
<ul>
{m.schedule.map((s) => (
<li key={s.month}>
{s.month}:{' '}
{fractionPresentation(s.amount).decimal} MZN
</li>
))}
</ul>
</>
)}
<p>
Guardar o rascunho conserva esta preparação; calcular o fecho
anual inclui-a no relatório. Só comprovativos efectivos podem
alimentar as deduções PPC abaixo. Não executa pagamento,
dispensa, guia ou juros.
</p>
<details>
<summary>Fonte e limites</summary>
<p>{ppcObligationSource}</p>
</details>
</section>
);
}
@@ -0,0 +1,277 @@
import {
newPtMzRoyaltyPreparation,
type InternationalPosition,
type PtMzRoyaltyPreparation,
} from '@/lib/irpc-international-credit';
export function SimulationIrpcPtCdt({
position: p,
patch,
prefix,
}: {
position: InternationalPosition;
patch: (v: Partial<InternationalPosition>) => void;
prefix: string;
}) {
if (!p.treatyMethod || p.treatyMethod === 'documented') return null;
const change = (key: keyof PtMzRoyaltyPreparation, value: string) =>
patch({
treatyPreparation: {
...newPtMzRoyaltyPreparation(),
...p.treatyPreparation,
[key]: value,
},
});
const facts: [keyof PtMzRoyaltyPreparation, string][] = [
['residentMz', 'Beneficiário residente convencional em Moçambique'],
['beneficialOwner', 'Beneficiário efectivo do rendimento'],
[
'payerResidentPt',
'Devedor/sociedade distribuidora residente em Portugal',
],
[
'sourcePortugal',
'Fonte portuguesa, incluindo correspondência de quem suporta o encargo',
],
['noLinkedEstablishment', 'Sem EP/instalação fixa ligada em Portugal'],
];
if (p.treatyMethod !== 'ptMzDividend')
facts.push(['armLength', 'Sem excesso por relações especiais']);
if (p.treatyMethod === 'ptMzRoyalty')
facts.push(
[
'royaltyDefinition',
'Definição convencional de royalties documentada',
],
['nonDigital', 'Caso ordinário não digital'],
);
else
facts.push([
'capitalDefinition',
'Definição convencional de dividendos/juros documentada',
]);
if (p.treatyMethod === 'ptMzInterest')
facts.push([
'governmentInterestExemption',
'Juros abrangidos pela isenção governamental do art.11(3)',
]);
return (
<fieldset className="space-y-2 rounded border p-3">
<legend>Portugal — correspondência CDT</legend>
<p>
Confirme rendimento bruto em MZN, residência convencional,
natureza e período coberto pelos documentos. O limite
convencional do caso qualificado restringe o imposto estrangeiro
elegível; não altera a parcela líquida nacional nem prova
pagamento. O limite manual anterior fica conservado.
</p>
{(
[
['gross', 'Rendimento bruto — MZN'],
['from', 'Início do período documentado — AAAA-MM-DD'],
['to', 'Fim do período documentado — AAAA-MM-DD'],
[
'reference',
'Suporte da residência, contrato e condições convencionais',
],
] as const
).map(([key, label]) => (
<label key={key} className="block">
{label}
<input
aria-label={prefix + label}
value={p.treatyPreparation?.[key] ?? ''}
onChange={(e) => change(key, e.target.value)}
/>
</label>
))}
{p.treatyMethod === 'ptMzInterest' &&
p.treatyPreparation?.governmentInterestExemption === 'yes' && (
<fieldset className="space-y-2 rounded border p-3">
<legend>
Fundamento da isenção dos juros — art.11(3)
</legend>
<label className="block">
Fundamento
<select
aria-label={
prefix +
'Fundamento da isenção governamental'
}
value={
p.treatyPreparation.governmentBasis ??
(p.treatyPreparation.governmentPayer
? 'payer'
: '')
}
onChange={(e) =>
change('governmentBasis', e.target.value)
}
>
<option value="">Escolha o fundamento</option>
<option value="payer">
Devedor público português —11(3)a
</option>
<option value="agreement">
Instituição/organismo e financiamento sob
acordo PT–MZ —11(3)b
</option>
</select>
</label>
{p.treatyPreparation.governmentBasis === 'agreement' ? (
<>
<p>
O beneficiário é o sujeito corrente:{' '}
{p.subjectNuit}. Confirme a
instituição/organismo, o financiamento por
ela concedido e o acordo entre os governos
de Portugal e Moçambique. A isenção não é
inferida pelo nome; outros
beneficiários/regimes conservam
correspondência própria.
</p>
{(
[
[
'institutionRecipient',
'Beneficiário corrente qualificado como instituição/organismo11(3)b',
],
[
'financingGranted',
'Juros resultam de financiamento concedido por esse beneficiário',
],
[
'bilateralAgreement',
'Financiamento no âmbito de acordo concluído entre os governos PT–MZ',
],
] as const
).map(([key, label]) => (
<label key={key} className="block">
{label}
<select
aria-label={prefix + label}
value={
p.treatyPreparation?.[key] ?? ''
}
onChange={(e) =>
change(key, e.target.value)
}
>
<option value="">
Informação em falta
</option>
<option value="yes">
Sim — documentado
</option>
<option value="no">
Não — documentado
</option>
</select>
</label>
))}
{(
[
[
'institutionReference',
'Suporte da qualificação do beneficiário corrente',
],
[
'financingReference',
'Suporte do financiamento e ligação aos juros',
],
[
'agreementReference',
'Acordo PT–MZ e cláusula aplicável a este financiamento',
],
] as const
).map(([key, label]) => (
<label key={key} className="block">
{label}
<input
aria-label={prefix + label}
value={
p.treatyPreparation?.[key] ?? ''
}
onChange={(e) =>
change(key, e.target.value)
}
/>
</label>
))}
</>
) : (
<>
<p>
Qualifique o devedor público português com
suporte próprio. A isenção na fonte não
gera, no portal, reembolso do imposto pago.
</p>
<label className="block">
Devedor dos juros
<select
aria-label={
prefix + 'Devedor público português'
}
value={
p.treatyPreparation
.governmentPayer ?? ''
}
onChange={(e) =>
change(
'governmentPayer',
e.target.value,
)
}
>
<option value="">Por documentar</option>
<option value="state">
Estado português
</option>
<option value="subdivision">
Subdivisão política/administrativa
portuguesa
</option>
<option value="local">
Autarquia portuguesa
</option>
</select>
</label>
<label className="block">
Suporte da qualificação do devedor
<input
aria-label={
prefix +
'Suporte do devedor público'
}
value={
p.treatyPreparation
.governmentPayerReference ?? ''
}
onChange={(e) =>
change(
'governmentPayerReference',
e.target.value,
)
}
/>
</label>
</>
)}
</fieldset>
)}
{facts.map(([key, label]) => (
<label key={key} className="block">
{label}
<select
aria-label={prefix + label}
value={p.treatyPreparation?.[key] ?? ''}
onChange={(e) => change(key, e.target.value)}
>
<option value="">Informação em falta</option>
<option value="yes">Sim — documentado</option>
<option value="no">Não — documentado</option>
</select>
</label>
))}
</fieldset>
);
}
@@ -28,7 +28,7 @@ const states = [
['absent', 'Ausência declarada com suporte'],
['not_applicable', 'Tratamento não aplicável — justificar'],
] as const;
function QualificationFields({
export function QualificationFields({
label,
value,
onChange,
+5 -1
View File
@@ -10,8 +10,9 @@ const navigation = [
export function TempoFooter() {
const { t, language } = useTempoUi();
const { portalInstitutionalContactEmail } = usePage<{
const { portalInstitutionalContactEmail, portalOperator } = usePage<{
portalInstitutionalContactEmail?: string | null;
portalOperator?: string | null;
}>().props;
return (
<footer
@@ -27,6 +28,9 @@ export function TempoFooter() {
>
<PortalProductIdentity />
</Link>
<p className="text-sm text-slate-300">
{t('Operado por')} {portalOperator || 'Tempo, Lda.'}
</p>
<div className="flex items-start gap-4">
<MapPin
className="mt-1 size-[22px] shrink-0 text-[#ed1c24]"
+1 -1
View File
@@ -1,4 +1,4 @@
import AppLayoutTemplate from '@/layouts/app/app-sidebar-layout';
import AppLayoutTemplate from '@/layouts/app/app-header-layout';
import type { BreadcrumbItem } from '@/types';
export default function AppLayout({
+11 -8
View File
@@ -1,16 +1,19 @@
import { AppContent } from '@/components/app-content';
import { AppHeader } from '@/components/app-header';
import { AppShell } from '@/components/app-shell';
import { Breadcrumbs } from '@/components/breadcrumbs';
import type { AppLayoutProps } from '@/types';
/** Global navigation is owned by PortalNavigation for every page. */
export default function AppHeaderLayout({
children,
breadcrumbs,
breadcrumbs = [],
}: AppLayoutProps) {
return (
<AppShell variant="header">
<AppHeader breadcrumbs={breadcrumbs} />
<AppContent variant="header">{children}</AppContent>
</AppShell>
<div className="flex min-w-0 flex-col">
{breadcrumbs.length > 0 && (
<div className="border-b px-5 py-3">
<Breadcrumbs breadcrumbs={breadcrumbs} />
</div>
)}
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
</div>
);
}
+84 -93
View File
@@ -65,27 +65,18 @@ export default function PortalNavigation({
}: {
children: ReactNode;
}) {
return <TempoUiProvider><PageTranslator /><PortalNavigationContent>{children}</PortalNavigationContent></TempoUiProvider>;
return (
<TempoUiProvider>
<PageTranslator />
<PortalNavigationContent>{children}</PortalNavigationContent>
</TempoUiProvider>
);
}
function PortalNavigationContent({ children }: { children: ReactNode }) {
const { t } = useTempoUi();
const { url, props, component } = usePage();
const printable = component === 'land-assistance/print';
const portalShell =
['welcome', 'PortalInvestidor', 'management-access'].includes(
component,
) ||
[
'qa/',
'portal/',
'investment/',
'portfolio/',
'desk/',
'investor-path/',
'auth/',
'land-assistance/',
].some((prefix) => component.startsWith(prefix));
const path = url.split('?')[0];
const parent = Object.keys(names)
.filter((p) => p !== '/' && path.startsWith(`${p}/`))
@@ -111,92 +102,92 @@ function PortalNavigationContent({ children }: { children: ReactNode }) {
? 'Definições da conta'
: 'Detalhes');
return (
<NavigationContextProvider>
<MenuPlacement.Provider value={true}>
<div className="min-h-screen [&_button]:scroll-mb-24 [&_input]:scroll-mb-24 [&_select]:scroll-mb-24 [&_textarea]:scroll-mb-24">
<nav
aria-label={t('Atalho para o conteúdo')}
className={printable ? 'print:hidden' : undefined}
<NavigationContextProvider>
<MenuPlacement.Provider value={true}>
<div className="min-h-screen [&_button]:scroll-mb-24 [&_input]:scroll-mb-24 [&_select]:scroll-mb-24 [&_textarea]:scroll-mb-24">
<nav
aria-label={t('Atalho para o conteúdo')}
className={printable ? 'print:hidden' : undefined}
>
<a
href="#portal-content"
className="sr-only focus:not-sr-only focus:block focus:bg-white focus:p-4 focus:text-emerald-900"
>
<a
href="#portal-content"
className="sr-only focus:not-sr-only focus:block focus:bg-white focus:p-4 focus:text-emerald-900"
>
{t('Saltar para o conteúdo')}
</a>
{t('Saltar para o conteúdo')}
</a>
</nav>
{props.homologationReserved === true && (
<aside
aria-label="Ambiente de homologação"
className="border-b border-amber-200 bg-amber-50 px-5 py-2 text-sm text-amber-950 print:hidden"
>
Homologação reservada à revisão da Tempo. Consulte o
conteúdo público; entre na sua conta quando iniciar
uma operação.
</aside>
)}
{
<div className={printable ? 'hidden' : 'contents'}>
<PortalMenu primary />
</div>
}
{path !== '/' && (
<nav
aria-label={t('Caminho de navegação')}
className={`bg-background text-foreground flex flex-wrap items-center gap-2 border-b px-5 py-3 text-sm ${printable ? 'print:hidden' : ''}`}
>
<>
<Link href="/" className="underline">
{t('Início')}
</Link>
<span aria-hidden="true">/</span>
{parent && (
<>
<Link
href={searchTerritoryHref(
parent,
url,
)}
className="underline"
>
{t(names[parent])}
</Link>
<span aria-hidden="true">/</span>
</>
)}
<span aria-current="page">{t(title)}</span>
</>
</nav>
{props.homologationReserved === true && (
<aside aria-label="Ambiente de homologação" className="border-b border-amber-200 bg-amber-50 px-5 py-2 text-sm text-amber-950 print:hidden">
Homologação reservada à revisão da Tempo. Consulte o conteúdo público; entre na sua conta quando iniciar uma operação.
</aside>
)}
{portalShell && (
)}
<div id="portal-content" tabIndex={-1}>
{
<div
className={
printable ? 'print:hidden' : undefined
}
>
<PortalMenu primary />
<NavigationContextSummary />
</div>
)}
{path !== '/' && (
<nav
aria-label={t('Caminho de navegação')}
className={`bg-background text-foreground flex flex-wrap items-center gap-2 border-b px-5 py-3 text-sm ${printable ? 'print:hidden' : ''}`}
>
<>
<Link href="/" className="underline">
{t('Início')}
</Link>
<span aria-hidden="true">/</span>
{parent && (
<>
<Link
href={searchTerritoryHref(
parent,
url,
)}
className="underline"
>
{t(names[parent])}
</Link>
<span aria-hidden="true">/</span>
</>
)}
<span aria-current="page">{t(title)}</span>
</>
</nav>
)}
<div id="portal-content" tabIndex={-1}>
{portalShell && (
<div
className={
printable ? 'print:hidden' : undefined
}
>
<NavigationContextSummary />
</div>
)}
<SearchFeedback />
{children}
</div>
<aside
aria-label={t('Contacto de apoio ao investidor')}
className={`mx-auto flex max-w-6xl justify-center px-5 pt-4 ${printable ? 'print:hidden' : ''}`}
>
<a
href="/apoio"
className="inline-flex min-h-11 max-w-full items-center justify-center rounded-lg bg-[#ed1c24] px-4 py-2 text-center text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[#c9141b] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#ed1c24]"
>
{t('Contactar Apoio ao Investidor')}
</a>
</aside>
<div className={printable ? 'print:hidden' : undefined}>
<TempoFooter />
</div>
}
<SearchFeedback />
{children}
</div>
</MenuPlacement.Provider>
</NavigationContextProvider>
<aside
aria-label={t('Contacto de apoio ao investidor')}
className={`mx-auto flex max-w-6xl justify-center px-5 pt-4 ${printable ? 'print:hidden' : ''}`}
>
<a
href="/apoio"
className="inline-flex min-h-11 max-w-full items-center justify-center rounded-lg bg-[#ed1c24] px-4 py-2 text-center text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[#c9141b] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#ed1c24]"
>
{t('Contactar Apoio ao Investidor')}
</a>
</aside>
<div className={printable ? 'print:hidden' : undefined}>
<TempoFooter />
</div>
</div>
</MenuPlacement.Provider>
</NavigationContextProvider>
);
}
+310 -44
View File
@@ -1,6 +1,7 @@
import { router, useForm } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
import { createOperationKey } from './operation-key';
import { profileJson } from './profile-json';
export type EducationDeclaration = {
id: string;
@@ -29,6 +30,7 @@ export type EducationReviewProps = {
kind: 'demands' | 'capacities';
actorId: number;
today: string;
preparation?: ReviewPreparation;
references?: Record<string, string | null>;
};
type Values = { version_no: number; status: string; review_reason: string };
@@ -53,6 +55,27 @@ type Attempt = {
statusGeneration: number;
base: Snapshot;
};
export type ReviewPreparationSnapshot = {
values: Values;
generation: { status: number; review_reason: number };
base: Snapshot;
pending: Attempt | null;
};
export type ReviewPreparation = {
actor_id: number;
kind: 'demands' | 'capacities';
record_id: string;
version: number;
operation_key: string | null;
snapshot: ReviewPreparationSnapshot | null;
unavailable: boolean;
};
type PreparationSave = {
actor_id: number;
version: number;
operation_key: string;
snapshot: ReviewPreparationSnapshot;
};
const object = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const positive = (value: unknown): value is number =>
@@ -79,6 +102,7 @@ export function useEducationReview({
kind,
actorId,
references = {},
preparation,
}: EducationReviewProps) {
const form = useForm<Values>({
version_no: row.version_no,
@@ -91,6 +115,8 @@ export function useEducationReview({
pending = useRef<Attempt | null>(null),
alive = useRef(true);
const generation = useRef({ status: 0, review_reason: 0 });
const observed = useRef({ row, references });
observed.current = { row, references };
const [uncertain, setUncertain] = useState(false),
[conflict, setConflict] = useState(false),
[refreshing, setRefreshing] = useState(false),
@@ -102,6 +128,229 @@ export function useEducationReview({
alive.current = false;
};
}, []);
const [savedPreparation, setSavedPreparation] = useState(
preparation ?? null,
);
const [persisting, setPersisting] = useState(false);
const preparationVersion = useRef(preparation?.version ?? 0);
const preparationSave = useRef<PreparationSave | null>(null);
const saving = useRef(false);
const preparationUrl = `/management/education/${kind}/${row.id}/review-preparation`;
function privateSnapshot(): ReviewPreparationSnapshot {
return structuredClone({
values: live.current,
generation: generation.current,
base: base.current,
pending: pending.current,
});
}
function validPreparation(value: unknown): value is ReviewPreparation {
const nonnegative = (n: unknown): n is number =>
typeof n === 'number' && Number.isSafeInteger(n) && n >= 0;
const values = (v: unknown): v is Values =>
object(v) &&
positive(v.version_no) &&
typeof v.review_reason === 'string' &&
['reviewed', 'rejected'].includes(String(v.status));
const evidence = (v: unknown): v is Snapshot =>
object(v) &&
positive(v.version_no) &&
nonnegative(v.quantity) &&
[
'qualification',
'status',
'period_start',
'period_end',
'valid_until',
'source',
'reference_date',
'employer',
].every((k) => typeof v[k] === 'string') &&
object(v.references) &&
Object.values(v.references).every(
(v) => v === null || typeof v === 'string',
);
if (
!object(value) ||
value.actor_id !== actorId ||
value.kind !== kind ||
value.record_id !== row.id ||
!nonnegative(value.version) ||
!(
value.operation_key === null ||
typeof value.operation_key === 'string'
) ||
typeof value.unavailable !== 'boolean'
)
return false;
if (value.snapshot === null) return true;
const state = value.snapshot;
if (
!object(state) ||
!values(state.values) ||
!evidence(state.base) ||
state.values.version_no !== state.base.version_no ||
!object(state.generation) ||
!nonnegative(state.generation.status) ||
!nonnegative(state.generation.review_reason)
)
return false;
return (
state.pending === null ||
(object(state.pending) &&
values(state.pending.values) &&
object(state.pending.values) &&
'client_key' in state.pending.values &&
typeof state.pending.values.client_key === 'string' &&
evidence(state.pending.base) &&
state.pending.values.version_no ===
state.pending.base.version_no &&
nonnegative(state.pending.reasonGeneration) &&
nonnegative(state.pending.statusGeneration))
);
}
async function persistPreparation(attempt?: Attempt): Promise<boolean> {
if (
invalid ||
preparation === undefined ||
saving.current ||
(attempt !== undefined && form.processing)
)
return false;
saving.current = true;
setPersisting(true);
try {
preparationSave.current ??= {
actor_id: actorId,
version: preparationVersion.current,
operation_key: createOperationKey(),
snapshot: privateSnapshot(),
};
const sent = preparationSave.current;
const value = await profileJson(preparationUrl, sent);
if (!alive.current || preparationSave.current !== sent)
return false;
if (!validPreparation(value))
throw new Error(
'A resposta da preparação não pôde ser conferida.',
);
if (value.unavailable || !value.snapshot)
throw new Error(
'A intenção guardada não pôde ser recuperada com segurança.',
);
setSavedPreparation(value);
if (value.operation_key !== sent.operation_key) {
setNotice(
'Há uma preparação mais recente noutra janela. Foi preservada; recupere e compare antes de continuar.',
);
return false;
}
preparationVersion.current = value.version;
preparationSave.current = null;
if (
attempt &&
sent.snapshot.pending?.values.client_key !==
attempt.values.client_key
) {
setNotice(
'A preparação anterior foi recuperada. Guarde a intenção mais recente antes de enviar a revisão.',
);
return false;
}
setNotice(
JSON.stringify(sent.snapshot) ===
JSON.stringify(privateSnapshot())
? 'Preparação privada guardada. Ainda não constitui uma decisão de revisão.'
: 'Preparação guardada; as edições feitas entretanto continuam por guardar.',
);
return true;
} catch (error) {
if (alive.current)
setNotice(
(error instanceof Error
? error.message
: 'Não foi possível guardar a preparação.') +
' A decisão não foi enviada. Repita a mesma gravação para recuperar a resposta incerta.',
);
return false;
} finally {
saving.current = false;
if (alive.current) setPersisting(false);
}
}
async function restorePreparation() {
if (
invalid ||
saving.current ||
form.processing ||
preparation === undefined
)
return;
saving.current = true;
setPersisting(true);
const before = JSON.stringify(privateSnapshot());
try {
const value = await profileJson(
`${preparationUrl}?actor_id=${actorId}`,
{},
'GET',
);
if (!alive.current) return;
if (!validPreparation(value))
throw new Error(
'A preparação guardada não pôde ser conferida.',
);
setSavedPreparation(value);
if (value.unavailable || !value.snapshot) {
setNotice(
value.unavailable
? 'A preparação guardada está indisponível e foi preservada.'
: 'Ainda não existe preparação guardada para esta conta e declaração.',
);
return;
}
if (before !== JSON.stringify(privateSnapshot())) {
setNotice(
'Conserve as novas edições: a preparação recuperada não as substituiu. Compare antes de recuperar novamente.',
);
return;
}
if (
(live.current.review_reason.trim() || pending.current) &&
!window.confirm(
'Substituir a preparação local pela versão privada guardada?',
)
)
return;
const state = structuredClone(value.snapshot);
preparationVersion.current = value.version;
preparationSave.current = null;
base.current = state.base;
generation.current = state.generation;
pending.current = state.pending;
live.current = state.values;
form.setData(state.values);
form.clearErrors();
setUncertain(state.pending !== null);
setConflict(false);
setAccessBlocked(false);
setNotice(
'Preparação recuperada. Confira a versão e a fonte actuais antes de enviar.',
);
if (state.pending)
acknowledge(observed.current.row, observed.current.references);
} catch (error) {
if (alive.current)
setNotice(
error instanceof Error
? error.message
: 'Não foi possível recuperar a preparação.',
);
} finally {
saving.current = false;
if (alive.current) setPersisting(false);
}
}
const invalid =
!positive(actorId) ||
row.review_context?.actor_id !== actorId ||
@@ -200,6 +449,7 @@ export function useEducationReview({
function submit() {
if (
invalid ||
saving.current ||
accessBlocked ||
refreshing ||
form.processing ||
@@ -226,53 +476,63 @@ export function useEducationReview({
const attempt = pending.current;
setUncertain(true);
setNotice('A aguardar confirmação da revisão.');
form.transform(() => attempt.values);
form.post(`/management/education/${kind}/${row.id}/review`, {
preserveScroll: true,
onSuccess: (page) => {
if (!alive.current || pending.current !== attempt) return;
const record = read(page.props),
labels = returnedReferences(page.props);
if (!record || !labels || !acknowledge(record, labels))
setNotice(
'A resposta não permitiu confirmar a revisão. Actualize a declaração ou recupere o mesmo envio.',
);
},
onError: () => {
if (!alive.current || pending.current !== attempt) return;
pending.current = null;
setUncertain(false);
setNotice(
'Reveja os campos assinalados. A decisão preparada foi conservada.',
);
},
onNetworkError: () => {
if (alive.current && pending.current === attempt)
setNotice(
'Sem confirmação da revisão. Recupere o mesmo envio antes de preparar outra decisão.',
);
},
onCancel: () => {
if (alive.current && pending.current === attempt)
setNotice(
'Envio interrompido sem confirmação. Recupere o mesmo envio.',
);
},
onHttpException: (response) => {
if (!alive.current || pending.current !== attempt) return;
if ([401, 403, 419].includes(response.status))
setAccessBlocked(true);
if (response.status === 422) {
const send = () => {
form.transform(() => attempt.values);
form.post(`/management/education/${kind}/${row.id}/review`, {
preserveScroll: true,
onSuccess: (page) => {
if (!alive.current || pending.current !== attempt) return;
const record = read(page.props),
labels = returnedReferences(page.props);
if (!record || !labels || !acknowledge(record, labels))
setNotice(
'A resposta não permitiu confirmar a revisão. Actualize a declaração ou recupere o mesmo envio.',
);
},
onError: () => {
if (!alive.current || pending.current !== attempt) return;
pending.current = null;
setUncertain(false);
}
if ([409, 422].includes(response.status)) setConflict(true);
setNotice(
'Não foi possível confirmar a revisão. Actualize a declaração e o acesso antes de continuar. A decisão preparada foi conservada.',
);
},
});
setNotice(
'Reveja os campos assinalados. A decisão preparada foi conservada.',
);
},
onNetworkError: () => {
if (alive.current && pending.current === attempt)
setNotice(
'Sem confirmação da revisão. Recupere o mesmo envio antes de preparar outra decisão.',
);
},
onCancel: () => {
if (alive.current && pending.current === attempt)
setNotice(
'Envio interrompido sem confirmação. Recupere o mesmo envio.',
);
},
onHttpException: (response) => {
if (!alive.current || pending.current !== attempt) return;
if ([401, 403, 419].includes(response.status))
setAccessBlocked(true);
if (response.status === 422) {
pending.current = null;
setUncertain(false);
}
if ([409, 422].includes(response.status)) setConflict(true);
setNotice(
'Não foi possível confirmar a revisão. Actualize a declaração e o acesso antes de continuar. A decisão preparada foi conservada.',
);
},
});
};
if (preparation !== undefined) {
setNotice('A conservar a intenção de revisão antes do envio.');
void persistPreparation(attempt).then((stored) => {
if (stored && alive.current && pending.current === attempt)
send();
});
} else send();
}
function refresh() {
if (refreshing || form.processing) return;
setRefreshing(true);
@@ -360,6 +620,11 @@ export function useEducationReview({
}, []);
return {
form,
persistPreparation: () => persistPreparation(),
restorePreparation,
preparationAvailable: preparation !== undefined,
savedPreparation,
persisting,
change,
submit,
refresh,
@@ -378,6 +643,7 @@ export function useEducationReview({
!refreshing &&
!form.processing,
blocked:
persisting ||
invalid ||
accessBlocked ||
refreshing ||
@@ -0,0 +1,75 @@
type PublicFunding = {
title: string;
kind: string;
summary: string;
institution_types?: string[];
institution_services?: string[];
products?: {
title: string;
instrument?: string | null;
availability?: string;
}[];
};
const descriptions: Record<string, string> = {
commercial_bank: 'Instituição de banca comercial.',
investment_bank: 'Instituição especializada em banca de investimento.',
bank: 'Instituição bancária.',
microbank: 'Instituição de microfinanças.',
cooperative: 'Cooperativa de crédito.',
savings: 'Organização de poupança e empréstimo.',
investment_manager:
'Sociedade especializada na gestão de fundos de investimento.',
investment_company: 'Sociedade de investimento.',
pension_manager: 'Entidade gestora de fundos de pensões.',
insurer: 'Instituição do sector segurador.',
reinsurer: 'Instituição especializada em resseguros.',
microinsurer: 'Instituição de microsseguros.',
broker: 'Entidade de corretagem de seguros.',
agent: 'Entidade de mediação de seguros.',
reinsurance_broker: 'Entidade de corretagem de resseguros.',
exchange_house: 'Instituição de serviços cambiais.',
electronic_money: 'Instituição de moeda electrónica.',
interbank: 'Entidade de serviços interbancários.',
supervisor: 'Entidade de supervisão do sistema financeiro.',
};
const services: Record<string, string> = {
equity: 'Capital e participação no investimento',
foreign_exchange: 'Câmbio',
guarantee: 'Garantias',
insurance: 'Seguros',
investment: 'Financiamento do investimento',
investment_management: 'Gestão de investimentos',
payments: 'Serviços de pagamento',
pension: 'Pensões',
working_capital: 'Financiamento de tesouraria',
};
export function fundingPublicPresentation(p: PublicFunding) {
const generic =
p.summary.trim() ===
'Instituição identificada no documento fornecido. Categoria, localização e contactos são informativos; produtos, disponibilidade e condições não documentados permanecem por confirmar.';
const description = generic
? (descriptions[p.institution_types?.[0] ?? ''] ??
'Entidade do sector financeiro.')
: p.summary
.replace(
/\s*Referência informativa, não concessão de financiamento\.?$/,
'',
)
.replace(/Público\/finalidade:/g, 'Soluções para')
.replace(/\.\./g, '.')
.trim();
const products = (p.products ?? [])
.filter((x) => x.availability !== 'closed')
.map((x) => ({ title: x.title, detail: x.instrument?.trim() ?? '' }));
return {
description,
products,
services: [
...new Set(
(p.institution_services ?? [])
.map((s) => services[s])
.filter(Boolean),
),
],
};
}
+143
View File
@@ -0,0 +1,143 @@
import { createOperationKey } from './operation-key.ts';
export const swotDimensions = {
strength: 'Forças — internas ao objecto',
weakness: 'Fraquezas — internas ao objecto',
opportunity: 'Oportunidades — externas ao objecto',
threat: 'Ameaças — externas ao objecto',
} as const;
export type SwotRow = {
id: string;
dimension: keyof typeof swotDimensions;
text: string;
reference: string;
source_key: string | null;
date: string | null;
classification: 'documented_fact' | 'hypothesis' | 'unconfirmed';
};
export type SwotDraft = {
sources_snapshot?: unknown;
snapshot_fingerprint?: string;
object_key: string;
scope: string | null;
rows: SwotRow[];
source_fingerprint: string;
review?: { reason: string; at: string } | null;
actions: {
id: string;
title: string;
kind: string;
href: string;
review_version: number;
}[];
};
export type Workspace = {
historical?: boolean;
initial_scope?: boolean;
saved_scopes?: { key: string; label: string; href: string }[];
case: { id: string; title: string; closed: boolean };
opportunity: string | null;
opportunity_label?: string | null;
version: number;
apiex: {
note: string | null;
additional_documents: string[];
review: { reason: string; at: string; fingerprint: string } | null;
exports: { id: string; at: string; context_revision: number }[];
external: {
id: string;
kind: string;
date: string;
reference: string;
proof_document: string;
related_id?: string;
export_id: string;
outcome?: string;
}[];
};
swot: SwotDraft;
api_state: string;
api_issues: string[];
api_fingerprint: string;
swot_fingerprint: string;
swot_review_required: boolean;
can_write: boolean;
can_review: boolean;
links: Record<string, string>;
objects: { key: string; label: string; kind: string }[];
sources: {
key: string;
label: string;
consulted?: unknown;
current?: unknown;
notice?: string;
}[];
reused?: Record<string, unknown>;
context: { revision: number; fields: Record<string, unknown> };
assessment: {
requirements: {
key: string;
label: string;
state: string;
required: boolean;
available: boolean;
reason: string;
document_id: string | null;
source: { reference: string; url: string };
}[];
};
documents: {
id: string;
document_id: string;
file_name: string;
available: boolean;
version_no: number;
}[];
history_next?: number | null;
history: {
version: number;
action: string;
at: string;
actor: number;
operation_key?: string;
}[];
};
/** A lost response retains the exact intent; editing fields never mutates a retry. */
export class PreparationAttempt {
private value: Record<string, unknown> | null = null;
start(
version: number,
opportunity: string | null,
action: string,
data: Record<string, unknown>,
reason: string,
) {
if (this.value)
throw Error('Resolva a tentativa anterior antes de iniciar outra.');
this.value = JSON.parse(
JSON.stringify({
...data,
version,
opportunity,
action,
reason,
operation_key: createOperationKey(),
}),
) as Record<string, unknown>;
return this.current()!;
}
current() {
return this.value ? structuredClone(this.value) : null;
}
complete() {
this.value = null;
}
canAdopt(view: Pick<Workspace, 'version' | 'history'>) {
return (
!this.value ||
view.version > Number(this.value.version) ||
view.history.some(
(h) => h.operation_key === this.value?.operation_key,
)
);
}
}
+15
View File
@@ -0,0 +1,15 @@
/** Reveal an existing stage without navigation, remounting fields or changing context. */
export function revealPathStage(root: HTMLElement, hash: string, codes: string[]): boolean {
const code = hash.startsWith("#") ? hash.slice(1) : "";
if (!codes.includes(code)) return false;
const target = root.ownerDocument.getElementById(code);
if (!target || !root.contains(target)) return false;
let current: HTMLElement | null = target;
while (current && current !== root) {
if (current.tagName === "DETAILS") (current as HTMLDetailsElement).open = true;
current = current.parentElement;
}
target.scrollIntoView({ block: "start" });
target.focus({ preventScroll: true });
return true;
}
+529 -15
View File
@@ -1,3 +1,17 @@
import {pecCorrectionVersion} from './irpc-pec-correction.ts';
import {pecPlanVersion} from './irpc-pec-plan.ts';
import {validatePecObligation,pecObligationReport,pecObligationVersion,type PecObligation} from './irpc-pec-obligation.ts';
import { validatePpcObligation, ppcObligationReport, ppcObligationVersion, type PpcObligation } from './irpc-ppc-obligation.ts';
import {
newInvestmentCredit,
validateInvestmentCredit,
calculateInvestmentCredit,
deductInvestmentCredit,
investmentCreditSource,
investmentAssetIdentity,
investmentAssetClaimed,
type InvestmentCredit,
} from './irpc-investment-credit.ts';
import { inputText } from './input-text.ts';
import {
carryPlan,
@@ -23,6 +37,7 @@ import {
requireProofParcel,
parcelProjection,
overlappingProofParcels,
canonicalParcel,
type FiscalProofParcel,
} from './fiscal-proof-parcel.ts';
import {
@@ -32,6 +47,8 @@ import {
internationalPosition,
internationalDeduction,
internationalCreditSource,
creditMoney,
creditCompare,
newInternationalCredits,
type InternationalCredits,
type InternationalTaxProof,
@@ -93,7 +110,7 @@ type AnnualRecord = {
model: AnnualModel;
};
export type AnnualBook = {
version: 1;
version: 1 | 2 | 3;
subjectNuit: string;
year: string;
reference: string;
@@ -108,6 +125,9 @@ export type AnnualBook = {
internationalCredits?: InternationalCredits;
creditCarry?: CreditCarry;
economicCredits?: EconomicCredits;
investmentCredit?: InvestmentCredit;
ppcObligation?: PpcObligation;
pecObligation?: PecObligation;
result?: AnnualRecord;
history: AnnualRecord[];
};
@@ -131,9 +151,46 @@ export const annualSource = (b: AnnualBook) => {
(b.pecPayments ? ' ' + pecSource : '') +
(b.internationalCredits ? ' ' + internationalCreditSource : '') +
(b.creditCarry ? ' ' + carrySource : '') +
(b.economicCredits ? ' ' + economicSource : '')
(b.economicCredits ? ' ' + economicSource : '') +
(b.investmentCredit ? ' ' + investmentCreditSource : '')
);
};
export function prepareInvestmentCredit(
d: Record<string, unknown>,
b: AnnualBook,
): AnnualBook {
if (b.investmentCredit) return b;
const next = prepareInternationalBook(b),
c = newInvestmentCredit(),
a = annualAssessment(d);
c.subjectNuit = b.subjectNuit;
c.year = b.year;
c.assessmentKey = b.assessmentKey;
c.qualification.from = a.period?.from ?? '';
c.qualification.to = a.period?.to ?? '';
return { ...next, version: 2, investmentCredit: c };
}
export function prepareInvestmentCarry(
d: Record<string, unknown>,
b: AnnualBook,
): AnnualBook {
const next = prepareInvestmentCredit(d, b);
if (next.investmentCredit!.carryForward) return next;
return {
...next,
version: 3,
investmentCredit: {
...next.investmentCredit!,
version: 2,
carryForward: {
reference: '',
useReconciled: '',
currentState: '',
currentReference: '',
},
},
};
}
export function prepareEconomicBook(b: AnnualBook): AnnualBook {
if (b.economicCredits) return b;
const next = prepareInternationalBook(b),
@@ -240,6 +297,10 @@ function books(d: Record<string, unknown>): Record<string, AnnualBook> {
return m;
}
export function validateAnnual(b: AnnualBook) {
if(b?.pecObligation!==undefined)validatePecObligation(b.pecObligation);
if (b?.ppcObligation !== undefined) validatePpcObligation(b.ppcObligation);
if (b?.investmentCredit !== undefined)
validateInvestmentCredit(b.investmentCredit);
if (b?.economicCredits !== undefined) validateEconomic(b.economicCredits);
if (b?.creditCarry !== undefined) validateCreditCarry(b.creditCarry);
if (b?.pecPayments !== undefined) validatePec(b.pecPayments);
@@ -247,7 +308,10 @@ export function validateAnnual(b: AnnualBook) {
validateInternational(b.internationalCredits);
if (
!b ||
b.version !== 1 ||
![1, 2, 3].includes(b.version) ||
(b.version >= 2 && b.investmentCredit === undefined) ||
(b.investmentCredit !== undefined &&
b.version !== (b.investmentCredit.carryForward ? 3 : 2)) ||
[
'subjectNuit',
'year',
@@ -467,7 +531,7 @@ export function reuseAnnualWithholding(
},
};
}
function dependencies(d: Record<string, unknown>) {
function dependencies(d: Record<string, unknown>, includeInvestment = false) {
let normal: unknown, L5: unknown;
try {
const a = annualAssessment(d);
@@ -496,6 +560,9 @@ function dependencies(d: Record<string, unknown>) {
...(b.internationalCredits ? [b.internationalCredits] : []),
...(b.creditCarry ? [b.creditCarry] : []),
...(b.economicCredits ? [b.economicCredits] : []),
...(includeInvestment && b.investmentCredit
? [b.investmentCredit]
: []),
]),
];
}
@@ -505,7 +572,14 @@ function key(d: Record<string, unknown>, b: AnnualBook) {
annualSettlementVersion,
acquisitionScope(d),
input,
dependencies(d),
dependencies(d, !!b.investmentCredit),
...(b.ppcObligation ? [ppcObligationVersion] : []),
...(b.pecObligation ? [pecObligationVersion] : []),
...(b.pecObligation?.plan ? [pecPlanVersion] : []),
...(b.pecObligation?.correction ? [pecCorrectionVersion] : []),
...(b.investmentCredit && b.creditCarry?.lots.length
? ['CBF15-credit-carry-combination/1']
: []),
]);
}
export function annualCurrent(d: Record<string, unknown>, b: AnnualBook) {
@@ -809,6 +883,7 @@ export function calculateAnnual(d: Record<string, unknown>, b: AnnualBook) {
...internationalGroupPending(
b.internationalCredits,
!!b.economicCredits,
!!b.investmentCredit,
),
);
const countries = b.internationalCredits.positions.map(
@@ -1154,12 +1229,28 @@ export function calculateAnnual(d: Record<string, unknown>, b: AnnualBook) {
b.subjectNuit,
b.year,
b.assessmentKey,
{
ordinary:
assessment?.kind === 'normal' &&
assessment.current &&
assessment.key === b.assessmentKey,
taxableBase: assessment?.record?.model.taxableBase ?? null,
colecta: assessment?.colecta ?? null,
},
);
pending.push(
...m.pending.map((e) => 'País ' + p.country + ': ' + e),
);
return m;
}) ?? [];
const derivedNational = positions.flatMap((p) =>
p.nationalDerivation ? [p.nationalDerivation.income] : [],
);
const currentBase = assessment?.record?.model.taxableBase;
if (currentBase && cmp(addFractions(derivedNational), currentBase) > 0n)
pending.push(
'As parcelas estrangeiras com fracção derivada excedem em conjunto a matéria colectável corrente; reconcilie a afectação por país.',
);
const economicRows =
b.economicCredits?.rows.map((r) => {
const issues = claims
@@ -1177,6 +1268,13 @@ export function calculateAnnual(d: Record<string, unknown>, b: AnnualBook) {
b.assessmentKey,
colecta,
issues,
{
ordinary:
assessment?.kind === 'normal' &&
assessment.current &&
assessment.key === b.assessmentKey,
taxableBase: assessment?.record?.model.taxableBase ?? null,
},
);
pending.push(
...m.pending.map(
@@ -1186,11 +1284,133 @@ export function calculateAnnual(d: Record<string, unknown>, b: AnnualBook) {
return m;
}) ?? [];
if (b.economicCredits) {
if (
currentBase &&
cmp(
addFractions(
economicRows.flatMap((r) =>
r.nationalDerivation
? [r.nationalDerivation.included]
: [],
),
),
currentBase,
) > 0n
)
pending.push(
'As parcelas económicas com quotas derivadas excedem em conjunto a matéria colectável; reconcilie os lucros e acréscimos por origem.',
);
if (b.economicCredits.rows.length && assessment?.kind !== 'normal')
pending.push(
'Crédito económico delimitado à colecta normal corrente;efeitos L7 exigem correspondência própria.',
);
}
const investmentPreview =
b.investmentCredit && assessment?.record
? calculateInvestmentCredit(
b.investmentCredit,
assessment.book,
assessment.record.model,
assessment.key,
assessment.current,
)
: null;
if (b.investmentCredit && !investmentPreview)
pending.push('CréditoCBF15 exige colecta L6 recuperável.');
if (investmentPreview) pending.push(...investmentPreview.pending);
if (
b.investmentCredit?.qualification.state === 'known' &&
b.investmentCredit.qualification.eligible === 'yes' &&
investmentPreview?.nominal &&
cmp(investmentPreview.nominal, zero()) > 0n
) {
for (const [scope, other] of Object.entries(books(d))) {
if (
scope === acquisitionScope(d) ||
other.subjectNuit !== b.subjectNuit ||
other.year !== b.year ||
!other.investmentCredit
)
continue;
try {
validateInvestmentCredit(other.investmentCredit);
const c = other.investmentCredit;
if (
c.qualification.state !== 'known' ||
c.qualification.eligible !== 'yes'
)
continue;
if (!c.assets.some(investmentAssetClaimed)) continue;
for (const r of b.investmentCredit.assets.filter(
investmentAssetClaimed,
)) {
const id = investmentAssetIdentity(r),
others = c.assets.filter(
(x) =>
investmentAssetClaimed(x) &&
investmentAssetIdentity(x) === id,
);
if (!others.length) continue;
const proof = creditMoney(r.proofAmount);
if (
others.some(
(x) =>
creditCompare(
creditMoney(x.proofAmount),
proof,
) !== 0n,
)
)
pending.push(
'Montante documentalCBF15 contraditório entre mapas; reconcilie a prova.',
);
const own = b.investmentCredit.assets.filter(
(x) =>
investmentAssetClaimed(x) &&
investmentAssetIdentity(x) === id,
);
if (
creditCompare(
addFractions(
[...own, ...others].map((x) =>
creditMoney(x.amount),
),
),
proof,
) > 0n
)
pending.push(
'InvestimentoCBF15 atribuído entre mapas ultrapassa o documento efectivo; não duplicar capacidade.',
);
}
const sameProject =
canonicalParcel(c.projectReference) ===
canonicalParcel(b.investmentCredit.projectReference);
const sharedAsset = c.assets
.filter(investmentAssetClaimed)
.some((r) =>
b.investmentCredit!.assets.some((p) =>
overlappingProofParcels(
{ ...r, identity: investmentAssetIdentity(r) },
{ ...p, identity: investmentAssetIdentity(p) },
),
),
);
if (sameProject || sharedAsset)
pending.push(
'CBF15 já preparado noutro mapa da entidade; consolide projecto/documentos sem duplicar: ' +
scope,
);
} catch (e) {
pending.push(
'Outro mapaCBF15 não recuperável para conferir duplicação: ' +
scope +
'; ' +
String(e),
);
}
}
}
const economicPreview = b.economicCredits
? economicDeduction(economicRows, colecta, true)
: null;
@@ -1247,6 +1467,42 @@ export function calculateAnnual(d: Record<string, unknown>, b: AnnualBook) {
)
: null;
if (carry) pending.push(...carry.pending);
if (investmentPreview && !pending.length) {
const beforeInternational = economicPreview?.afterCredit ?? colecta;
const preview = b.internationalCredits
? internationalDeduction(
positions,
beforeInternational,
true,
carry?.international ?? undefined,
)
: null;
const remaining = carry
? subtractFractions(
preview?.afterCredit ?? beforeInternational!,
carry.priorInternational,
)
: (preview?.afterCredit ?? beforeInternational);
if (
investmentPreview.projectCeiling &&
(!remaining || cmp(investmentPreview.projectCeiling, remaining) > 0n)
)
pending.push(
'Quota residualCBF15 do projecto excede a colecta após créditos económicos/internacionais; reconcilie a atribuição antes de deduzir.',
);
if (!pending.length && carry?.pec && remaining) {
const afterInvestment = deductInvestmentCredit(
investmentPreview, remaining, true,
).afterCredit;
if (
afterInvestment &&
cmp(addFractions([carry.pec, carry.priorPec]), afterInvestment) > 0n
)
pending.push(
'As parcelas de PEC corrente e anterior excedem a colecta residual após CBF15; reconcilie a escolha documentada sem reduzir valores automaticamente.',
);
}
}
const ready = pending.length === 0 && colecta !== null,
economic = b.economicCredits
? economicDeduction(economicRows, colecta, ready)
@@ -1267,10 +1523,14 @@ export function calculateAnnual(d: Record<string, unknown>, b: AnnualBook) {
carry.priorInternational,
)
: (international?.afterCredit ?? afterEconomic);
const investment = investmentPreview
? deductInvestmentCredit(investmentPreview, creditCollecta, ready)
: null;
const afterInvestment = investment?.afterCredit ?? creditCollecta;
const pec = b.pecPayments
? pecDeduction(
pecAvailable,
creditCollecta,
afterInvestment,
ready,
carry?.pec ?? undefined,
)
@@ -1278,10 +1538,10 @@ export function calculateAnnual(d: Record<string, unknown>, b: AnnualBook) {
const afterPec =
ready && carry
? subtractFractions(
pec?.afterPec ?? creditCollecta!,
pec?.afterPec ?? afterInvestment!,
carry.priorPec,
)
: (pec?.afterPec ?? creditCollecta);
: (pec?.afterPec ?? afterInvestment);
const arithmeticBalance = ready
? subtractFractions(
subtractFractions(afterPec!, knownWithholdings),
@@ -1348,6 +1608,7 @@ export function calculateAnnual(d: Record<string, unknown>, b: AnnualBook) {
claims,
...(economic ? { economicCredit: economic } : {}),
internationalCredit: international,
...(investment ? { investmentCredit: investment } : {}),
pecDeduction: pec,
...(carry
? {
@@ -1374,7 +1635,7 @@ export function calculateAnnual(d: Record<string, unknown>, b: AnnualBook) {
limitations: [
...(b.economicCredits
? [
'Crédito económico ordinário:IRPC correspondente aos lucros,acréscimo53 já incluído e quota proporcional documentados. Sem cálculo automático da inclusão ou da exclusão40;liquidação64(3),associações64(4),benefícios e regras especiais separados. Sem reembolso ou transporte económico presumidos.',
'Crédito económico: IRPC correspondente, inclusão/acréscimo53 e quota proporcional reconciliados com a colecta corrente. Partilha64(3) separa rendimento elegível e excesso com natureza de mais-valia; associações64(4) têm modalidade/adaptação próprias. Preparação L6 é explícita; exclusão40, benefícios e restantes regras mantêm âmbito próprio. Sem reembolso ou transporte económico presumidos.',
]
: []),
...(b.creditCarry
@@ -1384,10 +1645,16 @@ export function calculateAnnual(d: Record<string, unknown>, b: AnnualBook) {
: []),
...(b.internationalCredits
? [
'Crédito internacional do próprio exercício:fracção nacional,convenção e montantes MZN são documentados;sem cálculo presumido,transporte ou consumo oficial. ' +
(positions.some((p) => p.nationalDerivation)
? 'Crédito internacional do próprio exercício:fracção nacional derivada no caso L6 ordinário com parcela líquida correspondida;CDT e montantes MZN documentados;sem repartição presumida de custos/perdas,transporte ou consumo oficial. '
: 'Crédito internacional do próprio exercício:fracção nacional,convenção e montantes MZN são documentados;sem cálculo presumido,transporte ou consumo oficial. ') +
(b.economicCredits
? 'Benefícios positivos ficam separados;crédito económico no detalhe próprio.'
: 'Créditos económicos/benefícios positivos ficam separados.'),
? b.investmentCredit
? 'CBF15 do primeiro exercício no detalhe próprio;restantes benefícios positivos separados;crédito económico no detalhe próprio.'
: 'Benefícios positivos ficam separados;crédito económico no detalhe próprio.'
: b.investmentCredit
? 'CBF15 do primeiro exercício no detalhe próprio;restantes créditos económicos/benefícios positivos separados.'
: 'Créditos económicos/benefícios positivos ficam separados.'),
]
: []),
b.economicCredits
@@ -1457,6 +1724,8 @@ export function annualReportRows(
indeterminate: 'Indeterminado',
}[m.status];
return [
...(m.input.pecObligation ? pecObligationReport(m.input.pecObligation,m.subjectNuit,m.year,m.input.assessment) : []),
...(m.input.ppcObligation ? ppcObligationReport(m.input.ppcObligation,m.subjectNuit,m.year,m.input.assessment) : []),
[
'IRPC — L8',
'Fecho anual — âmbito e origem',
@@ -1580,6 +1849,51 @@ export function annualReportRows(
r.input.grossUpIncluded +
' MZN;quota proporcional: ' +
show(r.nationalLimit) +
(r.nationalDerivation
? '; derivação L6: colecta ' +
show(r.nationalDerivation.colecta) +
' × lucros mais acréscimo53 ' +
show(
r.nationalDerivation.included,
) +
' / matéria colectável ' +
show(
r.nationalDerivation
.taxableBase,
) +
'; exacta ' +
r.nationalLimit?.numerator +
'/' +
r.nationalLimit?.denominator
: '') +
(r.liquidation
? '; partilha64(3): valor atribuído ' +
show(r.liquidation.attributed) +
'; entradas efectivas ' +
show(r.liquidation.paidInCapital) +
'; limite ' +
show(r.liquidation.ceiling) +
'; rendimento elegível ' +
show(r.liquidation.eligibleIncome) +
'; excesso com natureza de mais-valia ' +
show(
r.liquidation.excessCapitalGain,
) +
'; ' +
r.liquidation.version +
'; ' +
r.liquidation.reference
: '') +
(r.association
? '; ' +
r.association.version +
'; modalidade ' +
r.association.kind +
'; tributação efectiva ' +
r.association.effectivelyTaxed +
'; ' +
r.association.reference
: '') +
';elegível: ' +
show(r.eligible) +
';uso efectivo anterior: ' +
@@ -1598,7 +1912,12 @@ export function annualReportRows(
';53: ' +
r.input.grossUpReference +
';quota: ' +
r.input.nationalLimitReference +
(r.nationalDerivation
? r.nationalDerivation.version +
'; correspondência: ' +
r.nationalDerivation.reference +
'; limite manual anterior conservado, não utilizado'
: r.input.nationalLimitReference) +
';uso: ' +
r.input.previousUse.reference,
] as [string, string, string, string],
@@ -1630,10 +1949,41 @@ export function annualReportRows(
show(p.paid) +
'; fracção nacional: ' +
show(p.nationalLimit) +
(p.nationalDerivation
? '; derivação L6: colecta ' +
show(p.nationalDerivation.colecta) +
' × parcela líquida ' +
show(p.nationalDerivation.income) +
' / matéria colectável ' +
show(
p.nationalDerivation
.taxableBase,
) +
'; exacta ' +
p.nationalLimit?.numerator +
'/' +
p.nationalLimit?.denominator
: '; limite documentado') +
'; limite CDT: ' +
(p.input.treaty === 'no'
? 'Não aplicável documentado'
: show(p.treatyLimit)) +
(p.treatyDerivation
? '; ' +
p.treatyDerivation.version +
'; bruto ' +
show(p.treatyDerivation.gross) +
' × ' +
p.treatyDerivation.rate +
'%; período ' +
p.treatyDerivation.from +
' a ' +
p.treatyDerivation.to +
'; ' +
p.treatyDerivation.reference +
'; ' +
p.treatyDerivation.source
: '') +
'; elegível: ' +
show(p.eligible) +
'; utilização anterior: ' +
@@ -1646,7 +1996,15 @@ export function annualReportRows(
p.pending.join(' | '),
p.input.incomeReference +
'; ' +
p.input.nationalLimitReference +
(p.nationalDerivation
? 'Limite manual anterior conservado, não utilizado nesta derivação'
: p.input.nationalLimitReference) +
(p.nationalDerivation
? '; correspondência: ' +
p.nationalDerivation.reference +
'; ' +
p.nationalDerivation.version
: '') +
'; ' +
p.input.treatyReference +
'; ' +
@@ -1685,7 +2043,9 @@ export function annualReportRows(
m.input.internationalCredits?.benefits
.state ?? '',
) +
'.',
(m.investmentCredit
? '. Resumo anterior dos benefícios conservado, não reaplicado; CBF15 no detalhe próprio.'
: '.'),
m.internationalCredit.source +
'; ' +
(m.input.internationalCredits?.reference ?? '') +
@@ -1774,6 +2134,160 @@ export function annualReportRows(
] as [string, string, string, string],
]
: []),
...(m.investmentCredit
? [
...m.investmentCredit.rows.map(
(r) =>
[
'IRPC — L8',
'CBF15 — ' +
r.input.issuer +
' / ' +
r.input.document +
' / ' +
r.input.parcel +
'; data ' +
r.input.date,
'Valor declarado: ' +
r.input.amount +
' ' +
r.input.currency +
'; investimento elegível: ' +
show(r.value),
r.input.reference +
'; ' +
r.reason +
'; parcela: ' +
r.input.parcelReference +
'; ' +
r.pending.join(' | '),
] as [string, string, string, string],
),
[
'IRPC — L8',
'CBF15 — crédito fiscal por investimento',
'Investimento: ' +
show(m.investmentCredit.investment) +
'; taxa ' +
(m.investmentCredit.rate === null
? 'não aplicável'
: m.investmentCredit.rate + '%') +
'; nominal ' +
show(m.investmentCredit.nominal) +
'; uso anterior ' +
show(m.investmentCredit.priorUse) +
(m.investmentCredit.input.carryForward
? '; TransporteCBF15/1: nominal corrente ' +
show(
m.investmentCredit.currentNominal ??
null,
) +
'; nominal de origem anterior ' +
show(
m.investmentCredit.carryNominal ?? null,
) +
'; parcela expirada ' +
show(m.investmentCredit.expired ?? null) +
'; ' +
m.investmentCredit.input.carryForward
.reference +
'; investimento corrente ' +
m.investmentCredit.input.carryForward
.currentState +
': ' +
m.investmentCredit.input.carryForward
.currentReference
: '') +
'; disponível ' +
show(m.investmentCredit.available) +
'; dedução ' +
show(m.investmentCredit.deducted) +
'; sem colecta neste exercício ' +
show(m.investmentCredit.unused) +
(m.investmentCredit.projectCeiling
? '; quota documental do projecto ' +
show(m.investmentCredit.projectCeiling)
: '') +
'; colecta após benefício ' +
show(m.investmentCredit.afterCredit),
m.investmentCredit.version +
'; ' +
m.investmentCredit.source +
(m.investmentCredit.window
? '; JanelaCBF15/1: exercícios ' +
m.investmentCredit.window.firstYear +
'–' +
m.investmentCredit.window.lastYear +
'; exercício ' +
m.investmentCredit.window.exercise +
'/5; ' +
(m.investmentCredit.window.status ===
'expired'
? 'prazo expirado'
: 'dentro do prazo')
: '') +
'; projecto ' +
m.investmentCredit.input.projectReference +
'; ' +
m.investmentCredit.input.qualification.reference +
'; período ' +
m.investmentCredit.input.qualification.from +
' a ' +
m.investmentCredit.input.qualification.to +
'; ' +
m.investmentCredit.input.qualification
.periodReference +
'; ' +
m.investmentCredit.input.locationReference +
'; ' +
'início ' +
m.investmentCredit.input.startDate +
'; ' +
m.investmentCredit.input.startReference +
'; reconhecimento ' +
m.investmentCredit.input.recognitionReference +
'; exclusividade ' +
(m.investmentCredit.input.soleProject === 'no'
? m.investmentCredit.input.projectQuota
?.reference
: m.investmentCredit.input
.soleProjectReference) +
'; outros benefícios: ' +
m.investmentCredit.input
.noOtherBenefitsReference +
(m.investmentCredit.input.carryForward
? '; origens: ' +
m.investmentCredit.input.assets
.filter(
(r) => r.originYear !== undefined,
)
.map(
(r) =>
r.issuer +
'/' +
r.document +
'/' +
r.parcel +
': exercício ' +
r.originYear +
'; NUIT ' +
r.originNuit +
'; projecto ' +
r.originProject +
', taxa ' +
r.originRate +
'%; ' +
r.originReference,
)
.join(' | ')
: '') +
'; uso anterior: ' +
m.investmentCredit.input.previousUse.reference +
'; ' +
m.investmentCredit.pending.join(' | '),
] as [string, string, string, string],
]
: []),
...(m.pecDeduction
? [
[
+154 -6
View File
@@ -51,6 +51,18 @@ export type EconomicPosition = {
grossUpReference: string;
nationalLimit: string;
nationalLimitReference: string;
association?: {
kind: '' | 'participation' | 'quota';
effectivelyTaxed: Choice;
reference: string;
};
liquidation?: {
attributed: string;
paidInCapital: string;
reference: string;
};
nationalMethod?: 'documented' | 'proportional';
nationalBasisReference?: string;
previousUse: CreditEvidence;
};
export type EconomicCredits = {
@@ -113,12 +125,37 @@ export function validateEconomic(c: EconomicCredits) {
throw Error(
'Crédito económico não recuperável; originais conservados.',
);
for (const r of c.rows)
if (
r?.liquidation !== undefined &&
(!r.liquidation ||
['attributed', 'paidInCapital', 'reference'].some(
(k) =>
typeof (
r.liquidation as unknown as Record<string, unknown>
)[k] !== 'string',
))
)
throw Error('Partilha não recuperável; originais conservados.');
for (const r of c.rows)
if (
r?.association !== undefined &&
(!r.association ||
!['', 'participation', 'quota'].includes(r.association.kind) ||
!['', 'yes', 'no'].includes(r.association.effectivelyTaxed) ||
typeof r.association.reference !== 'string')
)
throw Error('Associação não recuperável; originais conservados.');
const keys = Object.keys(newEconomicPosition('')).filter(
(k) => k !== 'previousUse',
);
for (const r of c.rows)
if (
!r ||
(r.nationalMethod !== undefined &&
!['documented', 'proportional'].includes(r.nationalMethod)) ||
(r.nationalBasisReference !== undefined &&
typeof r.nationalBasisReference !== 'string') ||
keys.some(
(k) =>
typeof (r as unknown as Record<string, unknown>)[k] !==
@@ -201,6 +238,57 @@ export function economicGroupPending(c: EconomicCredits) {
);
return pending;
}
export function economicIncome(r: EconomicPosition) {
const income = creditMoney(r.income);
if (r.kind === 'ordinary_profit') return { income };
if (r.kind === 'association') {
const a = r.association;
if (!a?.kind || !a.reference.trim())
throw Error(
'Identifique associação em participação ou à quota e o suporte da adaptação64(4).',
);
if (a.kind === 'participation' && a.effectivelyTaxed !== 'yes')
throw Error(
'Associação em participação exige rendimentos distribuídos efectivamente tributados; desconhecido não significa sim.',
);
return {
income,
association: {
version: 'IRPC-economic-association/1',
...structuredClone(a),
},
};
}
if (r.kind !== 'liquidation')
throw Error('Natureza económica por identificar.');
if (!r.liquidation?.reference.trim())
throw Error(
'Partilha64(3): identifique o valor atribuído, entradas efectivas para capital e classificação documental do rendimento.',
);
const attributed = creditMoney(r.liquidation.attributed),
paidInCapital = creditMoney(r.liquidation.paidInCapital);
if (creditCompare(income, attributed) > 0n)
throw Error(
'Rendimento da partilha não pode exceder o valor atribuído.',
);
const difference = subtractFractions(attributed, paidInCapital);
const ceiling =
creditCompare(difference, zero()) > 0n ? difference : zero();
const eligibleIncome = minimum(income, ceiling);
return {
income: eligibleIncome,
liquidation: {
version: 'IRPC-economic-liquidation/1',
attributed,
paidInCapital,
capitalIncome: income,
ceiling,
eligibleIncome,
excessCapitalGain: subtractFractions(income, eligibleIncome),
reference: r.liquidation.reference,
},
};
}
export function economicPosition(
r: EconomicPosition,
subjectNuit: string,
@@ -208,6 +296,7 @@ export function economicPosition(
assessmentKey: string,
colecta: FiscalFraction | null,
proofIssues: string[],
basis?: { ordinary: boolean; taxableBase: FiscalFraction | null },
) {
const pending = [...proofIssues];
let nominal: FiscalFraction | null = null,
@@ -216,6 +305,15 @@ export function economicPosition(
previousUse: FiscalFraction | null = null,
available: FiscalFraction | null = null,
included: FiscalFraction | null = null;
let liquidation: ReturnType<typeof economicIncome>['liquidation'];
let association: ReturnType<typeof economicIncome>['association'];
let nationalDerivation: {
version: string;
included: FiscalFraction;
taxableBase: FiscalFraction;
colecta: FiscalFraction;
reference: string;
} | null = null;
let status: 'qualified' | 'outside_scope' | 'indeterminate' =
'indeterminate';
try {
@@ -230,10 +328,12 @@ export function economicPosition(
throw Error(
'Titular, entidade distribuidora, exercício2026 e MZN por corresponder.',
);
if (r.kind !== 'ordinary_profit') {
if (
!['ordinary_profit', 'liquidation', 'association'].includes(r.kind)
) {
status = r.kind ? 'outside_scope' : 'indeterminate';
throw Error(
'Lucros ordinários64(1–2) por identificar; liquidação64(3) e associações64(4) exigem correspondência específica, sem converter a parcela em crédito internacional.',
'Identifique lucros ordinários64(1–2), partilha64(3) ou associação64(4), com correspondência própria; não converter a parcela em crédito internacional.',
);
}
if (
@@ -272,8 +372,11 @@ export function economicPosition(
throw Error(
'Confirme a inclusão dos lucros ilíquidos de retenção e a correspondência com a colecta corrente; a origem pode ter mudado.',
);
const qualified = economicIncome(r);
liquidation = qualified.liquidation;
association = qualified.association;
const tax = economicTaxProof(r),
income = creditMoney(r.income);
income = qualified.income;
nominal = fraction(
BigInt(tax.value.numerator) * 3n,
BigInt(tax.value.denominator) * 5n,
@@ -293,9 +396,41 @@ export function economicPosition(
'Acréscimo53(1)a incluído na base deve corresponder ao crédito nominal60%; reconcilie L6 sem alterar a colecta ou o histórico automaticamente.',
);
included = addFractions([income, nominal]);
nationalLimit = creditLimit(r.nationalLimit);
if (r.nationalMethod === 'proportional') {
if (
!basis?.ordinary ||
!basis.taxableBase ||
creditCompare(basis.taxableBase, zero()) <= 0n ||
!colecta ||
creditCompare(colecta, zero()) < 0n ||
!r.nationalBasisReference?.trim()
)
throw Error(
'Quota económica derivada exige colecta L6 ordinária corrente, matéria colectável positiva e correspondência dos lucros mais acréscimo53 já incluídos.',
);
if (creditCompare(included, basis.taxableBase) > 0n)
throw Error(
'Lucros mais acréscimo53 excedem a matéria colectável; reconcilie a inclusão antes de derivar a quota.',
);
nationalLimit = fraction(
BigInt(colecta.numerator) *
BigInt(included.numerator) *
BigInt(basis.taxableBase.denominator),
BigInt(colecta.denominator) *
BigInt(included.denominator) *
BigInt(basis.taxableBase.numerator),
);
nationalDerivation = {
version: 'IRPC-economic-quota/1',
included,
taxableBase: structuredClone(basis.taxableBase),
colecta: structuredClone(colecta),
reference: r.nationalBasisReference,
};
} else nationalLimit = creditLimit(r.nationalLimit);
if (
!r.nationalLimitReference.trim() ||
(r.nationalMethod !== 'proportional' &&
!r.nationalLimitReference.trim()) ||
!colecta ||
creditCompare(nationalLimit, colecta) > 0n ||
(creditCompare(included, zero()) === 0n &&
@@ -327,6 +462,9 @@ export function economicPosition(
id: r.id,
nominal,
nationalLimit,
...(nationalDerivation ? { nationalDerivation } : {}),
...(liquidation ? { liquidation } : {}),
...(association ? { association } : {}),
eligible,
previousUse,
available,
@@ -354,7 +492,17 @@ export function economicDeduction(
deducted,
afterCredit:
deducted && colecta ? subtractFractions(colecta, deducted) : null,
source: economicSource,
source:
economicSource +
(rows.some((r) => r.association)
? ' IRPC-economic-association/1:CIRPC64(4), associação em participação com tributação efectiva documentada ou associação à quota, com adaptação, inclusão L6 e imposto correspondente expressamente suportados.'
: '') +
(rows.some((r) => r.liquidation)
? ' IRPC-economic-liquidation/1:CIRPC64(3); rendimento de capitais documentado limitado à diferença positiva entre valor atribuído e entradas efectivas para capital. Excesso separado como mais-valia, sem crédito económico sobre essa parcela; inclusão L6 e imposto subjacente exigem correspondência expressa.'
: '') +
(rows.some((r) => r.nationalDerivation)
? ' IRPC-economic-quota/1:quota ordinária derivada da colecta e matéria colectável L6 com lucros mais acréscimo53 já correspondidos;sem alterar a base ou o acréscimo.'
: ''),
officialBalancesConsumed: false,
carryCreated: false,
};
+378 -5
View File
@@ -26,6 +26,46 @@ export type InternationalTaxProof = AnnualPpc & {
taxReference: string;
exerciseReference: string;
};
export type PtMzRoyaltyPreparation = {
governmentBasis?: '' | 'payer' | 'agreement';
institutionRecipient?: '' | 'yes' | 'no';
financingGranted?: '' | 'yes' | 'no';
bilateralAgreement?: '' | 'yes' | 'no';
institutionReference?: string;
financingReference?: string;
agreementReference?: string;
governmentPayer?: '' | 'state' | 'subdivision' | 'local';
governmentPayerReference?: string;
capitalDefinition?: '' | 'yes' | 'no';
governmentInterestExemption?: '' | 'yes' | 'no';
gross: string;
from: string;
to: string;
reference: string;
residentMz: '' | 'yes' | 'no';
beneficialOwner: '' | 'yes' | 'no';
sourcePortugal: '' | 'yes' | 'no';
payerResidentPt: '' | 'yes' | 'no';
noLinkedEstablishment: '' | 'yes' | 'no';
armLength: '' | 'yes' | 'no';
royaltyDefinition: '' | 'yes' | 'no';
nonDigital: '' | 'yes' | 'no';
};
export const newPtMzRoyaltyPreparation = (): PtMzRoyaltyPreparation => ({
gross: '',
from: '',
to: '',
reference: '',
residentMz: '',
beneficialOwner: '',
sourcePortugal: '',
payerResidentPt: '',
noLinkedEstablishment: '',
armLength: '',
royaltyDefinition: '',
nonDigital: '',
});
export type InternationalPosition = {
id: string;
country: string;
@@ -38,6 +78,14 @@ export type InternationalPosition = {
incomeReference: string;
nationalLimit: string;
nationalLimitReference: string;
nationalMethod?: 'documented' | 'proportional';
nationalBasisReference?: string;
treatyMethod?:
| 'documented'
| 'ptMzRoyalty'
| 'ptMzDividend'
| 'ptMzInterest';
treatyPreparation?: PtMzRoyaltyPreparation;
treaty: '' | 'yes' | 'no';
treatyLimit: string;
treatyReference: string;
@@ -149,6 +197,105 @@ export function validateInternational(c: InternationalCredits) {
) ||
!yesNo.includes(p.incomeIncluded) ||
!yesNo.includes(p.treaty) ||
(p.treatyMethod !== undefined &&
![
'documented',
'ptMzRoyalty',
'ptMzDividend',
'ptMzInterest',
].includes(p.treatyMethod)) ||
(p.treatyPreparation !== undefined &&
(!p.treatyPreparation ||
Object.keys(newPtMzRoyaltyPreparation()).some(
(k) =>
typeof (
p.treatyPreparation as unknown as Record<
string,
unknown
>
)[k] !== 'string',
) ||
[
'residentMz',
'beneficialOwner',
'sourcePortugal',
'payerResidentPt',
'noLinkedEstablishment',
'armLength',
'royaltyDefinition',
'nonDigital',
].some(
(k) =>
!yesNo.includes(
(
p.treatyPreparation as unknown as Record<
string,
string
>
)[k],
),
))) ||
(p.nationalMethod !== undefined &&
!['documented', 'proportional'].includes(p.nationalMethod)) ||
(p.nationalBasisReference !== undefined &&
typeof p.nationalBasisReference !== 'string') ||
(p.treatyPreparation &&
['capitalDefinition', 'governmentInterestExemption'].some(
(k) => {
const v = (
p.treatyPreparation as unknown as Record<
string,
unknown
>
)[k];
return (
v !== undefined &&
(typeof v !== 'string' || !yesNo.includes(v))
);
},
)) ||
(p.treatyPreparation?.governmentPayer !== undefined &&
!['', 'state', 'subdivision', 'local'].includes(
p.treatyPreparation.governmentPayer,
)) ||
(p.treatyPreparation?.governmentPayerReference !== undefined &&
typeof p.treatyPreparation.governmentPayerReference !==
'string') ||
(p.treatyPreparation?.governmentBasis !== undefined &&
!['', 'payer', 'agreement'].includes(
p.treatyPreparation.governmentBasis,
)) ||
(p.treatyPreparation &&
[
'institutionRecipient',
'financingGranted',
'bilateralAgreement',
].some((k) => {
const v = (
p.treatyPreparation as unknown as Record<
string,
unknown
>
)[k];
return (
v !== undefined &&
(typeof v !== 'string' || !yesNo.includes(v))
);
})) ||
(p.treatyPreparation &&
[
'institutionReference',
'financingReference',
'agreementReference',
].some((k) => {
const v = (
p.treatyPreparation as unknown as Record<
string,
unknown
>
)[k];
return v !== undefined && typeof v !== 'string';
})) ||
!evidenceValid(p.previousUse) ||
!Array.isArray(p.payments)
)
@@ -236,6 +383,7 @@ export function internationalProofValue(
export function internationalGroupPending(
c: InternationalCredits,
economicDetailed = false,
benefitsDetailed = false,
) {
const pending: string[] = [];
if (!c.state || !c.reference.trim())
@@ -254,7 +402,9 @@ export function internationalGroupPending(
...(economicDetailed
? []
: [['Dupla tributação económica', c.economic] as const]),
['Benefícios/créditos próprios', c.benefits] as const,
...(benefitsDetailed
? []
: [['Benefícios/créditos próprios', c.benefits] as const]),
]) {
try {
if (!e.state || !e.reference.trim())
@@ -271,6 +421,169 @@ export function internationalGroupPending(
}
return pending;
}
const validDate = (s: string) =>
/^20\d{2}-\d{2}-\d{2}$/.test(s) &&
Number.isFinite(Date.parse(s)) &&
new Date(s).toISOString().slice(0, 10) === s;
export function ptMzTreatyLimit(p: InternationalPosition) {
const q = p.treatyPreparation;
if (
p.treaty !== 'yes' ||
p.country !== 'PT' ||
p.qualificationCountry !== 'PT' ||
p.year !== '2026' ||
!q?.reference.trim()
)
throw Error(
'CDT PT–MZ12/23(3)a: royalties de fonte portuguesa de residente em Moçambique, exercício2026 e suporte por corresponder.',
);
for (const key of [
'residentMz',
'beneficialOwner',
'sourcePortugal',
'payerResidentPt',
'noLinkedEstablishment',
] as const)
if (q[key] !== 'yes')
throw Error(
'CDT PT–MZ royalties: condição por documentar — ' + key + '.',
);
if (p.treatyMethod !== 'ptMzDividend' && q.armLength !== 'yes')
throw Error(
'Excesso por relações especiais em juros/royalties por corresponder.',
);
if (
p.treatyMethod === 'ptMzRoyalty' &&
(q.royaltyDefinition !== 'yes' || q.nonDigital !== 'yes')
)
throw Error(
'Royalties ordinárias não digitais e definição convencional por documentar.',
);
if (p.treatyMethod !== 'ptMzRoyalty' && q.capitalDefinition !== 'yes')
throw Error(
'Definição convencional de dividendos/juros por documentar.',
);
let governmentExemption:
| { payer: 'state' | 'subdivision' | 'local'; reference: string }
| {
basis: 'agreement';
recipient: 'institution';
reference: string;
financingReference: string;
agreementReference: string;
}
| null = null;
if (p.treatyMethod === 'ptMzInterest') {
if (q.governmentInterestExemption === 'yes') {
if (q.governmentBasis === 'agreement') {
if (
q.institutionRecipient !== 'yes' ||
q.financingGranted !== 'yes' ||
q.bilateralAgreement !== 'yes' ||
!q.institutionReference?.trim() ||
!q.financingReference?.trim() ||
!q.agreementReference?.trim()
)
throw Error(
'Art11(3)b: instituição/organismo beneficiário corrente, financiamento por ele concedido e acordo entre os governos PT–MZ exigem correspondência documental própria.',
);
governmentExemption = {
basis: 'agreement',
recipient: 'institution',
reference: q.institutionReference,
financingReference: q.financingReference,
agreementReference: q.agreementReference,
};
} else if (
q.governmentBasis === undefined ||
q.governmentBasis === 'payer'
) {
if (!q.governmentPayer || !q.governmentPayerReference?.trim())
throw Error(
'Isenção11(3)a exige devedor Estado português/subdivisão/autarquia documentado; para11(3)b escolha e documente o fundamento próprio.',
);
governmentExemption = {
payer: q.governmentPayer,
reference: q.governmentPayerReference,
};
} else
throw Error(
'Escolha o fundamento da isenção; uma selecção limpa não reutiliza a decisão anterior.',
);
} else if (q.governmentInterestExemption !== 'no')
throw Error(
'Corresponda a aplicação ou não aplicação da isenção11(3); desconhecido não significa limite ordinário10%.',
);
}
if (
!validDate(q.from) ||
!validDate(q.to) ||
q.from > q.to ||
!p.payments.length ||
p.payments.some(
(r) => !validDate(r.date) || r.date < q.from || r.date > q.to,
)
)
throw Error(
'Comprovativos fora do período documentado da CDT/residência; não presumir cobertura.',
);
const gross = creditMoney(q.gross);
if (creditCompare(gross, creditMoney(p.incomeAmount)) < 0n)
throw Error(
'Rendimento bruto inferior à parcela líquida incluída; reconcilie custos/perdas e base.',
);
return {
version: governmentExemption
? 'basis' in governmentExemption
? 'IRPC-PT-MZ-agreement-interest/1'
: 'IRPC-PT-MZ-government-interest/1'
: p.treatyMethod === 'ptMzRoyalty'
? 'IRPC-PT-MZ-royalty-credit/1'
: 'IRPC-PT-MZ-capital-credit/1',
...(p.treatyMethod !== 'ptMzRoyalty' ? { nature: p.treatyMethod } : {}),
gross,
...(governmentExemption ? { governmentExemption } : {}),
rate: governmentExemption ? '0' : '10',
limit: governmentExemption
? zero()
: fraction(
BigInt(gross.numerator),
BigInt(gross.denominator) * 10n,
),
from: q.from,
to: q.to,
reference: governmentExemption
? q.reference +
('payer' in governmentExemption
? '; devedor ' +
governmentExemption.payer +
'; ' +
governmentExemption.reference
: '; instituição ' +
governmentExemption.reference +
'; financiamento ' +
governmentExemption.financingReference +
'; acordo PT–MZ ' +
governmentExemption.agreementReference)
: q.reference,
source: governmentExemption
? 'basis' in governmentExemption
? 'CDT PT–MZ, RAR36/1992 art11(3)b,PDF11, e23(3)a,PDF21; CIRPC65(2). Instituição/organismo beneficiário corrente, financiamento por ele concedido no âmbito de acordo entre governos PT–MZ documentados. Juros isentos na fonte, limite0; não gera crédito ou reembolso no portal de imposto pago em desconformidade. Outros beneficiários/regimes mantêm correspondência própria.'
: 'CDT PT–MZ, RAR36/1992 art11(3)a,PDF11, e23(3)a,PDF21; CIRPC65(2). Devedor Estado português/subdivisão/autarquia documentado. Juros isentos no Estado da fonte; limite convencional zero, sem crédito ou reembolso do imposto indevidamente pago pelo portal. Art11(3)b e demais situações permanecem separados.'
: p.treatyMethod === 'ptMzRoyalty'
? 'CDT PT–MZ, RAR36/1992 art12(2/4–6),PDF13–14 e23(3)a,PDF21; Protocolo RAR36/2009 arts1/7,PDF1–2; CIRPC65(2). Royalties ordinárias não digitais, sem EP/instalação fixa ligada nem excesso por relações especiais. Não confundir com retenção L5 paga por devedor moçambicano; sem isenção ou tax sparing presumidos.'
: 'CDT PT–MZ, RAR36/1992 art23(3)a,PDF21; CIRPC65(2). ' +
(p.treatyMethod === 'ptMzDividend'
? 'Dividendos10(2–4),PDF10; taxa10% actualizada pelo Protocolo RAR36/2009 art6,PDF2; IRPC/art2 actualizado pelo art1.'
: p.treatyMethod === 'ptMzInterest'
? 'Juros11(2–7),PDF11–12, excluída a isenção11(3); IRPC/art2 actualizado pelo Protocolo RAR36/2009 art1.'
: 'Royalties12(2/4–6),PDF13–14; definição actualizada pelo Protocolo RAR36/2009 art7,PDF2; IRPC/art2 actualizado pelo art1. Recorte ordinário não digital.') +
(p.treatyMethod === 'ptMzDividend'
? ' Sem EP/instalação fixa ligada.'
: ' Sem EP/instalação fixa ligada ou excesso convencional por relações especiais.') +
' Não confundir com retenção L5 de devedor moçambicano; sem isenção ou tax sparing presumidos.',
};
}
export function internationalPosition(
p: InternationalPosition,
paid: FiscalFraction,
@@ -278,6 +591,11 @@ export function internationalPosition(
subjectNuit: string,
year: string,
assessmentKey: string,
nationalBasis?: {
ordinary: boolean;
taxableBase: FiscalFraction | null;
colecta: FiscalFraction | null;
},
) {
const pending = [...proofIssues];
let nationalLimit: FiscalFraction | null = null,
@@ -286,6 +604,14 @@ export function internationalPosition(
eligible: FiscalFraction | null = null,
available: FiscalFraction | null = null,
aboveLimit: FiscalFraction | null = null;
let treatyDerivation: ReturnType<typeof ptMzTreatyLimit> | null = null;
let nationalDerivation: {
version: string;
income: FiscalFraction;
taxableBase: FiscalFraction;
colecta: FiscalFraction;
reference: string;
} | null = null;
try {
if (
!p.id.trim() ||
@@ -311,8 +637,46 @@ export function internationalPosition(
'Falta inclusão documentada do rendimento estrangeiro na matéria colectável.',
);
const income = creditMoney(p.incomeAmount);
nationalLimit = creditLimit(p.nationalLimit);
if (!p.nationalLimitReference.trim())
if (p.nationalMethod === 'proportional') {
const basis = nationalBasis;
if (
!basis?.ordinary ||
!basis.taxableBase ||
!basis.colecta ||
creditCompare(basis.taxableBase, zero()) <= 0n ||
creditCompare(basis.colecta, zero()) < 0n
)
throw Error(
'Derivação da fracção nacional exige matéria colectável positiva e colecta L6 ordinária corrente; efeitos L7 e casos especiais conservam correspondência própria.',
);
if (!p.nationalBasisReference?.trim())
throw Error(
'Identifique a correspondência da parcela líquida estrangeira na matéria colectável, incluindo custos/perdas/deduções aplicáveis; o portal não reparte esses valores por presunção.',
);
if (creditCompare(income, basis.taxableBase) > 0n)
throw Error(
'Parcela estrangeira excede a matéria colectável corrente; reconcilie custos, perdas e deduções antes de derivar a fracção.',
);
nationalLimit = fraction(
BigInt(basis.colecta.numerator) *
BigInt(income.numerator) *
BigInt(basis.taxableBase.denominator),
BigInt(basis.colecta.denominator) *
BigInt(income.denominator) *
BigInt(basis.taxableBase.numerator),
);
nationalDerivation = {
version: 'IRPC-national-fraction/1',
income,
taxableBase: structuredClone(basis.taxableBase),
colecta: structuredClone(basis.colecta),
reference: p.nationalBasisReference,
};
} else nationalLimit = creditLimit(p.nationalLimit);
if (
p.nationalMethod !== 'proportional' &&
!p.nationalLimitReference.trim()
)
throw Error(
'Fracção nacional exige suporte próprio; não é calculada nem presumida pelo portal.',
);
@@ -325,7 +689,10 @@ export function internationalPosition(
);
if (!p.treaty || !p.treatyReference.trim())
throw Error('Aplicabilidade da convenção e suporte em falta.');
if (p.treaty === 'yes') treatyLimit = creditLimit(p.treatyLimit);
if (p.treatyMethod && p.treatyMethod !== 'documented') {
treatyDerivation = ptMzTreatyLimit(p);
treatyLimit = treatyDerivation.limit;
} else if (p.treaty === 'yes') treatyLimit = creditLimit(p.treatyLimit);
else if (p.treatyLimit.trim())
throw Error('Ausência de convenção contradiz limite preenchido.');
if (!p.previousUse.state || !p.previousUse.reference.trim())
@@ -363,7 +730,9 @@ export function internationalPosition(
country: p.country,
paid,
nationalLimit,
...(nationalDerivation ? { nationalDerivation } : {}),
treatyLimit,
...(treatyDerivation ? { treatyDerivation } : {}),
previousUse,
eligible,
available,
@@ -403,7 +772,11 @@ export function internationalDeduction(
: null,
}
: {}),
source: internationalCreditSource,
source:
internationalCreditSource +
(positions.some((p) => p.nationalDerivation)
? ' Modo IRPC-national-fraction/1: no caso L6 ordinário corrente com parcela líquida documentada e correspondida, fracção = colecta antes de créditos × parcela líquida estrangeira / matéria colectável. Não efectua repartição de custos/perdas ou qualificação automática de CDT.'
: ''),
countryAllocationCreated: false,
carryCreated: false,
officialBalancesConsumed: false,
+641
View File
@@ -0,0 +1,641 @@
import {
newQualification,
qualifySpecialTreatment,
type Qualification,
} from './irpc-special.ts';
import type { NormalBook, NormalModel } from './irpc-normal.ts';
import {
creditMoney,
creditCompare,
creditLimit,
type CreditEvidence,
} from './irpc-international-credit.ts';
import {
canonicalParcel,
requireProofParcel,
overlappingProofParcels,
} from './fiscal-proof-parcel.ts';
import {
addFractions,
subtractFractions,
fraction,
type FiscalFraction,
} from './fiscal-rational.ts';
export const investmentCreditVersion = 'IRPC-CBF15/3';
export const investmentCreditSource =
'CBF,Lei4/2009,arts3/4/9/10/12/13/15; compilação oficial APIEX2025,PDF66–72,art15PDF71–72. Liquidação2026,regime normal residente,ano civil/MZN,colecta integral ou quota residual documentada do projecto. Janela de cinco exercícios desde o início aplicável15(4/7),incluindo o primeiro; activos efectivos de2026,com transporte apenas no detalhe preparado das origens históricas. Activo corpóreo novo afecto à exploração nacional,sem exclusões15(6). Cidade de Maputo5%,restantes províncias10%. Menor entre crédito disponível e colecta remanescente depois dos créditos económicos/internacionais,antesPEC/RF/PPC (RIRPC21). Sem criação automática de transporte,reembolso ou consumo de saldo oficial;outros exercícios,regimes e cumulações separados.';
type Choice = '' | 'yes' | 'no';
export type InvestmentAsset = {
originYear?: string;
originNuit?: string;
originProject?: string;
originReference?: string;
originRate?: string;
id: string;
issuer: string;
document: string;
parcel: string;
parcelReference: string;
date: string;
proofAmount: string;
amount: string;
currency: string;
realised: Choice;
newTangible: Choice;
domesticUse: Choice;
directProductive: Choice;
excluded15_6: Choice;
reference: string;
};
export const newInvestmentAsset = (id: string): InvestmentAsset => ({
id,
issuer: '',
document: '',
parcel: '',
parcelReference: '',
date: '',
proofAmount: '',
amount: '',
currency: 'MZN',
realised: '',
newTangible: '',
domesticUse: '',
directProductive: '',
excluded15_6: '',
reference: '',
});
export type InvestmentCredit = {
projectQuota?: { amount: string; reference: string };
version: 1 | 2;
carryForward?: {
reference: string;
useReconciled: Choice;
currentState: '' | 'known' | 'absent';
currentReference: string;
};
qualification: Qualification;
subjectNuit: string;
year: string;
assessmentKey: string;
projectReference: string;
location: '' | 'maputo_city' | 'other';
locationReference: string;
startKind: '' | 'new' | 'existing';
startDate: string;
startReference: string;
cbfRequirements: Choice;
recognition: '' | 'documented' | 'not_required';
recognitionReference: string;
genericOnly: Choice;
noOtherBenefits: Choice;
noOtherBenefitsReference: string;
soleProject: Choice;
soleProjectReference: string;
assets: InvestmentAsset[];
previousUse: CreditEvidence;
};
export const newInvestmentCredit = (): InvestmentCredit => ({
version: 1,
qualification: newQualification(),
subjectNuit: '',
year: '',
assessmentKey: '',
projectReference: '',
location: '',
locationReference: '',
startKind: '',
startDate: '',
startReference: '',
cbfRequirements: '',
recognition: '',
recognitionReference: '',
genericOnly: '',
noOtherBenefits: '',
noOtherBenefitsReference: '',
soleProject: '',
soleProjectReference: '',
assets: [],
previousUse: { state: '', amount: '', reference: '' },
});
export function validateInvestmentCredit(c: InvestmentCredit) {
const template = newInvestmentCredit();
if (
!c ||
![1, 2].includes(c.version) ||
Object.keys(template)
.filter(
(k) =>
![
'version',
'qualification',
'assets',
'previousUse',
].includes(k),
)
.some(
(k) =>
typeof (c as unknown as Record<string, unknown>)[k] !==
'string',
) ||
!c.qualification ||
!c.previousUse ||
!Array.isArray(c.assets) ||
new Set(c.assets.map((r) => r?.id)).size !== c.assets.length
)
throw Error('CréditoCBF15 não recuperável; original conservado.');
if (
c.projectQuota !== undefined &&
(!c.projectQuota ||
typeof c.projectQuota.amount !== 'string' ||
typeof c.projectQuota.reference !== 'string')
)
throw Error('QuotaCBF15 não recuperável; original conservado.');
if ((c.version === 2) !== (c.carryForward !== undefined))
throw Error(
'Formato do transporteCBF15 não recuperável; original conservado.',
);
if (
c.carryForward &&
(!c.carryForward ||
typeof c.carryForward.reference !== 'string' ||
typeof c.carryForward.currentReference !== 'string' ||
!['', 'known', 'absent'].includes(c.carryForward.currentState) ||
!['', 'yes', 'no'].includes(c.carryForward.useReconciled))
)
throw Error('TransporteCBF15 não recuperável; original conservado.');
const q = c.qualification;
if (
!['', 'known', 'absent', 'not_applicable'].includes(q.state) ||
!['', 'yes', 'no'].includes(q.eligible) ||
['from', 'to', 'reference', 'periodReference'].some(
(k) =>
typeof (q as unknown as Record<string, unknown>)[k] !==
'string',
) ||
!['', 'maputo_city', 'other'].includes(c.location) ||
!['', 'new', 'existing'].includes(c.startKind) ||
!['', 'documented', 'not_required'].includes(c.recognition) ||
[
'cbfRequirements',
'genericOnly',
'noOtherBenefits',
'soleProject',
].some(
(k) =>
!['', 'yes', 'no'].includes(
(c as unknown as Record<string, string>)[k],
),
) ||
!['', 'known', 'absent'].includes(c.previousUse.state) ||
typeof c.previousUse.amount !== 'string' ||
typeof c.previousUse.reference !== 'string'
)
throw Error('QualificaçãoCBF15 não recuperável; original conservado.');
for (const r of c.assets) {
if (
!r ||
[
'originYear',
'originReference',
'originRate',
'originNuit',
'originProject',
].some(
(k) =>
(r as unknown as Record<string, unknown>)[k] !==
undefined &&
(c.version !== 2 ||
typeof (r as unknown as Record<string, unknown>)[k] !==
'string'),
) ||
Object.keys(newInvestmentAsset('')).some(
(k) =>
typeof (r as unknown as Record<string, unknown>)[k] !==
'string',
) ||
[
'realised',
'newTangible',
'domesticUse',
'directProductive',
'excluded15_6',
].some(
(k) =>
!['', 'yes', 'no'].includes(
(r as unknown as Record<string, string>)[k],
),
)
)
throw Error(
'InvestimentoCBF15 não recuperável; original conservado.',
);
}
}
const zero = () => fraction(0n);
const minimum = (a: FiscalFraction, b: FiscalFraction) =>
creditCompare(a, b) < 0n ? a : b;
const iso = (s: string) =>
/^\d{4}-\d{2}-\d{2}$/.test(s) &&
Number.isFinite(Date.parse(s)) &&
new Date(s).toISOString().slice(0, 10) === s;
export const investmentAssetIdentity = (r: InvestmentAsset) =>
JSON.stringify([canonicalParcel(r.issuer), canonicalParcel(r.document)]);
export function calculateInvestmentCredit(
c: InvestmentCredit,
b: NormalBook,
m: NormalModel,
currentKey: string,
current: boolean,
) {
validateInvestmentCredit(c);
const decision = qualifySpecialTreatment(c.qualification, b, m),
pending = [...decision.pending];
if (c.noOtherBenefits !== 'yes' || !c.noOtherBenefitsReference.trim())
pending.push(
'Restantes benefícios próprios por reconciliar; o detalheCBF15 não os deduz nem ignora por presunção.',
);
let investment: FiscalFraction | null = null,
nominal: FiscalFraction | null = null,
priorUse: FiscalFraction | null = null,
available: FiscalFraction | null = null,
rate: number | null = null;
let currentNominal: FiscalFraction | undefined,
carryNominal: FiscalFraction | undefined,
expired: FiscalFraction | undefined;
let projectCeiling: FiscalFraction | undefined;
let window:
| {
firstYear: number;
lastYear: number;
currentYear: number;
exercise: number;
status: 'active' | 'expired';
}
| undefined;
const rows: {
input: InvestmentAsset;
value: FiscalFraction | null;
reason: string;
pending: string[];
}[] = [];
if (decision.status === 'calculated') {
try {
if (
!current ||
b.special ||
b.periodsFx ||
b.scope !== 'resident_general' ||
b.year !== '2026' ||
c.year !== b.year ||
c.subjectNuit !== b.base?.subjectNuit ||
!c.assessmentKey ||
c.assessmentKey !== currentKey ||
!m.colecta
)
throw Error(
'CBF15 exige colecta L6 normal corrente,residente/ano civil2026/MZN; outros âmbitos separados.',
);
if (
!c.projectReference.trim() ||
c.cbfRequirements !== 'yes' ||
!c.recognition ||
!c.recognitionReference.trim() ||
c.genericOnly !== 'yes' ||
c.noOtherBenefits !== 'yes' ||
!c.noOtherBenefitsReference.trim()
)
throw Error(
'CondiçõesCBF3/9/10,regime genérico13,reconhecimento/excepção documentada e ausência de outros benefícios por corresponder.',
);
if (c.soleProject === 'yes') {
if (!c.soleProjectReference.trim())
throw Error(
'Correspondência da colecta integral do projecto por documentar.',
);
} else if (c.soleProject === 'no') {
if (!c.projectQuota?.reference.trim())
throw Error(
'Quota residual da colecta do projecto após créditos económicos/internacionais exige suporte próprio.',
);
projectCeiling = creditLimit(c.projectQuota.amount);
if (creditCompare(projectCeiling, m.colecta) > 0n)
throw Error(
'Quota do projecto excede a colecta L6; reconcilie.',
);
} else
throw Error(
'Correspondência da colecta ao projecto por documentar.',
);
if (
!c.location ||
!c.locationReference.trim() ||
!c.startKind ||
!iso(c.startDate) ||
!c.startReference.trim()
)
throw Error(
'Local efectivo do investimento e início aplicável por documentar: início de exploração para projecto novo,procedimentos após aprovação para projecto em funcionamento.',
);
const firstYear = Number(c.startDate.slice(0, 4));
if (firstYear < 1 || firstYear > Number(b.year))
throw Error(
'Ano inicial inválido ou posterior à liquidação2026; não antecipar o crédito.',
);
window = {
firstYear,
lastYear: firstYear + 4,
currentYear: Number(b.year),
exercise: Number(b.year) - firstYear + 1,
status: Number(b.year) > firstYear + 4 ? 'expired' : 'active',
};
if (c.carryForward) {
const t = c.carryForward,
prior = c.assets.filter((r) => r.originYear !== undefined),
own = c.assets.filter((r) => r.originYear === undefined);
if (!t.reference.trim() || t.useReconciled !== 'yes')
throw Error(
'Transporte exige correspondência histórica e reconciliação do uso efectivo de todo o crédito, separado do histórico de simulações.',
);
if (
!t.currentReference.trim() ||
!t.currentState ||
(t.currentState === 'absent'
? own.length !== 0
: own.length === 0)
)
throw Error(
'Investimento corrente conhecido/ausente deve corresponder aos activos e ter suporte próprio; dados conservados.',
);
if (!prior.length)
throw Error(
'Transporte exige activo/documento e exercício de origem; não importar saldo agregado sem origem.',
);
}
if (!c.assets.length)
throw Error(
'Investimento conhecido exige activo/documento; zero não se presume.',
);
rate = c.location === 'maputo_city' ? 5 : 10;
} catch (e) {
pending.push(String(e));
}
for (const r of c.assets) {
const issues: string[] = [];
let value: FiscalFraction | null = null;
try {
const excluded =
r.realised === 'no' ||
r.newTangible === 'no' ||
r.domesticUse === 'no' ||
r.directProductive === 'no' ||
r.excluded15_6 === 'yes';
if (excluded && r.reference.trim()) {
rows.push({
input: structuredClone(r),
value: zero(),
reason: 'Condição negativa/exclusão15(5–6) documentada; valor conservado,crédito zero para este activo.',
pending: [],
});
continue;
}
requireProofParcel(r);
if (
r.currency !== 'MZN' ||
r.realised !== 'yes' ||
!r.id.trim() ||
!r.issuer.trim() ||
!r.document.trim() ||
!iso(r.date) ||
(r.originYear === undefined &&
!r.date.startsWith(b.year + '-')) ||
!r.reference.trim() ||
r.newTangible !== 'yes' ||
r.domesticUse !== 'yes' ||
r.directProductive !== 'yes' ||
r.excluded15_6 !== 'no'
)
throw Error(
'Activo/documento2026,corpóreo novo,afectação nacional produtiva e exclusões15(6) por corresponder.',
);
if (r.originYear !== undefined) {
const origin = Number(r.originYear),
first = Number(c.startDate.slice(0, 4));
if (
!c.carryForward ||
!/^\d{4}$/.test(r.originYear) ||
origin < first ||
origin >= Number(b.year) ||
origin > first + 4 ||
Number(r.date.slice(0, 4)) > origin ||
r.originNuit !== c.subjectNuit ||
!r.originProject?.trim() ||
canonicalParcel(r.originProject) !==
canonicalParcel(c.projectReference) ||
!r.originReference?.trim() ||
r.originRate !== String(rate)
)
throw Error(
'OrigemCBF15 anterior à liquidação, dentro do prazo original, taxa e qualificação histórica do mesmo projecto por corresponder; não renovar/revalorizar o crédito.',
);
}
if (c.startKind === 'existing' && r.date < c.startDate)
throw Error(
'Investimento anterior ao início documentado deste recorte; correspondência própria necessária.',
);
value = creditMoney(r.amount);
if (creditCompare(value, creditMoney(r.proofAmount)) > 0n)
throw Error(
'Investimento atribuído excede o documento efectivo.',
);
if (
c.assets.some(
(other) =>
other !== r &&
investmentAssetClaimed(other) &&
overlappingProofParcels(
{ ...r, identity: investmentAssetIdentity(r) },
{
...other,
identity: investmentAssetIdentity(other),
},
),
)
)
throw Error(
'Documento/parcela de investimento repetido; não duplicar activos por mudar id/data.',
);
} catch (e) {
issues.push(String(e));
value = null;
}
rows.push({
input: structuredClone(r),
value:
value &&
window?.status === 'expired' &&
r.originYear === undefined
? zero()
: value,
reason: issues.length
? issues.join(' ')
: r.originYear !== undefined
? 'Activo de origem histórica documentada; saldo depende do uso efectivo e prazo original.'
: window?.status === 'expired'
? 'Prazo15(4) terminado no quinto exercício; investimento2026 conservado,sem crédito.'
: 'Activo elegível documentado.',
pending: issues,
});
pending.push(...issues);
}
const pools = new Map<
string,
{ amount: FiscalFraction; proof: FiscalFraction }
>();
for (const row of rows) {
if (
!row.value ||
creditCompare(row.value, zero()) === 0n ||
row.pending.length
)
continue;
const id = investmentAssetIdentity(row.input),
proof = creditMoney(row.input.proofAmount),
existing = pools.get(id);
if (existing && creditCompare(existing.proof, proof) !== 0n)
pending.push(
'Montante total do mesmo documento difere entre parcelas; reconcilie a prova.',
);
const amount = addFractions([
existing?.amount ?? zero(),
row.value,
]);
pools.set(id, { amount, proof });
if (creditCompare(amount, proof) > 0n)
pending.push(
'Soma das parcelas elegíveis ultrapassa o documento efectivo; não duplicar investimento.',
);
}
if (!pending.length) {
investment = addFractions(
rows.flatMap((r) => (r.value ? [r.value] : [])),
);
nominal = fraction(
BigInt(investment.numerator) * BigInt(rate!),
BigInt(investment.denominator) * 100n,
);
if (c.carryForward) {
const value = (prior: boolean) => {
const total = addFractions(
rows
.filter(
(r) =>
(r.input.originYear !== undefined) ===
prior,
)
.flatMap((r) => (r.value ? [r.value] : [])),
);
return fraction(
BigInt(total.numerator) * BigInt(rate!),
BigInt(total.denominator) * 100n,
);
};
currentNominal = value(false);
carryNominal = value(true);
}
try {
if (!c.previousUse.state || !c.previousUse.reference.trim())
throw Error(
'Uso anterior efectivo do crédito por documentar; histórico de simulações não é uso fiscal.',
);
if (c.previousUse.state === 'absent') {
if (c.previousUse.amount.trim())
throw Error('Ausência de uso contradiz montante');
priorUse = zero();
} else priorUse = creditLimit(c.previousUse.amount);
if (creditCompare(priorUse, nominal) > 0n)
throw Error('Uso anterior excede o crédito nominal.');
available = subtractFractions(nominal, priorUse);
if (c.carryForward) {
expired = window?.status === 'expired' ? available : zero();
if (window?.status === 'expired') available = zero();
}
} catch (e) {
pending.push(String(e));
}
}
} else if (
['absent', 'not_applicable'].includes(decision.status) &&
!pending.length
) {
investment = nominal = priorUse = available = zero();
}
return {
version: investmentCreditVersion,
input: structuredClone(c),
decision,
rows,
investment,
...(projectCeiling === undefined ? {} : { projectCeiling }),
rate,
...(window ? { window } : {}),
nominal,
...(c.carryForward ? { currentNominal, carryNominal, expired } : {}),
priorUse,
available: pending.length ? null : available,
pending,
source:
(c.carryForward
? ' TransporteCBF15/1: activos de origem anterior identificados,qualificação/taxa histórica documentadas,uso efectivo agregado reconciliado,sem renovação do prazo15(4/7),consumo oficial ou reembolso automático. '
: '') +
(c.soleProject === 'no'
? investmentCreditSource.replace(
'colecta inteiramente do projecto documentada',
'quota residual documental da colecta do projecto após créditos económicos/internacionais',
) +
' QuotaCBF15-project/1: limite documental não superior ao saldo global corrente, com atribuição dos créditos anteriores ao projecto expressamente reconciliada; não é repartição automática de custos/perdas ou rendimentos.'
: investmentCreditSource),
};
}
export function deductInvestmentCredit(
model: ReturnType<typeof calculateInvestmentCredit>,
collecta: FiscalFraction | null,
ready: boolean,
) {
const deducted =
ready && collecta && model.available
? minimum(
model.available,
model.projectCeiling === undefined
? collecta
: minimum(collecta, model.projectCeiling),
)
: null;
return {
...model,
deducted,
afterCredit:
deducted && collecta ? subtractFractions(collecta, deducted) : null,
unused:
deducted && model.available
? subtractFractions(model.available, deducted)
: null,
officialBalancesConsumed: false,
carryCreated: false,
};
}
export function investmentAssetClaimed(r: InvestmentAsset) {
try {
return (
r.realised === 'yes' &&
r.newTangible === 'yes' &&
r.domesticUse === 'yes' &&
r.directProductive === 'yes' &&
r.excluded15_6 === 'no' &&
r.currency === 'MZN' &&
creditCompare(creditMoney(r.amount), zero()) > 0n
);
} catch {
return false;
}
}
+273
View File
@@ -0,0 +1,273 @@
import { canonicalParcel } from './fiscal-proof-parcel.ts';
import type { NormalBook } from './irpc-normal.ts';
import {
economicTaxProof,
economicDistributionIdentity,
economicIncome,
validateEconomic,
type EconomicPosition,
} from './irpc-economic-credit.ts';
import {
creditMoney,
creditLimit,
creditCompare,
} from './irpc-international-credit.ts';
import {
addFractions,
fractionPresentation,
subtractFractions,
fraction,
type FiscalFraction,
} from './fiscal-rational.ts';
export type EconomicInclusion = {
source: EconomicPosition;
profitAlreadyIncluded: string;
creditAlreadyIncluded: string;
reference: string;
};
export type EconomicInclusions = { version: 1; rows: EconomicInclusion[] };
export const economicInclusionVersion = 'IRPC-economic-inclusion/1';
export function validateEconomicInclusions(input: EconomicInclusions) {
if (
input?.version !== 1 ||
!Array.isArray(input.rows) ||
input.rows.some(
(r) =>
!r ||
typeof r.profitAlreadyIncluded !== 'string' ||
typeof r.creditAlreadyIncluded !== 'string' ||
typeof r.reference !== 'string',
)
)
throw Error('Inclusões económicas não recuperáveis.');
validateEconomic({
version: 1,
state: 'known',
reference: economicInclusionVersion,
rows: input.rows.map((r) => r.source),
});
}
export function economicInclusionMoney(value: FiscalFraction): string {
const presentation = fractionPresentation(value);
return presentation.approximate
? value.numerator + '/' + value.denominator
: presentation.decimal;
}
export function calculateEconomicInclusions(
input: EconomicInclusions,
subject: string,
year: string,
) {
validateEconomicInclusions(input);
const pending: string[] = [];
const effects: {
id: string;
topic: string;
amount: FiscalFraction | null;
effect: FiscalFraction | null;
reason: string;
}[] = [];
const seen = new Set<string>();
for (const row of input.rows) {
const r = row.source;
try {
if (
!r ||
!['ordinary_profit', 'liquidation', 'association'].includes(
r.kind,
) ||
!r.id.trim() ||
!/^\d{9}$/.test(r.payerNuit) ||
r.subjectNuit !== subject ||
r.year !== year ||
year !== '2026' ||
r.recipientResident !== 'yes' ||
r.payerResident !== 'yes' ||
r.payerIrpc !== 'yes' ||
r.payerExempt !== 'no' ||
r.article40 !== 'no' ||
!r.qualificationReference?.trim() ||
!r.article40Reference?.trim() ||
!row.reference?.trim() ||
!r.distributionDocument?.trim() ||
!r.distributionParcel?.trim() ||
!/^2026-\d{2}-\d{2}$/.test(r.distributionDate) ||
new Date(r.distributionDate).toISOString().slice(0, 10) !==
r.distributionDate
)
throw Error(
'Falta qualificação, origem, titular/exercício ou correspondência da inclusão; art40 e outros âmbitos separados.',
);
const identity =
r.distributionDocument.trim().toUpperCase() +
'|' +
r.distributionParcel.trim().toUpperCase();
const taxIdentity = JSON.stringify([
canonicalParcel(r.taxIssuer),
canonicalParcel(r.taxDocument),
canonicalParcel(r.taxParcel),
]);
const distributionIdentity = economicDistributionIdentity(r);
if (
seen.has(distributionIdentity) ||
seen.has('tax|' + taxIdentity)
)
throw Error(
'Origem/parcela económica repetida; consolide antes de corrigir a base.',
);
seen.add(distributionIdentity);
seen.add('tax|' + taxIdentity);
const qualification = economicIncome(r);
const tax = economicTaxProof(r).value;
const nominal = fraction(
BigInt(tax.numerator) * 3n,
BigInt(tax.denominator) * 5n,
);
if (
creditCompare(qualification.income, fraction(0n)) === 0n &&
creditCompare(nominal, fraction(0n)) > 0n
)
throw Error(
'Imposto subjacente positivo sem rendimento elegível para crédito económico.',
);
const specialReference = qualification.liquidation
? `${qualification.liquidation.version}; rendimento elegível ${economicInclusionMoney(qualification.liquidation.eligibleIncome)}; excesso com natureza de mais-valia ${economicInclusionMoney(qualification.liquidation.excessCapitalGain)} incluído no rendimento total a reconciliar. ${qualification.liquidation.reference}`
: qualification.association
? `${qualification.association.version}; ${qualification.association.kind}; ${qualification.association.reference}`
: '';
const targets = [creditMoney(r.income), nominal];
const included = [
creditMoney(row.profitAlreadyIncluded),
creditLimit(row.creditAlreadyIncluded),
];
if (
r.incomeIncluded === 'yes' &&
creditCompare(included[0], targets[0]) < 0n
)
throw Error(
'A origem L8 declara os lucros já incluídos; o montante anterior indicado contradiz essa declaração. Compare e corrija a origem antes de acrescentar novamente.',
);
if (
r.grossUpIncluded.trim() &&
creditCompare(creditLimit(r.grossUpIncluded), included[1]) !==
0n
)
throw Error(
'O crédito já acrescido indicado não corresponde à origem L8; reconcilie a diferença antes de alterar a base.',
);
targets.forEach((target, index) => {
const effect = subtractFractions(target, included[index]);
economicInclusionMoney(effect);
effects.push({
id: identity + ':' + index,
topic: 'incomeCorrections',
amount: fraction(
BigInt(effect.numerator) < 0n
? -BigInt(effect.numerator)
: BigInt(effect.numerator),
BigInt(effect.denominator),
),
effect,
reason: `${economicInclusionVersion}${[target, included[index], effect].some((v) => (BigInt(v.numerator) * 100n) % BigInt(v.denominator) !== 0n) ? '; IRPC-economic-inclusion-exact/1: fracções conservadas, sem arredondamento fiscal' : ''}; CIRPC64(1–2)/53(1)a; ${index ? 'acréscimo nominal' : 'lucros'}: alvo ${economicInclusionMoney(target)}; já considerado ${economicInclusionMoney(included[index])}; diferença ${economicInclusionMoney(effect)}. ${row.reference}${specialReference ? ' ' + specialReference : ''}`,
});
});
} catch (e) {
pending.push(String(e));
}
}
return {
version: economicInclusionVersion,
input: structuredClone(input),
effects,
pending,
net: addFractions(effects.flatMap((e) => (e.effect ? [e.effect] : []))),
};
}
export function prepareEconomicInclusion(
book: NormalBook,
row: EconomicInclusion,
): NormalBook {
if (
book.special ||
book.periodsFx ||
(book.schedules && book.schedules.period.kind !== 'calendar') ||
book.scope !== 'resident_general' ||
book.year !== '2026' ||
!book.base ||
!book.profit
)
throw Error(
'Prepare primeiro o lucro e a base L6 ordinária do sujeito/ano civil2026/MZN; períodos especiais e conversões têm correspondência própria.',
);
const old = book.economicInclusions ?? { version: 1 as const, rows: [] };
const rows = old.rows.filter(
(p) =>
economicDistributionIdentity(p.source) !==
economicDistributionIdentity(row.source),
);
const next: EconomicInclusions = {
version: 1,
rows: [...rows, structuredClone(row)],
};
const previous = calculateEconomicInclusions(
old,
book.base.subjectNuit,
book.year,
),
prepared = calculateEconomicInclusions(
next,
book.base.subjectNuit,
book.year,
);
if (previous.pending.length || prepared.pending.length)
throw Error([...previous.pending, ...prepared.pending].join(' '));
const topic = book.profit.topics.incomeCorrections;
if (
!topic.reference.trim() ||
!['known', 'absent'].includes(topic.state) ||
(topic.state === 'absent' && topic.amount.trim())
)
throw Error(
'Reconcilie as outras correcções aos proveitos antes de substituir parcelas.',
);
const current =
topic.state === 'absent'
? fraction(0n)
: old.rows.length
? creditLimit(topic.amount)
: creditMoney(topic.amount);
const remaining = subtractFractions(
current,
addFractions(
previous.effects.flatMap((e) => (e.amount ? [e.amount] : [])),
),
);
if (BigInt(remaining.numerator) < 0n)
throw Error(
'Montante de correcções inferior às parcelas anteriores; compare antes de substituir.',
);
const amount = addFractions([
remaining,
addFractions(
prepared.effects.flatMap((e) => (e.amount ? [e.amount] : [])),
),
]);
return {
...book,
economicInclusions: next,
profit: {
...book.profit,
topics: {
...book.profit.topics,
incomeCorrections: {
state: 'known',
amount: economicInclusionMoney(amount),
reference: topic.reference.includes(row.reference)
? topic.reference
: topic.reference + '\n' + row.reference,
},
},
},
};
}
+35 -10
View File
@@ -1,3 +1,4 @@
import { creditLimit, creditCompare } from './irpc-international-credit.ts';
import type { NormalDepreciationModel } from './irpc-normal-depreciation.ts';
import type { NormalCostsModel } from './irpc-normal-costs.ts';
import type { SchedulesModel } from './irpc-normal-schedules.ts';
@@ -126,7 +127,13 @@ export type NormalProfitModel = {
source: string;
};
const zero = () => fraction(0n, 1n);
function money(s: string) {
function money(s: string, exact = false) {
if (exact) {
const v = creditLimit(s.startsWith('-') ? s.slice(1) : s);
return s.startsWith('-')
? fraction(-BigInt(v.numerator), BigInt(v.denominator))
: v;
}
if (!/^-?\d+(?:\.\d{1,2})?$/.test(s)) throw Error('Montante inválido');
const v = decimalFraction(s.startsWith('-') ? s.slice(1) : s);
return s.startsWith('-')
@@ -155,6 +162,17 @@ export function closeNormalProfit(
schedules?: SchedulesModel,
costs?: NormalCostsModel,
depreciation?: NormalDepreciationModel,
economic?: {
effects: {
id: string;
topic: string;
amount: FiscalFraction | null;
effect: FiscalFraction | null;
reason: string;
}[];
net: FiscalFraction;
pending: string[];
},
): NormalProfitModel {
validateNormalProfit(input);
const pending: string[] = [],
@@ -277,8 +295,11 @@ export function closeNormalProfit(
}
} else {
try {
value = money(t.amount);
if (units(value) !== 0n)
value = money(
t.amount,
key === 'incomeCorrections' && !!economic,
);
if (BigInt(value.numerator) !== 0n)
reason =
'Montante conhecido,mas tratamento específico ainda por corresponder;conservado sem aplicação presumida.';
else
@@ -291,24 +312,26 @@ export function closeNormalProfit(
const mapped = [
...(schedules?.effects ?? []),
...(costs?.effects ?? []),
...(economic?.effects ?? []),
].filter((e) => e.topic === key);
const mappedAmount = addFractions(
mapped.flatMap((e) => (e.amount ? [e.amount] : [])),
);
const covered =
(schedules || costs) &&
(schedules || costs || economic) &&
mapped.length > 0 &&
mapped.every((e) => e.effect !== null) &&
value !== null &&
t.state === 'known' &&
units(
addFractions(
mapped.flatMap((e) => (e.amount ? [e.amount] : [])),
),
) === units(value);
(economic && key === 'incomeCorrections'
? creditCompare(mappedAmount, value) === 0n
: units(mappedAmount) === units(value));
if (covered)
reason =
'Montante reconciliado com as regras do mapa complementar; efeito fiscal discriminado por linha.';
if (
value === null ||
(units(value) !== 0n && !covered) ||
(BigInt(value.numerator) !== 0n && !covered) ||
(t.state === 'absent' && !!t.amount.trim()) ||
(mapped.length > 0 && !covered)
)
@@ -324,6 +347,7 @@ export function closeNormalProfit(
}
if (schedules) pending.push(...schedules.pending);
if (costs) pending.push(...costs.pending);
if (economic) pending.push(...economic.pending);
if (depreciation && !schedules) pending.push(...depreciation.pending);
return {
input: structuredClone(input),
@@ -335,6 +359,7 @@ export function closeNormalProfit(
? addFractions([
accounting,
additions,
...(economic ? [economic.net] : []),
...(schedules ? [schedules.net] : []),
...(costs ? [costs.net] : []),
...(depreciation && !schedules && depreciation.adjustment
+41
View File
@@ -1,4 +1,9 @@
import { inputText } from './input-text.ts';
import {
calculateEconomicInclusions,
validateEconomicInclusions,
type EconomicInclusions,
} from './irpc-normal-economic.ts';
import { irpcSpecialCombinationsVersion } from './irpc-special-combinations.ts';
import {
irpcSpecialVersion,
@@ -104,6 +109,7 @@ export type NormalAdjustment = {
reference: string;
};
export type NormalBook = {
economicInclusions?: EconomicInclusions;
version: 1;
year: string;
mappingReference: string;
@@ -123,6 +129,7 @@ export type NormalBook = {
history?: NormalRecord[];
};
export type NormalModel = {
economicInclusions?: ReturnType<typeof calculateEconomicInclusions>;
special?: SpecialModel;
formPreparation?: NormalFormPreparationModel;
periodsFx?: NormalPeriodFxModel;
@@ -213,6 +220,8 @@ function store(d: Record<string, unknown>) {
return s;
}
export function validateNormal(b: NormalBook) {
if (b?.economicInclusions !== undefined)
validateEconomicInclusions(b.economicInclusions);
if (b?.special !== undefined) validateSpecial(b.special);
if (b?.forms !== undefined) validateNormalFormPreparation(b.forms);
if (b?.periodsFx !== undefined) validateNormalPeriodFx(b.periodsFx);
@@ -333,6 +342,7 @@ function key(d: Record<string, unknown>, b: NormalBook) {
b.scopeReference,
b.rows,
...(b.profit ? [b.profit] : []),
...(b.economicInclusions ? [b.economicInclusions] : []),
...(b.base ? [b.base] : []),
...(b.assessment ? [b.assessment] : []),
...(b.schedules ? [b.schedules] : []),
@@ -569,6 +579,23 @@ export function calculateNormal(
!!periodsFx?.short,
)
: undefined;
const economicInclusions = b.economicInclusions
? calculateEconomicInclusions(
b.economicInclusions,
b.base?.subjectNuit ?? '',
b.year,
)
: undefined;
if (
economicInclusions &&
(b.special ||
b.periodsFx ||
(b.schedules && b.schedules.period.kind !== 'calendar') ||
b.scope !== 'resident_general')
)
economicInclusions.pending.push(
'Inclusão económica automática delimitada à base ordinária residente/ano civil2026/MZN; este âmbito exige correspondência própria.',
);
const profit = b.profit
? closeNormalProfit(
b.profit,
@@ -580,6 +607,7 @@ export function calculateNormal(
schedules,
costs,
depreciation,
economicInclusions,
)
: undefined;
if (profit && periodsFx)
@@ -677,6 +705,7 @@ export function calculateNormal(
? addFractions([accountingProfit, additions])
: null,
taxableProfit: profit?.taxableProfit ?? null,
...(economicInclusions ? { economicInclusions } : {}),
taxableBase: base?.taxableBase ?? null,
tax: null,
rows,
@@ -774,6 +803,18 @@ export function normalReportRows(
if (!r) return [];
const m = r.model;
return [
...(m.economicInclusions?.effects.map(
(e) =>
[
'IRPC normal — L6',
'Inclusão/correcção económica — ' + e.id,
'Parcela reconciliada: ' +
money(e.amount) +
'; efeito com sinal: ' +
money(e.effect),
e.reason,
] as [string, string, string, string],
) ?? []),
[
'IRPC normal — L6',
'Ajustamentos ao resultado financeiro',
+80
View File
@@ -0,0 +1,80 @@
export const pecCorrectionVersion = 'IRPC-PEC-turnover-correction/1';
export const pecCorrectionSource =
'RIRPC,Decreto9/2008,art29(3),BR16/I16-04-2008:volume de negócios determinado pelas vendas/serviços até ao final do exercício anterior,podendo ser rectificado no ano seguinte se distinto do usado no cálculo. Recorte:correcção documental do volume2025 na simulação2026;original,fundamento e diferença conservados. Não é declaração fiscal rectificativa submetida.';
export type PecCorrection = {
version: 1;
apply: '' | 'yes' | 'no';
originalTurnover: string;
revisedTurnover: string;
subjectNuit: string;
turnoverYear: string;
reference: string;
};
export const newPecCorrection = (
originalTurnover: string,
subjectNuit: string,
): PecCorrection => ({
version: 1,
apply: '',
originalTurnover,
revisedTurnover: '',
subjectNuit,
turnoverYear: '2025',
reference: '',
});
export function validatePecCorrection(c: PecCorrection) {
if (
!c ||
c.version !== 1 ||
!['', 'yes', 'no'].includes(c.apply) ||
[
'originalTurnover',
'revisedTurnover',
'subjectNuit',
'turnoverYear',
'reference',
].some(
(k) =>
typeof (c as unknown as Record<string, unknown>)[k] !==
'string',
)
)
throw Error('Rectificação PEC não recuperável;original preservado.');
}
export function correctedPecTurnover(
c: PecCorrection | undefined,
original: string,
subjectNuit: string,
previousYear: string,
) {
if (!c) return { value: original, pending: [] as string[], applied: false };
validatePecCorrection(c);
if (c.apply === 'no')
return { value: original, pending: [] as string[], applied: false };
const pending: string[] = [];
if (c.apply !== 'yes')
pending.push(
'PEC:escolha aplicar a rectificação ou conservar o volume original.',
);
if (
c.originalTurnover !== original ||
c.subjectNuit !== subjectNuit ||
c.turnoverYear !== previousYear ||
previousYear !== '2025'
)
pending.push(
'PEC:origem,titular ou exercício da rectificação divergem da base;reconfira sem sobrescrever os valores.',
);
if (
!/^\d{1,18}(\.\d{1,2})?$/.test(c.revisedTurnover) ||
!c.reference.trim()
)
pending.push(
'PEC:rectificação exige volume corrigido2025 em MZN e fonte/fundamento;valor desconhecido não equivale a zero.',
);
return {
value: pending.length ? '' : c.revisedTurnover,
pending,
applied: pending.length === 0,
};
}
+203
View File
@@ -0,0 +1,203 @@
import {validatePecCorrection,correctedPecTurnover,pecCorrectionSource,type PecCorrection} from './irpc-pec-correction.ts';
import {validatePecPlan,pecPlanReport,type PecPlan} from './irpc-pec-plan.ts';
import {
decimalFraction,
fraction,
subtractFractions,
fractionPresentation,
type FiscalFraction,
} from './fiscal-rational.ts';
export const pecObligationVersion = 'IRPC-PEC-obligation/1';
export const pecObligationSource =
'RIRPC,Decreto9/2008,art29(1–5),BR16/I16-04-2008,original SHA256 fc373ff5637edf75ec6a9595dec8763a8503d4ee786e3b7d8f1c742eae9d80ee.0,5% do volume de negócios anterior,limites30.000/100.000MZN,menos PPC efectivamente pagos no ano anterior.Junho/Agosto/Outubro;opção de pagamento único prevista,sem data ou repartição inventada. Ano de início excluído29(4);sem volume anterior exige último montante29(5). Recorte2026,residente geral comercial/industrial/agrícola,ano civil,MZN. Regimes/períodos especiais,rectificação29(3),base de anos anteriores29(5),guias/juros separados.';
export type PecObligation = {
version: 1;
plan?: PecPlan;
correction?: PecCorrection;
scope: '' | 'ordinary_resident' | 'other';
subjectNuit: string;
year: string;
activityStartYear: string;
activityReference: string;
previousYear: string;
previousTurnover: string;
turnoverReference: string;
previousPpcPaid: string;
ppcReference: string;
};
export const newPecObligation = (
subjectNuit = '',
year = '',
): PecObligation => ({
version: 1,
scope: '',
subjectNuit,
year,
activityStartYear: '',
activityReference: '',
previousYear: '',
previousTurnover: '',
turnoverReference: '',
previousPpcPaid: '',
ppcReference: '',
});
export function validatePecObligation(p: PecObligation) {
if(p?.correction!==undefined)validatePecCorrection(p.correction);
if(p?.plan!==undefined)validatePecPlan(p.plan);
if (
!p ||
p.version !== 1 ||
Object.keys(newPecObligation())
.filter((k) => k !== 'version')
.some(
(k) =>
typeof (p as unknown as Record<string, unknown>)[k] !==
'string',
) ||
!['', 'ordinary_resident', 'other'].includes(p.scope)
)
throw Error(
'Preparação da estimativa PEC não recuperável;original preservado.',
);
}
export function calculatePecObligation(
p: PecObligation,
subjectNuit: string,
year: string,
assessment: string,
) {
validatePecObligation(p);
const pending: string[] = [];
const turnover=correctedPecTurnover(p.correction,p.previousTurnover,p.subjectNuit,p.previousYear);
let percentage: FiscalFraction | null = null,
limited: FiscalFraction | null = null,
total: FiscalFraction | null = null,
notApplicable = false;
if (
p.scope !== 'ordinary_resident' ||
assessment !== 'normal' ||
year !== '2026' ||
p.year !== year ||
p.subjectNuit !== subjectNuit ||
!/^\d{9}$/.test(subjectNuit)
)
pending.push(
'PEC: confirmar titular2026,residente geral com actividade comercial/industrial/agrícola,ano civil e MZN;outros regimes/períodos separados.',
);
if (
!/^\d{4}$/.test(p.activityStartYear) ||
Number(p.activityStartYear) < 1 ||
Number(p.activityStartYear) > 2026 ||
!p.activityReference.trim()
)
pending.push(
'PEC: falta ano de início efectivo da actividade e suporte;constituição da empresa não substitui início.',
);
if (!pending.length && p.activityStartYear === '2026') {
notApplicable = true;
total = fraction(0n);
} else if (!pending.length) {
pending.push(...turnover.pending);
if (
p.previousYear !== '2025' ||
!p.turnoverReference.trim() ||
!p.ppcReference.trim()
)
pending.push(
'PEC: falta exercício anterior2025 e fontes do volume de negócios e PPC efectivamente pagos,mesmo quando zero.',
);
if (
![turnover.value, p.previousPpcPaid].every((v) =>
/^\d{1,18}(\.\d{1,2})?$/.test(v),
)
)
pending.push(
'PEC: valores anteriores desconhecidos/inválidos;zero deve ser explícito.',
);
if (!pending.length) {
const v = decimalFraction(turnover.value),
ppc = decimalFraction(p.previousPpcPaid);
if (BigInt(v.numerator) === 0n)
pending.push(
'PEC29(5):sem volume de negócios em2025,recuperar último montante dos anos anteriores;zero não determina automaticamente o mínimo.',
);
else {
percentage = fraction(
BigInt(v.numerator),
BigInt(v.denominator) * 200n,
);
const n = BigInt(percentage.numerator),
d = BigInt(percentage.denominator);
limited =
n < 30000n * d
? fraction(30000n)
: n > 100000n * d
? fraction(100000n)
: percentage;
const residual = subtractFractions(limited, ppc);
if (BigInt(residual.numerator) < 0n)
pending.push(
'PEC:PPC anteriores excedem o montante limitado;caso separado para reconciliação,não gera estimativa negativa ou reembolso.',
);
else total = residual;
}
}
}
return {
version: pecObligationVersion,
source: p.correction ? pecObligationSource.replace('rectificação29(3),','')+' '+pecCorrectionSource : pecObligationSource,
pending,
percentage,
limited,
total,
notApplicable,
turnoverCorrection: p.correction ? {original:p.correction.originalTurnover,revised:p.correction.revisedTurnover,applied:!notApplicable&&turnover.applied,pending:turnover.pending,reference:p.correction.reference,source:pecCorrectionSource}:null,
months:
total && !notApplicable ? ['2026-06', '2026-08', '2026-10'] : [],
instalmentAmountsDetermined: false,
paymentExecuted: false,
annualDeductionCreated: false,
};
}
export function pecObligationReport(
p: PecObligation,
subjectNuit: string,
year: string,
assessment: string,
): Array<[string, string, string, string]> {
const m = calculatePecObligation(p, subjectNuit, year, assessment),
show = (v: FiscalFraction | null) =>
v ? fractionPresentation(v).decimal + ' MZN' : 'Indeterminado';
const originalEstimate=p.correction ? calculatePecObligation({...p,correction:undefined},subjectNuit,year,assessment).total:null;
const correctionEffect=originalEstimate&&m.total ? fractionPresentation(subtractFractions(m.total,originalEstimate)).decimal+' MZN':'por determinar';
return [
...(p.correction ? [['IRPC — L8','PEC — rectificação do volume','Original: '+p.correction.originalTurnover+' MZN;corrigido: '+p.correction.revisedTurnover+' MZN;'+(m.turnoverCorrection?.applied?'aplicada à estimativa.':'conservada sem aplicação à estimativa.')+' Estimativa original: '+show(originalEstimate)+';efeito na estimativa: '+correctionEffect+'. '+turnoverCorrectionNotice(m.turnoverCorrection),p.correction.reference+'; '+pecCorrectionSource] as [string,string,string,string]]:[]),
...(p.plan ? pecPlanReport(p.plan,m.total,m.notApplicable,year) : []),
[
'IRPC — L8',
'PEC — estimativa fiscal',
m.pending.length
? m.pending.join(' ')
: m.notApplicable
? 'Não aplicável no ano de início da actividade29(4);total0 MZN.'
: '0,5%: ' +
show(m.percentage) +
';limitado antes de PPC: ' +
show(m.limited) +
';total estimado: ' +
show(m.total) +
';meses: ' +
m.months.join(', ') +
(p.plan ? '. Plano explícito apresentado separadamente;prazo legal não validado.' : '. Montantes por prestação e opção de pagamento único não determinados neste recorte.') + ' Preparação não é pagamento nem dedução anual.',
p.activityReference +
'; ' +
p.turnoverReference +
'; ' +
p.ppcReference +
'; ' +
m.source,
],
];
}
function turnoverCorrectionNotice(c:{pending:string[]}|null){return c?.pending.join(' ')??'';}
+147
View File
@@ -0,0 +1,147 @@
import {
decimalFraction,
addFractions,
subtractFractions,
fractionPresentation,
type FiscalFraction,
} from './fiscal-rational.ts';
export const pecPlanVersion = 'IRPC-PEC-plan/1';
export const pecPlanSource =
'RIRPC29(1):mesesJunho/Agosto/Outubro e opção de pagamento único. Montantes repartidos e mês único são hipótese explícita do utilizador,sem regra de igualdade,arredondamento,guia ou prazo legal presumidos. Conferência de prazo da opção única permanece separada.';
export type PecPlan = {
version: 1;
mode: '' | 'three' | 'single';
reference: string;
singleMonth: string;
june: string;
august: string;
october: string;
};
export const newPecPlan = (): PecPlan => ({
version: 1,
mode: '',
reference: '',
singleMonth: '',
june: '',
august: '',
october: '',
});
export function validatePecPlan(p: PecPlan) {
if (
!p ||
p.version !== 1 ||
Object.keys(newPecPlan())
.filter((k) => k !== 'version')
.some(
(k) =>
typeof (p as unknown as Record<string, unknown>)[k] !==
'string',
) ||
!['', 'three', 'single'].includes(p.mode)
)
throw Error('Projecção PEC não recuperável;original preservado.');
}
export function calculatePecPlan(
p: PecPlan,
total: FiscalFraction | null,
notApplicable: boolean,
year: string,
) {
validatePecPlan(p);
const pending: string[] = [];
let rows: Array<{ month: string; amount: FiscalFraction }> = [];
if (!total)
pending.push(
'Projecção PEC:total estimado indeterminada;reconciliar a base antes de preparar os montantes.',
);
else if (notApplicable || BigInt(total.numerator) === 0n)
pending.push(
'Projecção PEC:sem estimativa positiva,não se distribuem saídas de caixa.',
);
if (year !== '2026')
pending.push('Projecção PEC:exercício fora do recorte2026.');
if (!p.reference.trim())
pending.push(
'Projecção PEC:identifique o fundamento da opção e dos montantes preparados.',
);
if (!['three', 'single'].includes(p.mode))
pending.push(
'Projecção PEC:escolha três prestações ou pagamento único.',
);
if (!pending.length && total) {
if (p.mode === 'three') {
const values = [p.june, p.august, p.october];
if (!values.every((v) => /^\d{1,18}(\.\d{1,2})?$/.test(v)))
pending.push(
'Projecção PEC:preencha os três montantes em MZN,com até dois decimais;sem preenchimento automático.',
);
else {
const amounts = values.map(decimalFraction);
if (amounts.some((v) => BigInt(v.numerator) <= 0n))
pending.push(
'Projecção PEC:três prestações exigem três montantes positivos.',
);
else if (
BigInt(
subtractFractions(addFractions(amounts), total)
.numerator,
) !== 0n
)
pending.push(
'Projecção PEC:a soma das três prestações difere do total preparado;sem arredondamento ou compensação silenciosos.',
);
else
rows = amounts.map((amount, i) => ({
month: ['2026-06', '2026-08', '2026-10'][i],
amount,
}));
}
} else if (!/^2026-(0[1-9]|1[0-2])$/.test(p.singleMonth))
pending.push(
'Projecção PEC:indique o mês previsto do pagamento único;isto não valida o prazo legal.',
);
else rows = [{ month: p.singleMonth, amount: total }];
}
return {
version: pecPlanVersion,
source: pecPlanSource,
mode: p.mode,
pending,
rows,
total,
allocationReconciled: pending.length === 0,
legalDeadlineValidated: false,
paymentExecuted: false,
annualDeductionCreated: false,
};
}
export function pecPlanReport(
p: PecPlan,
total: FiscalFraction | null,
notApplicable: boolean,
year: string,
): Array<[string, string, string, string]> {
const m = calculatePecPlan(p, total, notApplicable, year);
return [
[
'IRPC — L8',
'PEC — projecção de tesouraria',
m.pending.length
? m.pending.join(' ')
: (m.mode === 'single'
? 'Saída de caixa concentrada (hipótese): '
: 'Distribuição de caixa (hipótese): ') +
m.rows
.map(
(r) =>
r.month +
': ' +
fractionPresentation(r.amount).decimal +
' MZN',
)
.join('; ') +
'. Soma reconciliada;prazo legal não validado. Não é pagamento ou dedução.',
p.reference + '; ' + m.source,
],
];
}
+168
View File
@@ -0,0 +1,168 @@
import {
decimalFraction,
fraction,
subtractFractions,
fractionPresentation,
type FiscalFraction,
} from './fiscal-rational.ts';
export const ppcObligationVersion = 'IRPC-PPC-obligation/1';
export const ppcObligationSource =
'RIRPC, Decreto 9/2008, arts.27(1)(a) e28(1–3), BR16/I,16-04-2008,exemplar preservado SHA256 fc373ff5637edf75ec6a9595dec8763a8503d4ee786e3b7d8f1c742eae9d80ee: imposto liquidado no exercício anterior nos termos21(1), líquido das retenções na fonte;80%, dividido em três montantes iguais arredondados por excesso ao milhar de meticais. Maio,Julho,Setembro. Recorte2026,residente geral,actividade comercial/industrial/agrícola,ano civil e exercício anterior completo2025,MZN. Períodos especiais,anualização28(3),dispensas,juros e guias separados.';
export type PpcObligation = {
version: 1;
scope: '' | 'ordinary_resident' | 'other';
subjectNuit: string;
year: string;
previousYear: string;
previousFullCalendarYear: '' | 'yes' | 'no';
previousTax: string;
previousWithholdings: string;
assessmentReference: string;
withholdingReference: string;
};
export const newPpcObligation = (
subjectNuit = '',
year = '',
): PpcObligation => ({
version: 1,
scope: '',
subjectNuit,
year,
previousYear: '',
previousFullCalendarYear: '',
previousTax: '',
previousWithholdings: '',
assessmentReference: '',
withholdingReference: '',
});
export function validatePpcObligation(p: PpcObligation) {
if (
!p ||
p.version !== 1 ||
Object.keys(newPpcObligation())
.filter((k) => k !== 'version')
.some(
(k) =>
typeof (p as unknown as Record<string, unknown>)[k] !==
'string',
) ||
!['', 'ordinary_resident', 'other'].includes(p.scope) ||
!['', 'yes', 'no'].includes(p.previousFullCalendarYear)
)
throw Error('Preparação PPC não recuperável; original preservado.');
}
export function calculatePpcObligation(
p: PpcObligation,
subjectNuit: string,
year: string,
assessment: string,
) {
validatePpcObligation(p);
const pending: string[] = [];
let base: FiscalFraction | null = null,
annualBeforeRounding: FiscalFraction | null = null,
instalment: FiscalFraction | null = null;
if (
p.scope !== 'ordinary_resident' ||
assessment !== 'normal' ||
p.previousFullCalendarYear !== 'yes' ||
year !== '2026' ||
p.year !== year ||
p.previousYear !== '2025' ||
!/^\d{9}$/.test(subjectNuit) ||
p.subjectNuit !== subjectNuit
)
pending.push(
'PPC: confirmar titular,2026,residente geral com actividade comercial/industrial/agrícola e exercício civil completo2025. Outros períodos/regimes e anualização permanecem separados.',
);
if (!p.assessmentReference.trim() || !p.withholdingReference.trim())
pending.push(
'PPC: falta suporte do imposto liquidado anterior21(1) e das retenções na fonte, incluindo zero conhecido. Colecta projectada corrente não substitui liquidação anterior.',
);
if (
![p.previousTax, p.previousWithholdings].every((v) =>
/^\d{1,18}(\.\d{1,2})?$/.test(v),
)
)
pending.push(
'PPC: montantes anteriores em MZN desconhecidos ou inválidos; zero deve ser explícito.',
);
if (!pending.length) {
base = subtractFractions(
decimalFraction(p.previousTax),
decimalFraction(p.previousWithholdings),
);
if (BigInt(base.numerator) < 0n)
pending.push(
'PPC: retenções superiores ao imposto anterior; reconciliar o caso antes de determinar a obrigação.',
);
else {
annualBeforeRounding = fraction(
BigInt(base.numerator) * 4n,
BigInt(base.denominator) * 5n,
);
const n = BigInt(annualBeforeRounding.numerator),
d = BigInt(annualBeforeRounding.denominator) * 3n * 1000n;
instalment = fraction(((n + d - 1n) / d) * 1000n);
}
}
return {
version: ppcObligationVersion,
source: ppcObligationSource,
pending,
base,
annualBeforeRounding,
instalment,
total: instalment
? fraction(
BigInt(instalment.numerator) * 3n,
BigInt(instalment.denominator),
)
: null,
schedule: instalment
? ['2026-05', '2026-07', '2026-09'].map((month) => ({
month,
amount: instalment,
}))
: [],
paymentExecuted: false,
annualDeductionCreated: false,
};
}
export function ppcObligationReport(
p: PpcObligation,
subjectNuit: string,
year: string,
assessment: string,
): Array<[string, string, string, string]> {
const m = calculatePpcObligation(p, subjectNuit, year, assessment),
show = (v: FiscalFraction | null) =>
v ? fractionPresentation(v).decimal + ' MZN' : 'Indeterminado';
return [
[
'IRPC — L8',
'PPC — estimativa fiscal',
m.pending.length
? m.pending.join(' ')
: 'Base líquida anterior: ' +
show(m.base) +
';80% antes de arredondar: ' +
show(m.annualBeforeRounding) +
'; cada prestação: ' +
show(m.instalment) +
'; total arredondado: ' +
show(m.total) +
'. ' +
m.schedule
.map((s) => s.month + ': ' + show(s.amount))
.join('; ') +
'. Previsão de obrigação; não é pagamento nem dedução anual.',
p.assessmentReference +
'; ' +
p.withholdingReference +
'; ' +
m.source,
],
];
}
+2
View File
@@ -1400,3 +1400,5 @@ function specialCombinationsReport(
],
];
}
export { qualify as qualifySpecialTreatment };
@@ -0,0 +1,41 @@
export function opportunityDescription(record: {
title: string;
summary?: string | null;
}): string {
const summary = record.summary
?.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const text =
summary || `Consulte a ficha de ${record.title} no InvesteMoz.`;
return Array.from(
text
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim(),
)
.slice(0, 160)
.join('');
}
export function recordedVerificationDate(value?: string | null): string {
if (!value?.trim()) return 'Não registada';
const parts = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
if (!parts) return 'Data não interpretável';
const [, year, month, day] = parts.map(Number);
const calendar = new Date(Date.UTC(year, month - 1, day));
if (
calendar.getUTCFullYear() !== year ||
calendar.getUTCMonth() !== month - 1 ||
calendar.getUTCDate() !== day
)
return 'Data não interpretável';
if (value.length > 10 && !/(Z|[+-]\d{2}:\d{2})$/.test(value))
return 'Fuso horário não registado';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return 'Data não interpretável';
return new Intl.DateTimeFormat('pt-MZ', {
dateStyle: 'medium',
timeZone: 'Africa/Maputo',
}).format(date);
}
+25 -26
View File
@@ -478,32 +478,40 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
: null)
: null;
return uniqueNavigationDestinations([
...(access.procurement
? [
{
id: 'procurement-area',
label: 'Concursos e anúncios',
children: [
link(
'procurement',
'Consultar concursos e anúncios',
'/concursos',
),
...(access.authenticated
? [
link(
'procurement-watched',
'Os concursos que acompanho',
'/concursos/acompanhar',
),
]
: []),
],
},
]
: []),
{
id: 'discovery',
label: 'Descoberta',
children: [
link(
'why',
'Porquê investir em Moçambique?',
'/porque-investir',
),
link(
'opportunities',
'Consultar oportunidades',
'/oportunidades',
),
link(
'simulator',
'Simulador Fiscal',
access.context?.simulator || '/simulador-fiscal',
),
link('sectors', 'Sectores de investimento', '/sectores'),
link('locations', 'Localizações', '/oportunidades?view=map'),
...(access.funding
? [
link(
'funding',
'Fundos e programas de apoio',
'Instituições financeiras e seguros',
'/fundos',
),
...(access.authenticated
@@ -517,15 +525,6 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
: []),
]
: []),
...(access.procurement
? [
link(
'procurement',
'Concursos e anúncios',
'/concursos',
),
]
: []),
{
id: 'related-catalogues',
label: 'Catálogos relacionados',
+2
View File
@@ -3,6 +3,7 @@ export class ProfileValidationError extends Error {
message: string,
public readonly fields: Record<string, string>,
public readonly reauthenticate = false,
public readonly status: number | null = null,
) {
super(message);
}
@@ -67,6 +68,7 @@ export async function profileJson(
]),
),
reauthenticate,
response.status,
);
return result;
}
+9
View File
@@ -260,6 +260,15 @@ export default function Case({
: 'por associar'}
.
</p>
<a
className="underline"
href={`/balcao-virtual/dossiers/${dossier.id}/pre-dossier`}
target="_blank"
rel="noopener noreferrer"
>
Pré-dossiê dos dados guardados — imprimir/guardar PDF (novo
separador)
</a>
{guidance ? (
<InvestorGuidance
caseId={dossier.id}
+31 -2
View File
@@ -1,7 +1,7 @@
import { Link } from '@inertiajs/react';
import EducationReview from '@/components/education-review';
import type { EducationReviewProps } from '@/lib/education-review';
import { Management, Pagination } from '../management/shared';
import { Management } from '../management/shared';
import type { Paginated } from '../management/shared';
type ReviewEntry = {
@@ -146,7 +146,36 @@ export default function Declaration(
)}
</article>
))}
<Pagination page={props.history} />
<nav
aria-label="Paginação do histórico de revisão"
className="flex flex-wrap items-center gap-4"
>
{props.history.prev_page_url && (
<Link
className="rounded border p-2"
href={props.history.prev_page_url}
preserveState
preserveScroll
>
Anterior
</Link>
)}
<span>
{props.history.total} registos · Página{' '}
{props.history.current_page} de{' '}
{props.history.last_page}
</span>
{props.history.next_page_url && (
<Link
className="rounded border p-2"
href={props.history.next_page_url}
preserveState
preserveScroll
>
Seguinte
</Link>
)}
</nav>
</section>
)}
</Management>
+429
View File
@@ -0,0 +1,429 @@
import { Link, router } from "@inertiajs/react";
import { useEffect, useRef, useState } from "react";
import {
financialCategories,
financialTypes,
} from "@/components/funding-classification";
import { profileJson } from "@/lib/profile-json";
import { Frame, card, button } from "@/pages/investment/shared";
type Item = {
code: string;
title: string;
summary: string;
source_document: string | null;
source_sha256: string | null;
categories: string[];
types: string[];
status: string;
locations: unknown[];
contacts: unknown[];
published: boolean;
};
type Receipt = {
id: string;
matches_reference: boolean;
publication: { version: number; hash: string } | null;
published?: boolean;
};
type Progress = {
selected: string[];
receipts: Record<string, Receipt>;
reason: string;
pending: Record<string, object>;
};
const emptyProgress = (): Progress => ({
selected: [],
receipts: {},
reason: "",
pending: {},
});
export function readProgress(key: string): Progress {
try {
const value = JSON.parse(sessionStorage.getItem(key) ?? "null");
if (
!value ||
!Array.isArray(value.selected) ||
!value.selected.every((c: unknown) => typeof c === "string") ||
!value.receipts ||
typeof value.receipts !== "object" ||
typeof value.reason !== "string" ||
!value.pending ||
typeof value.pending !== "object"
)
return emptyProgress();
return value;
} catch {
return emptyProgress();
}
}
function saveProgress(key: string, value: Progress): void {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch {
/* Native receipts remain authoritative if tab storage is unavailable. */
}
}
export function AssistedEditor({
batch,
}: {
batch: { dataset_hash: string; editor_id: number; items: Item[] };
}) {
const storageKey = `investmoz-funding-progress:${batch.editor_id}:${batch.dataset_hash}`;
const [restored] = useState(() => readProgress(storageKey));
const [selected, setSelected] = useState<string[]>(
restored.selected.filter((c) => batch.items.some((i) => i.code === c)),
);
const [receipts, setReceipts] = useState<Record<string, Receipt>>(
restored.receipts,
);
const [reviewed, setReviewed] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [reason, setReason] = useState(restored.reason);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const running = useRef(false);
const stop = useRef(false);
const pending = useRef<Record<string, object>>(restored.pending);
const [needsMfa, setNeedsMfa] = useState(false);
useEffect(() => {
saveProgress(storageKey, {
selected,
receipts,
reason,
pending: pending.current,
});
}, [storageKey, selected, receipts, reason]);
const ready = selected.filter(
(c) =>
receipts[c]?.matches_reference &&
receipts[c]?.publication &&
!receipts[c]?.published,
).length;
const published = selected.filter((c) => receipts[c]?.published).length;
async function run(publish: boolean) {
if (
running.current ||
!reviewed ||
!selected.length ||
(publish && (!confirmed || reason.trim().length < 20))
)
return;
running.current = true;
stop.current = false;
setBusy(true);
setMessage("");
try {
for (const code of selected) {
if (stop.current) break;
if (!publish && receipts[code]) continue;
setMessage(
`${publish ? "Publicação" : "Preparação"}: ${batch.items.find((i) => i.code === code)?.title}`,
);
if (publish) {
const receipt = receipts[code];
if (
!receipt?.matches_reference ||
!receipt.publication ||
receipt.published
)
continue;
const body = pending.current[code] ?? {
...receipt.publication,
reason: reason.trim(),
confirm: true,
};
pending.current[code] = body;
saveProgress(storageKey, {
selected,
receipts,
reason,
pending: pending.current,
});
await profileJson(
`/management/fundos/${receipt.id}/publicar`,
body,
);
setReceipts((old) => ({
...old,
[code]: { ...receipt, published: true },
}));
delete pending.current[code];
} else {
const plan = await profileJson(
`/management/fundos/referencias/${code}`,
{},
"GET",
);
if (plan.package_sha256 !== batch.dataset_hash)
throw new Error(
"As fontes mudaram. Actualize e confira novamente antes de continuar.",
);
const receipt = await profileJson(
`/management/fundos/referencias/${code}/preparar`,
{ hash: plan.hash },
);
setReceipts((old) => ({ ...old, [code]: receipt }));
}
}
setMessage(
stop.current
? "Pausa concluída. As operações confirmadas foram conservadas."
: publish
? "Publicação das fichas conferidas concluída. Consulte o catálogo."
: "Preparação concluída. Confira os resultados antes de confirmar a publicação.",
);
} catch (error) {
setNeedsMfa(
!!error &&
typeof error === "object" &&
"status" in error &&
(error.status === 401 ||
error.status === 403 ||
error.status === 419),
);
setMessage(
`${error instanceof Error ? error.message : "Operação interrompida."} Os resultados anteriores foram conservados. Se a sessão ou MFA expirou, autentique-se no portal e retome. A publicação incerta pode ser repetida com a mesma confirmação.`,
);
} finally {
running.current = false;
setBusy(false);
}
}
return (
<Frame title="Conferir instituições e serviços financeiros">
<p className={card}>
A preparação cria rascunhos privados. A publicação exige uma
confirmação separada e MFA recente no portal. As fichas
conservam as incertezas das fontes; a sua presença não garante
licença actual, crédito ou seguro.
</p>
<p>
Fonte do conjunto:{" "}
<code className="break-all">{batch.dataset_hash}</code>
</p>
<fieldset disabled={busy} className={card}>
<button
type="button"
className={button}
onClick={() => {
setSelected(
batch.items
.filter(
(i) =>
i.source_sha256 ===
"119bb5514d2777849b70aebfec21d28a41885cbdc7d9827fbebd764fe994c108",
)
.map((i) => i.code),
);
setConfirmed(false);
}}
>
Seleccionar todas as instituições (270)
</button>
<p role="status">
{selected.length} seleccionadas ·{" "}
{Object.keys(receipts).length} preparadas nesta retoma ·{" "}
{ready} prontas para publicar · {published} publicadas
</p>
<p>
O progresso fica guardado nesta aba, separado por conta e
fontes. Depois de renovar o MFA, retome os passos sem voltar
a preparar as fichas conferidas. As confirmações devem ser
marcadas novamente; nenhuma publicação é automática.
</p>
<details>
<summary>
Conferir a lista e alterar a selecção (
{batch.items.length} referências)
</summary>
{batch.items.map((item) => (
<details key={item.code} className="border-b py-2">
<summary>
<label>
<input
type="checkbox"
checked={selected.includes(item.code)}
onChange={(e) => {
setSelected((old) =>
e.target.checked
? [...old, item.code]
: old.filter(
(c) =>
c !== item.code,
),
);
setConfirmed(false);
}}
/>{" "}
{item.title}
</label>{" "}
—{" "}
{receipts[item.code]?.published
? "Publicação confirmada"
: receipts[item.code]
? receipts[item.code].matches_reference
? "Conferida, aguarda publicação"
: "Edições existentes: rever individualmente"
: item.published
? "Já tem publicação"
: "Por preparar"}
</summary>
<p>{item.summary}</p>
<p>
{item.categories
.map((c) => financialCategories[c] ?? c)
.join(", ")}{" "}
—{" "}
{item.types
.map((t) => financialTypes[t] ?? t)
.join(", ")}{" "}
— {item.status}
</p>
<p>
{item.source_document}{" "}
<code className="break-all">
{item.source_sha256}
</code>
</p>
<p>
{item.locations.length} localizações;{" "}
{item.contacts.length} contactos.
</p>
<Link
className="underline"
href={`/management/fundos/referencias/${item.code}`}
>
Conferir fonte e dados completos
</Link>
{receipts[item.code] && (
<Link
className="ml-4 underline"
href={`/management/fundos/${receipts[item.code].id}`}
>
Alterar ficha existente
</Link>
)}
</details>
))}
</details>
<label className="block py-3">
<input
type="checkbox"
checked={reviewed}
onChange={(e) => setReviewed(e.target.checked)}
/>{" "}
Conferi as fontes e as fichas seleccionadas, incluindo dados
por confirmar.
</label>
<button
type="button"
className={button}
disabled={!reviewed || !selected.length}
onClick={() => void run(false)}
>
1. Preparar e conferir seleccionadas
</button>
<label className="block py-3">
Fundamento da publicação (mínimo 20 caracteres)
<textarea
className="block w-full border p-2"
value={reason}
onChange={(e) => setReason(e.target.value)}
maxLength={2000}
/>
</label>
<label className="block py-3">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
/>{" "}
Confirmo a publicação pública das versões conferidas,
preservando os dados e as incertezas apresentados.
</label>
<p>
Para publicar: termine a preparação, marque a conferência,
preencha o fundamento e confirme a publicação. Fichas com
edições existentes exigem revisão individual.
</p>
<button
type="button"
className={button}
disabled={
!reviewed ||
!confirmed ||
reason.trim().length < 20 ||
!selected.some(
(c) =>
receipts[c]?.matches_reference &&
!receipts[c].published,
)
}
onClick={() => void run(true)}
>
2. Publicar conferidas
</button>
</fieldset>
{needsMfa && (
<div role="alert" className={card}>
<p>
A sessão ou confirmação MFA precisa de renovação. O
progresso desta aba foi conservado.
</p>
<button
className={button}
type="button"
disabled={busy}
onClick={() =>
router.post(
"/logout",
{},
{
onSuccess: () =>
router.visit(
"/management/fundos/referencias-assistidas",
),
},
)
}
>
Renovar entrada com MFA e retomar
</button>
</div>
)}
{busy && (
<button
type="button"
className={button}
onClick={() => {
stop.current = true;
}}
>
Pausar após esta ficha
</button>
)}
<p role="status" aria-live="polite">
{message}
</p>
<Link className="underline" href="/fundos">
Consultar catálogo
</Link>
</Frame>
);
}
export default function Assisted({
batch,
}: {
batch: { dataset_hash: string; editor_id: number; items: Item[] };
}) {
return (
<AssistedEditor
key={`${batch.editor_id}:${batch.dataset_hash}`}
batch={batch}
/>
);
}
+63 -3
View File
@@ -1,3 +1,8 @@
import {
InstitutionFields,
emptyInstitution,
type InstitutionDetails,
} from '@/components/funding-institution';
import { Link, router, useForm } from '@inertiajs/react';
import { useState } from 'react';
import type { ReactNode } from 'react';
@@ -21,7 +26,8 @@ type WindowDraft = {
application_url: string;
criteria: Criterion[];
};
type Draft = {
type Draft = InstitutionDetails & {
funding_needs: string[];
title: string;
kind: string;
summary: string;
@@ -51,6 +57,8 @@ type RecordData = {
};
type Plan = { version: number; hash: string; payload: Draft };
const empty: Draft = {
...emptyInstitution,
funding_needs: [],
title: '',
kind: 'fund',
summary: '',
@@ -239,6 +247,8 @@ function Editor({
kinds,
windowModes,
history,
institutionNatures = {},
needs = {},
}: {
record: RecordData | null;
references: Record<
@@ -247,6 +257,8 @@ function Editor({
>;
kinds: Record<string, string>;
windowModes: Record<string, string>;
institutionNatures?: Record<string, string>;
needs?: Record<string, string>;
history: { version: number; reason: string; published_at: string }[];
}) {
const form = useForm({
@@ -267,7 +279,9 @@ function Editor({
}
function text(
key: {
[K in keyof Draft]: Draft[K] extends string ? K : never;
[K in keyof Draft]-?: Draft[K] extends string | null | undefined
? K
: never;
}[keyof Draft],
label: string,
type = 'text',
@@ -359,6 +373,31 @@ function Editor({
))}
</select>
</Field>
{draft.kind !== 'institution' && (
<label>
Finalidades documentadas
<select
multiple
className={input}
value={draft.funding_needs}
onChange={(e) =>
change({
funding_needs: Array.from(
e.target.selectedOptions,
(o) => o.value,
),
})
}
>
{Object.entries(needs).map(([id, label]) => (
<option key={id} value={id}>
{label}
</option>
))}
</select>
<span>Sem selecção: finalidade por confirmar.</span>
</label>
)}
<Field label="Resumo">
<textarea
className={input}
@@ -390,6 +429,18 @@ function Editor({
{!draft.manager_organization_id &&
text('manager_name', 'Gestor indicado pela fonte')}
{text('source_url', 'Endereço da fonte', 'url')}
{!draft.source_url && (
<>
{text(
'source_document_name',
'Documento de origem',
)}
{text(
'source_document_sha256',
'SHA-256 do documento',
)}
</>
)}
{text('source_reference', 'Documento, edição e referência')}
{text(
'source_checked_on',
@@ -419,8 +470,17 @@ function Editor({
</select>
</Field>
</section>
{draft.kind === 'institution' && (
<InstitutionFields
value={draft}
change={change}
territories={references.territories}
natures={institutionNatures}
needs={needs}
/>
)}
<section className={card}>
<Field label="Cobertura do programa">
<Field label="Cobertura do programa/instituição — distinta da presença física">
<select
className={input}
value={draft.coverage}
+125 -9
View File
@@ -1,3 +1,8 @@
import {fundingPublicPresentation} from '@/lib/funding-public-presentation';
import {
financialCategories,
financialTypes,
} from '@/components/funding-classification';
import { Link, useForm } from '@inertiajs/react';
import { Frame, card, button } from '@/pages/investment/shared';
import { type Programme, windowLabels } from './shared';
@@ -8,6 +13,7 @@ export default function Index({
kinds,
management,
options,
needs = {},
}: {
items: {
data: Programme[];
@@ -20,15 +26,30 @@ export default function Index({
territory?: string;
certainty?: string;
state?: string;
need?: string;
context?: string;
opportunity?: string;
category?: string;
institution_type?: string;
location_mode?: string;
include_historical?: string;
};
options: {
sectors: { id: number; name: string }[];
territories: { id: string; name: string }[];
};
kinds: Record<string, string>;
needs?: Record<string, string>;
management: boolean;
}) {
const form = useForm({
category: filters.category ?? '',
institution_type: filters.institution_type ?? '',
location_mode: filters.location_mode ?? 'coverage',
include_historical: filters.include_historical ?? '0',
context: filters.context ?? '',
opportunity: filters.opportunity ?? '',
need: filters.need ?? '',
q: filters.q ?? '',
kind: filters.kind ?? '',
sector: filters.sector ?? '',
@@ -37,18 +58,18 @@ export default function Index({
state: filters.state ?? '',
});
return (
<Frame title="Fundos e programas de apoio">
<Frame title="Instituições financeiras, fundos e programas de apoio">
{management && (
<Link className={button} href="/management/fundos">
Gerir fundos e programas
</Link>
)}
<Link className="underline" href="/fundos/guardados">
Programas que guardei
A minha selecção
</Link>
<p>
Consulte os programas e as condições documentadas. Cada chamada
tem as suas próprias datas e critérios.
Encontre bancos, seguradoras, fundos e outros parceiros financeiros.
Explore as soluções e os contactos adequados ao seu investimento.
</p>
<form
className={`${card} flex flex-wrap gap-4`}
@@ -57,6 +78,88 @@ export default function Index({
form.get('/fundos');
}}
>
<label>
Categoria
<select
className="block rounded border p-2"
value={form.data.category}
onChange={(e) =>
form.setData('category', e.target.value)
}
>
<option value="">Todas</option>
{Object.entries(financialCategories).map(
([id, label]) => (
<option key={id} value={id}>
{label}
</option>
),
)}
</select>
</label>
<label>
Tipo
<select
className="block rounded border p-2"
value={form.data.institution_type}
onChange={(e) =>
form.setData('institution_type', e.target.value)
}
>
<option value="">Todos</option>
{Object.entries(financialTypes).map(([id, label]) => (
<option key={id} value={id}>
{label}
</option>
))}
</select>
</label>
<label>
Localização a pesquisar
<select
className="block rounded border p-2"
value={form.data.location_mode}
onChange={(e) =>
form.setData('location_mode', e.target.value)
}
>
<option value="physical">
Sede, balcão ou presença física
</option>
<option value="coverage">
Cobertura do financiamento/produto
</option>
</select>
</label>
<label>
<input
type="checkbox"
checked={form.data.include_historical === '1'}
onChange={(e) =>
form.setData(
'include_historical',
e.target.checked ? '1' : '0',
)
}
/>
Incluir instituições históricas/fundidas, identificadas como
tal
</label>
<label>
Necessidade do processo
<select
className="block rounded border p-2"
value={form.data.need}
onChange={(e) => form.setData('need', e.target.value)}
>
<option value="">Todas / por identificar</option>
{Object.entries(needs).map(([id, label]) => (
<option key={id} value={id}>
{label}
</option>
))}
</select>
</label>
<label>
Palavras-chave
<input
@@ -164,21 +267,34 @@ export default function Index({
{items.data.map((item) => (
<article className={card} key={item.id}>
<h2 className="text-xl font-semibold">
<Link className="underline" href={`/fundos/${item.id}`}>
<Link
className="underline"
href={`/fundos/${item.id}?${new URLSearchParams(Object.entries(filters).filter((entry): entry is [string, string] => typeof entry[1] === 'string'))}`}
>
{item.payload.title}
</Link>
</h2>
<p>{item.payload.summary}</p>
<p>Gestor: {item.payload.manager_name}</p>
<p>{fundingPublicPresentation(item.payload).description}</p>
{item.payload.kind==='institution' && <div><h3 className="font-semibold">Produtos e serviços</h3>{fundingPublicPresentation(item.payload).products.length ? <ul>{fundingPublicPresentation(item.payload).products.map(p=><li key={p.title}>{p.title}{p.detail ? ': '+p.detail : ''}</li>)}</ul> : <p>{fundingPublicPresentation(item.payload).services.join(' · ') || 'Consulte as soluções disponíveis junto da instituição.'}</p>}</div>}
<p>
{(item.payload.institution_categories ?? [])
.map((k) => financialCategories[k] ?? k)
.join(' · ')}{' '}
·{' '}
{(item.payload.institution_types ?? [])
.map((k) => financialTypes[k] ?? k)
.join(' · ')}
</p>
{item.payload.kind!=='institution' && <p>Entidade gestora: {item.payload.manager_name}</p>}
{Object.entries(item.filter_explanation ?? {}).map(
([key, message]) => (
<p key={key}>{message}</p>
),
)}
{item.payload.windows.length === 0 ? (
{item.payload.kind==='institution' && item.payload.windows.length===0 ? null : item.payload.windows.length === 0 ? (
<p>
Não existe uma janela de candidatura confirmada
nesta ficha.
para este programa.
</p>
) : (
<ul>
+32 -11
View File
@@ -1,8 +1,10 @@
import { Link } from '@inertiajs/react';
import { Frame, card, button } from '@/pages/investment/shared';
import { Link } from "@inertiajs/react";
import { Frame, card, button } from "@/pages/investment/shared";
export default function Manage({
items,
references = [],
}: {
references?: { code: string; title: string }[];
items: {
data: {
id: string;
@@ -22,11 +24,30 @@ export default function Manage({
Catálogo público
</Link>
<Link
className="ml-4 underline"
href="/management/fundos/referencias/fdel"
className={button}
href="/management/fundos/referencias-assistidas"
>
Preparar referência FDEL
Preparar e conferir as instituições fornecidas
</Link>
<details className={card}>
<summary>
Preparar referências fornecidas ({references.length})
</summary>
<p>
Prepara rascunhos privados; repetições reutilizam a ficha e
preservam edições. Publicação é conferida separadamente.
</p>
{references.map((r) => (
<p key={r.code}>
<Link
className="underline"
href={`/management/fundos/referencias/${r.code}`}
>
{r.title}
</Link>
</p>
))}
</details>
{items.data.map((item) => (
<article className={card} key={item.id}>
<Link
@@ -36,10 +57,10 @@ export default function Manage({
{item.title}
</Link>
<p>
Versão de trabalho {item.version} —{' '}
Versão de trabalho {item.version} —{" "}
{item.published
? 'com publicação'
: 'sem publicação pública'}
? "com publicação"
: "sem publicação pública"}
</p>
</article>
))}
@@ -50,11 +71,11 @@ export default function Manage({
key={index}
href={link.url}
className="underline"
aria-current={link.active ? 'page' : undefined}
aria-current={link.active ? "page" : undefined}
>
{link.label
.replace(/&laquo;/g, '«')
.replace(/&raquo;/g, '»')}
.replace(/&laquo;/g, "«")
.replace(/&raquo;/g, "»")}
</Link>
) : null,
)}
+10
View File
@@ -1,3 +1,7 @@
import {
InstitutionView,
type InstitutionDetails,
} from '@/components/funding-institution';
import { Link, useForm } from '@inertiajs/react';
import { Frame, card, button } from '@/pages/investment/shared';
import type { Criterion } from './shared';
@@ -7,6 +11,7 @@ type Reference = {
edition: string;
provenance: { url: string; checked_on: string; reference: string }[];
draft: {
kind?: string;
title: string;
summary: string;
manager_name: string;
@@ -61,6 +66,11 @@ export default function ReferencePage({ reference }: { reference: Reference }) {
<p>{reference.draft.sectors_text}</p>
<Criteria items={reference.draft.criteria} />
</section>
{reference.draft.kind === 'institution' && (
<InstitutionView
value={reference.draft as unknown as InstitutionDetails}
/>
)}
<section className={card}>
<h2>Chamadas documentadas</h2>
{reference.draft.windows.map((window) => (
+3 -3
View File
@@ -18,7 +18,7 @@ export default function Saved({
};
}) {
return (
<Frame title="Programas que guardei">
<Frame title="A minha selecção">
<Link className="underline" href="/fundos">
Consultar fundos e programas
</Link>
@@ -39,7 +39,7 @@ export default function Saved({
className="underline"
href={`/fundos/${item.id}`}
>
Consultar ficha publicada
Consultar detalhes
</Link>
{item.current_version !== item.saved_version && (
<p>
@@ -51,7 +51,7 @@ export default function Saved({
</>
) : (
<p>
A ficha já não está disponível publicamente. A sua
Esta informação já não está disponível para consulta pública. A sua
referência guardada foi conservada.
</p>
)}
+2 -1
View File
@@ -1,3 +1,4 @@
import type { InstitutionDetails } from '@/components/funding-institution';
export type Criterion = {
code: string;
question: string;
@@ -28,7 +29,7 @@ export type Programme = {
version_id: string;
version: number;
published_at: string;
payload: {
payload: Partial<InstitutionDetails> & {
title: string;
kind: string;
summary: string;
+45 -23
View File
@@ -1,3 +1,12 @@
import {fundingPublicPresentation} from '@/lib/funding-public-presentation';
import {
InstitutionView,
type InstitutionDetails,
} from '@/components/funding-institution';
import {
FundingDossierChoice,
type FundingDossier,
} from '@/components/funding-dossier-choice';
import { Link, router } from '@inertiajs/react';
import FundingCriteriaAssessment from '@/components/funding-criteria-assessment';
import { Frame, card, button } from '@/pages/investment/shared';
@@ -7,24 +16,47 @@ export default function Show({
record,
saved,
authenticated,
dossier = null,
selection = {},
needs = {},
destinations = [],
}: {
record: Programme;
saved: { version: number; saved_at: string } | null;
authenticated: boolean;
dossier?: FundingDossier | null;
selection?: Record<string, string>;
needs?: Record<string, string>;
destinations?: { id: string; opportunity: string | null; title: string }[];
}) {
const payload = record.payload;
return (
<Frame title={payload.title}>
<Link className="underline" href="/fundos/guardados">
Programas que guardei
A minha selecção
</Link>
<Link className="underline" href="/fundos">
<Link
className="underline"
href={`/fundos?${new URLSearchParams(selection)}`}
>
Voltar aos fundos e programas
</Link>
<p>{payload.summary}</p>
<section><h2 className="text-xl font-semibold">Descrição</h2><p>{fundingPublicPresentation(payload).description}</p></section>
{payload.kind === 'institution' && (
<InstitutionView value={payload as InstitutionDetails} />
)}
{authenticated && (
<FundingDossierChoice
key={`${record.id}:${record.version_id}:${dossier?.id ?? ''}:${dossier?.opportunity ?? ''}:${selection.territory ?? ''}`}
record={record}
dossier={dossier}
selection={selection}
needs={needs}
destinations={destinations}
/>
)}
<p>
Gestor: {payload.manager_name}. Versão publicada:{' '}
{record.version}.
Entidade responsável: {payload.manager_name}.
</p>
{(payload.territory_paths ?? []).map((path, index) => (
<p key={index}>
@@ -50,24 +82,14 @@ export default function Show({
: 'Cobertura por confirmar'}
{payload.coverage_text ? ` — ${payload.coverage_text}` : ''}
</p>
<p>
<a
className="underline"
href={payload.source_url}
target="_blank"
rel="noopener noreferrer"
>
Consultar fonte: {payload.source_reference}
</a>
. Consultada em {payload.source_checked_on}.
</p>
<section className={card}>
{payload.source_url && <details><summary>Informação institucional</summary><a className="underline" href={payload.source_url} target="_blank" rel="noopener noreferrer">Consultar informação da entidade</a><p>Actualização consultada em {payload.source_checked_on}.</p></details>}
{(payload.kind!=='institution'||payload.windows.length>0) && ( <section className={card}>
<h2 className="text-xl font-semibold">
Janelas de candidatura
</h2>
{payload.windows.length === 0 && (
<p>
Não há uma janela de candidatura confirmada nesta ficha.
Não há uma janela de candidatura confirmada para este programa.
</p>
)}
{payload.windows.map((item) => (
@@ -112,11 +134,11 @@ export default function Show({
)}
</article>
))}
</section>
<FundingCriteriaAssessment
</section>)}
{(payload.kind!=='institution'||payload.criteria.length>0||payload.windows.some(window=>window.criteria.length>0)) && ( <FundingCriteriaAssessment
key={record.id + ':' + record.version_id}
record={record}
/>
/>)}
{payload.documents.length > 0 && (
<section className={card}>
<h2>Documentos associados</h2>
@@ -141,7 +163,7 @@ export default function Show({
<section className={card}>
<h2>Guardar interesse</h2>
<p>
Guarda uma referência privada ao programa. Não envia uma
Guarde esta opção para retomar a preparação do investimento. Não envia uma
candidatura nem contacta a entidade.
</p>
{saved ? (
@@ -172,7 +194,7 @@ export default function Show({
})
}
>
Guardar programa na minha área privada
Guardar na minha selecção
</button>
) : (
<Link className="underline" href="/login">
+26 -1
View File
@@ -9,7 +9,11 @@ import {
} from '@/lib/search-territory';
import type { EducationalArticle } from '@/components/educational-article-card';
import { ClassificationSummary } from '@/components/opportunity-classification';
import { Link, router, usePage } from '@inertiajs/react';
import { Head, Link, router, usePage } from '@inertiajs/react';
import {
opportunityDescription,
recordedVerificationDate,
} from '@/lib/opportunity-information';
import { useState } from 'react';
import { downloadPublicOpportunityOffline } from '@/lib/public-opportunity-offline';
import InvestmentMapPanel from '@/components/investment-map-panel';
@@ -46,6 +50,23 @@ export default function Detail({
const [offlineMessage, setOfflineMessage] = useState('');
return (
<Frame title={r.title}>
<Head>
<meta
head-key="description"
name="description"
content={opportunityDescription(r)}
/>
<meta
head-key="og:title"
property="og:title"
content={r.title}
/>
<meta
head-key="og:description"
property="og:description"
content={opportunityDescription(r)}
/>
</Head>
{r.maturity_label === 'Em preparação' && (
<p className="rounded border border-amber-700 bg-amber-50 p-4">
<strong>Em preparação.</strong> Consulte a oportunidade e
@@ -273,6 +294,10 @@ export default function Detail({
<dl className={card + ' space-y-4'}>
{[
['Promotor', r.promoter_name],
[
'Última verificação registada',
recordedVerificationDate(r.last_validated_at),
],
['Investimento', money(r)],
['Maturidade', r.maturity_label],
[
File diff suppressed because it is too large Load Diff
+220 -20
View File
@@ -1,3 +1,8 @@
import {
financialCategories,
financialTypes,
evidenceLabels,
} from '@/components/funding-classification';
import {
ProactiveSummary,
proactiveView,
@@ -16,7 +21,8 @@ import FintechInvestment, {
} from '@/components/fintech-investment';
import { createOperationKey } from '@/lib/operation-key';
import { Head, Link, router, useForm, usePage } from '@inertiajs/react';
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { revealPathStage } from '@/lib/investor-path-stage';
import {
pathStates,
recoverPathResult,
@@ -38,6 +44,39 @@ type Step = {
source_paragraph: number;
};
type Path = {
funding_selections?: {
id: string;
title: string;
programme_id: string;
version: number;
need: string;
institution_key?: string;
categories?: string[];
institution_types?: string[];
location?: {
id: string;
label: string;
address?: string;
source_reference: string;
source_document_sha256?: string;
evidence_status?: string;
location_text?: string;
} | null;
opportunity: string | null;
notice: string;
product?: {
title: string;
conditions?: string;
availability: string;
} | null;
}[];
preparation_action?: {
id: string;
title: string;
kind: string;
case_id: string;
opportunity: string | null;
} | null;
context_opportunity: string | null;
opportunity_options: NonNullable<DossierContext['opportunity_choices']>;
private_investment?: Preparation | null;
@@ -177,16 +216,30 @@ function Stage({ step, path }: { step: Step; path: Path }) {
'expired',
];
const states = path.canWrite ? ownerStates : reviewStates;
const licensing = step.code === 'S08';
const Container = licensing ? 'section' : 'details';
const Heading = licensing ? 'h3' : 'summary';
const Reference = licensing ? 'div' : 'details';
const ReferenceHeading = licensing ? 'h4' : 'summary';
const Record = licensing ? 'section' : 'details';
const RecordHeading = licensing ? 'h4' : 'summary';
return (
<details
<Container
id={step.code}
open={path.next?.code === step.code}
className="space-y-3 rounded-xl border p-4"
tabIndex={-1}
open={licensing ? undefined : path.next?.code === step.code}
className={`space-y-3 rounded-xl border p-4 ${licensing ? 'md:col-span-2' : ''}`}
>
<summary className="cursor-pointer font-semibold">
<Heading
className={
licensing
? 'text-lg font-semibold'
: 'cursor-pointer font-semibold'
}
>
{step.title} · {pathStates[step.state]}
</summary>
<p>{pathStates[step.state]}</p>
</Heading>
{!licensing && <p>{pathStates[step.state]}</p>}
{step.context_review_required && (
<label className="block rounded border border-amber-600 bg-amber-50 p-3">
<input
@@ -258,10 +311,14 @@ function Stage({ step, path }: { step: Step; path: Path }) {
. Pode continuar a preparar.
</p>
))}
<details className="text-xs">
<summary className="cursor-pointer underline">
<Reference className="text-xs">
<ReferenceHeading
className={
licensing ? 'font-semibold' : 'cursor-pointer underline'
}
>
Fonte e referência desta etapa
</summary>
</ReferenceHeading>
<p>
Referência interna {step.code}. Documento original,
parágrafo {step.source_paragraph}.{' '}
@@ -271,12 +328,22 @@ function Stage({ step, path }: { step: Step; path: Path }) {
? 'Acto externo registado a partir de documento.'
: 'Preparação declarada pelo promotor.'}
</p>
</details>
</Reference>
{step.preparation_allowed && (path.canWrite || path.canReview) && (
<details>
<summary className="cursor-pointer underline">
<Record
aria-label={
licensing ? 'Registar licenciamento' : undefined
}
>
<RecordHeading
className={
licensing
? 'font-semibold'
: 'cursor-pointer underline'
}
>
Registar avanço ou pendência
</summary>
</RecordHeading>
<form
className="mt-3 grid gap-3"
onSubmit={(event) => {
@@ -424,18 +491,58 @@ function Stage({ step, path }: { step: Step; path: Path }) {
))}
<button
disabled={form.processing}
className="rounded border p-2"
className={
licensing
? 'rounded bg-emerald-800 px-4 py-3 font-semibold text-white disabled:opacity-50'
: 'rounded border p-2'
}
>
Guardar registo
</button>
</form>
</details>
</Record>
)}
</details>
</Container>
);
}
export default function InvestmentPath(path: Path) {
const root = useRef<HTMLElement>(null);
const stageCodes = path.steps.map((step) => step.code).join(',');
useEffect(() => {
const reveal = () => {
if (root.current) {
revealPathStage(
root.current,
window.location.hash,
stageCodes.split(','),
);
if (
path.preparation_action &&
window.location.hash === '#tarefas'
) {
const target =
root.current.ownerDocument.getElementById('tarefas');
if (
target?.tagName === 'DETAILS' &&
root.current.contains(target)
) {
(target as HTMLDetailsElement).open = true;
target.scrollIntoView({ block: 'start' });
}
}
}
};
reveal();
window.addEventListener('hashchange', reveal);
return () => window.removeEventListener('hashchange', reveal);
}, [
path.case.id,
path.context_opportunity,
stageCodes,
path.next?.code,
path.preparation_action?.id,
]);
const { auth, flash } = usePage<{
auth: { user: { id: number } };
flash: { success?: string };
@@ -449,7 +556,9 @@ export default function InvestmentPath(path: Path) {
const task = useForm({
version: path.case.version,
code: 'S02',
title: '',
title: path.preparation_action?.title ?? '',
preparation_action: path.preparation_action?.id ?? null,
opportunity: path.preparation_action?.opportunity ?? null,
due_at: '',
consent: false,
});
@@ -471,7 +580,10 @@ export default function InvestmentPath(path: Path) {
);
}
return (
<main className="mx-auto max-w-6xl min-w-0 space-y-6 p-4 [overflow-wrap:anywhere] md:p-8 [&_input]:max-w-full [&_input]:min-w-0 [&_label]:min-w-0 [&_select]:max-w-full [&_select]:min-w-0">
<main
ref={root}
className="mx-auto max-w-6xl min-w-0 space-y-6 p-4 [overflow-wrap:anywhere] md:p-8 [&_input]:max-w-full [&_input]:min-w-0 [&_label]:min-w-0 [&_select]:max-w-full [&_select]:min-w-0"
>
<Head title={path.case.title} />
<Link className="underline" href="/investimentos">
Os meus investimentos
@@ -705,6 +817,9 @@ export default function InvestmentPath(path: Path) {
{
(
{
funding:
'Instituições financeiras e apoios',
preparation: 'Preparação APIEX e SWOT',
simulator: 'Plano e simulador',
desk: 'Balcão e histórico',
library: 'Biblioteca',
@@ -948,6 +1063,11 @@ export default function InvestmentPath(path: Path) {
>
Preparar os dados para a constituição da empresa
</Link>
{path.tools.preparation && (
<Link className="underline" href={path.tools.preparation}>
Preparação APIEX e análise SWOT deste investimento
</Link>
)}
{path.tools.company && (
<Link className="underline" href={path.tools.company}>
Comparar formas jurídicas para este investimento
@@ -970,10 +1090,90 @@ export default function InvestmentPath(path: Path) {
Anexar ou conferir no Balcão
</Link>
</section>
<details className="space-y-3 rounded-xl border p-4">
<section
id="financiamento"
className="rounded-xl border bg-white p-4"
>
<h2>Instituições e serviços ligados ao processo</h2>
<p>
Escolhas privadas para preparação; não são candidaturas
enviadas nem concessões de financiamento.
</p>
{path.tools.funding && (
<Link className="underline" href={path.tools.funding}>
Pesquisar bancos, seguradoras e outras instituições
</Link>
)}
{(path.funding_selections ?? []).map((row) => (
<article key={row.id}>
<h3>
{row.title} · versão consultada {row.version}
</h3>
<p>
{row.product?.title ?? 'Produto por confirmar'} ·{' '}
{row.need}
</p>
<p>
{row.product?.conditions ||
'Condições por confirmar'}
</p>
<p>{row.notice}</p>
<p>
Identidade:{' '}
{row.institution_key ?? 'Referência anterior'} ·
Categoria:{' '}
{(row.categories ?? [])
.map((k) => financialCategories[k] ?? k)
.join(' · ') || 'Por classificar'}{' '}
· Tipo:{' '}
{(row.institution_types ?? [])
.map((k) => financialTypes[k] ?? k)
.join(' · ') || 'Por classificar'}
</p>
{row.location && (
<section>
<h4>
Sede/balcão escolhido: {row.location.label}
</h4>
<p>
{row.location.location_text} ·{' '}
{row.location.address}
</p>
<p>
{row.location.evidence_status
? evidenceLabels[
row.location.evidence_status
]
: ''}{' '}
· {row.location.source_reference}
</p>
<p>
SHA-256 da origem:{' '}
{row.location.source_document_sha256}
</p>
</section>
)}
<Link
className="underline"
href={`/fundos/${row.programme_id}?context=${path.case.id}${row.opportunity ? '&opportunity=' + encodeURIComponent(row.opportunity) : ''}`}
>
Consultar ficha actual (pode ter sido alterada ou
retirada)
</Link>
</article>
))}
</section>
<details id="tarefas" className="space-y-3 rounded-xl border p-4">
<summary className="cursor-pointer text-lg font-semibold">
Acompanhamento interno
</summary>
{path.preparation_action && (
<p role="status">
Acção ligada à SWOT deste dossier:{' '}
{path.preparation_action.title}. Confira a etapa e a
data antes de assumir a tarefa.
</p>
)}
{path.canWrite && (
<form
className="grid min-w-0 gap-3 [&_input]:max-w-full [&_input]:min-w-0 [&_label]:min-w-0 [&_select]:max-w-full [&_select]:min-w-0"
@@ -0,0 +1,284 @@
import { Link, router } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
import { createOperationKey } from '@/lib/operation-key';
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
import { Management, button, control } from './shared';
import type { Paginated } from './shared';
type Preview = {
hash: string;
title: string;
before: boolean;
featured: boolean;
reason: string;
};
export default function OpportunityCuration({
record,
etag: initialEtag,
history,
basePath,
}: {
record: { id: number; title: string; featured: boolean };
etag: string;
history: Paginated<{
id: number;
actor: number;
at: string;
featured: boolean;
reason: string;
}>;
basePath: string;
}) {
const [featured, setFeatured] = useState(!record.featured);
const [currentFeatured, setCurrentFeatured] = useState(record.featured);
const [reason, setReason] = useState('');
const [etag, setEtag] = useState(initialEtag);
const [key, setKey] = useState(createOperationKey);
const [preview, setPreview] = useState<Preview | null>(null);
const [confirmed, setConfirmed] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const inFlight = useRef(false);
const epoch = useRef(0);
const seenRecord = useRef(record.id);
const feedback = useRef<HTMLDivElement>(null);
const endpoint = `${basePath}/${record.id}/destaque`;
useEffect(() => {
if (seenRecord.current !== record.id) {
setReason('');
setMessage(
'Foi aberta outra oportunidade; prepare uma decisão própria.',
);
} else if (etag !== initialEtag) {
setMessage(
'A versão observada mudou; o fundamento foi conservado e precisa de nova prévia.',
);
}
seenRecord.current = record.id;
epoch.current++;
inFlight.current = false;
setBusy(false);
setEtag(initialEtag);
setFeatured(!record.featured);
setCurrentFeatured(record.featured);
setPreview(null);
setConfirmed(false);
setKey(createOperationKey());
}, [record.id, initialEtag, record.featured]);
function invalidate() {
setPreview(null);
setConfirmed(false);
setKey(createOperationKey());
}
async function send(confirm: boolean) {
if (inFlight.current || (confirm && (!preview || !confirmed))) return;
inFlight.current = true;
setBusy(true);
setErrors({});
setMessage('');
const currentEpoch = epoch.current;
try {
const result = await profileJson(
`${endpoint}/${confirm ? 'confirmar' : 'previa'}`,
{
etag,
client_key: key,
featured,
reason,
...(confirm
? { hash: preview?.hash, confirm: confirmed }
: {}),
},
);
if (epoch.current !== currentEpoch) return;
if (confirm) {
setPreview(null);
setConfirmed(false);
setEtag(result.etag);
setCurrentFeatured(result.featured);
setFeatured(!result.featured);
setKey(createOperationKey());
setReason('');
setMessage(
result.replayed
? 'Decisão já registada. O estado actual é recuperado.'
: 'Decisão de destaque registada.',
);
router.reload({
only: ['record', 'etag', 'history'],
});
} else {
setPreview(result);
setConfirmed(false);
}
} catch (error) {
if (epoch.current !== currentEpoch) return;
setMessage(
error instanceof Error
? error.message
: 'Não foi possível concluir. Repita sem editar para recuperar a decisão.',
);
if (error instanceof ProfileValidationError)
setErrors(error.fields);
} finally {
if (epoch.current === currentEpoch) {
inFlight.current = false;
setBusy(false);
feedback.current?.focus();
}
}
}
return (
<Management title="Curadoria de destaque">
<h1 className="text-2xl font-bold">{record.title}</h1>
<p>
Estado actual:{' '}
{currentFeatured ? 'Em destaque' : 'Sem destaque'}. A curadoria
não publica uma oportunidade nem aprova o investimento.
</p>
<div ref={feedback} tabIndex={-1} role="status" aria-live="polite">
{message}
</div>
<fieldset disabled={busy} className="space-y-4 rounded border p-4">
<legend>Decisão editorial individual</legend>
<label className="block">
Escolha
<select
className={control}
value={featured ? 'yes' : 'no'}
onChange={(event) => {
setFeatured(event.target.value === 'yes');
invalidate();
}}
>
<option value="yes">Colocar em destaque</option>
<option value="no">Retirar do destaque</option>
</select>
</label>
<label className="block">
Fundamento
<textarea
className={control}
value={reason}
minLength={20}
maxLength={1000}
onChange={(event) => {
setReason(event.target.value);
invalidate();
}}
/>
</label>
{Object.entries(errors).map(([field, value]) => (
<p key={field} role="alert">
{value}
</p>
))}
<button
type="button"
className={button}
onClick={() => send(false)}
>
Conferir prévia
</button>
{preview && (
<section
className="space-y-3 rounded border p-4"
aria-label="Prévia da decisão editorial"
>
<h2 className="font-bold">{preview.title}</h2>
<p>
{preview.before ? 'Em destaque' : 'Sem destaque'} →{' '}
{preview.featured ? 'Em destaque' : 'Sem destaque'}
</p>
<p className="whitespace-pre-wrap">{preview.reason}</p>
<label className="block">
<input
type="checkbox"
checked={confirmed}
onChange={(event) =>
setConfirmed(event.target.checked)
}
/>{' '}
Confirmo esta decisão e o fundamento apresentado.
</label>
<button
type="button"
className={button}
disabled={!confirmed}
onClick={() => send(true)}
>
Confirmar decisão de destaque
</button>
</section>
)}
<p>
Se a resposta se perder, repita a confirmação sem editar os
campos.
</p>
</fieldset>
{!busy && (
<Link
href={`${basePath}/${record.id}/edit`}
className="underline"
>
Voltar à mesma oportunidade
</Link>
)}
<section className="space-y-3">
<h2 className="text-xl font-bold">
Histórico de decisões de destaque — página{' '}
{history.current_page}
</h2>
{history.data.length ? (
history.data.map((item) => (
<article key={item.id} className="rounded border p-3">
<p>
{item.at} · Responsável {item.actor} ·{' '}
{item.featured
? 'Em destaque'
: 'Destaque retirado'}
</p>
<p className="whitespace-pre-wrap">{item.reason}</p>
</article>
))
) : (
<p>
Ainda sem decisões de curadoria registadas neste
percurso.
</p>
)}
{!busy && (
<nav
aria-label="Páginas do histórico de curadoria"
className="flex gap-4"
>
{history.prev_page_url && (
<Link
href={history.prev_page_url}
preserveState
preserveScroll
>
Anterior
</Link>
)}
{history.next_page_url && (
<Link
href={history.next_page_url}
preserveState
preserveScroll
>
Seguinte
</Link>
)}
</nav>
)}
</section>
</Management>
);
}
@@ -97,10 +97,12 @@ export default function OpportunityDraft({
publicationPreview,
publicationState,
promotionHref,
curationHref,
auth,
canEditDraft = false,
} = usePage<{
promotionHref?: string | null;
curationHref?: string | null;
basePath?: string;
profileMode?: boolean;
profileOrganization?: string | null;
@@ -306,6 +308,11 @@ export default function OpportunityDraft({
}
>
{!profileMode && <Link href={basePath}>Voltar à gestão</Link>}
{curationHref && (
<Link className="ml-3 underline" href={curationHref}>
Curadoria de destaque
</Link>
)}
{promotionHref && (
<Link className="ml-3 underline" href={promotionHref}>
Divulgação promocional — Em preparação
+18 -5
View File
@@ -432,9 +432,10 @@ function Simulator() {
}
export default function Services(p: Props) {
const { flash, auth } = usePage<{
const { flash, auth, portalInstitutionalContactEmail } = usePage<{
flash?: { success?: string };
auth: { user: unknown };
portalInstitutionalContactEmail?: string | null;
}>().props;
const currentUrl = usePage<{ url: string }>().url;
const filter = useForm({
@@ -1053,6 +1054,19 @@ export default function Services(p: Props) {
<>
<section className="space-y-3">
<h2 className="text-xl font-bold">Contactos</h2>
{portalInstitutionalContactEmail &&
portalInstitutionalContactEmail !==
p.contacts?.email && (
<p>
Contacto institucional:{' '}
<a
className="underline"
href={`mailto:${portalInstitutionalContactEmail}`}
>
{portalInstitutionalContactEmail}
</a>
</p>
)}
{p.contacts?.email && (
<p>
Email:{' '}
@@ -1073,9 +1087,8 @@ export default function Services(p: Props) {
)}
{!p.contacts?.email && !p.contacts?.phone && (
<p>
Utilize o formulário para contactar a
equipa. Os contactos directos ainda não
estão publicados.
Para pedidos de apoio ao investimento,
utilize o formulário desta página.
</p>
)}
</section>
@@ -1259,7 +1272,7 @@ export default function Services(p: Props) {
</p>
))}
<Link href="/investidor" className="underline">
Abrir fichas e manifestações de interesse da
Consultar oportunidades e manifestações de interesse da
carteira
</Link>
</>
+2 -2
View File
@@ -131,7 +131,7 @@ export default function WhyInvest() {
],
[
'Território e ligações',
'Compare localizações, corredores e parques com o mercado que pretende servir. Confira acessos, logística, infra-estruturas e as condições documentadas de cada ficha.',
'Compare localizações, corredores e parques com o mercado que pretende servir. Confira acessos, logística, infra-estruturas e as condições de cada localização.',
'/oportunidades?view=map',
],
[
@@ -163,7 +163,7 @@ export default function WhyInvest() {
],
[
'Localizações e enquadramentos',
'Explore o mapa e as fichas de corredores, clusters, zonas, parques e projectos. Cada referência conserva a natureza e o fundamento que lhe estão associados.',
'Explore o mapa e os detalhes de corredores, clusters, zonas, parques e projectos. Cada referência conserva a natureza e o fundamento que lhe estão associados.',
'/oportunidades?view=map',
],
[
+102 -28
View File
@@ -1,12 +1,16 @@
import { useEffect } from 'react';
import { useTempoUi } from '@/components/tempo-brand';
import { translateText } from '@/translation/service';
import { TranslationRequestError, translateTexts } from '@/translation/service';
import { protectTranslationTokens, restoreTranslationTokens } from '@/translation/utils';
// Never change the structure of React's DOM or the value of a form control.
const excluded = 'script,style,noscript,code,pre,textarea,[contenteditable]:not([contenteditable="false"]),[translate="no"],[data-no-translate],.no-translate';
const attributes = ['placeholder', 'title', 'aria-label', 'alt'] as const;
type Target = { node: Text | Element; attribute?: string; original: string; translated?: string };
type QueuedText = { value: string; resolve: (value: string) => void; reject: (error: unknown) => void };
const batchMaxItems = 8;
const batchMaxChars = 2200;
const requestMaxChars = 9000;
function read(target: Target): string | null {
return target.attribute ? (target.node as Element).getAttribute(target.attribute) : target.node.nodeValue;
@@ -21,48 +25,111 @@ export function PageTranslator() {
const { language } = useTempoUi();
useEffect(() => {
document.documentElement.lang = language;
if (language === 'pt') return;
let active = true;
const abort = new AbortController();
const targets = new Map<Node, Map<string, Target>>();
const cache = new Map<string, Promise<string>>();
const queue: Array<() => void> = [];
const queue: QueuedText[] = [];
let running = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
let unavailable = false;
let failureReported = false;
const run = () => {
const schedule = (delay = 0) => {
if (!active || unavailable || timer || running >= 2 || !queue.length) return;
running++;
queue.shift()?.();
// Stay below the API's per-minute limit on large pages.
timer = setTimeout(() => { timer = undefined; run(); }, 650);
timer = setTimeout(() => {
timer = undefined;
void flush();
}, delay);
};
const rejectQueued = (error: unknown) => {
if (timer) clearTimeout(timer);
timer = undefined;
queue.splice(0).forEach((item) => item.reject(error));
};
const enqueue = (value: string): Promise<string> =>
new Promise((resolve, reject) => {
queue.push({ value, resolve, reject });
schedule();
});
const flush = async () => {
if (!active || unavailable || running >= 2 || !queue.length) return;
const batch: QueuedText[] = [];
let batchLength = 0;
while (queue.length && batch.length < batchMaxItems) {
const next = queue[0];
if (batch.length > 0 && batchLength + next.value.length > batchMaxChars) break;
batch.push(queue.shift()!);
batchLength += next.value.length;
}
running++;
schedule(650);
try {
const translated = await translateTexts(
batch.map((item) => item.value),
'auto',
language,
abort.signal,
);
batch.forEach((item, index) => item.resolve(translated[index]));
} catch (error) {
const isUnavailable = !abort.signal.aborted
&& error instanceof TranslationRequestError
&& (error.status === 503 || error.status === null);
if (isUnavailable) {
unavailable = true;
rejectQueued(error);
}
batch.forEach((item) => item.reject(error));
} finally {
running--;
schedule(650);
}
};
const splitForRequests = (value: string): string[] => {
const chunks: string[] = [];
let start = 0;
while (start < value.length) {
let end = Math.min(start + requestMaxChars, value.length);
if (end < value.length) {
const boundary = value.lastIndexOf(' ', end);
if (boundary > start) end = boundary + 1;
}
chunks.push(value.slice(start, end));
start = end;
}
return chunks;
};
const translate = (value: string): Promise<string> => {
const previous = cache.get(value);
if (previous) return previous;
const promise = new Promise<string>((resolve, reject) => {
queue.push(() => {
const protectedText = protectTranslationTokens(value);
translateText({ text: protectedText.text, sourceLanguage: 'auto', targetLanguage: language }, abort.signal)
.then(({ translatedText }) => {
for (const token of protectedText.replacements.keys()) {
if (!translatedText.includes(token)) throw new Error('Translation lost a protected token');
}
resolve(restoreTranslationTokens(translatedText, protectedText.replacements));
})
.catch((error) => {
if (!abort.signal.aborted && (error?.status === 503 || error?.status === null)) unavailable = true;
reject(error);
})
.finally(() => { running--; run(); });
});
run();
const protectedText = protectTranslationTokens(value);
const promise = Promise.all(
splitForRequests(protectedText.text).map(enqueue),
).then((translatedChunks) => {
const translatedText = translatedChunks.join('');
for (const token of protectedText.replacements.keys()) {
if (!translatedText.includes(token)) {
throw new Error('Translation lost a protected token');
}
}
return restoreTranslationTokens(translatedText, protectedText.replacements);
});
cache.set(value, promise);
promise.catch(() => cache.delete(value));
return promise;
};
const consider = (node: Text | Element, attribute?: string) => {
const element = node instanceof Element ? node : node.parentElement;
if (!element || element.closest(excluded)) return;
@@ -70,7 +137,7 @@ export function PageTranslator() {
if (element.closest('option:not([value])')) return;
const key = attribute ?? '';
const value = attribute ? (node as Element).getAttribute(attribute) : node.nodeValue;
if (!value || value.length > 10000 || !/\p{L}/u.test(value)) return;
if (!value || !/\p{L}/u.test(value)) return;
let entries = targets.get(node);
if (!entries) { entries = new Map(); targets.set(node, entries); }
const old = entries.get(key);
@@ -81,8 +148,14 @@ export function PageTranslator() {
if (!active || !node.isConnected || entries?.get(key) !== target || read(target) !== value) return;
target.translated = translated;
write(target, translated);
}).catch(() => { /* Keep the original visible when translation fails. */ });
}).catch((error) => {
if (!abort.signal.aborted && !failureReported) {
failureReported = true;
console.error('Page translation failed; original text was kept.', error);
}
});
};
const scan = (root: Node) => {
if (root instanceof Text) { consider(root); return; }
if (!(root instanceof Element)) return;
@@ -95,6 +168,7 @@ export function PageTranslator() {
else if (node instanceof Element) for (const name of attributes) if (node.hasAttribute(name)) consider(node, name);
}
};
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'characterData') scan(mutation.target);
@@ -102,8 +176,8 @@ export function PageTranslator() {
else mutation.addedNodes.forEach(scan);
}
});
observer.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: [...attributes] });
scan(document.body);
observer.observe(document.documentElement, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: [...attributes] });
scan(document.documentElement);
return () => {
active = false;
abort.abort();
+42
View File
@@ -160,3 +160,45 @@ export async function translateText(
targetLanguage: payload.target_lang ?? request.targetLanguage,
};
}
export async function translateTexts(
texts: string[],
sourceLanguage: string,
targetLanguage: string,
signal?: AbortSignal,
): Promise<string[]> {
if (texts.length === 0) return [];
const payload = await fetchJson<{ translated_text?: unknown }>(
`${translationApiBaseUrl}/translate`,
{
method: "POST",
signal,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
text: texts,
source_lang: sourceLanguage,
target_lang: targetLanguage,
}),
},
1,
1,
);
if (
!Array.isArray(payload.translated_text) ||
payload.translated_text.length !== texts.length ||
payload.translated_text.some(
(text) => typeof text !== "string" || text.trim() === "",
)
) {
throw new TranslationRequestError(
"Translation response does not contain all translated texts",
{ status: 200 },
);
}
return payload.translated_text;
}
@@ -0,0 +1,66 @@
<!doctype html>
<html lang="pt-MZ">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Pré-dossiê — {{ $case['title'] }} — InvesteMoz</title>
<style>
@page { size: A4; margin: 18mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #172e29; background: #f3f6f5; font: 16px/1.5 Arial, sans-serif; }
main { max-width: 960px; margin: 24px auto; padding: 32px; background: white; }
h1 { font-size: 28px; } h2 { margin-top: 28px; font-size: 21px; break-after: avoid; }
p, pre, td, th { overflow-wrap: anywhere; }
pre { white-space: pre-wrap; font: inherit; }
table { width: 100%; border-collapse: collapse; } th, td { border: 1px solid #d9d9d9; padding: 10px; text-align: left; vertical-align: top; }
th { background: #edf4f1; } tr { break-inside: avoid; }
.notice { padding: 14px; border-left: 4px solid #b57918; background: #fff8e8; }
nav { display: flex; gap: 20px; flex-wrap: wrap; } button, a { font: inherit; color: #125a4b; }
@media (max-width: 600px) { main { margin: 0; padding: 18px; } }
@media print { body { background: white; font-size: 10.5pt; } main { margin: 0; padding: 0; } nav { display: none; } }
</style>
</head>
<body><main>
<nav aria-label="Acções do pré-dossiê">
<button type="button" onclick="window.print()">Imprimir / guardar como PDF</button>
<a href="{{ url('/balcao-virtual/dossiers/'.$case['id']) }}">Voltar ao mesmo dossier</a>
</nav>
<header>
<p>InvesteMoz · {{ config('portal.operator_name') ?: 'Tempo, Lda.' }}</p>
<h1>Pré-dossiê de preparação do investimento</h1>
<p>{{ $case['title'] }}</p>
<p>Dossier {{ $case['id'] }} · Versão {{ $case['version'] }} · {{ empty($case['closed_at']) ? 'Aberto' : 'Encerrado' }}</p>
<p>Consulta em {{ now('Africa/Maputo')->format('d/m/Y H:i') }} (CAT, UTC+2).</p>
</header>
<p class="notice">Documento de preparação com os dados actualmente acessíveis nesta conta. Não constitui aprovação de investimento, concessão de benefícios fiscais, licença, certificação da APIEX ou submissão oficial a uma entidade pública. As lacunas permanecem por preencher; a impressão não altera o dossier ou os seus pedidos.</p>
@foreach(['profile' => 'Perfil declarado', 'company' => 'Entidade declarada', 'investment' => 'Investimento declarado'] as $key => $label)
@if(isset($case['payload'][$key]))
<section><h2>{{ $label }}</h2><pre>{{ is_array($case['payload'][$key]) ? json_encode($case['payload'][$key], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $case['payload'][$key] }}</pre></section>
@endif
@endforeach
@if(!empty($case['simulation_id']))
<section><h2>Preparação fiscal associada</h2><p>Referência: {{ $case['simulation_id'] }}. A associação identifica a preparação existente; não confirma regras, elegibilidade ou benefícios. Consulte as fontes e versões no simulador antes de utilizar um resultado.</p></section>
@endif
<section>
<h2>Pedidos no mesmo Balcão</h2>
<table><thead><tr><th>Referência</th><th>Estado</th><th>Versão</th></tr></thead><tbody>
@forelse($requests as $item)
<tr><td>{{ $item->protocol ?: 'Rascunho — sem submissão' }}</td><td>{{ $states[$item->status] ?? $item->status }}</td><td>{{ $item->version }}</td></tr>
@empty
<tr><td colspan="3">Ainda sem pedidos acessíveis neste dossier.</td></tr>
@endforelse
</tbody></table>
</section>
<section>
<h2>Documentos acessíveis</h2>
<p>A relação não anexa ficheiros nem confirma validade jurídica ou aprovação.</p>
<ul>@forelse($documents as $document)
<li>{{ $document->file_name ?? $document->title ?? 'Documento' }} · {{ $document->category ?? 'Categoria não indicada' }}</li>
@empty
<li>Ainda sem documentos acessíveis.</li>
@endforelse</ul>
</section>
<footer><p>Os dados em falta não foram substituídos por estimativas. Conserve a data e a versão desta cópia; alterações posteriores exigem nova consulta.</p></footer>
</main></body>
</html>
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Preparação documental — {{ $workspace['case']['title'] }}</title><style>body{font:16px sans-serif;max-width:70rem;margin:2rem auto;padding:1rem;color:#17202a}table{border-collapse:collapse;width:100%}td,th{padding:.6rem;border:1px solid #aaa;text-align:left;vertical-align:top}pre{white-space:pre-wrap;overflow-wrap:anywhere}h1,h2{break-after:avoid}</style></head><body>
<h1>Preparação documental APIEX</h1>
<p>{{ $workspace['case']['title'] }} · Dossier {{ $workspace['case']['id'] }} · Oportunidade {{ $workspace['opportunity'] ?? 'contexto único do dossier' }} · Revisão de contexto {{ $workspace['context']['revision'] }}</p>
<p>Preparação para entrega externa. Não é formulário oficial, submissão electrónica nem aprovação. Os modelos existentes só integram o pacote quando associados como documentos. Não foi criada uma interface de recepção APIEX.</p>
<h2>Índice e requisitos aplicáveis</h2><table><thead><tr><th>Requisito</th><th>Situação</th><th>Fundamento</th><th>Documento/versão</th></tr></thead><tbody>
@foreach($workspace['assessment']['requirements'] as $requirement)
<tr><td>{{ $requirement['label'] }}</td><td>{{ $requirement['state'] === 'not_applicable' ? 'Não aplicável' : ($requirement['available'] ? 'Disponível' : ($requirement['state'] === 'indeterminate' ? 'Por determinar' : 'Em falta/indisponível')) }}</td><td>{{ $requirement['reason'] }} {{ $requirement['source']['reference'] }} · {{ $requirement['source']['url'] }}</td><td>{{ $requirement['document']['file_name'] ?? '—' }} · {{ $requirement['document']['version_no'] ?? '—' }}</td></tr>
@endforeach
</tbody></table>
<h2>Anexos organizados</h2><ol>
@foreach($workspace['selected_documents'] as $n => $document)
<li>@if($manifest)<a href="{{ 'anexos/'.sprintf('%02d-', $n + 1).preg_replace('/[^\pL\pN._-]+/u', '-', basename($document['file_name'])) }}">{{ $document['file_name'] }}</a>@else{{ $document['file_name'] }}@endif · versão {{ $document['version_no'] }} · SHA256 {{ $document['sha256'] }}</li>
@endforeach
</ol>
<h2>Investidor e empresa reutilizados</h2><pre>{{ json_encode($workspace['reused'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre><h2>Dados reutilizados do contexto</h2><pre>{{ json_encode($workspace['context']['fields'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
<p>{{ $workspace['apiex']['note'] }}</p>
<h2>Revisão</h2><p>{{ $workspace['apiex']['review']['reason'] ?? 'Por conferir' }} · {{ $workspace['apiex']['review']['at'] ?? '' }}</p>
@if($manifest)<h2>Identificação da exportação</h2><p>{{ $manifest['export']['id'] }} · {{ $manifest['export']['at'] }} · {{ $manifest['export']['fingerprint'] }}</p>@endif
</body></html>
+2
View File
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route;
Route::get('/fundos', [Funding::class, 'index'])->name('funding.index');
Route::get('/fundos/guardados', [Funding::class, 'saved'])->middleware(['auth', 'verified'])->name('funding.saved');
Route::get('/fundos/{id}', [Funding::class, 'show'])->whereUuid('id')->name('funding.show');
Route::post('/fundos/{id}/dossier', [Funding::class, 'dossier'])->whereUuid('id')->middleware(['auth', 'verified', 'throttle:30,1']);
Route::post('/fundos/{id}/criterios', [Funding::class, 'assess'])->whereUuid('id')->middleware('throttle:30,1');
Route::get('/fundos/{id}/versoes/{version}/documentos/{resource}', [Funding::class, 'download'])->whereUuid('id')->whereUuid('version')->whereNumber('resource');
Route::put('/fundos/{id}/interesse', [Funding::class, 'saveInterest'])->whereUuid('id')->middleware(['auth', 'verified', 'throttle:30,1']);
@@ -13,6 +14,7 @@ Route::delete('/fundos/{id}/interesse', [Funding::class, 'removeInterest'])->whe
Route::middleware(['auth', 'verified'])->prefix('management/fundos')->group(function () {
Route::get('/', [Funding::class, 'manage']);
Route::get('/referencias-assistidas', [Funding::class, 'assisted']);
Route::get('/novo', [Funding::class, 'edit']);
Route::get('/referencias/{code}', [Funding::class, 'reference'])->where('code', '[a-z0-9-]+');
Route::post('/referencias/{code}/preparar', [Funding::class, 'prepareReference'])->where('code', '[a-z0-9-]+');
+6
View File
@@ -4,6 +4,7 @@ use App\Http\Controllers\CompanyComparisonController;
use App\Http\Controllers\CompanyPreparationController;
use App\Http\Controllers\InvestmentComplementController;
use App\Http\Controllers\InvestmentGuideController;
use App\Http\Controllers\InvestmentPreparationController;
use App\Http\Controllers\InvestorContextController;
use App\Http\Controllers\InvestorPathController as Path;
use App\Http\Controllers\LegacyContentController;
@@ -22,6 +23,11 @@ Route::middleware(['auth', 'verified', PrivateInvestmentResponse::class, Virtual
Route::put('/contexto-atual', [InvestorContextController::class, 'focus']);
Route::get('/fontes-contexto', [InvestorContextController::class, 'options']);
Route::get('/opcoes-corredor', [InvestorContextController::class, 'corridorOptions']);
Route::get('/{id}/preparacao', [InvestmentPreparationController::class, 'show'])->whereUuid('id');
Route::post('/{id}/preparacao', [InvestmentPreparationController::class, 'change'])->whereUuid('id');
Route::get('/{id}/preparacao/previa', [InvestmentPreparationController::class, 'preview'])->whereUuid('id');
Route::get('/{id}/preparacao/exportar/{exportId}', [InvestmentPreparationController::class, 'download'])->whereUuid('id')->whereUuid('exportId');
Route::post('/{id}/preparacao/exportar', [InvestmentPreparationController::class, 'export'])->whereUuid('id');
Route::get('/{id}/forma-juridica', [CompanyComparisonController::class, 'show'])->whereUuid('id');
Route::post('/{id}/forma-juridica', [CompanyComparisonController::class, 'save'])->whereUuid('id');
Route::get('/{id}/preparar-empresa', [CompanyPreparationController::class, 'show'])->whereUuid('id');
+1
View File
@@ -18,6 +18,7 @@ Route::middleware(['auth', 'verified', PrivateInvestmentResponse::class])->group
Route::get('contextos', [Guidance::class, 'contexts']);
Route::post('dossiers', [Desk::class, 'createCase']);
Route::get('dossiers/{id}', [Desk::class, 'showCase'])->whereUuid('id');
Route::get('dossiers/{id}/pre-dossier', [Desk::class, 'preDossier'])->whereUuid('id');
Route::put('dossiers/{id}/estado', [Desk::class, 'caseState'])->whereUuid('id');
Route::post('dossiers/{id}/etapas', [Desk::class, 'stage'])->whereUuid('id');
Route::post('dossiers/{id}/pedidos', [Desk::class, 'createRequest'])->whereUuid('id');
+15
View File
@@ -32,6 +32,7 @@ use App\Http\Controllers\InvestmentTerritoryController;
use App\Http\Controllers\InvestorWorkspaceController;
use App\Http\Controllers\LegacyContentController;
use App\Http\Controllers\OpportunityClassificationController;
use App\Http\Controllers\OpportunityCurationController;
use App\Http\Controllers\OpportunityManagementController;
use App\Http\Controllers\OpportunityOriginReviewController;
use App\Http\Controllers\OpportunityPromotionController;
@@ -46,6 +47,7 @@ use App\Http\Controllers\ProductUsageMetricsController;
use App\Http\Controllers\PublicBusinessOpportunityController;
use App\Http\Controllers\PublicGeographyController;
use App\Http\Controllers\PublicPortalController;
use App\Http\Controllers\PublicSitemapController;
use App\Http\Controllers\SectorReconciliationController;
use App\Http\Controllers\TerritoryGeometryImportController;
use App\Http\Middleware\AiEnabled;
@@ -66,6 +68,9 @@ use Laravel\Fortify\Features;
// Route::inertia('/', 'welcome')->name('home');
Route::get('/', [PortalServicesController::class, 'home'])->name('home');
Route::get('/sitemap.xml', [PublicSitemapController::class, 'index']);
Route::get('/sitemap/pages.xml', [PublicSitemapController::class, 'pages']);
Route::get('/sitemap/opportunities/{page}.xml', [PublicSitemapController::class, 'opportunities'])->whereNumber('page');
Route::inertia('/contextos-de-investimento', 'portal/investment-contexts')->name('portal.investment-contexts');
Route::get('/formularios/fiscais', function (Request $request) {
$input = $request->validate(['imposto' => ['nullable', 'in:iva,irpc']]);
@@ -80,6 +85,9 @@ foreach (['sectores' => 'sectors', 'como-investir' => 'procedures', 'legislacao'
Route::get('/'.$path, [PortalServicesController::class, 'page'])->defaults('page', $page)->name('services.'.$page);
}
Route::get('/Simulador', [PortalServicesController::class, 'page'])->defaults('page', 'simulator')->name('services.simulator.alias');
foreach (['simulador' => 'services.simulator', 'contactos' => 'services.support'] as $legacyPath => $destination) {
Route::get('/'.$legacyPath, fn (Request $request) => redirect()->to(route($destination, $request->query(), false), 301));
}
Route::get('/documentos/{id}', [PortalServicesController::class, 'showDocument'])->whereNumber('id')->name('services.document');
Route::get('/documentos/{id}/view', [PortalServicesController::class, 'viewDocument'])->whereNumber('id')->name('services.document.view');
Route::get('/documentos/{id}/download', [PortalServicesController::class, 'download'])->whereNumber('id')->name('services.download');
@@ -217,6 +225,9 @@ Route::middleware(['auth', 'verified', EnsurePortalProfiles::class])->group(func
Route::get('/minhas-oportunidades/{id}/edit', [$profiles, 'edit'])->whereNumber('id');
Route::get('/minhas-oportunidades/{id}/view', [$profiles, 'edit'])->whereNumber('id');
Route::get('/minhas-oportunidades/{id}/response', [OpportunityManagementController::class, 'canonicalResponse'])->whereNumber('id');
Route::get('/minhas-oportunidades/{id}/destaque', [OpportunityCurationController::class, 'show'])->whereNumber('id')->middleware(PrivateInvestmentResponse::class);
Route::post('/minhas-oportunidades/{id}/destaque/previa', [OpportunityCurationController::class, 'preview'])->whereNumber('id')->middleware(PrivateInvestmentResponse::class);
Route::post('/minhas-oportunidades/{id}/destaque/confirmar', [OpportunityCurationController::class, 'confirm'])->whereNumber('id')->middleware(PrivateInvestmentResponse::class);
Route::post('/minhas-oportunidades/drafts', [OpportunityManagementController::class, 'save']);
Route::put('/minhas-oportunidades/{id}/draft', [OpportunityManagementController::class, 'save'])->whereNumber('id');
Route::get('/minhas-oportunidades/{id}/classification', [OpportunityClassificationController::class, 'index'])->whereNumber('id');
@@ -258,6 +269,9 @@ Route::middleware(['auth', 'verified', EnsureScopedPortalAdministration::class])
Route::get('opportunities/create', [OpportunityManagementController::class, 'edit']);
Route::get('opportunities/{id}/edit', [OpportunityManagementController::class, 'edit'])->whereNumber('id');
Route::get('opportunities/{id}/view', [OpportunityManagementController::class, 'edit'])->whereNumber('id');
Route::get('opportunities/{id}/destaque', [OpportunityCurationController::class, 'show'])->whereNumber('id')->middleware(PrivateInvestmentResponse::class);
Route::post('opportunities/{id}/destaque/previa', [OpportunityCurationController::class, 'preview'])->whereNumber('id')->middleware(PrivateInvestmentResponse::class);
Route::post('opportunities/{id}/destaque/confirmar', [OpportunityCurationController::class, 'confirm'])->whereNumber('id')->middleware(PrivateInvestmentResponse::class);
Route::post('opportunities/drafts', [OpportunityManagementController::class, 'save']);
Route::put('opportunities/{id}/draft', [OpportunityManagementController::class, 'save'])->whereNumber('id');
Route::post('opportunities/{id}/lifecycle', [OpportunityManagementController::class, 'lifecycle'])->whereNumber('id');
@@ -280,6 +294,7 @@ Route::middleware(['auth', 'verified', EnsureScopedPortalAdministration::class])
Route::get('{kind}/{id}', [EducationController::class, 'declaration'])->whereIn('kind',['demands','capacities'])->whereUuid('id')->name('declaration');
Route::post('catalog/{kind}', [EducationController::class, 'catalog'])->whereIn('kind', ['skills', 'occupations', 'providers', 'courses'])->name('catalog');
Route::post('{kind}', [EducationController::class, 'store'])->whereIn('kind', ['demands', 'capacities'])->name('store');
Route::match(['GET', 'POST'], '{kind}/{id}/review-preparation', [EducationController::class, 'reviewPreparation'])->whereIn('kind', ['demands','capacities'])->whereUuid('id');
Route::post('{kind}/{id}/review', [EducationController::class, 'review'])->whereIn('kind', ['demands', 'capacities'])->whereUuid('id')->name('review');
Route::get('demands/{id}/analysis', [EducationController::class, 'analysis'])->whereUuid('id')->name('analysis');
Route::post('demands/{id}/investment', [EducationInvestmentController::class, 'store'])->whereUuid('id')->name('investment');
@@ -0,0 +1,174 @@
<?php
namespace Tests\Feature;
use App\Models\Course;
use App\Models\LabourDemand;
use App\Models\Occupation;
use App\Models\Project;
use App\Models\Skill;
use App\Models\TrainingCapacity;
use App\Models\TrainingProvider;
use App\Models\User;
use App\Services\EducationReviews;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class EducationReviewPreparationTest extends TestCase
{
use DatabaseTransactions;
private User $reviewer;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
config(['education.enabled' => true]);
$identity = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $identity->name);
$this->assertSame(55441, (int) $identity->port);
$this->reviewer = $this->manager();
}
private function manager(): User
{
$u = User::factory()->create();
config(['portal.managers' => [...config('portal.managers', []), $u->email]]);
$this->actingAs($u);
return $u;
}
private function record(string $kind): LabourDemand|TrainingCapacity
{
$place = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $place, 'name' => 'SYNTHETIC province', 'code' => $place, 'location_type' => 'province']);
$data = ['occupation_id' => Occupation::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC occupation'])->id, 'skill_id' => Skill::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC skill'])->id,
'geo_location_id' => $place, 'qualification' => 'SYNTHETIC qualification', 'quantity' => 20, 'period_start' => today()->addMonth()->toDateString(), 'period_end' => today()->addMonths(2)->toDateString(),
'source' => 'SYNTHETIC documentary source', 'reference_date' => today()->toDateString(), 'valid_until' => today()->addYear()->toDateString(), 'submitted_by' => User::factory()->create()->id, 'status' => 'submitted', 'version_no' => 1];
if ($kind === 'demands') {
return LabourDemand::create([...$data, 'project_id' => Project::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC project', 'status' => 'draft'])->id, 'employer' => 'SYNTHETIC employer']);
}
$provider = TrainingProvider::create(['name' => 'SYNTHETIC provider', 'status' => 'candidate']);
$course = Course::create(['provider_id' => $provider->id, 'code' => (string) Str::uuid(), 'title' => 'SYNTHETIC course', 'status' => 'draft']);
return TrainingCapacity::create([...$data, 'course_id' => $course->id]);
}
private function state(LabourDemand|TrainingCapacity $record): array
{
$base = ['version_no' => 1, 'qualification' => 'SYNTHETIC qualification', 'quantity' => 20, 'status' => 'submitted', 'period_start' => $record->getRawOriginal('period_start'), 'period_end' => $record->getRawOriginal('period_end'),
'valid_until' => $record->getRawOriginal('valid_until'), 'source' => 'SYNTHETIC documentary source', 'reference_date' => today()->toDateString(), 'employer' => '', 'references' => []];
return ['values' => ['version_no' => 1, 'status' => 'reviewed', 'review_reason' => ' SENSITIVE SYNTHETIC PRIVATE REVIEW NOTE '], 'generation' => ['status' => 0, 'review_reason' => 1], 'base' => $base, 'pending' => null];
}
private function url(string $kind, LabourDemand|TrainingCapacity $r): string
{
return '/management/education/'.$kind.'/'.$r->id.'/review-preparation';
}
public function test_private_preparation_is_encrypted_actor_scoped_idempotent_and_preserves_the_declaration(): void
{
foreach (['demands', 'capacities'] as $kind) {
$record = $this->record($kind);
$before = $record->fresh()->getAttributes();
$url = $this->url($kind, $record);
$body = ['actor_id' => $this->reviewer->id, 'version' => 0, 'operation_key' => (string) Str::uuid(), 'snapshot' => $this->state($record)];
$response = $this->postJson($url, $body)->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('version', 1);
$this->assertStringContainsString('"references":{}', $response->getContent());
$saved = $response->json();
$this->assertSame($body['snapshot'], $saved['snapshot']);
$this->postJson($url, $body)->assertOk()->assertJsonPath('version', 1);
$query = DB::table('audit_events')->where('source_system', 'portal-education-review')->where('entity_id', $record->id)->where('action', 'review_preparation_saved');
$this->assertSame(1, $query->count());
$this->assertStringNotContainsString('SENSITIVE SYNTHETIC PRIVATE REVIEW NOTE', $query->value('after_data'));
$this->getJson($url)->assertOk()->assertJsonPath('snapshot.values.review_reason', $body['snapshot']['values']['review_reason']);
$this->assertSame($before, $record->fresh()->getAttributes());
$this->assertSame(0, DB::table('audit_events')->where('entity_id', $record->id)->where('action', 'review_committed')->count());
$this->get('/management/education/'.$kind.'/'.$record->id)->assertOk()->assertInertia(fn ($page) => $page->where('preparation.version', 1)->where('history.total', 0));
$this->manager();
$this->getJson($url)->assertOk()->assertJsonPath('version', 0)->assertJsonPath('snapshot', null);
$this->actingAs($this->reviewer);
$this->postJson($url, [...$body, 'snapshot' => [...$body['snapshot'], 'values' => [...$body['snapshot']['values'], 'review_reason' => 'DIFFERENT NOTE USING THE SAME KEY']]])->assertConflict();
}
}
public function test_private_preparation_optimistic_version_and_late_replay_never_replace_newer_notes(): void
{
$r = $this->record('demands');
$url = $this->url('demands', $r);
$first = ['actor_id' => $this->reviewer->id, 'version' => 0, 'operation_key' => (string) Str::uuid(), 'snapshot' => $this->state($r)];
$this->postJson($url, $first)->assertOk();
$second = [...$first, 'version' => 1, 'operation_key' => (string) Str::uuid()];
$second['snapshot']['values']['review_reason'] = 'SYNTHETIC deliberate newer note from a second window.';
$this->postJson($url, $second)->assertOk()->assertJsonPath('version', 2);
$this->postJson($url, $first)->assertOk()->assertJsonPath('version', 2)->assertJsonPath('snapshot.values.review_reason', $second['snapshot']['values']['review_reason']);
$this->postJson($url, [...$first, 'operation_key' => (string) Str::uuid()])->assertConflict();
$this->assertSame(2, DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_preparation_saved')->count());
$other = $this->manager();
$this->postJson($url, $first)->assertConflict();
$this->getJson($url.'?actor_id='.$this->reviewer->id)->assertConflict();
$this->getJson($url)->assertOk()->assertJsonPath('snapshot', null)->assertJsonPath('actor_id', $other->id);
}
public function test_uncertain_review_is_recovered_from_its_private_intent_and_same_native_command_once(): void
{
$r = $this->record('demands');
$state = $this->state($r);
$key = (string) Str::uuid();
$state['pending'] = ['values' => [...$state['values'], 'client_key' => $key], 'reasonGeneration' => 1, 'statusGeneration' => 0, 'base' => $state['base']];
$state['values']['review_reason'] = 'SYNTHETIC later edit retained after an uncertain response.';
$state['generation']['review_reason'] = 2;
$url = $this->url('demands', $r);
$this->postJson($url, ['actor_id' => $this->reviewer->id, 'version' => 0, 'operation_key' => (string) Str::uuid(), 'snapshot' => $state])->assertOk();
$native = '/management/education/demands/'.$r->id.'/review';
$this->post($native, $state['pending']['values'])->assertRedirect();
$recovered = $this->getJson($url)->assertOk()->json('snapshot');
$this->assertSame($state, $recovered);
$this->post($native, $recovered['pending']['values'])->assertRedirect();
$this->assertSame(2, $r->fresh()->version_no);
$this->assertSame(1, DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_committed')->count());
$context = app(EducationReviews::class)->context($this->reviewer, $r->fresh());
$this->assertSame($key, $context['commands'][0]['key']);
$page = $this->get('/management/education/demands/'.$r->id)->assertOk()->viewData('page')['props'];
$directory = base_path('outputs/education-review-preparation-20261111');
if (! is_dir($directory)) {
mkdir($directory, 0700, true);
}
file_put_contents($directory.'/recovered-page.json', json_encode(['synthetic' => true, 'transactional_QA' => true, 'props' => array_intersect_key($page, array_flip(['declaration', 'kind', 'actorId', 'today', 'references', 'preparation', 'history', 'backUrl']))], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
public function test_unreadable_private_preparation_and_unauthorised_access_preserve_previous_bytes(): void
{
$r = $this->record('demands');
$url = $this->url('demands', $r);
$bytes = json_encode(['education_preparation' => ['actor_id' => $this->reviewer->id, 'version' => 1, 'operation_key' => (string) Str::uuid(), 'hash' => str_repeat('a', 64), 'snapshot' => 'SYNTHETIC unreadable original encrypted bytes']], JSON_THROW_ON_ERROR);
DB::table('audit_events')->insert(['source_system' => 'portal-education-review', 'actor_user_id' => $this->reviewer->id, 'entity_type' => 'labour_demands', 'entity_id' => $r->id, 'action' => 'review_preparation_saved', 'data_classification' => 'restricted', 'after_data' => $bytes]);
$stored = DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_preparation_saved')->value('after_data');
$this->getJson($url)->assertOk()->assertJsonPath('unavailable', true)->assertJsonPath('snapshot', null);
$this->postJson($url, ['actor_id' => $this->reviewer->id, 'version' => 1, 'operation_key' => (string) Str::uuid(), 'snapshot' => $this->state($r)])->assertConflict();
$this->assertSame($stored, DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_preparation_saved')->value('after_data'));
$this->actingAs(User::factory()->create());
$this->getJson($url)->assertForbidden();
$this->postJson($url, [])->assertForbidden();
}
public function test_invalid_decrypted_snapshots_are_unavailable_without_losing_the_encrypted_original(): void
{
foreach (['{', json_encode(['unexpected' => 'SYNTHETIC invalid snapshot'], JSON_THROW_ON_ERROR)] as $plain) {
$r = $this->record('demands');
$cipher = Crypt::encryptString($plain);
DB::table('audit_events')->insert(['source_system' => 'portal-education-review', 'actor_user_id' => $this->reviewer->id, 'entity_type' => 'labour_demands', 'entity_id' => $r->id, 'action' => 'review_preparation_saved', 'data_classification' => 'restricted',
'after_data' => json_encode(['education_preparation' => ['version' => 1, 'operation_key' => (string) Str::uuid(), 'hash' => str_repeat('a', 64), 'snapshot' => $cipher]], JSON_THROW_ON_ERROR)]);
$this->getJson($this->url('demands', $r))->assertOk()->assertJsonPath('unavailable', true)->assertJsonPath('snapshot', null);
$actual = json_decode(DB::table('audit_events')->where('entity_id', $r->id)->value('after_data'), true);
$this->assertSame($cipher, $actual['education_preparation']['snapshot']);
}
}
}
@@ -0,0 +1,143 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\FundingDossierPreparation;
use App\Services\FundingProgrammeReferences;
use App\Services\FundingProgrammes;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Tests\TestCase;
final class FinancialInstitutionsSourceTest extends TestCase
{
use DatabaseTransactions;
public function test_assisted_editor_prepares_without_publishing_and_preserves_deliberate_edits(): void
{
$this->withoutVite();
$actor = User::factory()->create();
config(['portal.managers' => [$actor->email]]);
$this->actingAs($actor);
$this->get('/management/fundos/referencias-assistidas')->assertOk()->assertInertia(fn ($page) => $page->component('funding/assisted')->has('batch.items', 275));
$plan = $this->getJson('/management/fundos/referencias/arko')->assertOk()->json();
$receipt = $this->postJson('/management/fundos/referencias/arko/preparar', ['hash' => $plan['hash']])->assertOk()->assertJsonPath('created', true)->assertJsonPath('matches_reference', true)->json();
$id = $receipt['id'];
$this->assertNull(DB::table('funding_programmes')->where('id', $id)->value('published_version_id'));
$this->postJson('/management/fundos/referencias/arko/preparar', ['hash' => $plan['hash']])->assertOk()->assertJsonPath('created', false)->assertJsonPath('id', $id);
$body = [...$receipt['publication'], 'reason' => 'SYNTHETIC explicit editorial publication confirmation, rolled back.'];
$this->postJson('/management/fundos/'.$id.'/publicar', $body)->assertUnprocessable();
$published = $this->postJson('/management/fundos/'.$id.'/publicar', [...$body, 'confirm' => true])->assertOk()->json('version_id');
$this->postJson('/management/fundos/'.$id.'/publicar', [...$body, 'confirm' => true])->assertOk()->assertJsonPath('version_id', $published);
$row = DB::table('funding_programmes')->where('id', $id)->first();
$draft = json_decode($row->draft, true);
$draft['summary'] .= ' Deliberate QA editorial clarification.';
DB::table('funding_programmes')->where('id', $id)->update(['draft' => json_encode($draft), 'version' => $row->version + 1]);
$this->postJson('/management/fundos/referencias/arko/preparar', ['hash' => $plan['hash']])->assertOk()->assertJsonPath('matches_reference', false)->assertJsonPath('publication', null);
$this->assertSame($draft, json_decode(DB::table('funding_programmes')->where('id', $id)->value('draft'), true));
$other = User::factory()->create();
$this->actingAs($other)->get('/management/fundos/referencias-assistidas')->assertForbidden();
}
public function test_city_or_branch_text_is_searchable_without_treating_wildcards_as_search_commands(): void
{
$this->withoutVite();
Http::preventStrayRequests();
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$actor = User::factory()->create();
config(['portal.managers' => [$actor->email]]);
$this->actingAs($actor);
$references = app(FundingProgrammeReferences::class);
$plan = $references->preview($actor, 'arko');
$id = $references->prepare($actor, 'arko', $plan['hash'])['id'];
$service = app(FundingProgrammes::class);
$publication = $service->preview($actor, $id);
$service->publish($actor, $id, ['version' => $publication['version'], 'hash' => $publication['hash'], 'confirm' => true, 'reason' => 'SYNTHETIC QA city search through the existing controller, rolled back.']);
$province = DB::table('geo_locations')->where('location_type', 'province')->where('name', 'Sofala')->value('id');
$filters = ['q' => 'Beira', 'territory' => $province, 'location_mode' => 'physical', 'category' => 'insurance', 'institution_type' => 'insurer'];
$this->get('/fundos?'.http_build_query($filters))->assertOk()->assertInertia(fn ($page) => $page->component('funding/index')->where('items.data.0.id', $id));
$this->get('/fundos?'.http_build_query([...$filters, 'q' => '%']))->assertOk()->assertInertia(fn ($page) => $page->component('funding/index')->has('items.data', 0));
$record = $service->published($id);
$location = collect($record['payload']['locations'])->first(fn ($loc) => $loc['territory_id'] === $province && $loc['role'] === 'branch');
$case = $this->postJson('/investimentos', ['title' => 'SYNTHETIC continuity during investor preparation', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$tracking = app(FundingDossierPreparation::class);
$context = $tracking->context($actor, $case, null);
$saved = $this->postJson('/fundos/'.$id.'/dossier', ['context' => $case, 'version' => $context['version'], 'version_id' => $record['version_id'], 'location_id' => $location['id'], 'location_mode' => 'physical', 'territory' => $province, 'need' => 'insurance', 'operation_key' => (string) Str::uuid(), 'consent' => true])->assertOk()->json();
$this->post('/investimentos/'.$case.'/aplicabilidade', ['version' => $saved['version'], 'land_needed' => 'unknown', 'external_funding' => 'yes', 'viability' => 'unknown', 'reason' => 'SYNTHETIC next preparation step retains selected insurer and provenance.'])->assertRedirect();
$continued = $tracking->context($actor, $case, null);
$this->assertSame($saved['selections'], $continued['selections']);
$this->assertGreaterThan($saved['version'], $continued['version']);
$this->assertSame('insurance', $continued['selections'][0]['need']);
Http::assertNothingSent();
}
public function test_supplied_institutions_can_be_found_by_physical_location_category_and_type_and_remain_traceable_in_one_dossier(): void
{
$identity = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $identity->name);
$this->assertSame(55441, (int) $identity->port);
$this->withoutVite();
Http::preventStrayRequests();
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$actor = User::factory()->create();
config(['portal.managers' => [$actor->email]]);
$this->actingAs($actor);
$source = json_decode(file_get_contents(database_path('data/funding-institutions-nov2026.json')), true, 512, JSON_THROW_ON_ERROR);
$references = app(FundingProgrammeReferences::class);
$catalogue = app(FundingProgrammes::class);
$records = [];
foreach ($source['references'] as $code => $reference) {
$plan = $references->preview($actor, $code);
$id = $references->prepare($actor, $code, $plan['hash'])['id'];
$this->assertNull(DB::table('funding_programmes')->where('id', $id)->value('published_version_id'), $code);
$ready = $catalogue->preview($actor, $id);
$catalogue->publish($actor, $id, ['version' => $ready['version'], 'hash' => $ready['hash'], 'confirm' => true, 'reason' => 'SYNTHETIC QA publication of supplied documentary facts; rolled back.']);
$records[$code] = $catalogue->published($id);
$this->assertSame($reference['draft']['institution_key'], $records[$code]['payload']['institution_key']);
}
$this->assertCount(270, $records);
$this->assertCount(270, array_unique(array_map(fn ($r) => $r['payload']['institution_key'], $records)));
$province = DB::table('geo_locations')->where('location_type', 'province')->where('name', 'Nampula')->value('id');
$filters = ['territory' => $province, 'location_mode' => 'physical', 'category' => 'insurance', 'institution_type' => 'insurer', 'certainty' => 'documented'];
$found = $catalogue->catalogueQuery($filters)->pluck('p.code')->all();
$this->assertContains('arko', $found);
$this->assertNotContains('bci', $found);
$this->assertNotContains('issm', $found);
$this->assertFalse($catalogue->catalogueQuery(['territory' => $province, 'need' => 'investment'])->where('p.id', $records['arko']['id'])->exists(), 'Insurer is not silently a credit provider');
$this->assertCount(8, $catalogue->catalogueQuery(['institution_type' => 'pension_manager'])->get());
$this->assertFalse($catalogue->catalogueQuery([])->where('p.id', $records['abc-historical']['id'])->exists());
$this->assertTrue($catalogue->catalogueQuery(['include_historical' => true])->where('p.id', $records['abc-historical']['id'])->exists());
$this->assertSame('access-bank', $records['abc-historical']['payload']['successor_key']);
$this->get('/fundos?'.http_build_query($filters))->assertOk()->assertInertia(fn ($p) => $p->component('funding/index')->where('filters.category', 'insurance')->where('filters.institution_type', 'insurer'));
$case = $this->postJson('/investimentos', ['title' => 'SYNTHETIC bank and insurance preparation', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$tracking = app(FundingDossierPreparation::class);
$before = $tracking->context($actor, $case, null);
$bank = ['context' => $case, 'version' => $before['version'], 'version_id' => $records['bci']['version_id'], 'need' => 'investment', 'territory' => $province, 'product' => 'bci-oferta', 'operation_key' => (string) Str::uuid(), 'consent' => true];
$afterBank = $this->postJson('/fundos/'.$records['bci']['id'].'/dossier', $bank)->assertOk()->json();
$location = collect($records['arko']['payload']['locations'])->first(fn ($loc) => $loc['territory_id'] === $province && $loc['role'] === 'branch');
$this->assertNotNull($location);
$insurance = ['context' => $case, 'version' => $afterBank['version'], 'version_id' => $records['arko']['version_id'], 'need' => 'insurance', 'territory' => $province, 'location_mode' => 'physical', 'location_id' => $location['id'], 'operation_key' => (string) Str::uuid(), 'consent' => true];
$saved = $this->postJson('/fundos/'.$records['arko']['id'].'/dossier', $insurance)->assertOk()->assertJsonCount(2, 'selections')->json();
$this->postJson('/fundos/'.$records['arko']['id'].'/dossier', $insurance)->assertOk()->assertJsonCount(2, 'selections');
$this->postJson('/fundos/'.$records['arko']['id'].'/dossier', [...$insurance, 'version' => $saved['version'], 'operation_key' => (string) Str::uuid(), 'location_id' => 'foreign-location'])->assertUnprocessable();
$catalogue->withdraw($actor, $records['arko']['id'], $records['arko']['version'], 'SYNTHETIC QA withdrawal without rewriting investor history.');
$recovered = $tracking->context($actor, $case, null);
$this->assertSame($saved['selections'], $recovered['selections']);
$chosen = $recovered['selections'][1];
$this->assertSame('arko', $chosen['institution_key']);
$this->assertSame(['insurance'], $chosen['categories']);
$this->assertContains('insurer', $chosen['institution_types']);
$this->assertSame($location['id'], $chosen['location']['id']);
$this->assertSame($source['source']['sha256'], $chosen['location']['source_document_sha256']);
$this->getJson('/investimentos/'.$case)->assertOk()->assertJsonCount(2, 'funding_selections');
$directory = base_path('outputs/funding-directory-20261110/institutions');
if (! is_dir($directory)) {
mkdir($directory, 0700, true);
}
file_put_contents($directory.'/content.json', json_encode(['synthetic_editorial_publication_rolled_back' => true, 'source' => $source['source'], 'records' => $records, 'nampula_insurers' => $found, 'dossier_recovered' => $recovered, 'no_external_submission' => true], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
Http::assertNothingSent();
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\FundingDossierPreparation;
use App\Services\FundingProgrammeReferences;
use App\Services\FundingProgrammes;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
final class FundingDirectoryTest extends TestCase
{
use DatabaseTransactions;
private User $editor;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$identity = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $identity->name);
$this->assertSame(55441, (int) $identity->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
Http::preventStrayRequests();
$this->editor = User::factory()->create();
config(['portal.managers' => [$this->editor->email]]);
$this->actingAs($this->editor);
}
private function prepared(string $code): string
{
$references = app(FundingProgrammeReferences::class);
$plan = $references->preview($this->editor, $code);
return $references->prepare($this->editor, $code, $plan['hash'])['id'];
}
private function publish(string $id): array
{
$service = app(FundingProgrammes::class);
$plan = $service->preview($this->editor, $id);
$service->publish($this->editor, $id, ['version' => $plan['version'], 'hash' => $plan['hash'], 'confirm' => true, 'reason' => 'SYNTHETIC QA editorial publication; rolled back, not live content.']);
return $service->published($id);
}
private function province(string $name): string
{
return DB::table('geo_locations')->where('location_type', 'province')->where('name', $name)->value('id');
}
public function test_provided_content_is_queryable_with_sources_contacts_coverage_and_no_automatic_publication_or_duplicates(): void
{
$references = app(FundingProgrammeReferences::class);
$service = app(FundingProgrammes::class);
$before = $service->publicQuery()->count();
$codes = ['bci', 'millennium-bim', 'absa', 'standard-bank', 'moza', 'bni', 'gapi', 'fecop', 'semeia', 'fcid-viii', 'fae'];
$records = [];
foreach ($codes as $code) {
$id = $this->prepared($code);
$this->assertSame($id, $this->prepared($code));
$this->assertNull(DB::table('funding_programmes')->where('id', $id)->value('published_version_id'));
$this->assertSame($before + count($records), $service->publicQuery()->count());
$records[$code] = $this->publish($id);
$this->assertNotEmpty($records[$code]['payload']['source_url']);
}
$this->assertSame($before + 11, $service->publicQuery()->count());
$this->assertCount(2, $records['bni']['payload']['products']);
$this->assertContains('Maputo Cidade', array_column($records['bni']['payload']['locations'][0]['territory_path'], 'name'));
$this->assertSame('national', $records['bni']['payload']['products'][0]['coverage']);
$this->assertCount(6, $records['fcid-viii']['payload']['territory_ids']);
$this->assertSame('closed', $records['semeia']['payload']['windows'][0]['state']);
$this->assertSame('unknown', $records['fae']['payload']['coverage']);
$filters = ['territory' => $this->province('Cabo Delgado'), 'need' => 'investment'];
$found = $service->catalogueQuery($filters)->pluck('p.code')->all();
$this->assertContains('bci', $found);
$this->assertContains('bni', $found);
$this->assertNotContains('fcid-viii', $found);
$this->get('/fundos?'.http_build_query($filters))->assertOk()->assertInertia(fn ($p) => $p->component('funding/index')->where('filters.need', 'investment'));
$this->get('/fundos/'.$records['bni']['id'])->assertOk()->assertInertia(fn ($p) => $p->component('funding/show')->where('record.payload.contacts.1.value', '+258 21 498 581'));
file_put_contents(base_path('outputs/funding-directory-20261110/content-qa.json'), json_encode(['synthetic_editorial_publication_rolled_back' => true, 'provided_source_sha256' => '065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617', 'records' => $records, 'cabo_delgado_investment_results' => $found], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
Http::assertNothingSent();
}
public function test_location_and_need_must_match_the_same_product_and_unknown_is_explicit(): void
{
$service = app(FundingProgrammes::class);
$draft = app(FundingProgrammeReferences::class)->preview($this->editor, 'bci')['draft'];
$draft['institution_key'] = 'synthetic-same-product';
$draft['title'] = 'SYNTHETIC QA — cross product scope';
$draft['products'][0]['coverage'] = 'territorial';
$draft['products'][0]['territory_ids'] = [$this->province('Maputo Província')];
$draft['products'][0]['needs'] = ['investment'];
$other = $draft['products'][0];
$other['code'] = 'synthetic-working';
$other['needs'] = ['working_capital'];
$other['territory_ids'] = [$this->province('Nampula')];
$draft['products'][] = $other;
$id = $service->save($this->editor, null, ['version' => 0, 'code' => 'synthetic-cross', 'reason' => 'SYNTHETIC QA cross product coverage fixture.', 'draft' => $draft]);
$this->publish($id);
$base = ['territory' => $this->province('Nampula')];
$this->assertFalse($service->catalogueQuery([...$base, 'need' => 'investment'])->where('p.id', $id)->exists());
$this->assertTrue($service->catalogueQuery([...$base, 'need' => 'working_capital'])->where('p.id', $id)->exists());
$draft['products'][0]['coverage'] = 'unknown';
$draft['products'][0]['territory_ids'] = [];
$draft['products'][0]['availability'] = 'unknown';
$service->save($this->editor, $id, ['version' => 1, 'code' => 'synthetic-cross', 'reason' => 'SYNTHETIC QA unknown coverage, not absence.', 'draft' => $draft]);
$this->publish($id);
$this->assertTrue($service->catalogueQuery([...$base, 'need' => 'investment'])->where('p.id', $id)->exists());
$this->assertFalse($service->catalogueQuery([...$base, 'need' => 'investment', 'certainty' => 'documented'])->where('p.id', $id)->exists());
$this->expectException(ValidationException::class);
$service->save($this->editor, null, ['version' => 0, 'code' => 'synthetic-duplicate', 'reason' => 'SYNTHETIC QA duplicate stable institution guard.', 'draft' => $draft]);
}
public function test_selection_continues_in_existing_dossier_and_recovers_snapshot_without_sending_an_application(): void
{
$id = $this->prepared('bni');
$record = $this->publish($id);
$caseId = $this->postJson('/investimentos', ['title' => 'SYNTHETIC QA funding preparation', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
$context = app(FundingDossierPreparation::class)->context($this->editor, $caseId, null);
$this->get('/fundos?'.http_build_query(['context' => $caseId, 'territory' => $this->province('Nampula'), 'need' => 'investment']))->assertOk()->assertInertia(fn ($page) => $page->component('funding/index')->where('items.data', fn ($rows) => collect($rows)->contains('id', $id)));
$this->get('/fundos/'.$id.'?'.http_build_query(['context' => $caseId, 'territory' => $this->province('Nampula'), 'need' => 'investment']))->assertOk()->assertInertia(fn ($page) => $page->component('funding/show')->where('record.id', $id)->where('dossier.id', $caseId)->where('record.payload.contacts.1.value', '+258 21 498 581'));
$body = ['context' => $caseId, 'version' => $context['version'], 'version_id' => $record['version_id'], 'product' => 'bni-oferta', 'need' => 'investment', 'territory' => $this->province('Nampula'), 'operation_key' => (string) Str::uuid(), 'consent' => true];
$saved = $this->postJson('/fundos/'.$id.'/dossier', $body)->assertOk()->json();
$this->assertCount(1, $saved['selections']);
$this->assertSame($record['version_id'], $saved['selections'][0]['version_id']);
$this->postJson('/fundos/'.$id.'/dossier', $body)->assertOk();
$this->postJson('/fundos/'.$id.'/dossier', [...$body, 'operation_key' => (string) Str::uuid()])->assertOk()->assertJsonCount(1, 'selections');
$this->postJson('/fundos/'.$id.'/dossier', [...$body, 'need' => 'equity'])->assertConflict();
$this->postJson('/fundos/'.$id.'/dossier', [...$body, 'opportunity' => 'opportunity:not-in-this-dossier', 'operation_key' => (string) Str::uuid()])->assertUnprocessable();
$this->assertSame(1, DB::table('desk_events')->where('case_id', $caseId)->where('action', 'funding_preparation_saved')->count());
$this->getJson('/investimentos/'.$caseId)->assertOk()->assertJsonCount(1, 'funding_selections');
$service = app(FundingProgrammes::class);
$service->withdraw($this->editor, $id, $record['version'], 'SYNTHETIC QA withdrawal preserves consulted reference.');
$recovered = app(FundingDossierPreparation::class)->context($this->editor, $caseId, null);
$this->assertSame($saved['selections'], $recovered['selections']);
$this->actingAs(User::factory()->create())->postJson('/fundos/'.$id.'/dossier', $body)->assertForbidden();
$this->actingAs($this->editor);
DB::table('desk_cases')->where('id', $caseId)->update(['closed_at' => now()]);
$this->postJson('/fundos/'.$id.'/dossier', $body)->assertConflict();
$this->assertCount(1, app(FundingDossierPreparation::class)->context($this->editor, $caseId, null)['selections']);
file_put_contents(base_path('outputs/funding-directory-20261110/dossier-qa.json'), json_encode(['synthetic' => true, 'recovered' => $recovered, 'one_native_event' => true, 'foreign_actor_refused' => true, 'closed_dossier_read_only' => true, 'no_external_submission' => true], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
Http::assertNothingSent();
}
}
@@ -0,0 +1,169 @@
<?php
namespace Tests\Feature;
use App\Services\InvestmentGeography;
use App\Support\IsolatedQaDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
/** Temporary tables shadow QA tables on this connection only; no migrations or permanent fixture writes. */
class HomeTerritorialStatisticsTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
throw new \LogicException('Explicit isolated QA identity required for temporary fixtures.');
}
DB::beginTransaction();
foreach ([
'opportunities' => 'id bigint, status text, published_at timestamptz, deleted_at timestamptz, owner_user_id bigint, workflow_context jsonb, primary_territory_id uuid, province text, geography_scope text',
'opportunity_locations' => 'opportunity_id bigint, geo_location_id uuid',
'geo_locations' => 'id uuid, parent_id uuid, name text, location_type text, deleted_at timestamptz, data_classification text, source_system text, external_id text',
'geo_territory_profiles' => 'geo_location_id uuid, source_id uuid, status text, valid_from date, valid_to date',
'geo_sources' => 'id uuid, status text',
'audit_events' => 'id bigint, source_system text, after_data jsonb, occurred_at timestamptz',
] as $table => $columns) {
DB::statement('CREATE TEMP TABLE '.$table.' ('.$columns.') ON COMMIT DROP');
}
}
protected function tearDown(): void
{
if (DB::transactionLevel() > 0) {
DB::rollBack();
}
parent::tearDown();
}
private function territory(string $name, string $type, ?string $parent = null, array $attributes = []): string
{
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(array_merge([
'id' => $id, 'parent_id' => $parent, 'name' => $name, 'location_type' => $type,
'deleted_at' => null, 'data_classification' => 'public', 'source_system' => 'cartografia-workbook',
'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id,
], $attributes));
return $id;
}
private function opportunity(int $id, array $attributes = []): void
{
DB::table('opportunities')->insert(array_merge([
'id' => $id, 'status' => 'published', 'published_at' => now()->subMinute(),
'deleted_at' => null, 'owner_user_id' => null, 'workflow_context' => null,
'primary_territory_id' => null, 'province' => null, 'geography_scope' => 'unspecified',
], $attributes));
}
private function statistics(): array
{
return app(InvestmentGeography::class)->opportunityStatistics();
}
public function test_multiple_territories_and_repeated_links_do_not_multiply_the_opportunity_total(): void
{
$country = $this->territory('QA country', 'country');
$first = $this->territory('QA province A', 'province', $country);
$other = $this->territory('QA province B', 'province', $country);
$district = $this->territory('QA district', 'district', $first);
$this->opportunity(1, ['primary_territory_id' => $district, 'province' => 'Stale declaration']);
foreach ([$district, $district, $first, $other, $other] as $id) {
DB::table('opportunity_locations')->insert(['opportunity_id' => 1, 'geo_location_id' => $id]);
}
$this->assertSame(['opportunities' => 1, 'provinces' => 2], $this->statistics());
$this->opportunity(2, ['primary_territory_id' => $first]);
$this->opportunity(3);
$this->assertSame(['opportunities' => 3, 'provinces' => 2], $this->statistics());
}
public function test_publication_guards_apply_to_both_counts(): void
{
$country = $this->territory('QA country', 'country');
$public = $this->territory('QA public province', 'province', $country);
$hidden = $this->territory('QA unpublished coverage', 'province', $country);
$this->opportunity(1, ['primary_territory_id' => $public]);
foreach ([
['status' => 'draft'], ['published_at' => now()->addDay()], ['published_at' => null],
['deleted_at' => now()], ['owner_user_id' => 9, 'workflow_context' => '{"publication":{"visibility":"restricted"}}'],
] as $index => $attributes) {
$this->opportunity($index + 2, ['primary_territory_id' => $hidden, ...$attributes]);
}
$this->opportunity(20, ['primary_territory_id' => $hidden]);
DB::table('audit_events')->insert(['id' => 1, 'source_system' => 'opportunity-origin-review', 'occurred_at' => now(),
'after_data' => '{"opportunity_id":20,"classification":"example"}']);
$this->assertSame(['opportunities' => 1, 'provinces' => 1], $this->statistics());
}
public function test_no_territory_and_national_scope_do_not_invent_provincial_coverage(): void
{
$country = $this->territory('QA country', 'country');
$this->territory('QA known province', 'province', $country);
$this->opportunity(1);
$this->opportunity(2, ['province' => 'QA known province', 'geography_scope' => 'national']);
$this->opportunity(3, ['workflow_context' => '{"corridor_agribusiness":{"corridor":"Corredor de Maputo"}}']);
$this->assertSame(['opportunities' => 3, 'provinces' => 0], $this->statistics());
}
public function test_legacy_names_require_a_public_province_and_cannot_override_canonical_links(): void
{
$country = $this->territory('QA country', 'country');
$first = $this->territory('QA province A', 'province', $country);
$this->territory('QA province B', 'province', $country);
$this->opportunity(1, ['province' => ' qa PROVINCE A ']);
$this->opportunity(2, ['province' => 'Unknown province']);
$this->opportunity(3, ['province' => ' ']);
$this->opportunity(4, ['province' => 'QA province B', 'primary_territory_id' => $first]);
$this->opportunity(5, ['province' => 'QA province B']);
DB::table('opportunity_locations')->insert(['opportunity_id' => 5, 'geo_location_id' => $first]);
$this->assertSame(['opportunities' => 5, 'provinces' => 1], $this->statistics());
}
public function test_private_removed_broken_and_cyclic_ancestry_are_not_counted(): void
{
$country = $this->territory('QA country', 'country');
$private = $this->territory('QA private', 'province', $country, ['data_classification' => 'restricted']);
$child = $this->territory('QA private child', 'district', $private);
$removed = $this->territory('QA removed', 'province', $country, ['deleted_at' => now()]);
$broken = $this->territory('QA broken', 'province', (string) Str::uuid());
$cycle = $this->territory('QA cycle', 'province');
DB::table('geo_locations')->where('id', $cycle)->update(['parent_id' => $cycle]);
foreach ([$private, $child, $removed, $broken, $cycle] as $index => $id) {
$this->opportunity($index + 1, ['primary_territory_id' => $id]);
}
$this->assertSame(['opportunities' => 5, 'provinces' => 0], $this->statistics());
}
public function test_city_and_province_are_distinct_but_duplicate_province_names_are_not_added(): void
{
$country = $this->territory('QA country', 'country');
foreach (['Maputo Cidade', 'Maputo Província', ' maputo cidade '] as $index => $name) {
$this->opportunity($index + 1, ['primary_territory_id' => $this->territory($name, 'province', $country)]);
}
$this->assertSame(['opportunities' => 3, 'provinces' => 2], $this->statistics());
}
public function test_empty_catalogue_returns_zeroes_and_query_count_does_not_grow_with_links(): void
{
DB::enableQueryLog();
DB::flushQueryLog();
$this->assertSame(['opportunities' => 0, 'provinces' => 0], $this->statistics());
$emptyCount = count(DB::getQueryLog());
DB::disableQueryLog();
$country = $this->territory('QA country', 'country');
$province = $this->territory('QA province', 'province', $country);
for ($id = 1; $id <= 120; $id++) {
$this->opportunity($id, ['primary_territory_id' => $province]);
DB::table('opportunity_locations')->insert(['opportunity_id' => $id, 'geo_location_id' => $province]);
}
DB::enableQueryLog();
DB::flushQueryLog();
$this->assertSame(['opportunities' => 120, 'provinces' => 1], $this->statistics());
$this->assertSame($emptyCount, count(DB::getQueryLog()));
$this->assertLessThanOrEqual(2, $emptyCount);
DB::disableQueryLog();
}
}
@@ -0,0 +1,302 @@
<?php
namespace Tests\Feature;
use App\Models\EconomicCluster;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\ClusterCompetitiveness;
use App\Services\CorridorAgribusinessOpportunities;
use App\Services\InvestmentCorridorContext;
use App\Services\InvestmentPreparationWorkspace;
use App\Services\PrivateInvestmentPreparation;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
class InvestmentPreparationWorkspaceTest extends TestCase
{
use DatabaseTransactions;
private User $owner;
private string $caseId;
private array $documents = [];
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$db = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $db->name);
$this->assertSame(55441, (int) $db->port);
config(['investor-path.enabled' => true, 'investor-path.private_preparation' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
Http::preventStrayRequests();
Storage::fake('local');
$this->owner = User::factory()->create();
$this->actingAs($this->owner);
$this->caseId = $this->postJson('/investimentos', ['title' => 'SYNTHETIC private APIEX and SWOT QA', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
foreach (PrivateInvestmentPreparation::DOCUMENTS as $key) {
$id = (string) Str::uuid();
$bytes = 'SYNTHETIC QA ONLY '.$key;
$sha = hash('sha256', $bytes);
Storage::disk('local')->put('qa-preparation/'.$id, $bytes);
DB::table('documents')->insert(['id' => $id, 'file_name' => $key.'.txt', 'media_type' => 'text/plain', 'storage_key' => 'qa-preparation/'.$id, 'sha256' => $sha, 'malware_scanned_sha256' => $sha, 'malware_scan_status' => 'clean', 'status' => 'active', 'uploaded_by' => $this->owner->id]);
DB::table('desk_documents')->insert(['id' => (string) Str::uuid(), 'case_id' => $this->caseId, 'document_id' => $id, 'category' => 'support', 'source_reference' => 'SYNTHETIC fixture, not a real scan/submission', 'linked_by' => $this->owner->id]);
$this->documents[$key] = $id;
}
$this->context(['investment_form' => 'direct', 'investment_preparation' => 'yes', 'investment_activity_scope' => 'general', 'implementation_mode' => 'domestic_entity', 'capital_origin' => 'national', 'site_tenure' => 'lease', 'project_concession' => 'no', 'significant_implications' => 'no', 'land_extension' => 'no', 'forest_concession' => 'no', 'total_investment' => '1000000', 'currency' => 'MZN', 'expected_start_date' => '2026-01-01', 'entity_stage' => 'being_formed'], $this->documents);
}
private function context(array $declarations, array $links = [], array $selection = []): void
{
$view = $this->getJson('/investimentos/'.$this->caseId.'/contexto')->assertOk()->json();
$data = ['version' => $view['case']['version'], 'selection' => $selection, 'declarations' => $declarations, 'document_links' => $links];
$plan = $this->postJson('/investimentos/'.$this->caseId.'/contexto/previa', $data)->assertOk()->json();
$this->postJson('/investimentos/'.$this->caseId.'/contexto/confirmar', [...$data, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
}
private function workspace(): array
{
return $this->getJson('/investimentos/'.$this->caseId.'/preparacao')->assertOk()->assertHeader('Cache-Control', 'no-store, private')->json();
}
private function action(array $view, string $action, array $data = []): array
{
return $this->postJson('/investimentos/'.$this->caseId.'/preparacao', ['version' => $view['version'], 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC explicit review, not real external action', 'action' => $action, ...$data])->assertOk()->json();
}
public function test_apiex_existing_dossier_requirements_review_zip_and_external_delivery_without_submission_or_approval(): void
{
$before = DB::table('journey_steps')->orderBy('id')->get()->toJson();
$view = $this->workspace();
$this->assertSame([], $view['api_issues']);
$this->assertSame('preparing', $view['api_state']);
$view = $this->action($view, 'apiex_review', ['fingerprint' => $view['api_fingerprint']]);
$this->assertSame('ready', $view['api_state']);
$this->get('/investimentos/'.$this->caseId.'/preparacao/previa')->assertOk()->assertSee('Não é formulário oficial', false);
$export = (string) Str::uuid();
$body = ['version' => $view['version'], 'operation_key' => $export, 'reason' => 'SYNTHETIC download prepared package'];
$download = $this->postJson('/investimentos/'.$this->caseId.'/preparacao/exportar', $body)->assertOk()->assertHeader('Content-Type', 'application/zip')->assertHeader('X-Export-Id', $export);
$file = tempnam(sys_get_temp_dir(), 'qa-apiex-');
file_put_contents($file, $download->getContent());
$zip = new \ZipArchive;
try {
$this->assertTrue($zip->open($file));
$manifest = json_decode($zip->getFromName('manifesto.json'), true, flags: JSON_THROW_ON_ERROR);
$this->assertSame($this->caseId, $manifest['case_id']);
$this->assertSame($export, $manifest['export']['id']);
$this->assertSame($this->owner->email, $manifest['reused']['investor']['email']);
$this->assertSame($manifest['export']['prepared']['context'], $manifest['context']);
$this->assertStringContainsString('Índice', $zip->getFromName('indice.html'));
foreach ($manifest['export']['documents'] as $n => $doc) {
$this->assertSame($doc['sha256'], hash('sha256', $zip->getFromName('anexos/'.sprintf('%02d-', $n + 1).$doc['file_name'])));
}
$zip->close();
} finally {
unlink($file);
}
$this->postJson('/investimentos/'.$this->caseId.'/preparacao/exportar', $body)->assertOk();
$this->context(['project_summary' => 'SYNTHETIC later context, old archive must remain exact']);
$oldPackage = $this->get('/investimentos/'.$this->caseId.'/preparacao/exportar/'.$export)->assertOk();
$this->assertSame(hash('sha256', $download->getContent()), hash('sha256', $oldPackage->getContent()));
$view = $this->workspace();
$this->assertCount(1, $view['apiex']['exports']);
$this->assertSame('preparing', $view['api_state']);
$before = DB::table('journey_steps')->orderBy('id')->get()->toJson();
foreach (['delivery', 'clarification', 'response', 'decision'] as $kind) {
$related = collect($view['apiex']['external'])->firstWhere('kind', 'clarification')['id'] ?? null;
$view = $this->action($view, 'external', ['kind' => $kind, 'date' => now()->toDateString(), 'reference' => 'SYNTHETIC '.$kind, 'proof_document' => $this->documents['identity'], 'export_id' => $export, 'related_id' => $related, 'outcome' => $kind === 'decision' ? 'conditional' : null]);
$this->assertSame($kind === 'decision' ? 'decision_received' : 'delivery_recorded', $view['api_state']);
}
$out = base_path('outputs/investment-preparation-20261110');
if (! is_dir($out)) {
mkdir($out, 0700, true);
}
file_put_contents($out.'/apiex-package.zip', $download->getContent());
file_put_contents($out.'/apiex-recovered.json', json_encode([...$view, '_qa_archive' => ['initial_sha256' => hash('sha256', $download->getContent()), 'after_context_change_sha256' => hash('sha256', $oldPackage->getContent())]], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
$this->assertSame($before, DB::table('journey_steps')->orderBy('id')->get()->toJson());
$this->assertCount(4, $view['apiex']['external']);
Http::assertNothingSent();
DB::table('desk_cases')->where('id', $this->caseId)->update(['closed_at' => now()]);
$this->get('/investimentos/'.$this->caseId.'/preparacao/exportar/'.$export)->assertOk()->assertHeader('Content-Type', 'application/zip');
$this->assertSame($this->documents['identity'], $view['apiex']['external'][0]['proof_snapshot']['document_id']);
$this->assertStringNotContainsString('SYNTHETIC delivery', DB::table('audit_events')->where('source_system', 'investor-preparation')->get(['after_data'])->toJson());
}
public function test_swot_context_evidence_four_dimensions_review_action_recovery_and_source_change_preserve_deliberate_edits(): void
{
$view = $this->workspace();
$rows = [];
foreach (['strength', 'weakness', 'opportunity', 'threat'] as $dimension) {
$rows[] = ['id' => (string) Str::uuid(), 'dimension' => $dimension, 'text' => 'SYNTHETIC deliberate '.$dimension, 'reference' => 'SYNTHETIC source, no economic conclusion', 'source_key' => null, 'date' => now()->toDateString(), 'classification' => 'unconfirmed'];
}
$view = $this->action($view, 'swot_save', ['draft' => ['object_key' => 'investment:'.$this->caseId, 'scope' => 'SYNTHETIC exact investment scope', 'rows' => $rows, 'source_fingerprint' => $view['swot_fingerprint']]]);
$saved = $view['version'];
$view = $this->action($view, 'swot_review', ['fingerprint' => $view['swot_fingerprint']]);
$this->assertFalse($view['swot_review_required']);
$view = $this->action($view, 'swot_action', ['conclusion_id' => $rows[3]['id'], 'kind' => 'risk', 'title' => 'SYNTHETIC investigate evidence gap']);
$this->assertSame($this->caseId, $view['swot']['actions'][0]['case_id']);
$this->assertStringContainsString('/investimentos/'.$this->caseId, $view['swot']['actions'][0]['href']);
$continued = $this->getJson($view['swot']['actions'][0]['href'])->assertOk()->assertJsonPath('preparation_action.id', $view['swot']['actions'][0]['id'])->json();
$legacy = $this->postJson('/investimentos/'.$this->caseId.'/tarefas', ['version' => $continued['case']['version'], 'code' => 'S01', 'title' => 'SYNTHETIC independent existing stage task', 'due_at' => now()->addDay()->startOfMinute()->toISOString(), 'consent' => true])->assertCreated()->json('id');
$taskBody = ['version' => $continued['case']['version'], 'code' => 'S01', 'title' => $view['swot']['actions'][0]['title'], 'due_at' => now()->addDay()->startOfMinute()->toISOString(), 'consent' => true, 'preparation_action' => $view['swot']['actions'][0]['id'], 'opportunity' => $view['opportunity']];
$task = $this->postJson('/investimentos/'.$this->caseId.'/tarefas', $taskBody)->assertCreated()->json('id');
$this->postJson('/investimentos/'.$this->caseId.'/tarefas', $taskBody)->assertCreated()->assertJsonPath('id', $task);
$this->assertSame('swot:'.$view['swot']['actions'][0]['id'], DB::table('tasks')->where('id', $task)->value('guidance_key'));
$this->assertSame('SYNTHETIC independent existing stage task', DB::table('tasks')->where('id', $legacy)->value('title'));
$this->assertSame($this->caseId, DB::table('tasks')->where('id', $task)->value('desk_case_id'));
$this->assertSame($view['swot']['actions'][0]['title'], DB::table('tasks')->where('id', $task)->value('title'));
$this->context(['project_summary' => 'SYNTHETIC changed source context']);
$back = $this->workspace();
$this->assertTrue($back['swot_review_required']);
$this->assertSame($rows, $back['swot']['rows']);
$this->postJson('/investimentos/'.$this->caseId.'/preparacao', ['action' => 'swot_action', 'reason' => 'SYNTHETIC stale review', 'version' => $back['version'], 'operation_key' => (string) Str::uuid(), 'conclusion_id' => $rows[0]['id'], 'kind' => 'idea', 'title' => 'Should not advance'])->assertConflict();
$back = $this->action($back, 'swot_restore', ['revision' => $saved]);
$this->assertTrue($back['swot_review_required']);
$this->assertSame($rows, $back['swot']['rows']);
$this->assertCount(1, $back['swot']['actions']);
$this->assertGreaterThan($saved, $back['version']);
file_put_contents(base_path('outputs/investment-preparation-20261110/swot-recovered.json'), json_encode([...$back, '_qa_native_task' => ['id' => $task, 'case_id' => DB::table('tasks')->where('id', $task)->value('desk_case_id'), 'title' => DB::table('tasks')->where('id', $task)->value('title'), 'origin_key' => DB::table('tasks')->where('id', $task)->value('guidance_key')]], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
public function test_access_stale_updates_replay_and_scan_changes_do_not_bypass_existing_dossier_guards(): void
{
$view = $this->workspace();
$body = ['version' => $view['version'], 'action' => 'apiex_save', 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC preservation', 'additional_documents' => [], 'note' => 'SYNTHETIC deliberate note'];
$this->postJson('/investimentos/'.$this->caseId.'/preparacao', $body)->assertOk()->assertJsonPath('replayed', false);
$this->postJson('/investimentos/'.$this->caseId.'/preparacao', $body)->assertOk()->assertJsonPath('replayed', true);
$this->postJson('/investimentos/'.$this->caseId.'/preparacao', [...$body, 'note' => 'OTHER'])->assertConflict();
$this->postJson('/investimentos/'.$this->caseId.'/preparacao', [...$body, 'operation_key' => (string) Str::uuid()])->assertConflict();
$view = $this->workspace();
$view = $this->action($view, 'apiex_review', ['fingerprint' => $view['api_fingerprint']]);
$export = (string) Str::uuid();
$this->postJson('/investimentos/'.$this->caseId.'/preparacao/exportar', ['version' => $view['version'], 'operation_key' => $export, 'reason' => 'SYNTHETIC archive security boundary'])->assertOk();
DB::table('documents')->where('id', $this->documents['identity'])->update(['malware_scan_status' => 'pending']);
$blocked = $this->get('/investimentos/'.$this->caseId.'/preparacao/exportar/'.$export)->assertNotFound();
file_put_contents(base_path('outputs/investment-preparation-20261110/archive-security.json'), json_encode(['synthetic' => true, 'revoked_scan_cached_download_status' => $blocked->getStatusCode(), 'encrypted_archive_count' => count(Storage::disk('local')->allFiles('investor-preparations/'.$this->caseId))], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
$back = $this->workspace();
$this->assertNotSame('ready', $back['api_state']);
$this->assertNotEmpty($back['api_issues']);
$this->actingAs(User::factory()->create());
$this->getJson('/investimentos/'.$this->caseId.'/preparacao')->assertForbidden();
$this->actingAs($this->owner);
DB::table('desk_cases')->where('id', $this->caseId)->update(['closed_at' => now()]);
$this->postJson('/investimentos/'.$this->caseId.'/preparacao', [...$body, 'version' => $back['version'], 'operation_key' => (string) Str::uuid()])->assertConflict();
}
public function test_swot_reuses_only_published_competitiveness_and_flags_a_changed_public_source_without_replacing_conclusions(): void
{
$cluster = EconomicCluster::create(['code' => 'SWOT-'.bin2hex(random_bytes(6)), 'name' => 'SYNTHETIC cluster SWOT QA', 'sector' => 'Energia', 'status' => 'planned']);
$portfolio = InvestmentDossier::create(['cluster_id' => $cluster->id, 'title' => 'SYNTHETIC portfolio source', 'source' => 'SYNTHETIC QA only', 'content' => ['sector' => 'Energia'], 'publication_status' => 'published', 'approval_basis' => 'document_owner', 'approval_evidence' => 'SYNTHETIC QA fixture only', 'published_at' => now()]);
$service = app(ClusterCompetitiveness::class);
$draft = ['version' => 1, 'clusterId' => $cluster->id, 'sector' => 'Energia', 'region' => 'SYNTHETIC scope', 'forces' => []];
foreach (ClusterCompetitiveness::FORCES as $force) {
$draft['forces'][$force] = ['pressure' => 'medium', 'evidence' => 'SYNTHETIC documented test only', 'source' => 'SYNTHETIC source', 'period' => '2026', 'rationale' => 'SYNTHETIC rationale', 'limitations' => 'No economic inference', 'opportunity' => 'Hypothesis only'];
}
$service->change($cluster, $this->owner->id, 0, 'saved', ['draft' => $draft]);
$service->change($cluster, $this->owner->id, 1, 'approved', ['reason' => 'SYNTHETIC explicit QA source approval']);
$this->context([], [], ['portfolio' => 'portfolio:'.$portfolio->id]);
$view = $this->workspace();
$key = 'cluster:'.$cluster->id;
$this->assertContains($key, array_column($view['objects'], 'key'));
$rows = [];
foreach (['strength', 'weakness', 'opportunity', 'threat'] as $dimension) {
$rows[] = ['id' => (string) Str::uuid(), 'dimension' => $dimension, 'text' => 'SYNTHETIC deliberate '.$dimension, 'reference' => 'SYNTHETIC qualitative approved Competitiveness source', 'source_key' => $key, 'date' => now()->toDateString(), 'classification' => 'hypothesis'];
}
$view = $this->action($view, 'swot_save', ['draft' => ['object_key' => $key, 'scope' => 'SYNTHETIC same cluster and region', 'rows' => $rows, 'source_fingerprint' => $view['swot_fingerprint']]]);
$view = $this->action($view, 'swot_review', ['fingerprint' => $view['swot_fingerprint']]);
$approvedFingerprint = $view['swot_fingerprint'];
$draft['forces']['rivalry']['evidence'] = 'PRIVATE-UNAPPROVED-SWOT-SOURCE';
$service->change($cluster, $this->owner->id, 2, 'saved', ['draft' => $draft]);
$back = $this->workspace();
$this->assertFalse($back['swot_review_required']);
$this->assertStringNotContainsString('PRIVATE-UNAPPROVED-SWOT-SOURCE', json_encode($back['sources']));
$privateFingerprint = $back['swot_fingerprint'];
$service->change($cluster, $this->owner->id, 3, 'approved', ['reason' => 'SYNTHETIC explicit new public QA source']);
$back = $this->workspace();
$this->assertTrue($back['swot_review_required']);
$this->assertSame($rows, $back['swot']['rows']);
$changedFingerprint = $back['swot_fingerprint'];
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email], 'portal.profile_access_enabled' => false]);
$this->actingAs($manager);
$team = $this->workspace();
$this->assertTrue($team['can_review']);
$this->assertFalse($team['can_write']);
$team = $this->action($team, 'swot_review', ['fingerprint' => $team['swot_fingerprint']]);
$this->assertFalse($team['swot_review_required']);
$this->postJson('/investimentos/'.$this->caseId.'/preparacao', ['action' => 'apiex_save', 'version' => $team['version'], 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC forbidden team overwrite', 'note' => 'must not save', 'additional_documents' => []])->assertForbidden();
$portfolio->update(['publication_status' => 'draft']);
$withdrawn = $this->workspace();
$this->assertTrue($withdrawn['swot_review_required']);
$this->assertSame($rows, $withdrawn['swot']['rows']);
$this->assertNotEmpty($withdrawn['swot']['sources_snapshot']);
file_put_contents(base_path('outputs/investment-preparation-20261110/source-changes.json'), json_encode(['synthetic' => true, 'approved_fingerprint' => $approvedFingerprint, 'private_edit_fingerprint' => $privateFingerprint, 'new_public_fingerprint' => $changedFingerprint, 'withdrawal_review_required' => $withdrawn['swot_review_required'], 'conclusions_preserved' => $rows === $withdrawn['swot']['rows'], 'team_reviewed_without_owner_write' => $team['can_review'] && ! $team['can_write']], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
}
public function test_opportunities_in_one_dossier_keep_independent_preparation_versions_and_cannot_reuse_another_export(): void
{
$entries = array_slice(app(CorridorAgribusinessOpportunities::class)->entries(), 0, 2);
$records = Opportunity::whereIn('management_creation_key', array_column($entries, 'key'))->orderBy('id')->get();
$this->assertCount(2, $records);
$sites = collect(app(InvestmentCorridorContext::class)->provinces('pemba-lichinga'))->pluck('id');
$before = $this->getJson('/investimentos/'.$this->caseId.'/contexto')->assertOk()->json();
$body = ['version' => $before['case']['version'], 'selection' => [], 'opportunity_choices' => [
['source' => 'opportunity:'.$records[0]->id, 'territory' => 'territory:'.$sites[0], 'activities' => ['production', 'processing']],
['source' => 'opportunity:'.$records[1]->id, 'territory' => 'territory:'.$sites[1], 'activities' => ['trade']],
]];
$plan = $this->postJson('/investimentos/'.$this->caseId.'/contexto/previa', $body)->assertOk()->json();
$this->postJson('/investimentos/'.$this->caseId.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$this->getJson('/investimentos/'.$this->caseId.'/preparacao')->assertUnprocessable();
$first = 'opportunity:'.$records[0]->id;
$second = 'opportunity:'.$records[1]->id;
$a = $this->getJson('/investimentos/'.$this->caseId.'/preparacao?opportunity='.urlencode($first))->assertOk()->json();
$this->assertSame($first, $a['opportunity']);
$this->postJson('/investimentos/'.$this->caseId.'/preparacao', ['version' => 0, 'opportunity' => $first, 'action' => 'apiex_save', 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC scope1 only', 'note' => 'SYNTHETIC deliberate scope1', 'additional_documents' => []])->assertOk()->assertJsonPath('version', 1);
$b = $this->getJson('/investimentos/'.$this->caseId.'/preparacao?opportunity='.urlencode($second))->assertOk()->json();
$this->assertSame(0, $b['version']);
$this->assertSame('', $b['apiex']['note']);
$this->assertNotSame($a['context']['fields']['province_id'], $b['context']['fields']['province_id']);
$this->assertStringContainsString(urlencode($second), $b['links']['context']);
$this->postJson('/investimentos/'.$this->caseId.'/preparacao', ['version' => 0, 'opportunity' => $second, 'action' => 'external', 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC cross-scope refused', 'kind' => 'delivery', 'date' => now()->toDateString(), 'reference' => 'SYNTHETIC nonexistent other export', 'export_id' => (string) Str::uuid(), 'proof_document' => $this->documents['identity']])->assertUnprocessable();
$context = $this->getJson('/investimentos/'.$this->caseId.'/contexto')->assertOk()->json();
$keep = ['version' => $context['case']['version'], 'selection' => [], 'opportunity_choices' => [$body['opportunity_choices'][1]]];
$plan = $this->postJson('/investimentos/'.$this->caseId.'/contexto/previa', $keep)->assertOk()->json();
$this->postJson('/investimentos/'.$this->caseId.'/contexto/confirmar', [...$keep, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$retired = $this->getJson('/investimentos/'.$this->caseId.'/preparacao?opportunity='.urlencode($first))->assertOk()->json();
$this->assertTrue($retired['historical']);
$this->assertFalse($retired['can_write']);
$this->assertSame('SYNTHETIC deliberate scope1', $retired['apiex']['note']);
$this->assertContains($first, array_column($retired['saved_scopes'], 'key'));
file_put_contents(base_path('outputs/investment-preparation-20261110/opportunity-scopes.json'), json_encode(['synthetic' => true, 'first_scope' => $first, 'second_scope' => $second, 'second_version_unchanged' => $b['version'], 'retired_scope_readable' => $retired['historical'], 'retired_scope_writable' => $retired['can_write'], 'histories' => array_column($retired['saved_scopes'], 'key')], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
}
public function test_history_paginates_past_twenty_five_without_replacing_working_data_or_external_records(): void
{
$service = app(InvestmentPreparationWorkspace::class);
for ($version = 0; $version < 27; $version++) {
$service->change($this->owner, $this->caseId, ['version' => $version, 'action' => 'apiex_save', 'operation_key' => (string) Str::uuid(), 'reason' => 'SYNTHETIC history boundary fixture through writer', 'note' => 'SYNTHETIC version'.($version + 1), 'additional_documents' => []]);
}
$first = $this->workspace();
$this->assertCount(25, $first['history']);
$this->assertSame(3, $first['history_next']);
$next = $this->getJson('/investimentos/'.$this->caseId.'/preparacao?history_before='.$first['history_next'])->assertOk()->json();
$this->assertSame([2, 1], array_column($next['history'], 'version'));
$this->assertNull($next['history_next']);
$this->assertSame(27, $next['version']);
$this->assertSame('SYNTHETIC version27', $next['apiex']['note']);
$last = $this->action($next, 'swot_restore', ['revision' => 1]);
$this->assertSame(28, $last['version']);
file_put_contents(base_path('outputs/investment-preparation-20261110/history-pages.json'), json_encode(['synthetic' => true, 'first_versions' => array_column($first['history'], 'version'), 'next_versions' => array_column($next['history'], 'version'), 'new_version_after_restore' => $last['version']], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
$this->assertSame('SYNTHETIC version27', $last['apiex']['note']);
}
}
@@ -0,0 +1,36 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcEconomicAssociationDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_economic_association_uses_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$d = json_decode(file_get_contents(base_path('outputs/irpc-economic-association-20261110/fixture.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $d['person_type']];
unset($d['person_type']);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => $personal, 'economic_snapshot' => $d], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$back = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($d['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($d['business_plan_snapshot'], $back['business_plan_snapshot']);
$b = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$d['context_opportunity']];
$this->assertSame('proportional', $b['economicCredits']['rows'][0]['nationalMethod']);
$this->assertSame(['numerator' => '896', 'denominator' => '25'], $b['result']['model']['economicCredit']['rows'][0]['nationalLimit']);
$this->assertSame(['numerator' => '688', 'denominator' => '1'], $b['result']['model']['arithmeticBalance']);
$this->assertSame('participation', $b['result']['model']['economicCredit']['rows'][0]['association']['kind']);
file_put_contents(base_path('outputs/irpc-economic-association-20261110/recovered.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
@@ -0,0 +1,36 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcEconomicFractionDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_exact_economic_inclusion_uses_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$d = json_decode(file_get_contents(base_path('outputs/irpc-economic-fraction-20261110/fixture.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $d['person_type']];
unset($d['person_type']);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => $personal, 'economic_snapshot' => $d], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$back = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($d['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($d['business_plan_snapshot'], $back['business_plan_snapshot']);
$b = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcNormal'][$d['context_opportunity']];
$this->assertSame('IRPC-economic-inclusion/1', $b['result']['model']['economicInclusions']['version']);
$this->assertSame(['numerator' => '56003', 'denominator' => '500'], $b['result']['model']['economicInclusions']['net']);
$this->assertSame(['numerator' => '4512006', 'denominator' => '3125'], $b['result']['model']['colecta']);
$this->assertNotEmpty($b['history']);
file_put_contents(base_path('outputs/irpc-economic-fraction-20261110/recovered.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
@@ -0,0 +1,36 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcEconomicInclusionDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_economic_inclusion_uses_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$d = json_decode(file_get_contents(base_path('outputs/irpc-economic-inclusion-20261110/fixture.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $d['person_type']];
unset($d['person_type']);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => $personal, 'economic_snapshot' => $d], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$back = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($d['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($d['business_plan_snapshot'], $back['business_plan_snapshot']);
$b = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcNormal'][$d['context_opportunity']];
$this->assertSame('IRPC-economic-inclusion/1', $b['result']['model']['economicInclusions']['version']);
$this->assertSame(['numerator' => '112', 'denominator' => '1'], $b['result']['model']['economicInclusions']['net']);
$this->assertSame(['numerator' => '36096', 'denominator' => '25'], $b['result']['model']['colecta']);
$this->assertNotEmpty($b['history']);
file_put_contents(base_path('outputs/irpc-economic-inclusion-20261110/recovered.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
@@ -0,0 +1,36 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcEconomicLiquidationDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_economic_liquidation_uses_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$d = json_decode(file_get_contents(base_path('outputs/irpc-economic-liquidation-20261110/fixture.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $d['person_type']];
unset($d['person_type']);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => $personal, 'economic_snapshot' => $d], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$back = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($d['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($d['business_plan_snapshot'], $back['business_plan_snapshot']);
$b = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$d['context_opportunity']];
$this->assertSame('proportional', $b['economicCredits']['rows'][0]['nationalMethod']);
$this->assertSame(['numerator' => '896', 'denominator' => '25'], $b['result']['model']['economicCredit']['rows'][0]['nationalLimit']);
$this->assertSame(['numerator' => '688', 'denominator' => '1'], $b['result']['model']['arithmeticBalance']);
$this->assertSame(['numerator' => '50', 'denominator' => '1'], $b['result']['model']['economicCredit']['rows'][0]['liquidation']['excessCapitalGain']);
file_put_contents(base_path('outputs/irpc-economic-liquidation-20261110/recovered.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
@@ -0,0 +1,35 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcEconomicQuotaDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_derived_economic_quota_uses_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$d = json_decode(file_get_contents(base_path('outputs/irpc-economic-quota-20261110/fixture.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $d['person_type']];
unset($d['person_type']);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => $personal, 'economic_snapshot' => $d], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$back = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($d['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($d['business_plan_snapshot'], $back['business_plan_snapshot']);
$b = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$d['context_opportunity']];
$this->assertSame('proportional', $b['economicCredits']['rows'][0]['nationalMethod']);
$this->assertSame(['numerator' => '256', 'denominator' => '5'], $b['result']['model']['economicCredit']['rows'][0]['nationalLimit']);
$this->assertSame(['numerator' => '3244', 'denominator' => '5'], $b['result']['model']['arithmeticBalance']);
file_put_contents(base_path('outputs/irpc-economic-quota-20261110/recovered.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
@@ -0,0 +1,36 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcEconomicSpecialInclusionDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_economic_special_inclusion_uses_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$d = json_decode(file_get_contents(base_path('outputs/irpc-economic-special-inclusion-20261110/fixture.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $d['person_type']];
unset($d['person_type']);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => $personal, 'economic_snapshot' => $d], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$back = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($d['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($d['business_plan_snapshot'], $back['business_plan_snapshot']);
$b = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcNormal'][$d['context_opportunity']];
$this->assertSame('IRPC-economic-inclusion/1', $b['result']['model']['economicInclusions']['version']);
$this->assertSame(['numerator' => '162', 'denominator' => '1'], $b['result']['model']['economicInclusions']['net']);
$this->assertSame(['numerator' => '36496', 'denominator' => '25'], $b['result']['model']['colecta']);
$this->assertNotEmpty($b['history']);
file_put_contents(base_path('outputs/irpc-economic-special-inclusion-20261110/recovered.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
@@ -0,0 +1,53 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcInvestmentCarryDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_derived_national_fraction_round_trips_through_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$id = null;
$etag = null;
foreach (['carry', 'expired'] as $name) {
$snapshot = json_decode(file_get_contents(base_path('outputs/irpc-cbf15-carry-20261110/'.$name.'.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $snapshot['person_type']];
unset($snapshot['person_type']);
$payload = ['personal_snapshot' => $personal, 'economic_snapshot' => $snapshot];
if ($id === null) {
$id = $this->postJson('/api/v1/simulation-drafts', $payload, ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
} else {
$this->putJson('/api/v1/simulation-drafts/'.$id, $payload, ['If-Match' => $etag])->assertOk();
}
$response = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk();
$etag = $response->headers->get('ETag');
$back = $response->json('data.economic_snapshot');
$this->assertSame($snapshot['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($snapshot['business_plan_snapshot'], $back['business_plan_snapshot']);
$book = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$snapshot['context_opportunity']];
$this->assertSame(3, $book['version']);
$this->assertCount(array_search($name, ['carry', 'expired'], true) + 6, $book['history']);
$model = $book['result']['model'];
$this->assertSame($name === 'carry' ? 'active' : 'expired', $model['investmentCredit']['window']['status']);
$this->assertSame(2, $book['investmentCredit']['version']);
$this->assertSame(['numerator' => $name === 'carry' ? '3000' : '0', 'denominator' => '1'], $model['investmentCredit']['available']);
$this->assertSame(['numerator' => $name === 'carry' ? '0' : '3000', 'denominator' => '1'], $model['investmentCredit']['expired']);
$this->assertSame('IRPC-CBF15/3', $model['investmentCredit']['version']);
$this->assertSame(['numerator' => $name === 'carry' ? '200' : '0', 'denominator' => '1'], $model['investmentCredit']['deducted']);
$this->assertSame(['numerator' => $name === 'carry' ? '500' : '700', 'denominator' => '1'], $model['arithmeticBalance']);
file_put_contents(base_path('outputs/irpc-cbf15-carry-20261110/recovered-'.$name.'.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
}
@@ -0,0 +1,46 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcInvestmentCombinationDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_combination_and_explicit_pe_c_reconciliation_survive_private_draft_recovery(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$id = null;
$etag = null;
foreach (['combined' => 138, 'quota' => 438, 'overallocated' => null, 'reconciled' => -500, 'expired' => 538] as $name => $balance) {
$snapshot = json_decode(file_get_contents(base_path('outputs/irpc-cbf15-combination-20261111/'.$name.'.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $snapshot['person_type']];
unset($snapshot['person_type']);
$payload = ['personal_snapshot' => $personal, 'economic_snapshot' => $snapshot];
if ($id === null) {
$id = $this->postJson('/api/v1/simulation-drafts', $payload, ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
} else {
$this->putJson('/api/v1/simulation-drafts/'.$id, $payload, ['If-Match' => $etag])->assertOk();
}
$response = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk();
$etag = $response->headers->get('ETag');
$back = $response->json('data.economic_snapshot');
$this->assertSame($snapshot['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$book = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$snapshot['context_opportunity']];
$this->assertCount(1, $book['history']);
$this->assertSame(['numerator' => '138', 'denominator' => '1'], $book['history'][0]['model']['arithmeticBalance']);
$this->assertSame($balance === null ? null : ['numerator' => (string) $balance, 'denominator' => '1'], $book['result']['model']['arithmeticBalance']);
$this->assertFalse($book['result']['model']['investmentCredit']['officialBalancesConsumed']);
file_put_contents(base_path('outputs/irpc-cbf15-combination-20261111/recovered-'.$name.'.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
}
@@ -0,0 +1,49 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcInvestmentCreditDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_derived_national_fraction_round_trips_through_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$id = null;
$etag = null;
foreach (['city', 'province', 'capped'] as $name) {
$snapshot = json_decode(file_get_contents(base_path('outputs/irpc-cbf15-20261110/'.$name.'.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $snapshot['person_type']];
unset($snapshot['person_type']);
$payload = ['personal_snapshot' => $personal, 'economic_snapshot' => $snapshot];
if ($id === null) {
$id = $this->postJson('/api/v1/simulation-drafts', $payload, ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
} else {
$this->putJson('/api/v1/simulation-drafts/'.$id, $payload, ['If-Match' => $etag])->assertOk();
}
$response = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk();
$etag = $response->headers->get('ETag');
$back = $response->json('data.economic_snapshot');
$this->assertSame($snapshot['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($snapshot['business_plan_snapshot'], $back['business_plan_snapshot']);
$book = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$snapshot['context_opportunity']];
$this->assertSame(2, $book['version']);
$this->assertCount(array_search($name, ['city', 'province', 'capped'], true) + 1, $book['history']);
$model = $book['result']['model'];
$this->assertSame('IRPC-CBF15/1', $model['investmentCredit']['version']);
$this->assertSame(['numerator' => $name === 'city' ? '500' : ($name === 'province' ? '1000' : '1400'), 'denominator' => '1'], $model['investmentCredit']['deducted']);
$this->assertSame(['numerator' => $name === 'city' ? '200' : ($name === 'province' ? '-300' : '-500'), 'denominator' => '1'], $model['arithmeticBalance']);
file_put_contents(base_path('outputs/irpc-cbf15-20261110/recovered-'.$name.'.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
}
@@ -0,0 +1,37 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcInvestmentQuotaDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_documented_project_quota_uses_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$d = json_decode(file_get_contents(base_path('outputs/irpc-cbf15-20261110/partial.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $d['person_type']];
unset($d['person_type']);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => $personal, 'economic_snapshot' => $d], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$back = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($d['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($d['business_plan_snapshot'], $back['business_plan_snapshot']);
$b = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$d['context_opportunity']];
$this->assertSame(2, $b['version']);
$this->assertSame(['numerator' => '200', 'denominator' => '1'], $b['result']['model']['investmentCredit']['projectCeiling']);
$this->assertSame(['numerator' => '200', 'denominator' => '1'], $b['result']['model']['investmentCredit']['deducted']);
$this->assertSame(['numerator' => '500', 'denominator' => '1'], $b['result']['model']['arithmeticBalance']);
$this->assertCount(4, $b['history']);
file_put_contents(base_path('outputs/irpc-cbf15-20261110/recovered-partial.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
@@ -0,0 +1,50 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcInvestmentWindowDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_derived_national_fraction_round_trips_through_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$id = null;
$etag = null;
foreach (['fifth', 'expired'] as $name) {
$snapshot = json_decode(file_get_contents(base_path('outputs/irpc-cbf15-window-20261110/'.$name.'.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $snapshot['person_type']];
unset($snapshot['person_type']);
$payload = ['personal_snapshot' => $personal, 'economic_snapshot' => $snapshot];
if ($id === null) {
$id = $this->postJson('/api/v1/simulation-drafts', $payload, ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
} else {
$this->putJson('/api/v1/simulation-drafts/'.$id, $payload, ['If-Match' => $etag])->assertOk();
}
$response = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk();
$etag = $response->headers->get('ETag');
$back = $response->json('data.economic_snapshot');
$this->assertSame($snapshot['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($snapshot['business_plan_snapshot'], $back['business_plan_snapshot']);
$book = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$snapshot['context_opportunity']];
$this->assertSame(2, $book['version']);
$this->assertCount(array_search($name, ['fifth', 'expired'], true) + 5, $book['history']);
$model = $book['result']['model'];
$this->assertSame($name === 'fifth' ? 'active' : 'expired', $model['investmentCredit']['window']['status']);
$this->assertSame('IRPC-CBF15/2', $model['investmentCredit']['version']);
$this->assertSame(['numerator' => $name === 'fifth' ? '200' : '0', 'denominator' => '1'], $model['investmentCredit']['deducted']);
$this->assertSame(['numerator' => $name === 'fifth' ? '500' : '700', 'denominator' => '1'], $model['arithmeticBalance']);
file_put_contents(base_path('outputs/irpc-cbf15-window-20261110/recovered-'.$name.'.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
}
@@ -0,0 +1,52 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcNationalFractionDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_derived_national_fraction_round_trips_through_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$id = null;
$etag = null;
foreach (['ordinary', 'exact', 'missing'] as $name) {
$snapshot = json_decode(file_get_contents(base_path('outputs/irpc-national-fraction-20261110/'.$name.'.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $snapshot['person_type']];
unset($snapshot['person_type']);
$payload = ['personal_snapshot' => $personal, 'economic_snapshot' => $snapshot];
if ($id === null) {
$id = $this->postJson('/api/v1/simulation-drafts', $payload, ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
} else {
$this->putJson('/api/v1/simulation-drafts/'.$id, $payload, ['If-Match' => $etag])->assertOk();
}
$response = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk();
$etag = $response->headers->get('ETag');
$back = $response->json('data.economic_snapshot');
$this->assertSame($snapshot['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($snapshot['business_plan_snapshot'], $back['business_plan_snapshot']);
$book = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$snapshot['context_opportunity']];
$this->assertSame('proportional', $book['internationalCredits']['positions'][0]['nationalMethod']);
$model = $book['result']['model'];
if ($name === 'missing') {
$this->assertSame('indeterminate', $model['status']);
$this->assertNull($model['arithmeticBalance']);
} else {
$this->assertSame(['numerator' => $name === 'ordinary' ? '32' : '2', 'denominator' => $name === 'ordinary' ? '1' : '625'], $model['internationalCredit']['positions'][0]['nationalLimit']);
$this->assertSame('IRPC-national-fraction/1', $model['internationalCredit']['positions'][0]['nationalDerivation']['version']);
}
file_put_contents(base_path('outputs/irpc-national-fraction-20261110/recovered-'.$name.'.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
}
@@ -0,0 +1,48 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcPecCorrectionDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_pec_obligation_and_calendar_survive_native_draft_without_new_paid_deductions(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$id = null;
$etag = null;
foreach (['higher' => 138, 'lower' => 138, 'retained' => 138, 'changed_base' => 138, 'zero' => 138] as $name => $balance) {
$snapshot = json_decode(file_get_contents(base_path('outputs/irpc-pec-correction-20261111/'.$name.'.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $snapshot['person_type']];
unset($snapshot['person_type']);
$payload = ['personal_snapshot' => $personal, 'economic_snapshot' => $snapshot];
if ($id === null) {
$id = $this->postJson('/api/v1/simulation-drafts', $payload, ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
} else {
$this->putJson('/api/v1/simulation-drafts/'.$id, $payload, ['If-Match' => $etag])->assertOk();
}
$response = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk();
$etag = $response->headers->get('ETag');
$back = $response->json('data.economic_snapshot');
$this->assertSame($snapshot['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$book = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$snapshot['context_opportunity']];
$this->assertCount(1, $book['history']);
$this->assertSame(['numerator' => '138', 'denominator' => '1'], $book['history'][0]['model']['arithmeticBalance']);
$this->assertSame($balance === null ? null : ['numerator' => (string) $balance, 'denominator' => '1'], $book['result']['model']['arithmeticBalance']);
$this->assertSame($book['pecObligation'], $book['result']['model']['input']['pecObligation']);
$this->assertSame('SYNTHETIC corrected sales2025 reviewed2026', $book['pecObligation']['correction']['reference']);
$this->assertSame('SYNTHETIC effective activity'.($name === 'start' ? '2026' : '2020'), $book['pecObligation']['activityReference']);
file_put_contents(base_path('outputs/irpc-pec-correction-20261111/recovered-'.$name.'.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
}
@@ -0,0 +1,48 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcPecObligationDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_pec_obligation_and_calendar_survive_native_draft_without_new_paid_deductions(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$id = null;
$etag = null;
foreach (['ordinary' => 138, 'minimum' => 138, 'maximum' => 138, 'start' => 138, 'zero_turnover' => 138, 'unknown' => 138] as $name => $balance) {
$snapshot = json_decode(file_get_contents(base_path('outputs/irpc-pec-obligation-20261111/'.$name.'.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $snapshot['person_type']];
unset($snapshot['person_type']);
$payload = ['personal_snapshot' => $personal, 'economic_snapshot' => $snapshot];
if ($id === null) {
$id = $this->postJson('/api/v1/simulation-drafts', $payload, ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
} else {
$this->putJson('/api/v1/simulation-drafts/'.$id, $payload, ['If-Match' => $etag])->assertOk();
}
$response = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk();
$etag = $response->headers->get('ETag');
$back = $response->json('data.economic_snapshot');
$this->assertSame($snapshot['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$book = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$snapshot['context_opportunity']];
$this->assertCount(1, $book['history']);
$this->assertSame(['numerator' => '138', 'denominator' => '1'], $book['history'][0]['model']['arithmeticBalance']);
$this->assertSame($balance === null ? null : ['numerator' => (string) $balance, 'denominator' => '1'], $book['result']['model']['arithmeticBalance']);
$this->assertSame($book['pecObligation'], $book['result']['model']['input']['pecObligation']);
$this->assertSame('2025', $book['pecObligation']['previousYear']);
$this->assertSame('SYNTHETIC effective activity'.($name === 'start' ? '2026' : '2020'), $book['pecObligation']['activityReference']);
file_put_contents(base_path('outputs/irpc-pec-obligation-20261111/recovered-'.$name.'.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcPecPlanDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_pec_obligation_and_calendar_survive_native_draft_without_new_paid_deductions(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$id = null;
$etag = null;
foreach (['three' => 138, 'single' => 138, 'mismatch' => 138, 'changed_total' => 138] as $name => $balance) {
$snapshot = json_decode(file_get_contents(base_path('outputs/irpc-pec-plan-20261111/'.$name.'.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $snapshot['person_type']];
unset($snapshot['person_type']);
$payload = ['personal_snapshot' => $personal, 'economic_snapshot' => $snapshot];
if ($id === null) {
$id = $this->postJson('/api/v1/simulation-drafts', $payload, ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
} else {
$this->putJson('/api/v1/simulation-drafts/'.$id, $payload, ['If-Match' => $etag])->assertOk();
}
$response = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk();
$etag = $response->headers->get('ETag');
$back = $response->json('data.economic_snapshot');
$this->assertSame($snapshot['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$book = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$snapshot['context_opportunity']];
$this->assertCount(1, $book['history']);
$this->assertSame(['numerator' => '138', 'denominator' => '1'], $book['history'][0]['model']['arithmeticBalance']);
$this->assertSame($balance === null ? null : ['numerator' => (string) $balance, 'denominator' => '1'], $book['result']['model']['arithmeticBalance']);
$this->assertSame($book['pecObligation'], $book['result']['model']['input']['pecObligation']);
$this->assertSame('SYNTHETIC cash-flow hypothesis for viability', $book['pecObligation']['plan']['reference']);
$this->assertSame('SYNTHETIC effective activity'.($name === 'start' ? '2026' : '2020'), $book['pecObligation']['activityReference']);
file_put_contents(base_path('outputs/irpc-pec-plan-20261111/recovered-'.$name.'.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
}
@@ -0,0 +1,48 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcPpcObligationDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_ppc_obligation_and_calendar_survive_native_draft_without_new_paid_deductions(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$id = null;
$etag = null;
foreach (['ordinary' => 138, 'rounded' => 138, 'zero' => 138, 'unknown' => 138, 'other_period' => 138] as $name => $balance) {
$snapshot = json_decode(file_get_contents(base_path('outputs/irpc-ppc-obligation-20261111/'.$name.'.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $snapshot['person_type']];
unset($snapshot['person_type']);
$payload = ['personal_snapshot' => $personal, 'economic_snapshot' => $snapshot];
if ($id === null) {
$id = $this->postJson('/api/v1/simulation-drafts', $payload, ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
} else {
$this->putJson('/api/v1/simulation-drafts/'.$id, $payload, ['If-Match' => $etag])->assertOk();
}
$response = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk();
$etag = $response->headers->get('ETag');
$back = $response->json('data.economic_snapshot');
$this->assertSame($snapshot['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$book = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$snapshot['context_opportunity']];
$this->assertCount(1, $book['history']);
$this->assertSame(['numerator' => '138', 'denominator' => '1'], $book['history'][0]['model']['arithmeticBalance']);
$this->assertSame($balance === null ? null : ['numerator' => (string) $balance, 'denominator' => '1'], $book['result']['model']['arithmeticBalance']);
$this->assertSame($book['ppcObligation'], $book['result']['model']['input']['ppcObligation']);
$this->assertSame('2025', $book['ppcObligation']['previousYear']);
$this->assertSame('SYNTHETIC prior assessment21(1)', $book['ppcObligation']['assessmentReference']);
file_put_contents(base_path('outputs/irpc-ppc-obligation-20261111/recovered-'.$name.'.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
}
@@ -0,0 +1,36 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcPtMzAgreementDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_agreement_pt_mz_credit_uses_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$d = json_decode(file_get_contents(base_path('outputs/irpc-pt-mz-agreement-20261110/fixture.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $d['person_type']];
unset($d['person_type']);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => $personal, 'economic_snapshot' => $d], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$back = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($d['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($d['business_plan_snapshot'], $back['business_plan_snapshot']);
$b = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$d['context_opportunity']];
$this->assertSame('ptMzInterest', $b['internationalCredits']['positions'][0]['treatyMethod']);
$this->assertSame(['numerator' => '0', 'denominator' => '1'], $b['result']['model']['internationalCredit']['positions'][0]['treatyLimit']);
$this->assertSame(['numerator' => '708', 'denominator' => '1'], $b['result']['model']['arithmeticBalance']);
$this->assertSame('agreement', $b['result']['model']['internationalCredit']['positions'][0]['treatyDerivation']['governmentExemption']['basis']);
file_put_contents(base_path('outputs/irpc-pt-mz-agreement-20261110/recovered.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
@@ -0,0 +1,46 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcPtMzCapitalDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_derived_pt_mz_capital_credit_uses_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$id = null;
$etag = null;
foreach (['dividend', 'interest'] as $kind) {
$d = json_decode(file_get_contents(base_path('outputs/irpc-pt-mz-'.$kind.'-20261110/fixture.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $d['person_type']];
unset($d['person_type']);
$payload = ['personal_snapshot' => $personal, 'economic_snapshot' => $d];
if ($id === null) {
$id = $this->postJson('/api/v1/simulation-drafts', $payload, ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
} else {
$this->putJson('/api/v1/simulation-drafts/'.$id, $payload, ['If-Match' => $etag])->assertOk();
}
$response = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk();
$etag = $response->headers->get('ETag');
$back = $response->json('data.economic_snapshot');
$this->assertSame($d['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($d['business_plan_snapshot'], $back['business_plan_snapshot']);
$b = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$d['context_opportunity']];
$this->assertSame($kind === 'dividend' ? 'ptMzDividend' : 'ptMzInterest', $b['internationalCredits']['positions'][0]['treatyMethod']);
$this->assertSame(['numerator' => '6', 'denominator' => '1'], $b['result']['model']['internationalCredit']['positions'][0]['treatyLimit']);
$this->assertSame(['numerator' => '702', 'denominator' => '1'], $b['result']['model']['arithmeticBalance']);
file_put_contents(base_path('outputs/irpc-pt-mz-'.$kind.'-20261110/recovered.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcPtMzCreditDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_derived_pt_mz_credit_uses_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$d = json_decode(file_get_contents(base_path('outputs/irpc-pt-mz-credit-20261110/fixture.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $d['person_type']];
unset($d['person_type']);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => $personal, 'economic_snapshot' => $d], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$back = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($d['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($d['business_plan_snapshot'], $back['business_plan_snapshot']);
$b = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$d['context_opportunity']];
$this->assertSame('ptMzRoyalty', $b['internationalCredits']['positions'][0]['treatyMethod']);
$this->assertSame(['numerator' => '6', 'denominator' => '1'], $b['result']['model']['internationalCredit']['positions'][0]['treatyLimit']);
$this->assertSame(['numerator' => '702', 'denominator' => '1'], $b['result']['model']['arithmeticBalance']);
file_put_contents(base_path('outputs/irpc-pt-mz-credit-20261110/recovered.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
@@ -0,0 +1,36 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class IrpcPtMzGovernmentDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_government_pt_mz_credit_uses_the_existing_private_draft_writer(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('proactive_investment_20261027_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::findOrFail(1021));
$d = json_decode(file_get_contents(base_path('outputs/irpc-pt-mz-government-20261110/fixture.json')), true, flags: JSON_THROW_ON_ERROR);
$personal = ['person_type' => $d['person_type']];
unset($d['person_type']);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => $personal, 'economic_snapshot' => $d], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$back = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($d['modelo_a_snapshot'], $back['modelo_a_snapshot']);
$this->assertSame($d['business_plan_snapshot'], $back['business_plan_snapshot']);
$b = json_decode($back['modelo_a_snapshot'], true, flags: JSON_THROW_ON_ERROR)['irpcAnnual'][$d['context_opportunity']];
$this->assertSame('ptMzInterest', $b['internationalCredits']['positions'][0]['treatyMethod']);
$this->assertSame(['numerator' => '0', 'denominator' => '1'], $b['result']['model']['internationalCredit']['positions'][0]['treatyLimit']);
$this->assertSame(['numerator' => '708', 'denominator' => '1'], $b['result']['model']['arithmeticBalance']);
$this->assertSame('state', $b['result']['model']['internationalCredit']['positions'][0]['treatyDerivation']['governmentExemption']['payer']);
file_put_contents(base_path('outputs/irpc-pt-mz-government-20261110/recovered.json'), json_encode($back, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
+3 -3
View File
@@ -20,15 +20,15 @@ class PublicPortalNavigationTest extends TestCase
Opportunity::create(['sector_id' => $sector->id, 'reference' => 'CACHE-1', 'title' => 'Solar',
'slug' => 'solar', 'status' => 'published', 'published_at' => now()->subMinute(), 'is_featured' => true]);
$first = $this->getJson('/api/v1/home')->assertOk()->json();
$cached = Cache::get('portal_home_sector_organization_v1_pt');
$cached = Cache::get('portal_home_territorial_v2_pt');
$this->assertIsArray($cached['sectors']);
$this->assertIsArray($cached['featuredOpportunities']);
$restored = unserialize(serialize($cached), ['allowed_classes' => false]);
$this->assertSame($cached, $restored);
Cache::put('portal_home_sector_organization_v1_pt', $restored);
Cache::put('portal_home_territorial_v2_pt', $restored);
$this->assertSame($first, $this->getJson('/api/v1/home')->assertOk()->json());
$sector->update(['name' => 'Energia actualizada']);
$this->assertNull(Cache::get('portal_home_sector_organization_v1_pt'));
$this->assertNull(Cache::get('portal_home_territorial_v2_pt'));
$this->getJson('/api/v1/home')->assertJsonPath('data.sectors.0.name', 'Energia actualizada');
}
+26
View File
@@ -3,6 +3,7 @@
namespace Tests\Feature;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\Request as ClientRequest;
use Tests\TestCase;
class TranslationApiTest extends TestCase
@@ -24,4 +25,29 @@ class TranslationApiTest extends TestCase
$this->postJson('/api/translate', $input)->assertOk()->assertJsonPath('translated_text', 'Hello');
$this->postJson('/api/translate', $input)->assertStatus(503);
}
public function test_it_translates_text_batches_for_full_page_translation(): void
{
config(['services.translation.url' => 'http://translator.test']);
Http::fake(['translator.test/translate' => Http::response(['translatedText' => ['Home', 'About us']])]);
$this->postJson('/api/translate', [
'text' => ['Início', 'Sobre nós'],
'source_lang' => 'auto',
'target_lang' => 'en',
])->assertOk()
->assertJsonPath('translated_text.0', 'Home')
->assertJsonPath('translated_text.1', 'About us');
Http::assertSent(fn (ClientRequest $request) => $request['q'] === ['Início', 'Sobre nós']);
}
public function test_it_rejects_translation_batches_that_exceed_the_request_limit(): void
{
$this->postJson('/api/translate', [
'text' => array_fill(0, 9, 'Texto'),
'source_lang' => 'auto',
'target_lang' => 'en',
])->assertUnprocessable();
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace Tests\Unit;
use App\Http\Controllers\VirtualDeskController;
use App\Http\Middleware\VirtualDeskInstalled;
use App\Models\User;
use App\Services\VirtualDesk;
use Illuminate\Foundation\Testing\TestCase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Mockery;
use Symfony\Component\HttpKernel\Exception\HttpException;
class DeskPreDossierTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
DB::shouldReceive('connection')->never();
}
public function test_export_reuses_the_existing_case_authorisation_before_reading_any_data(): void
{
$actor = new User;
$actor->id = 9;
$id = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
$request = Request::create('/balcao-virtual/dossiers/'.$id.'/pre-dossier');
$request->setUserResolver(fn () => $actor);
$desk = Mockery::mock(VirtualDesk::class);
$desk->shouldReceive('case')->once()->with($actor, $id)->andThrow(new HttpException(403));
$this->expectException(HttpException::class);
app(VirtualDeskController::class)->preDossier($request, $id, $desk);
}
public function test_preparation_document_escapes_declared_data_and_preserves_unknowns_and_internal_status(): void
{
$html = view('desk.pre-dossier', [
'case' => ['id' => 'qa-case', 'title' => '<script>alert(1)</script>', 'version' => 3, 'closed_at' => null,
'payload' => ['profile' => ['name' => '<img src=x onerror=alert(2)>'], 'investment' => ['amount' => null]], 'simulation_id' => 'qa-existing-simulation'],
'requests' => collect([(object) ['protocol' => null, 'status' => 'draft', 'version' => 1]]),
'documents' => collect([(object) ['file_name' => '<script>document</script>', 'category' => 'licença declarada']]),
'states' => ['draft' => 'Rascunho'],
])->render();
$this->assertStringNotContainsString('<script>', $html);
$this->assertStringContainsString('&lt;script&gt;', $html);
$this->assertStringContainsString('&lt;img', $html);
$this->assertStringContainsString('Rascunho — sem submissão', $html);
$this->assertStringContainsString('window.print()', $html);
$this->assertStringContainsString('Não constitui aprovação', $html);
$this->assertStringContainsString('não confirma regras, elegibilidade ou benefícios', $html);
$this->assertStringContainsString('null', $html);
}
public function test_preparation_route_keeps_the_authenticated_installed_workspace_gates(): void
{
$route = app('router')->getRoutes()->match(Request::create('/balcao-virtual/dossiers/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/pre-dossier'));
$middleware = $route->gatherMiddleware();
$this->assertContains('auth', $middleware);
$this->assertContains('verified', $middleware);
$this->assertContains(VirtualDeskInstalled::class, $middleware);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Tests\Unit;
use App\Http\Controllers\Api\V1\HomeController;
use App\Models\Opportunity;
use App\Models\Sector;
use Illuminate\Foundation\Testing\TestCase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
class HomeTerritorialCacheTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['cache.default' => 'array']);
DB::shouldReceive('connection')->never();
}
public function test_controller_reads_the_new_plain_data_cache_instead_of_the_legacy_metric(): void
{
$current = ['stats' => ['opportunities' => 1, 'provinces' => 2, 'sectors' => 1], 'sectors' => [], 'featuredOpportunities' => []];
Cache::put('portal_home_sector_organization_v1_pt', ['stats' => ['opportunities' => 99, 'provinces' => 1]]);
Cache::put('portal_home_territorial_v2_pt', unserialize(serialize($current), ['allowed_classes' => false]));
$response = app(HomeController::class)->show(Request::create('/api/v1/home?locale=pt'));
$this->assertSame($current, $response->getData(true)['data']);
}
public function test_existing_model_events_invalidate_both_locales_of_the_new_cache(): void
{
foreach ([Opportunity::class, Sector::class] as $model) {
foreach (['saved', 'deleted'] as $event) {
Cache::put('portal_home_territorial_v2_pt', ['stats' => []]);
Cache::put('portal_home_territorial_v2_en', ['stats' => []]);
Event::dispatch('eloquent.'.$event.': '.$model, [new $model]);
$this->assertNull(Cache::get('portal_home_territorial_v2_pt'));
$this->assertNull(Cache::get('portal_home_territorial_v2_en'));
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More