feat(iva): add fundraising group and correct production option calendar in QA

This commit is contained in:
2026-10-24 15:47:02 +02:00
parent a5ac8ca1c9
commit 72f4187d3b
11 changed files with 190 additions and 17 deletions
@@ -1,5 +1,30 @@
# Rastreabilidade, utilização e formação
## 24/10/2026 — calendário G2I corrigido; G2J demonstrado em QA
**Candidata QA: G2I/G2J, por integrar. Homologação d4eae917/recibo7696af68 inalterada.** Os29ramos entregues continuam encerrados;L1aberto;P001 exclusivamente05/06. Estatísticas/IAadiadas.
### Correcção dirigida do calendário e do regresso (P020–P022)
Fonte conferida visualmente: [Código Civil, exemplar FAO](https://faolex.fao.org/docs/pdf/moz210121.pdf), PDF27/p.1909,279.º,c), termo às24h do dia correspondente ou último dia do mesmo mês; PDF29/p.1911,296.º, aplicação aos prazos legais na falta de regra especial. Original privado SHA256 `a0212d4e3b1bd0f809f63fb69326c13d5e53d953e4653cd2a885ffe6c1ccef83`; consulta não importa/publica nem activa cálculos. CIVA republicado Lei13/2016, PDF11/p.1426(269),11(3), conferido em recorte ampliado: exige entrega da declaração antes de expirar o prazo e fixa efeitos no1Janeiroseguinte. O resumo/código anterior omitira a condição de entrega e invertia a comparação; essa comparação foi corrigida, conservando o histórico.
**29/02/2024+5anos→28/02/2029 às24h.** Uma declaração admissível de2029 produz efeitos em01/01/2030, sem cessação desde o início de28/02. Declaração anterior à opção é divergência; entrega após o termo não tem efeitos presumidos por este tratamento; efeitos que antecipem o fim da permanência mínima são recusados. Sem declaração, a tributação continua, sem caducidade automática. Datas registadas por dia: a data do termo representa o dia até às24h, não o início desse dia. Não foi adicionada convenção de hora de entrega.
Motor específico `production-option/2`; apenas as três opções tributadas deG2I mudam de chave. Snapshots/resultadosv1 continuam guardados como históricos; não são apresentados como cálculo actual v2 nem recalculados automaticamente. Isenção agrícolaP019 não tem opção11 e não depende deste calendário. Restantes provasG2Ife69a16e/a5ac8ca1 reutilizadas.
Provas dirigidas:2testesfrontend (art11 efronteira279c), incluindo termo de2029 e efeitos2030, último dia do mês, viragem de ano, declaração antes do termo, entrega tardia e efeitos prematuros. Verificação de calendário não estende o calculadorIVA além de2026. APIQAisolada `simulator_fiscal_delta_20261021_test:55441`,1teste/10asserções, transacção revertida: datas, versão,48,00MZN e resultado anterior no histórico conservados. Relatório recuperado: início29/02/2020, termo28/02/2025 às24h, ausência expressa de declaração e continuidade tributada emOutubro2026, contexto318/Manica; conferência visual HTML. Provas finais Word/PDF aceites reutilizadas: renderizadores inalterados, sem nova afirmação de paginação. Artefactos `outputs/iva-l1-g2-production-20261024/calendar-*`.
### G2J — P016 angariação ocasional de fundos
Um ramo, dois tratamentos: `fundraising_public` e `fundraising_nonprofit`. CIVA2016PDF10/p.1426(268),9(2)(h); PDF15/p.1426(273),19(1); Lei22/2022art1PDF1/p.2252(25),9(1–3), ePDF2/p.2252(26),10(1–2). Operação interna2026/MZN, campo07, isenção sem direito nesta operação. Exige actividade habitual elegível9(1)a/9(2)a,b,c,d,e,g/9(3)a,b, condições da actividade/entidade, manifestação ocasional em proveito exclusivo e máximo8manifestações/ano por entidade. Não estender a qualquer angariação ou contar vendas como manifestações.
Reutiliza oportunidade/localização/moeda/valor/preço/período e condições/fundamento. Só neste tratamento são pedidos referência da entidade, total anual incluindo actividades fora do dossier, número da manifestação e actividade habitual elegível. Desconhecido/nona manifestação/actividade incompatível→indeterminado; totais divergentes da mesma entidade/ano no livro são explicados, sem substituir dados. O total é declarado, sem alegar cruzamento automático de todos os dossiers.
2testesfrontend novos: entidades pública/sem fins lucrativos,oitava/nona manifestação, retirada individual de condições, actividade incompatível, total desconhecido, totais divergentes e duas vendas na mesma manifestação. Preservados integralmente10livros anteriores:9continuam actuais sem recálculo; sóG2I passa a histórico por mudança deliberada do calendário. APIprivadaQA1teste/10asserções; interface real alterou8→9(indeterminado)→8,cálculo/guarda revisão3→API→recuperação→relatórioHTML,07=200,00MZN,05=0,00,contexto319/Manica/2026-11, fontes/versão e totais anuais1/8. Tipos/buildpassaram. Sem schema/importação/permissão/outro imposto. Artefactos `outputs/iva-l1-g2-fundraising-20261024/`.
**Saldo:29entregues+5QA(G2I4/G2JP016)+98funcionaispendentes+P001=133IDs.** G2:32=25entregues+5QA+2pendentes(P008/P035). Motor72tratamentoscondicionados+2opçõesindeterminadas. Correcção de calendário não cria ID nem fechaP001. Entrega conjuntaG2I/G2J: ainda requer destino/main frescos, backup/restauro, activação pelo processoGit e conferência só do delta; nenhuma instalação declarada nesta passagem.
## 24/10/2026 — G2I produção própria e opção tributada em QA
**Candidata fe69a16eeab6e401c1d02635c873cf8903eafe02; quatro ramos P019–P022, sete tratamentos.** Base instalada d4eae917/recibo7696af68;29ramosentreguespreservadossemreensaios. Homologação inalterada nesta passagem;L1aberto.
@@ -17,7 +42,7 @@ CIVA republicado Lei13/2016PDF10,p.1426(268),9(7):própriaprodução,recursospr
**Reutilização da opção:**a origem pode ser escolhida entre as linhas do dossier; não se assume automaticamente a primeira nem a equivalência dos sujeitos passivos. Antes de substituir dados deliberados, a interface explica o impacto e pede confirmação. A data específica da operação de destino é conservada; a origem copiada segue no snapshot e no relatório. Prova dirigida: reutilizar de g2i-2(data da operação15/10) para g2i-4 preservou20/10, com declaração/estado comuns e origem identificada; revisão final5 recuperada pela API.
**Limitação delimitada:**inícioem29/02edeclaraçãoderegressoem28/02doquintoano,semcorrespondênciadeaniversárionocalendário,nãoéresolvidopelasfontesfiscaisconferidas.Permaneceindeterminadonestecaso,sem inventar convenção fiscal;declarar em data inequivocamente posterior ou confirmar a regra de contagem aplicável. Élimitaçãointerna às opções P020–P022,nãonovoIDnemalargamentodeP001;asrestantesdatas/operaçõesavançam.P001continuaexclusivamente05/06.
**Limitação inicial, superada pela correcção acima (registo histórico):**inícioem29/02edeclaraçãoderegressoem28/02doquintoano,semcorrespondênciadeaniversárionocalendário,nãoéresolvidopelasfontesfiscaisconferidas.Permaneceindeterminadonestecaso,sem inventar convenção fiscal;declarar em data inequivocamente posterior ou confirmar a regra de contagem aplicável. Élimitaçãointerna às opções P020–P022,nãonovoIDnemalargamentodeP001;asrestantesdatas/operaçõesavançam.P001continuaexclusivamente05/06.
**Provas dirigidas:**3testesfrontendcobrem7tratamentos,datasanuais/início,efeitosfuturos,regressodeclaradoeficaz,cincoanos,ausênciaexplícita,estadodesconhecido,divergênciadata/estado,calendárioinválido,29/02→28/02indeterminado,regressoposterior,nãocaducidadeealteraçãoqueconservaresultadohistórico.FixturecongeladaG2Hpreservanovecontextosanteriorescomentradas/resultados/chavesactuais,semrecálculo.Naamostra:01=300,00;02=48,00;05=100,00;07=300,00MZN,preçolíquido/incluído.APIprivadaPOST→GET,QAisolada `simulator_fiscal_delta_20261021_test:55441`,guardadedestino/transacçãorevertida,1teste/14asserções;snapshotintegral,datas e campos recuperados.Interface real:opçãoanualde2026→efeitos2027/indeterminado;dataválidareposta→cálculo;regressodesconhecido→indeterminado;ausênciaexplicitada→revisão3guardada;payloadreal→API→recuperação→relatórioHTMLcomdatas/efeitos/versão/valores/contexto318Manica/2026-10.MoedaMZN. Tipos/buildpassaram. Renderizadoresinalterados,provasWord/PDF/fracçõesaceitesreutilizadas,semnovaafirmaçãodepaginação.
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { fundraisingActivities } from '@/lib/iva-fundraising';
import { emptyProductionTaxOption, type ProductionTaxOption } from '@/lib/iva-production-option';
import { modeloA06Correspondence, newSalesOperation, operationTreatments, recordSales, reuseSales, salesBook, salesCurrent, salesReportRows, saveSalesBook, splitSales, type SalesBook, type SalesOperation } from '@/lib/iva-operations';
@@ -28,8 +29,12 @@ export function SimulationOperations({draft,onChange}:{draft:Record<string,unkno
<label>Representação do preço<select className="block rounded border p-2" value={row.basis} onChange={e=>change(row.id,{basis:e.target.value as SalesOperation['basis']})}><option value="">Ainda desconhecida</option><option value="net">Sem IVA</option><option value="included">IVA incluído</option></select></label>
<label>Tratamento da operação<select className="block rounded border p-2" value={row.treatment} onChange={e=>change(row.id,{treatment:e.target.value,confirmed:[]})}>{operationTreatments.map(t=><option value={t.id} key={t.id}>{t.label}</option>)}</select></label>
</div>
{row.treatment.startsWith('fundraising_')&&<fieldset className="space-y-2 rounded border p-2"><legend>Manifestação ocasional — limite anual da entidade</legend><p>O total é declarado para a entidade em todo o ano, incluindo manifestações fora deste dossier. Várias vendas da mesma manifestação não são novas manifestações.</p>
{([['entityReference','Referência da entidade organizadora'],['annualEvents','Total anual de manifestações da entidade, incluindo esta'],['eventNumber','Número desta manifestação no ano']] as const).map(([field,label])=><label className="block" key={field}>{label}<input value={row.fundraiser?.[field]??''} onChange={e=>change(row.id,{fundraiser:{entityReference:'',annualEvents:'',eventNumber:'',activity:'',...row.fundraiser,[field]:e.target.value}})}/></label>)}
<label className="block">Actividade habitual isenta<select value={row.fundraiser?.activity??''} onChange={e=>change(row.id,{fundraiser:{entityReference:'',annualEvents:'',eventNumber:'',...row.fundraiser,activity:e.target.value}})}><option value="">Por identificar</option>{fundraisingActivities.filter(([id])=>row.treatment==='fundraising_nonprofit'?!id.endsWith('_public'):id!=='statutory_membership').map(([id,label])=><option key={id} value={id}>{label}</option>)}</select></label>
</fieldset>}
{row.treatment.startsWith('production_')&&row.treatment.endsWith('_opted')&&<fieldset className="space-y-2 rounded border p-2"><legend>Opção de tributação da produção — artigo11</legend>
<p>Complete apenas as datas em falta. A opção não cessa automaticamente ao fim de cinco anos. Uma declaração de regresso deve respeitar o prazo mínimo e produz efeitos no ano seguinte.</p>
<p>Complete apenas as datas em falta. A opção não cessa automaticamente ao fim de cinco anos. O prazo termina às 24 horas do dia correspondente; se faltar esse dia, no último dia do mesmo mês. O artigo11(3) exige declaração antes de expirar o prazo e fixa os efeitos em 1 de Janeiro seguinte, respeitando a permanência mínima.</p>
{book.rows.some(source=>source.id!==row.id&&source.taxOption)&&<label className="block">Reutilizar opção já preenchida neste dossier<select value={reuseOption?.rowId===row.id?reuseOption.sourceId:''} onChange={e=>setReuseOption(e.target.value?{rowId:row.id,sourceId:e.target.value}:null)}><option value="">Escolha a origem, se aplicável ao mesmo sujeito passivo</option>{book.rows.filter(source=>source.id!==row.id&&source.taxOption).map(source=><option key={source.id} value={source.id}>{source.description} — {source.establishment}</option>)}</select></label>}
{reuseOption?.rowId===row.id&&<div><p>Ao confirmar, substituirá o enquadramento e as datas da opção desta linha pelos da origem escolhida, conservando a data desta operação. Confirme que se aplicam ao mesmo sujeito passivo; o resultado anterior permanece no histórico.</p><button type="button" onClick={()=>run(()=>{const source=book.rows.find(r=>r.id===reuseOption.sourceId);if(!source?.taxOption)throw new Error('Origem da opção já não disponível; dados conservados.');change(row.id,{taxOption:{...structuredClone(source.taxOption),operationDate:row.taxOption?.operationDate??'',origin:{rowId:source.id,description:source.description,establishment:source.establishment}}});setReuseOption(null);})}>Confirmar reutilização da opção escolhida</button><button type="button" onClick={()=>setReuseOption(null)}>Conservar os dados desta linha</button></div>}
<label className="block">Tipo de declaração de opção<select value={row.taxOption?.mode??''} onChange={e=>change(row.id,{taxOption:{...emptyProductionTaxOption(),...row.taxOption,mode:e.target.value as ProductionTaxOption['mode']}})}><option value="">Por indicar</option><option value="annual">Opção com efeitos no ano seguinte</option><option value="startup">Opção na declaração de início da actividade</option></select></label>
+8
View File
@@ -0,0 +1,8 @@
export type FundraisingContext={entityReference:string;annualEvents:string;eventNumber:string;activity:string};
export const fundraisingActivities=[['health_public','Saúde pública — 9(1)(a)'],['social_assistance','Assistência social — 9(2)(a)'],['security_public','Segurança pública — 9(2)(b)'],['social_care','Equipamentos sociais — 9(2)(c)'],['activity_facilities','Prática artística/desportiva/recreativa — 9(2)(d)'],['cultural_visits','Visitas culturais — 9(2)(e)'],['statutory_membership','Serviços colectivos por quota — 9(2)(g)'],['education_public','Ensino público — 9(3)(a)'],['training_public','Formação pública — 9(3)(b)']];
export function fundraisingError(value:FundraisingContext|undefined,nonprofit:boolean):string|undefined {
if(!value||typeof value.entityReference!=='string'||!value.entityReference.trim())return 'Identifique a entidade organizadora; o limite anual não é por oportunidade.';
if(typeof value.annualEvents!=='string'||typeof value.eventNumber!=='string'||!/^[1-8]$/.test(value.annualEvents)||!/^[1-8]$/.test(value.eventNumber)||Number(value.eventNumber)>Number(value.annualEvents))return 'Indique o total anual da entidade (incluindo esta manifestação) e o número desta manifestação: de1a8, sem exceder o total. Desconhecido não significa zero.';
const activities=fundraisingActivities.map(([id])=>id).filter(id=>nonprofit?!id.endsWith('_public'):id!=='statutory_membership');
if(!activities.includes(value.activity))return 'Escolha uma actividade habitual isenta abrangida por9(2)(h) e compatível com a entidade; não basta ser uma angariação de fundos.';
}
+7 -1
View File
@@ -1,4 +1,5 @@
import { acquisitionScope, readAcquisitionStore } from './iva-acquisitions.ts';
import { fundraisingError, type FundraisingContext } from './iva-fundraising.ts';
import { productionOptionCheck, productionOptionVersion, type ProductionTaxOption } from './iva-production-option.ts';
import { monthlySalesFromDraft, readMonthlyIvaSettings } from './monthly-iva.ts';
import { addFractions, decimalFraction, fraction, fractionLabel, fractionPresentation, type FiscalFraction } from './fiscal-rational.ts';
@@ -54,6 +55,7 @@ const listedGoodsTreatments:OperationTreatment[] = [
['insecticides','Insecticidas','Bem transmitido identificado como insecticida; não qualquer produto químico'],
].map(([id,label,condition])=>({id:`listed_${id}`,label:`Lista legal — ${label}`,field:'05',rate:0,article:'CIVA arts.9(10),19(1)(b)(v), redacção Lei22/2022',source:'Lei22/2022 art.1, PDF2, p.2252(26),9(10); CIVA republicado Lei13/2016, PDF15, p.1426(273),19(1)(b)(v). Âmbito técnico2026/MZN',conditions:[internalOperation,'Transmissão do próprio bem enumerado, identificado; não um serviço autónomo nem extensão por sector, marca ou destino',condition]}));
export const operationTreatments:OperationTreatment[] = [
...([false,true] as const).map(nonprofit=>({...socialTreatment(`fundraising_${nonprofit?'nonprofit':'public'}`,`Angariação ocasional de fundos — ${nonprofit?'organismo sem finalidade lucrativa':'entidade pública'}`,'h',['Actividade habitual isenta identificada:9(1)(a),9(2)(a/b/c/d/e/g) ou9(3)(a/b), com condições dessa isenção e fundamento identificados','Manifestação ocasional destinada à angariação de fundos em proveito exclusivo da entidade organizadora','Total anual declarado inclui esta manifestação e as realizadas fora deste dossier; não superior a oito no ano'],nonprofit),logicVersion:'fundraising/1',source:'CIVA republicado Lei13/2016, PDF10, p.1426(268),9(2)(h); PDF15, p.1426(273),19(1); Lei22/2022 art.1, PDF1, p.2252(25),9(1–3), e PDF2, p.2252(26),10(1–2). Âmbito técnico2026/MZN'})),
...(['agriculture','forestry','livestock','fishing'] as const).flatMap((kind,index)=>{
const label=['Produção agrícola, avicultura e apicultura','Produção silvícola','Produção pecuária','Pesca'][index],letter=['a','b','c','d'][index];
const conditions=[internalOperation,'Operação no âmbito da própria produção identificada, utilizando os próprios recursos; não mera revenda/comércio','Se houver transformação, é acessória, pelo próprio produtor sobre produtos da sua produção, por meios geralmente utilizados nas respectivas explorações'];
@@ -108,7 +110,7 @@ export const operationTreatments:OperationTreatment[] = [
{id:'foreign_operation',label:'Operação no estrangeiro — analisar territorialidade',field:null,rate:null,article:'CIVA art.6: localização por natureza da operação; art.19(1)(b)(ii) não determina sozinho a caixa06 do formulário',source:source2016,conditions:[]},
{id:'other',label:'Outra operação / enquadramento ainda desconhecido',field:null,rate:null,article:'Fundamento específico ainda necessário; não presumir taxa ou isenção',source:'Sem fonte operacional determinada para esta linha',conditions:[]},
];
export type SalesOperation = {id:string;period:string;description:string;establishment:string;amount:string;basis:''|'net'|'included';treatment:string;confirmed:number[];evidence:string;sourceAmount:string|null;sourcePeriod:string;sourceCurrency:string;source:string;taxOption?:ProductionTaxOption; splitSource?:{id:string;amount:string|null;period:string;currency:string;basis:string}};
export type SalesOperation = {id:string;period:string;description:string;establishment:string;amount:string;basis:''|'net'|'included';treatment:string;confirmed:number[];evidence:string;sourceAmount:string|null;sourcePeriod:string;sourceCurrency:string;source:string;taxOption?:ProductionTaxOption;fundraiser?:FundraisingContext; splitSource?:{id:string;amount:string|null;period:string;currency:string;basis:string}};
export type SalesBook = {currency:string;rows:SalesOperation[];periods:Array<{period:string;complete:boolean}>; result?:SalesResult; history?:SalesResult[]};
export type SalesLineResult = {id:string;field:string|null;net:string|null;vat:string|null;gross:string|null;rate:number|null;status:'calculated'|'exempt'|'indeterminate';reason:string;article:string;source:string;exact?:{net:FiscalFraction;vat:FiscalFraction;gross:FiscalFraction}};
export type SalesResult = {version:string;revision:number;calculatedAt:string;opportunity:string;location:string;currency:string;key:string;inputs:SalesOperation[];rows:SalesLineResult[];periods:Array<{period:string;fields:Record<string,string|null>;exactFields?:Record<string,FiscalFraction|null>;reasons:string[]}>;rounding:string};
@@ -156,6 +158,7 @@ export function evaluateSalesOperation(row:SalesOperation,currency:string):Sales
if(!row.description.trim()||!row.establishment.trim())return stop('Indique a operação e o estabelecimento a que pertence; não inferidos da localização do investimento.');
if(rule.conditions.some((_,i)=>!row.confirmed.includes(i))||!row.evidence.trim())return stop('Condições da operação ou fundamento/pressuposto em falta; o sector não prova o tratamento fiscal.');
if(row.sourceAmount!==null&&amount(row.sourceAmount)!==n)return stop('Divergência com o valor reutilizado: conserve a origem e prepare a repartição explicitamente; não substituir silenciosamente.');
if(row.treatment==='fundraising_public'||row.treatment==='fundraising_nonprofit'){const error=fundraisingError(row.fundraiser,row.treatment==='fundraising_nonprofit');if(error)return stop(error);}
const option=row.treatment.startsWith('production_')&&row.treatment.endsWith('_opted')?productionOptionCheck(row.taxOption,row.period):{};
if(option.error)return stop(option.error);
if(row.basis==='included'&&rule.rate===5){
@@ -177,6 +180,8 @@ export function calculateSales(draft:Record<string,unknown>,book:SalesBook,now=n
const start=readMonthlyIvaSettings(String(draft.monthly_iva_settings??'')).start;
const rows=book.rows.map(r=>{
const result=evaluateSalesOperation(r,book.currency);
const fundraiser=r.fundraiser;
if(r.treatment.startsWith('fundraising_')&&fundraiser&&book.rows.some(v=>v.id!==r.id&&v.treatment.startsWith('fundraising_')&&v.period.slice(0,4)===r.period.slice(0,4)&&String(v.fundraiser?.entityReference??'').trim()===String(fundraiser.entityReference??'').trim()&&v.fundraiser?.annualEvents!==fundraiser.annualEvents))return {...result,net:null,vat:null,gross:null,status:'indeterminate' as const,reason:'Totais anuais divergentes para a mesma entidade; resolva a divergência sem substituir dados de outra linha.'};
if(r.splitSource) {
const source=r.splitSource,group=book.rows.filter(v=>v.splitSource?.id===source.id),index=Number(source.period.slice(5))-Number(start.slice(5));
if(group.some(v=>v.period!==source.period||v.basis!==source.basis||v.sourceCurrency!==source.currency||JSON.stringify(v.splitSource)!==JSON.stringify(source))||sum(group.map(v=>v.amount))!==(amount(source.amount)===null?null:money(amount(source.amount)!))||!live||live.amounts[index]!==source.amount)return {...result,exact:undefined,net:null,vat:null,gross:null,status:'indeterminate' as const,reason:'Repartição não reconciliada com o total, período, moeda e representação do preço da origem; nenhuma parcela foi presumida.'};
@@ -210,6 +215,7 @@ export function salesReportRows(draft:Record<string,unknown>):Array<[string,stri
const context=`${r.currency}; oportunidade ${r.opportunity}; ${r.location||'localização não indicada'}; ${r.version}; revisão ${r.revision}; ${r.calculatedAt}`;
const out:Array<[string,string,string,string]>=[['IVA mensal','Operações IVA L1',salesCurrent(draft,book)?'Resultado correspondente às entradas':'Histórico desactualizado; não aplicar à projecção actual',context],['IVA mensal','Método e limites L1',r.rounding,'Sem apuramento integral, dedução automática ou submissão AT']];
for(const row of r.inputs){const value=r.rows.find(v=>v.id===row.id)!;out.push(['IVA mensal',`${row.period} — ${row.description} — ${row.establishment}`,`Declarado ${row.amount||'em falta'} ${row.sourceCurrency}; ${row.basis==='included'?'IVA incluído':row.basis==='net'?'sem IVA':'base desconhecida'}; líquido ${value.net??'Indeterminado'}; IVA ${value.vat??'Indeterminado'}; bruto ${value.gross??'Indeterminado'}; campo ${value.field??'não determinado'}; ${value.reason}`,`${context}; ${value.article}; ${value.source}; origem: ${row.source}; pressuposto/documento: ${row.evidence||'em falta'}; condições confirmadas: ${row.confirmed.join(',')||'nenhuma'}`]);}
for(const row of r.inputs.filter(v=>v.fundraiser))out.push(['IVA mensal',`Manifestação ocasional — ${row.description}`,`Entidade ${row.fundraiser!.entityReference}; ano ${row.period.slice(0,4)}; total anual declarado ${row.fundraiser!.annualEvents}; manifestação ${row.fundraiser!.eventNumber}; actividade ${row.fundraiser!.activity}`,`${context}; dados declarados para a entidade, incluindo manifestações fora deste dossier; não é contagem automática das vendas.`]);
for(const value of r.rows.filter(v=>v.exact))out.push(['IVA mensal',`Precisão exacta — ${value.id}`,`Líquido ${fractionLabel(value.exact!.net)} MZN; IVA ${fractionLabel(value.exact!.vat)} MZN; bruto ${fractionLabel(value.exact!.gross)} MZN`,`${context}; apresentação decimal a6casas, aproximação quando a fracção não termina; não aplicar à declaração sem a sua regra própria.`]);
for(const p of r.periods){out.push(['IVA mensal',`Operações L1 — ${p.period}`,Object.entries(p.fields).map(([f,v])=>`${f}: ${v??'Indeterminado'}`).join('; '),`${context}; ${p.reasons.join(' ')}`]);
if(p.exactFields)out.push(['IVA mensal',`Precisão exacta do período — ${p.period}`,Object.entries(p.exactFields).map(([f,v])=>`${f}: ${v?fractionLabel(v):'Indeterminado'}`).join('; '),`${context}; totais em fracções MZN, calculados antes da apresentação decimal, sem regra declarativa presumida.`]);
+16 -6
View File
@@ -1,5 +1,14 @@
export type ProductionTaxOption = {mode:''|'annual'|'startup';declarationDate:string;startupDate:string;returnStatus:''|'none'|'declared';returnDeclarationDate:string;operationDate:string;origin?:{rowId:string;description:string;establishment:string}};
export const productionOptionVersion='production-option/1';
export const productionOptionVersion='production-option/2';
// Civil Code 279(c), read with 296: the term ends at 24:00 on this date.
// A missing anniversary day is clamped within the same month, never rolled to March.
export function productionMinimumTermEnd(effective:string):string {
const year=Number(effective.slice(0,4))+5,month=Number(effective.slice(5,7)),day=Number(effective.slice(8,10));
const leap=year%4===0&&(year%100!==0||year%400===0);
const lastDay=[31,leap?29:28,31,30,31,30,31,31,30,31,30,31][month-1];
return `${String(year).padStart(4,'0')}-${String(month).padStart(2,'0')}-${String(Math.min(day,lastDay)).padStart(2,'0')}`;
}
export const productionReturnEffective=(declarationDate:string):string=>String(Number(declarationDate.slice(0,4))+1).padStart(4,'0')+'-01-01';
export const emptyProductionTaxOption = ():ProductionTaxOption=>({mode:'',declarationDate:'',startupDate:'',returnStatus:'',returnDeclarationDate:'',operationDate:''});
export function productionOptionCheck(option:ProductionTaxOption|undefined,period:string):{error?:string;summary?:string} {
const valid=(value:unknown):value is string=>typeof value==='string'&&/^\d{4}-\d{2}-\d{2}$/.test(value)&&Number(value.slice(0,4))>=1&&Number.isFinite(new Date(value+'T00:00:00Z').valueOf())&&new Date(value+'T00:00:00Z').toISOString().slice(0,10)===value;
@@ -16,11 +25,12 @@ export function productionOptionCheck(option:ProductionTaxOption|undefined,perio
let end='';
if(option.returnStatus==='declared'){
if(!valid(option.returnDeclarationDate))return {error:'Data da declaração de regresso à isenção em falta ou inválida; não presumir uma data.'};
const anniversary=String(Number(effective.slice(0,4))+5).padStart(4,'0')+effective.slice(4);
if(effective.endsWith('-02-29')&&option.returnDeclarationDate===anniversary.slice(0,4)+'-02-28')return {error:'Regresso no aniversário de cinco anos de início em29/02: o acervo conferido não define este caso de calendário; não presumir a data limite.'};
if(option.returnDeclarationDate<anniversary)return {error:'A declaração de regresso indicada antecede o período mínimo de cinco anos; reveja o histórico, sem presumir regresso eficaz.'};
end=String(Number(option.returnDeclarationDate.slice(0,4))+1).padStart(4,'0')+'-01-01';
const anniversary=productionMinimumTermEnd(effective);
if(option.returnDeclarationDate<option.declarationDate)return {error:'A declaração de regresso antecede a declaração de opção; resolva a divergência do histórico.'};
if(option.returnDeclarationDate>anniversary)return {error:`A declaração de regresso indicada é posterior ao termo às 24 horas de ${anniversary}; o CIVA, art.11(3), exige entrega antes de expirar o prazo. Não presumir efeitos para esta declaração tardia.`};
end=productionReturnEffective(option.returnDeclarationDate);
if(end<=anniversary)return {error:`O regresso não pode produzir efeitos antes de terminado o prazo mínimo, às 24 horas de ${anniversary}.`};
if(option.operationDate>=end)return {error:`Opção já cessou na data desta operação; regresso à isenção com efeitos ${end}. Reveja o tratamento.`};
}
return {summary:`Art.11 (${productionOptionVersion}): declaração ${option.declarationDate} (${option.mode==='startup'?'opção no início da actividade':'opção anual'}); efeitos ${effective}; operação ${option.operationDate}; ${end?`declaração de regresso ${option.returnDeclarationDate}, efeitos ${end}`:'ausência de declaração de regresso expressamente indicada; tributação continua, sem caducidade automática ao fim de cinco anos'}.${option.origin?` Opção reutilizada de ${option.origin.description} (${option.origin.rowId}), estabelecimento ${option.origin.establishment}; escolha confirmada nesta linha.`:''}`};
return {summary:`Art.11 (${productionOptionVersion}): declaração ${option.declarationDate} (${option.mode==='startup'?'opção no início da actividade':'opção anual'}); efeitos ${effective}; termo mínimo às 24 horas de ${productionMinimumTermEnd(effective)} (Código Civil, arts.279.º,c), e296.º); operação ${option.operationDate}; ${end?`declaração de regresso ${option.returnDeclarationDate}, efeitos ${end} (CIVA, art.11(3)); o termo do prazo não antecipa estes efeitos`:'ausência de declaração de regresso expressamente indicada; tributação continua, sem caducidade automática ao fim de cinco anos'}.${option.origin?` Opção reutilizada de ${option.origin.description} (${option.origin.rowId}), estabelecimento ${option.origin.establishment}; escolha confirmada nesta linha.`:''}`};
}
+32
View File
@@ -0,0 +1,32 @@
<?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 IvaFundraisingDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_fundraising_context_preserves_accepted_opportunities(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::factory()->create(['email_verified_at' => now()]));
$draft = json_decode(file_get_contents(getenv('IVA_G2J_INPUT') ?: base_path('outputs/iva-l1-g2-fundraising-20261024/calculated.json')), true, flags: JSON_THROW_ON_ERROR);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => [], 'economic_snapshot' => $draft], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$recovered = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($draft['modelo_a_snapshot'], $recovered['modelo_a_snapshot']);
$book = json_decode($recovered['modelo_a_snapshot'], true)['salesOperations']['opportunity:319'];
$this->assertCount(2, $book['result']['rows']);
$this->assertSame('200.00', $book['result']['periods'][0]['fields']['07']);
$this->assertSame('0.00', $book['result']['periods'][0]['fields']['05']);
$this->assertSame(json_decode($draft['modelo_a_snapshot'], true)['salesOperations']['opportunity:310'], json_decode($recovered['modelo_a_snapshot'], true)['salesOperations']['opportunity:310']);
$this->assertSame(json_decode($draft['modelo_a_snapshot'], true)['salesOperations']['opportunity:309'], json_decode($recovered['modelo_a_snapshot'], true)['salesOperations']['opportunity:309']);
if (getenv('IVA_G2J_RECOVERED')) file_put_contents(getenv('IVA_G2J_RECOVERED'), json_encode($recovered, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
@@ -0,0 +1,34 @@
<?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 IvaProductionCalendarDraftTest extends TestCase
{
use DatabaseTransactions;
public function test_calendar_delta_round_trips_without_replacing_previous_result(): void
{
$target = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $target->name);
$this->assertSame(55441, (int) $target->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
$this->actingAs(User::factory()->create(['email_verified_at' => now()]));
$draft = json_decode(file_get_contents(base_path('outputs/iva-l1-g2-production-20261024/calendar-calculated.json')), true, flags: JSON_THROW_ON_ERROR);
$id = $this->postJson('/api/v1/simulation-drafts', ['personal_snapshot' => [], 'economic_snapshot' => $draft], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated()->json('data.id');
$recovered = $this->getJson('/api/v1/simulation-drafts/'.$id)->assertOk()->json('data.economic_snapshot');
$this->assertSame($draft['modelo_a_snapshot'], $recovered['modelo_a_snapshot']);
$book = json_decode($recovered['modelo_a_snapshot'], true)['salesOperations']['opportunity:318'];
$this->assertSame('2020-02-29', $book['rows'][2]['taxOption']['startupDate']);
$this->assertStringContainsString('24 horas de 2025-02-28', $book['result']['rows'][2]['reason']);
$this->assertStringContainsString('production-option/2', $book['result']['rows'][2]['reason']);
$this->assertSame('48.00', $book['result']['periods'][0]['fields']['02']);
$original = json_decode(file_get_contents(base_path('tests/frontend/fixtures/iva-production.json')), true, flags: JSON_THROW_ON_ERROR);
$old = json_decode($original['modelo_a_snapshot'], true)['salesOperations']['opportunity:318']['result'];
$this->assertSame($old, $book['history'][array_key_last($book['history'])]);
file_put_contents(base_path('outputs/iva-l1-g2-production-20261024/calendar-recovered.json'), json_encode($recovered, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}
File diff suppressed because one or more lines are too long
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import fs from 'node:fs';
import {operationTreatments,newSalesOperation,evaluateSalesOperation,recordSales,saveSalesBook,salesBook,salesCurrent} from '../../resources/js/lib/iva-operations.ts';
import {createProjectDossierReport} from '../../resources/js/lib/project-dossier-report.ts';
const seed=()=>JSON.parse(fs.readFileSync(new URL('./fixtures/iva-production.json',import.meta.url),'utf8'));
function fixture(){const draft=seed();draft.context_opportunity='opportunity:319';draft.project_title='SYNTHETIC QA G2J fundraising';
let book={currency:'MZN',rows:['fundraising_public','fundraising_nonprofit'].map((id,i)=>({...newSalesOperation(`g2j-${i}`,'2026-11','MZN'),description:`SYNTHETIC QA ${id}`,establishment:'SYNTHETIC QA Manica',amount:'100',basis:i?'included':'net',treatment:id,confirmed:operationTreatments.find(t=>t.id===id).conditions.map((_,n)=>n),evidence:'SYNTHETIC QA: entidade, actividade habitual isenta, proveito exclusivo e total anual expressamente pressupostos',fundraiser:{entityReference:`SYNTHETIC-ENTITY-${i}`,annualEvents:i?'8':'1',eventNumber:i?'8':'1',activity:'social_assistance'}})),periods:[{period:'2026-11',complete:true}]};book=recordSales(draft,book);draft.modelo_a_snapshot=saveSalesBook(draft,book);return {draft,book};}
test('one fundraising branch: public/nonprofit, eighth admitted, ninth/unknown/activity mismatch indeterminate',()=>{
const {draft,book}=fixture();assert.equal(book.result.periods[0].fields['07'],'200.00');
for(const row of book.rows){assert.equal(evaluateSalesOperation(row,'MZN').vat,'0.00');for(const n of row.confirmed)assert.equal(evaluateSalesOperation({...row,confirmed:row.confirmed.filter(i=>i!==n)},'MZN').vat,null);
for(const patch of [{annualEvents:'9'},{annualEvents:''},{annualEvents:'0'},{eventNumber:'9'},{eventNumber:'2',annualEvents:'1'},{entityReference:''},{activity:'religious_personnel'},{activity:''}])assert.equal(evaluateSalesOperation({...row,fundraiser:{...row.fundraiser,...patch}},'MZN').vat,null);
assert.equal(evaluateSalesOperation({...row,evidence:''},'MZN').vat,null);
}
const nonprofit=book.rows[1];assert.equal(evaluateSalesOperation({...nonprofit,fundraiser:{...nonprofit.fundraiser,activity:'health_public'}},'MZN').vat,null);
const conflicting={...book,rows:book.rows.map(r=>({...r,fundraiser:{...r.fundraiser,entityReference:'SAME'}}))};assert.ok(recordSales(draft,conflicting).result.rows.every(r=>r.vat===null));
const sameEvent={...book,rows:[book.rows[0],{...book.rows[0],id:'second-sale'}]};assert.equal(recordSales(draft,sameEvent).result.periods[0].fields['07'],'200.00');
});
test('fundraising context reports annual data preserving ten books; only changed G2I calendar version becomes historical',()=>{
const {draft,book}=fixture(),old=JSON.parse(seed().modelo_a_snapshot).salesOperations,store=JSON.parse(draft.modelo_a_snapshot).salesOperations;
for(const [id,value] of Object.entries(old)){assert.deepEqual(store[id],value);assert.equal(salesCurrent({...draft,context_opportunity:id},value),id!=='opportunity:318');}
assert.equal(Object.keys(old).length,10);assert.equal(salesCurrent(draft,salesBook(draft)),true);
const model=JSON.stringify(createProjectDossierReport(draft,{selectedSections:new Set(['finance'])}));
for(const token of ['fundraising_public','fundraising_nonprofit','200.00','SYNTHETIC-ENTITY-1','2026-11','opportunity:319','Manica','total anual declarado 8'])assert.ok(model.includes(token),token);
assert.equal(model.includes('production_forestry_opted'),false);assert.equal(book.result.rows.length,2);
});
if(process.env.IVA_G2J_OUT)fs.writeFileSync(process.env.IVA_G2J_OUT,JSON.stringify(fixture().draft,null,2));
+27 -2
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import fs from 'node:fs';
import {operationTreatments,newSalesOperation,evaluateSalesOperation,recordSales,saveSalesBook,salesBook,salesCurrent} from '../../resources/js/lib/iva-operations.ts';
import {createProjectDossierReport} from '../../resources/js/lib/project-dossier-report.ts';
import {productionMinimumTermEnd,productionReturnEffective,productionOptionCheck} from '../../resources/js/lib/iva-production-option.ts';
const ids=['production_agriculture', 'production_forestry', 'production_forestry_opted', 'production_livestock', 'production_livestock_opted', 'production_fishing', 'production_fishing_opted'];
function seed(){return JSON.parse(fs.readFileSync(new URL('./fixtures/iva-listed.json',import.meta.url),'utf8'));}
function fixture(){const draft=seed();draft.context_opportunity='opportunity:318';draft.project_title='SYNTHETIC QA — G2I social';
@@ -39,12 +40,36 @@ test('article11 distinguishes annual effects, startup, five-year minimum and eff
assert.equal(check({declarationDate:'2019-12-31',returnDeclarationDate:''}).vat,'16.00');
for(const patch of [{mode:''},{returnStatus:''},{returnStatus:'declared',returnDeclarationDate:''},{returnStatus:'none',returnDeclarationDate:'2026-01-01'},{declarationDate:''},{declarationDate:'2026-99-99'},{declarationDate:'2024-02-30'},{operationDate:'2026-11-01'},{operationDate:''},{returnDeclarationDate:'not-a-date'}])assert.equal(check(patch).vat,null);
assert.equal(check({mode:'startup',declarationDate:'2020-02-28',startupDate:'2020-02-29',returnDeclarationDate:'2025-02-28'}).vat,null);
assert.match(check({mode:'startup',declarationDate:'2020-02-28',startupDate:'2020-02-29',returnDeclarationDate:'2025-03-01'}).reason,/regresso.*2026-01-01/);
assert.equal(check({mode:'startup',declarationDate:'2020-02-28',startupDate:'2020-02-29',returnDeclarationDate:'2026-03-01'}).vat,'16.00');
assert.match(check({mode:'startup',declarationDate:'2020-02-28',startupDate:'2020-02-29',returnDeclarationDate:'2025-02-28'}).reason,/regresso.*2026-01-01/);
assert.match(check({mode:'startup',declarationDate:'2020-02-28',startupDate:'2020-02-29',returnDeclarationDate:'2025-03-01'}).reason,/declaração tardia/);
assert.equal(check({mode:'startup',declarationDate:'2020-02-28',startupDate:'2020-02-29',returnDeclarationDate:'2026-03-01'}).vat,null);
assert.equal(operationTreatments.some(t=>t.id==='production_agriculture_opted'),false);
const changed={...book,rows:book.rows.map(r=>r.id===row.id?{...r,taxOption:{...r.taxOption,declarationDate:'2025-12-31'}}:r)};
assert.equal(salesCurrent(draft,changed),false);assert.deepEqual(changed.result,book.result);
const report=JSON.stringify(createProjectDossierReport(draft,{selectedSections:new Set(['finance'])}));
for(const token of ['2024-12-15','2025-01-01','2026-10-15','ausência de declaração de regresso'])assert.ok(report.includes(token),token);
});
test('Civil Code 279(c): calendar term at 24:00 is separate from article11 return effects',()=>{
assert.equal(productionMinimumTermEnd('2024-02-29'),'2029-02-28');
assert.equal(productionMinimumTermEnd('2023-03-01'),'2028-03-01');
assert.equal(productionMinimumTermEnd('2096-02-29'),'2101-02-28');
assert.equal(productionMinimumTermEnd('2024-12-31'),'2029-12-31');
assert.equal(productionReturnEffective('2029-02-28'),'2030-01-01');
const option={mode:'startup',declarationDate:'2024-02-28',startupDate:'2024-02-29',returnStatus:'declared',returnDeclarationDate:'2029-02-28',operationDate:'2029-02-28'};
// Schedule checks only: these dates do not extend the VAT calculator beyond its admitted 2026 period.
for(const date of ['2029-02-28','2029-03-01','2029-12-31']){
const result=productionOptionCheck({...option,operationDate:date},date.slice(0,7));
assert.equal(result.error,undefined);assert.match(result.summary,/24 horas de 2029-02-28/);assert.match(result.summary,/efeitos 2030-01-01/);
}
for(const date of ['2030-01-01','2030-01-02'])assert.match(productionOptionCheck({...option,operationDate:date},date.slice(0,7)).error,/cessou.*2030-01-01/);
assert.equal(productionOptionCheck({...option,returnDeclarationDate:'2029-02-27'},'2029-02').error,undefined);
assert.match(productionOptionCheck({...option,returnDeclarationDate:'2028-12-31'},'2029-02').error,/antes de terminado o prazo mínimo/);
assert.match(productionOptionCheck({...option,returnDeclarationDate:'2029-03-01'},'2029-02').error,/declaração tardia/);
const {draft,book}=fixture();
const row=book.rows.find(r=>r.treatment==='production_forestry_opted');
const leap={...row,taxOption:{mode:'startup',declarationDate:'2020-02-28',startupDate:'2020-02-29',returnStatus:'declared',returnDeclarationDate:'2025-02-28',operationDate:'2026-10-15'}};
assert.match(evaluateSalesOperation(leap,'MZN').reason,/regresso.*2026-01-01/);
const report=JSON.stringify(createProjectDossierReport(draft,{selectedSections:new Set(['finance'])}));
assert.ok(report.includes('24 horas de 2030-01-01'));assert.ok(report.includes('production-option/2'));
});
if(process.env.IVA_G2I_OUT)fs.writeFileSync(process.env.IVA_G2I_OUT,JSON.stringify(fixture().draft,null,2));