Merge branch 'front' of https://git.tempo.co.mz/nala/Investidor-V2
tests / ci (push) Canceled after 0s
tests / ci (push) Canceled after 0s
This commit is contained in:
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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
? [
|
||||
[
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(' ')??'';}
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(/«/g, '«')
|
||||
.replace(/»/g, '»')}
|
||||
.replace(/«/g, "«")
|
||||
.replace(/»/g, "»")}
|
||||
</Link>
|
||||
) : null,
|
||||
)}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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-]+');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('<script>', $html);
|
||||
$this->assertStringContainsString('<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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user