Compare commits

...
Author SHA1 Message Date
nala 0d7b70459d fix: explain IVA query conditions and distinguish integration failures 2026-10-21 13:35:22 +02:00
15 changed files with 327 additions and 24 deletions
@@ -19,13 +19,15 @@ class SimulationTaxParameterController extends Controller
$input['period_start'],
$input['period_end'],
$input['context'] ?? [],
$input['component'] === 'IVA',
);
return response()->json([
'component' => $input['component'],
'period' => ['start' => $input['period_start'], 'end' => $input['period_end']],
'assumptions' => $input['context'] ?? [],
'parameters' => $data,
'parameters' => $input['component'] === 'IVA' ? $data['parameters'] : $data,
'diagnostics' => $input['component'] === 'IVA' ? $data['diagnostics'] : [],
])->header('Cache-Control', 'no-store');
}
}
@@ -21,7 +21,7 @@ class ResolveSimulationTaxParametersRequest extends FormRequest
'component' => ['required', 'in:IRPC,IVA,ISPC,IRPS,INSS,RETENCOES,DIREITOS_ADUANEIROS,ICE'],
'period_start' => ['required', 'date_format:Y-m-d'],
'period_end' => ['required', 'date_format:Y-m-d', 'after_or_equal:period_start'],
'context' => ['sometimes', 'array:fiscal_periodicity,fiscal_operation,fiscal_residency_assessment,fiscal_residency_country,fiscal_income_category,fiscal_establishments,fiscal_documentation,fiscal_customs_code,fiscal_vat_refund_state'],
'context' => ['sometimes', 'array:currency,fiscal_periodicity,fiscal_operation,fiscal_residency_assessment,fiscal_residency_country,fiscal_income_category,fiscal_establishments,fiscal_documentation,fiscal_customs_code,fiscal_vat_refund_state'],
'context.*' => ['string', 'max:160'],
];
}
@@ -50,7 +50,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
'environmental_category', 'community_benefits', 'tax_calculation', 'fiscal_scenario_mode', 'base', 'rate', 'reduction',
'sales_taxable', 'sales_exempt', 'sales_zero_rate', 'exports', 'deductible_costs', 'non_deductible_costs',
'carried_losses', 'payroll', 'outbound_payments', 'imports', 'claimed_benefits', 'reference_date',
'fiscal_period', 'fiscal_periodicity', 'fiscal_month', 'fiscal_quarter', 'fiscal_reference_date', 'irps_salary_view', 'fiscal_component', 'fiscal_income_category', 'fiscal_residency_assessment',
'fiscal_currency', 'fiscal_period', 'fiscal_periodicity', 'fiscal_month', 'fiscal_quarter', 'fiscal_reference_date', 'irps_salary_view', 'fiscal_component', 'fiscal_income_category', 'fiscal_residency_assessment',
'fiscal_operation', 'fiscal_activity', 'fiscal_establishments', 'fiscal_documentation',
'fiscal_customs_code', 'fiscal_vat_refund_state',
'offer_or_demand', 'opportunity_type', 'opportunity_type_other', 'opportunity_description',
@@ -279,7 +279,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
'currency', 'funds_origin_countries', 'environmental_category', 'tax_calculation', 'fiscal_scenario_mode', 'offer_or_demand',
'opportunity_type', 'confidentiality', 'publish_requested', 'terms_accepted', 'truth_declaration',
'reuse_simulation', 'contact_authorisation', 'publication_authorisation', 'public_fields_confirmation', 'validation_acknowledged',
'fiscal_period', 'fiscal_periodicity', 'fiscal_month', 'fiscal_quarter', 'fiscal_reference_date', 'irps_salary_view', 'fiscal_component', 'fiscal_income_category', 'fiscal_residency_assessment', 'fiscal_operation',
'fiscal_currency', 'fiscal_period', 'fiscal_periodicity', 'fiscal_month', 'fiscal_quarter', 'fiscal_reference_date', 'irps_salary_view', 'fiscal_component', 'fiscal_income_category', 'fiscal_residency_assessment', 'fiscal_operation',
'fiscal_documentation', 'fiscal_vat_refund_state'],
'personal_snapshot.selection_labels' => ['person_type', 'investor_origin', 'tax_residency_country', 'fiscal_residency_country', 'entity_stage', 'legal_form', 'preferred_channel'],
];
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Services;
/** Explains a failed selection; never changes answers, eligibility or calculation. */
final class SimulationIvaDiagnostics
{
public function explain(array $versions, string $start, string $end, array $context): array
{
$diagnostics = [];
foreach ($versions as $version) {
$parameter = $version['parameters']['IVA_GENERAL_RATE'] ?? null;
if (! is_array($parameter)) {
continue;
}
$issues = [];
$add = function (string $category, ?string $field, string $label, mixed $received, string $expected, string $message) use (&$issues): void {
$issues[] = compact('category', 'field', 'label', 'received', 'expected', 'message');
};
if ($start < $version['valid_from'] || ($version['valid_until'] !== null && $end > $version['valid_until'])) {
$add('period_unavailable', 'fiscal_period', 'Período', "$start a $end", $version['valid_from'].' a '.($version['valid_until'] ?? 'sem data final indicada'), 'Esta versão da regra não abrange todo o período seleccionado. Não altere a data real da operação para obter um resultado.');
}
foreach ($parameter['conditions'] ?? [] as $key => $expected) {
$received = $context[$key] ?? null;
if ($received === $expected) {
continue;
}
if ($key === 'currency') {
$category = $received === null || $received === '' ? 'missing_field' : (preg_match('/^[A-Z]{3}$/D', $received) ? 'out_of_scope' : 'invalid_value');
$add($category, 'fiscal_currency', 'Moeda', $received, $expected, $category === 'missing_field' ? 'Indique a moeda da operação.' : 'Esta versão aceita apenas '.$expected.'. Reveja a moeda ou utilize um cálculo adequado à operação; a resposta não foi alterada.');
} elseif ($key === 'operation' && ! array_key_exists('operation', $context) && $expected === 'internal_general_net' && in_array($context['fiscal_operation'] ?? null, ['import', 'export', 'non_resident', 'refund'], true)) {
$add('out_of_scope', 'fiscal_operation', 'Operação', $context['fiscal_operation'], 'Operação interna à taxa geral, sobre base líquida já apurada', 'A operação declarada está excluída desta versão. Utilize um cálculo adequado à operação; não altere a resposta para obter um resultado.');
} elseif ($key === 'operation' && ! array_key_exists('operation', $context)) {
$add('system_configuration', 'fiscal_operation', 'Operação', $context['fiscal_operation'] ?? null, $expected === 'internal_general_net' ? 'Operação interna à taxa geral, sobre base líquida já apurada' : $expected, 'Limitação de integração do sistema: esta consulta não comunica o enquadramento específico exigido pela versão. A resposta genérica não permite inferi-lo. Utilize o formulário IVA — taxa geral, com declaração explícita do âmbito; não altere respostas para forçar compatibilidade.');
} elseif ($key === 'operation') {
$add($received === null || $received === '' ? 'missing_field' : 'out_of_scope', 'fiscal_operation', 'Operação', $received, $expected === 'internal_general_net' ? 'Operação interna à taxa geral, sobre base líquida já apurada' : $expected, 'A operação não está abrangida por esta versão; utilize um cálculo adequado à operação declarada.');
} else {
$add('system_configuration', null, 'Configuração da regra', null, 'Condições publicadas ligadas a campos deste formulário', 'O sistema não consegue relacionar todas as condições desta versão com o formulário. Não é uma falha das suas respostas.');
}
}
if (! SimulationFiscalSourceAcceptance::eligible($version['source'] ?? [], $version['source_sha256'] ?? null)
|| ($version['source_version_no'] ?? null) !== ($version['source']['version_no'] ?? null)
|| ($version['rule_definition']['language'] ?? null) !== 'investmoz.tax.v1') {
$add('system_configuration', null, 'Regra', null, 'Versão publicada com fonte e configuração coerentes', 'A configuração ou a fonte da regra não permite resolver este parâmetro. Não altere as respostas; é necessária correcção do sistema.');
}
if ($issues !== []) {
$source = array_intersect_key($version['source'] ?? [], array_flip(['title', 'diploma', 'article', 'url']));
$diagnostics[] = ['label' => 'IVA — taxa geral', 'rule_version' => $version['rule_version'], 'validity' => ['start' => $version['valid_from'], 'end' => $version['valid_until']], 'source' => $source, 'issues' => $issues];
}
}
return $diagnostics;
}
}
@@ -53,9 +53,9 @@ final class SimulationTaxParameterResolver
* never executes a tax formula and requires each parameter to declare context.
*
* @param array<string,string> $context
* @return list<array<string,mixed>>
* @return list<array<string,mixed>>|array{parameters:list<array<string,mixed>>,diagnostics:list<array<string,mixed>>}
*/
public function resolvePublishedForPeriod(string $component, string $periodStart, string $periodEnd, array $context): array
public function resolvePublishedForPeriod(string $component, string $periodStart, string $periodEnd, array $context, bool $diagnostics = false): array
{
$start = $this->date($periodStart);
$end = $this->date($periodEnd);
@@ -67,14 +67,14 @@ final class SimulationTaxParameterResolver
->join('investment_legal_sources as source', 'source.id', '=', 'rule.legal_source_id')
->where('rule.component', $component)
->where('rule.status', 'published')
->whereDate('rule.valid_from', '<=', $periodEnd)
->where(fn ($query) => $query->whereNull('rule.valid_until')->orWhereDate('rule.valid_until', '>=', $periodStart))
->when(! $diagnostics, fn ($query) => $query->whereDate('rule.valid_from', '<=', $periodEnd)
->where(fn ($query) => $query->whereNull('rule.valid_until')->orWhereDate('rule.valid_until', '>=', $periodStart)))
->where(fn ($query) => $query->where('source.validation_status', 'validated')->orWhere(fn ($fiscal) => $fiscal->where('source.validation_status', 'pending')->where('source.fiscal_acceptance->scope', 'fiscal_parameters')
->where('source.fiscal_acceptance->basis', 'Tempo-official-sources-20261008')
->whereRaw("source.fiscal_acceptance->>'sha256'=rule.source_sha256")))
->whereColumn('source.version_no', 'rule.source_version_no')
->whereDate('source.valid_from', '<=', $periodStart)
->where(fn ($query) => $query->whereNull('source.valid_until')->orWhereDate('source.valid_until', '>=', $periodEnd))
->when(! $diagnostics, fn ($query) => $query->whereDate('source.valid_from', '<=', $periodStart)
->where(fn ($query) => $query->whereNull('source.valid_until')->orWhereDate('source.valid_until', '>=', $periodEnd)))
->select([
'rule.id', 'rule.rule_code', 'rule.rule_version', 'rule.component', 'rule.scope_key',
'rule.valid_from', 'rule.valid_until', 'rule.parameters', 'rule.rule_definition',
@@ -121,7 +121,11 @@ final class SimulationTaxParameterResolver
];
})->all());
return $this->resolveAvailableParameters($versions, $component, $periodStart, $periodEnd, $context);
$parameters = $this->resolveAvailableParameters($versions, $component, $periodStart, $periodEnd, $context);
$resolvedIva = array_filter($parameters, fn (array $parameter): bool => $parameter['parameter_code'] === 'IVA_GENERAL_RATE' && $parameter['status'] === 'resolved');
return $diagnostics ? ['parameters' => $parameters, 'diagnostics' => $resolvedIva !== [] ? [] : (new SimulationIvaDiagnostics)->explain($versions, $periodStart, $periodEnd, $context)] : $parameters;
}
/**
@@ -1179,5 +1179,57 @@
"calculations_exporters_repeated": false
},
"remaining": []
},
"iva_query_diagnostic_qa_20261021": {
"recorded_utc": "2026-10-21T11:35:00.737064+00:00",
"scope": "QA only, parameter-query diagnosis; no calculation/export retest, no destination writes",
"reported_surface": "Componentes fiscais e contributivas, IVA_GENERAL_RATE, annual 2026-01-01 to2026-12-31, indeterminate_context",
"confirmed_origin": "Implementation integration gap: query sent fiscal_operation and no currency/operation, whereas the published IVA parameter requires operation=internal_general_net and currency=MZN. Resolver collapsed every mismatch to indeterminate_context and UI blamed responses without individual evidence.",
"original_answers_known": "Only the period/component/message were provided. No original currency or operation is asserted; USD/domestic are explicit directed QA cases, not inferred original answers.",
"diagnostic_categories": [
"missing_field",
"invalid_value",
"out_of_scope",
"period_unavailable",
"system_configuration"
],
"changes": [
"Server projects each failed criterion, field, received value, expected criterion, source and exact rule version; no expression projection",
"Explicit fiscal_currency answer for IVA query, persisted and recovered; no copy/conversion from budget or IVA-base currency",
"Generic domestic is never silently equated to internal_general_net; integration limitation is explained and links to explicit IVA form",
"Inline notes plus summary links, human IVA — taxa geral label and Ver fundamento disclosure",
"Period diagnostic can inspect published candidates outside queried period under existing visibility policy; selection/calculation eligibility unchanged",
"No eligible visible candidate remains explicitly uncertain; no invented input fault"
],
"verification": {
"new_unit_tests": 5,
"new_unit_assertions": 24,
"targeted_api_tests": 1,
"targeted_api_assertions": 16,
"existing_resolver_regression_tests": 7,
"existing_resolver_regression_assertions": 25,
"typescript": "passed",
"build": "passed, exit0",
"pint": "passed",
"edge": "Normal simulator query, annual2026; missing currency then explicitly chosen USD; inline/summary linked fields focused; source/version disclosure; USD and generic operation preserved after save/recovery; internal code and expression absent from query text",
"no_simulation_created_by_api_diagnostic": true
},
"qa_fixture": {
"rule_version": 3,
"state_after_proof": "suspended",
"consumer_enabled": false,
"database": "simulator_fiscal_delta_20261021_test",
"imported_into_homologation": false
},
"test_database": "simulator_fiscal_checks_20261021_test, existing QA schema correction applied before targeted API test; isolated guard preserved",
"proofs": [
"outputs/simulator-continuation-20261020/iva-diagnostic-api-test.txt",
"outputs/simulator-continuation-20261020/iva-diagnostic-summary.txt",
"outputs/simulator-continuation-20261020/iva-diagnostic-summary.png",
"outputs/simulator-continuation-20261020/iva-diagnostic-build-final.txt"
],
"main_integrated": false,
"homologation_rule_changed": false,
"legal_coverage_expanded": false
}
}
@@ -1961,3 +1961,22 @@ Para suspender, desligar `INVESTMOZ_IVA_CONSUMER_ENABLED` e reiniciar graciosame
Activação separada aplicada em21/10/2026 às10:55:58UTC, alterando apenas as três chaves do consumidor. Ligação exacta aoID acima, rule_version1 eenabled=true; restante configuração preservada. `/api/v1/fiscal-consumer` confirmouenabled=true porHTTP200; a leitura do resolvedor, sem executar cálculo, confirmouIVA_GENERAL_RATE=16percent, mesmoID/versão/fonte no contextoMZN/internal_general_net de2026. Cinco eventos de governação; contagens de resultados/relatórios preservadas, sem simulações sintéticas no destino. O restauro de segurança e a configuração anterior estão conservados no arquivo privado identificado no recibo.
Suspensão concreta: configurar `INVESTMOZ_IVA_CONSUMER_ENABLED=false` e executar o reinício gracioso existente `php8.4 artisan queue:restart` na releaseactual como operador autorizado; ou `POST /api/v2/investment/admin/tax-rules/01a15e1a-5338-700f-8a45-f87707d696a7/suspend`, com `version_no:5` enquanto corrente e evidência verdadeira de pelo menos20caracteres, pela administração autenticada/MFA. Não repetir a suspensão se o estado/versão já tiver mudado. Conservar a ligação e todos os registos; futuras retomas respeitam o estado da regra e o procedimento de versão aplicável, sem escolher automaticamente outra regra.
### Diagnóstico da consulta IVA — correcção em QA,21/10/2026
O caso reportado identifica «Componentes fiscais e contributivas», IVA_GENERAL_RATE, período01/01/2026–31/12/2026 e «As condições publicadas não correspondem às respostas indicadas». É consulta de parâmetros, não execução do consumidor IVA. O utilizador não forneceu a moeda ou a operação originais; não se afirma que tenha seleccionadoUSD, omitido um campo ou indicado uma operação excluída.
**Causa confirmada no contrato:** o formulário envia `fiscal_operation`, sem `operation` ou `currency`; a versão publicada exige `operation=internal_general_net` e `currency=MZN`. A validação da consulta nem aceitava esses nomes canónicos. O resolvedor reduzia qualquer condição falhada a `indeterminate_context`; a interface mostrava apenas a frase genérica e o código interno. Esta restrição de ligação é escolha de implementação, não condição organizacional ou jurídica adicional da Tempo. O mesmo contrato reproduziu o estado no período anual reportado, independentemente de se presumir uma moeda no caso original.
**Correcção delimitada:** cada condição não satisfeita tem categoria, campo, valor recebido e critério esperado, ligados à versão e fonte. Distinguem-se campo em falta, formato inválido, operação/moeda fora do âmbito, versão que não cobre o período e configuração do sistema. Quando não existe versão elegível visível e a evidência não permite separar ausência de cobertura de indisponibilidade de fonte/configuração, a interface mantém essa incerteza; não inventa uma causa nem atribui culpa às respostas.
A consulta IVA passa a ter moeda explícita (`fiscal_currency`), guardada no rascunho, sem presumirMZN ou copiar moeda do orçamento ou do cálculo IVA. A API recebe esse valor em `context.currency`. A operação genérica não se converte automaticamente no âmbito «operação interna à taxa geral sobre base líquida»: a limitação de integração é explicada com a resposta recebida e o critério esperado, e existe ligação para o formulário IVA com declaração explícita do âmbito. Uma importação/exportação/não residente/reembolso expressamente seleccionado é identificado como exclusão deste lote; não se altera a resposta para a fazer passar.
As notas aparecem junto dos campos e num resumo com ligações que focam o campo correspondente, mantendo o rascunho. O nome apresentado é «IVA — taxa geral»; «Ver fundamento» contém versão, vigência e fonte, sem expressãoDSL. As falhas de consulta/configuração são identificadas como sistema. O percurso de cálculo e as suas regras de admissão não foram ampliados. A consulta diagnóstica pode observar versões publicadas fora do período seleccionado, sob a mesma política de visibilidade, apenas para explicar a falta de cobertura; não as torna elegíveis.
**Prova dirigida:**5testes unitários novos/24asserções;1testeAPI/16asserções no clone isolado simulator_fiscal_checks_20261021_test;7testes existentes do resolvedor/25asserções; TypeScript, build(exit0), Pint e diff-check. A primeira execuçãoAPI detectou que esse clone ainda não tinha a coluna fiscal_acceptance; aplicou-se nele a correcção de esquema já aceite, com a guardaQA intacta, e repetiu-se apenas o testeAPI afectado. Não é diagnóstico da base de homologação. A comparação por índice do ensaio foi ajustada à ordem das chavesJSONB; não se alterou o critério fiscal.
No Edge, simulador normal QA em127.0.0.1:18580: consulta anual2026, operação genérica explicitamente escolhida para o ensaio; moeda inicialmente vazia, depoisUSD escolhido expressamente como caso dirigido. Confirmaram-se notas, resumo, foco em «Rever moeda»/«Rever operação», fundamento da versão sintética3, eUSD/ano/periodicidade/operação conservados após gravação e recuperação do mesmo rascunho. A captura de prova é um ensaioUSD, não evidência deUSD no caso original. Não se executou cálculo nem se exportou relatório. A fixture publicada apenas emQA para observar o diagnóstico foi depois suspensa; o consumidorQA permaneceu desligado. Nenhuma fixture foi importada, regra alterada ou consumidor activado na homologação nesta correcção.
Provas privadas na pasta `outputs/simulator-continuation-20261020`: iva-diagnostic-api-test.txt, iva-diagnostic-summary.txt, iva-diagnostic-summary.png e iva-diagnostic-build-final.txt. O reciboJSON existente tem o estado detalhado. A candidata permanece na branch `codex/iva-diagnostics-QA`, separada de main; não é uma nova publicação fiscal nem altera a entrega autorizada anteriormente.
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { fiscalComponentCodes } from '@/lib/simulation-contract';
import { ivaIssueText, type IvaDiagnostic, type IvaIssue } from '@/lib/iva-diagnostics';
type Parameter = {
parameter_code: string;
@@ -56,6 +57,8 @@ export function SimulationFiscalStatus({
periodStart,
periodEnd,
context,
onDiagnostics,
onCorrect,
}: {
salaryView?: string;
scenarioMode?: string;
@@ -63,8 +66,11 @@ export function SimulationFiscalStatus({
periodStart?: string;
periodEnd?: string;
context?: Record<string, string>;
onDiagnostics?: (issues: IvaIssue[]) => void;
onCorrect?: (field: string) => void;
}) {
const [parameters, setParameters] = useState<Parameter[]>([]);
const [diagnostics, setDiagnostics] = useState<IvaDiagnostic[]>([]);
const [requestState, setRequestState] = useState('idle');
const [retry, setRetry] = useState(0);
@@ -79,10 +85,14 @@ export function SimulationFiscalStatus({
!periodEnd
) {
setParameters([]);
setDiagnostics([]);
onDiagnostics?.([]);
setRequestState('idle');
return;
}
const abort = new AbortController();
setDiagnostics([]);
onDiagnostics?.([]);
setRequestState('loading');
void fetch('/api/v1/tax-parameters/resolve', {
method: 'POST',
@@ -104,11 +114,15 @@ export function SimulationFiscalStatus({
if (!response.ok) throw new Error('lookup_failed');
const payload = (await response.json()) as {
parameters?: Parameter[];
diagnostics?: IvaDiagnostic[];
};
if (abort.signal.aborted) return;
setParameters(
Array.isArray(payload.parameters) ? payload.parameters : [],
);
const details = Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
setDiagnostics(details);
onDiagnostics?.(details.flatMap((detail) => detail.issues));
setRequestState('done');
})
.catch((error: unknown) => {
@@ -158,7 +172,7 @@ export function SimulationFiscalStatus({
{requestState === 'loading'
? 'A consultar parâmetros aprovados para o período e âmbito indicados…'
: requestState === 'error'
? 'Não foi possível consultar os parâmetros; o resultado permanece indeterminado.'
? 'Falha na consulta do sistema: não foi possível obter os parâmetros. O problema não é atribuído às respostas; estas foram preservadas. Tente novamente.'
: !periodStart || !periodEnd
? 'Indique um período exacto. Para periodicidade mensal ou trimestral, seleccione o mês ou trimestre.'
: `Período consultado: ${periodStart} a ${periodEnd}.`}
@@ -186,6 +200,22 @@ export function SimulationFiscalStatus({
{salaryViewMessages[salaryView]}
</p>
)}
{diagnostics.length > 0 && <div role="alert" className="space-y-3 rounded border border-amber-700 p-3">
<h4 className="font-semibold">IVA — taxa geral: condições não satisfeitas</h4>
<p>Não foi possível resolver este parâmetro. As respostas foram preservadas; esta consulta não executa o cálculo do imposto.</p>
{diagnostics.map((detail, index) => <div key={index}>
<ul className="list-disc pl-5">{detail.issues.map((issue, issueIndex) => <li key={issueIndex}>
{ivaIssueText(issue)}{' '}
{issue.field && <a className="underline" href={`#field-${issue.field}`} onClick={(event) => { if (onCorrect) { event.preventDefault(); onCorrect(issue.field!); } }}>Rever {issue.label.toLowerCase()}</a>}
{issue.category === 'system_configuration' && issue.field === 'fiscal_operation' && <a className="ml-2 underline" href="#published-iva">Abrir IVA — taxa geral</a>}
</li>)}</ul>
<details className="mt-2"><summary className="cursor-pointer underline">Ver fundamento</summary>
<p>IVA — taxa geral · versão {detail.rule_version}. Vigência: {detail.validity.start} a {detail.validity.end ?? 'sem data final indicada'}.</p>
<p>{detail.source.title} — {detail.source.diploma}, {detail.source.article}</p>
{detail.source.url?.startsWith('https://') && <a className="underline" href={detail.source.url} target="_blank" rel="noreferrer">Consultar fonte</a>}
</details>
</div>)}
</div>}
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{fiscalComponentCodes.map((code) => {
const selected =
@@ -211,13 +241,13 @@ export function SimulationFiscalStatus({
</p>
) : parameters.length === 0 ? (
<p className="text-sm">
Indeterminado — nenhum parâmetro publicado
{code === 'IVA' ? 'Indeterminado — não existe uma versão elegível visível nesta consulta. Consulte o diagnóstico do período acima, quando disponível. Sem esse diagnóstico, não é possível distinguir ausência de cobertura de indisponibilidade da regra ou da fonte; não se atribui o problema às suas respostas.' : <>Indeterminado — nenhum parâmetro publicado
aplicável. Faltam parâmetros aprovados para{' '}
{code}, no período {periodStart} a{' '}
{periodEnd} e no âmbito indicado. A consulta
não identifica um conjunto obrigatório de
parâmetros; não é possível determinar o
imposto nem substituir as lacunas por zero.
imposto nem substituir as lacunas por zero.</>}
</p>
) : (
<div className="space-y-2 text-sm">
@@ -227,7 +257,7 @@ export function SimulationFiscalStatus({
className="border-t pt-2"
>
<strong>
{parameter.parameter_code}
{parameter.parameter_code === 'IVA_GENERAL_RATE' ? 'IVA — taxa geral' : parameter.parameter_code}
</strong>
{parameter.status === 'resolved' ? (
<>
@@ -238,10 +268,10 @@ export function SimulationFiscalStatus({
</p>
<p>
Regra{' '}
{parameter.rule_code} v
{parameter.parameter_code === 'IVA_GENERAL_RATE' ? 'IVA — taxa geral' : parameter.rule_code} v
{parameter.rule_version}
; âmbito{' '}
{parameter.scope_key}
{parameter.parameter_code === 'IVA_GENERAL_RATE' ? 'operação interna geral sobre base líquida em MZN' : parameter.scope_key}
</p>
<p>
Vigência:{' '}
@@ -279,13 +309,13 @@ export function SimulationFiscalStatus({
target="_blank"
rel="noreferrer"
>
Consultar fundamento
Ver fundamento
</a>
)}
</>
) : (
<p>
{labels[parameter.status] ??
{(parameter.parameter_code === 'IVA_GENERAL_RATE' && diagnostics.length ? 'Consulte acima cada condição e o fundamento da versão.' : labels[parameter.status]) ??
'Indeterminado — não foi possível validar o parâmetro.'}
</p>
)}
+14 -2
View File
@@ -56,6 +56,7 @@ import { SimulationFinancialTools } from '@/components/simulation-financial-tool
import { SimulationCashflowPlanner } from '@/components/simulation-cashflow-planner';
import { SimulationBusinessPlan } from '@/components/simulation-business-plan';
import { SimulationFiscalStatus } from '@/components/simulation-fiscal-status';
import { ivaIssueText, type IvaIssue } from '@/lib/iva-diagnostics';
import {
ProjectMilestonePlanner,
ProjectTeamPlanner,
@@ -810,6 +811,7 @@ export function SimulationForm() {
});
const [step, setStep] = useState(0);
const [errors, setErrors] = useState<Record<string, string>>({});
const [ivaIssues, setIvaIssues] = useState<IvaIssue[]>([]);
const [serverFieldErrors, setServerFieldErrors] = useState<
Record<string, string>
>({});
@@ -1936,6 +1938,7 @@ export function SimulationForm() {
) {
if (!ready) return;
setContinueLaterSaved(false);
setIvaIssues((previous) => previous.filter((issue) => issue.field !== key));
setServerFieldErrors((previous) => {
const next = { ...previous };
delete next[key];
@@ -3285,6 +3288,7 @@ export function SimulationForm() {
: Boolean(field.required);
const fieldError =
serverFieldErrors[field.key] ?? errors[field.key];
const ivaNotes = ivaIssues.filter((issue) => issue.field === field.key).map((issue, index) => <p key={index} role="note" className="mt-1 text-sm text-amber-950">{ivaIssueText(issue)}</p>);
if (field.options) {
const parentKey =
field.key === 'district_id'
@@ -3331,6 +3335,7 @@ export function SimulationForm() {
}
/>
<FieldGuidance fieldKey={field.key} />
{ivaNotes}
</div>
);
}
@@ -3405,6 +3410,7 @@ export function SimulationForm() {
{fieldError}
</p>
)}
{ivaNotes}
{field.key === 'reduction' && (
<p className="mt-1 text-sm text-slate-600">
Hipótese sua, não representa um
@@ -3609,6 +3615,12 @@ export function SimulationForm() {
aria-label="Consulta de parâmetros fiscais"
>
<SimulationFiscalStatus
onDiagnostics={setIvaIssues}
onCorrect={(key) => {
const target = journeySections.findIndex((section) => (simulationFieldGroups[section.id] ?? []).some((field) => field.key === key));
if (target >= 0) setStep(target);
window.requestAnimationFrame(() => document.getElementById(`field-${key}`)?.focus());
}}
salaryView={String(draft.irps_salary_view ?? '')}
scenarioMode={
openedTool === 'fiscal'
@@ -3618,7 +3630,7 @@ export function SimulationForm() {
component={String(draft.fiscal_component ?? '')}
periodStart={getFiscalPeriodRange(draft)?.start}
periodEnd={getFiscalPeriodRange(draft)?.end}
context={Object.fromEntries(
context={{ ...Object.fromEntries(
[
'fiscal_periodicity',
'fiscal_operation',
@@ -3635,7 +3647,7 @@ export function SimulationForm() {
? [[key, draft[key] as string]]
: [],
),
)}
), ...(typeof draft.fiscal_currency === 'string' && draft.fiscal_currency !== '' ? {currency: draft.fiscal_currency} : {}) }}
/>
</div>
)}
@@ -51,8 +51,8 @@ export function SimulationPublishedIva({ busy, onCalculate }: {
} catch { /* The execution flow already preserves and reports connection errors. */ }
}
return <section aria-label="IVA geral sobre base líquida" className="space-y-3 rounded border border-emerald-700 p-4">
<h4 className="font-semibold">IVA geral sobre base líquida</h4>
return <section id="published-iva" tabIndex={-1} aria-label="IVA — taxa geral" className="space-y-3 rounded border border-emerald-700 p-4">
<h4 className="font-semibold">IVA — taxa geral</h4>
<p role="status">{message}</p>
<p className="text-sm">Cobertura: exigibilidade em 2026, operação interna à taxa geral. A base tributável é indicada por si; não é copiada do orçamento. Sem isenções, taxas reduzidas, bases especiais, importações, deduções, declaração ou pagamento. O cálculo não valida o enquadramento jurídico.</p>
<fieldset disabled={busy} className="space-y-3">
+26
View File
@@ -0,0 +1,26 @@
export type IvaIssue = {
category: 'missing_field' | 'invalid_value' | 'out_of_scope' | 'period_unavailable' | 'system_configuration';
field: string | null;
label: string;
received: unknown;
expected: string;
message: string;
};
export type IvaDiagnostic = {
label: string;
rule_version: number;
validity: { start: string; end: string | null };
source: { title?: string; diploma?: string; article?: string; url?: string };
issues: IvaIssue[];
};
export const ivaIssueCategories = {
missing_field: 'Campo em falta', invalid_value: 'Valor inválido', out_of_scope: 'Fora do âmbito',
period_unavailable: 'Regra indisponível para o período', system_configuration: 'Configuração do sistema',
};
export function ivaReceived(value: unknown): string {
const labels: Record<string, string> = { domestic: 'Operação interna', internal_general_net: 'Operação interna à taxa geral, sobre base líquida', import: 'Importação', export: 'Exportação', refund: 'Reembolso', unknown: 'Não sei', non_resident: 'Operação com não residente', other: 'Outra operação' };
return typeof value === 'string' && value !== '' ? (labels[value] ?? value) : 'Não recebido';
}
export function ivaIssueText(issue: IvaIssue): string {
return `${issue.label}: ${ivaIssueCategories[issue.category]}. Recebido: ${ivaReceived(issue.received)}. Esperado: ${issue.expected}. ${issue.message}`;
}
+6
View File
@@ -965,6 +965,12 @@ export const simulationFieldGroups: Record<
},
],
tax: [
{
key: 'fiscal_currency',
label: 'Moeda da operação fiscal (sem conversão automática)',
options: 'currency',
showWhen: (d) => d.fiscal_component === 'iva' && d.fiscal_scenario_mode !== 'custom_planning',
},
{
key: 'fiscal_period',
label: 'Ano do período fiscal a simular',
@@ -76,6 +76,33 @@ class SimulationPublishedFiscalTest extends TestCase
return Simulation::with('reports')->findOrFail($id);
}
public function test_iva_query_diagnostics_explain_the_reported_contract_gap_without_calculating_or_changing_answers(): void
{
$count = Simulation::count();
$input = ['component' => 'IVA', 'period_start' => '2026-01-01', 'period_end' => '2026-12-31',
'context' => ['fiscal_operation' => 'domestic', 'currency' => 'USD']];
$this->actingAs($this->consumer)->postJson('/api/v1/tax-parameters/resolve', $input)->assertOk()
->assertJsonPath('assumptions.currency', 'USD')
->assertJsonPath('parameters.0.status', 'indeterminate_context')
->assertJsonPath('diagnostics.0.label', 'IVA — taxa geral')
->assertJsonPath('diagnostics.0.rule_version', 1)
->assertJsonPath('diagnostics.0.issues.1.category', 'system_configuration')
->assertJsonPath('diagnostics.0.issues.1.received', 'domestic')
->assertJsonPath('diagnostics.0.issues.0.category', 'out_of_scope')
->assertJsonPath('diagnostics.0.issues.0.received', 'USD')
->assertJsonPath('diagnostics.0.issues.0.expected', 'MZN');
unset($input['context']['currency']);
$this->postJson('/api/v1/tax-parameters/resolve', $input)->assertOk()
->assertJsonPath('diagnostics.0.issues.0.category', 'missing_field');
$input['context']['currency'] = 'MZN';
$input['period_start'] = '2022-01-01';
$input['period_end'] = '2022-12-31';
$this->postJson('/api/v1/tax-parameters/resolve', $input)->assertOk()
->assertJsonPath('parameters', [])
->assertJsonPath('diagnostics.0.issues.0.category', 'period_unavailable');
self::assertSame($count, Simulation::count());
}
public function test_normal_simulator_admits_published_rule_and_reuses_history_and_reports(): void
{
$key = (string) Str::uuid();
@@ -0,0 +1,71 @@
<?php
namespace Tests\Unit;
use App\Services\SimulationIvaDiagnostics;
use App\Services\SimulationTaxParameterResolver;
use PHPUnit\Framework\TestCase;
final class SimulationIvaDiagnosticsTest extends TestCase
{
private function version(): array
{
return ['id' => 'synthetic-qa', 'rule_code' => 'IVA-GERAL-BASE-LIQUIDA', 'rule_version' => 1,
'component' => 'IVA', 'status' => 'published', 'scope_key' => 'mz.internal-general-net.mzn',
'valid_from' => '2023-01-01', 'valid_until' => null, 'source_version_no' => 1,
'source_sha256' => str_repeat('a', 64), 'rule_definition' => ['language' => 'investmoz.tax.v1'],
'parameters' => ['IVA_GENERAL_RATE' => ['value' => '16', 'unit' => 'percent', 'conditions' => ['operation' => 'internal_general_net', 'currency' => 'MZN']]],
'source' => ['validation_status' => 'validated', 'version_no' => 1, 'valid_from' => '2023-01-01', 'valid_until' => null,
'title' => 'Synthetic source', 'diploma' => 'QA only', 'article' => 'Fixture', 'url' => 'https://example.invalid/official']];
}
public function test_reported_annual_query_contract_reproduces_the_generic_failure_and_identifies_both_missing_links(): void
{
$version = $this->version();
$context = ['fiscal_operation' => 'domestic'];
$original = $context;
$before = (new SimulationTaxParameterResolver)->resolveAvailableParameters([$version], 'IVA', '2026-01-01', '2026-12-31', $context);
self::assertSame('indeterminate_context', $before[0]['status']);
$details = (new SimulationIvaDiagnostics)->explain([$version], '2026-01-01', '2026-12-31', $context);
self::assertSame(['system_configuration', 'missing_field'], array_column($details[0]['issues'], 'category'));
self::assertSame('domestic', $details[0]['issues'][0]['received']);
self::assertSame('MZN', $details[0]['issues'][1]['expected']);
self::assertSame($original, $context);
self::assertArrayNotHasKey('rule_definition', $details[0]);
self::assertArrayNotHasKey('rule_code', $details[0]);
}
public function test_all_failed_conditions_are_separate_and_currency_is_not_assumed(): void
{
foreach (['' => 'missing_field', 'usd' => 'invalid_value', 'USD' => 'out_of_scope'] as $value => $category) {
$details = (new SimulationIvaDiagnostics)->explain([$this->version()], '2026-01-01', '2026-12-31', ['operation' => 'import', 'currency' => $value]);
self::assertCount(2, $details[0]['issues']);
self::assertSame('out_of_scope', $details[0]['issues'][0]['category']);
self::assertSame($category, $details[0]['issues'][1]['category']);
}
}
public function test_period_and_broken_source_are_not_reported_as_incorrect_answers(): void
{
$version = $this->version();
$version['source_version_no'] = 2;
$details = (new SimulationIvaDiagnostics)->explain([$version], '2022-01-01', '2022-12-31', ['operation' => 'internal_general_net', 'currency' => 'MZN']);
self::assertSame(['period_unavailable', 'system_configuration'], array_column($details[0]['issues'], 'category'));
self::assertSame('2022-01-01 a 2022-12-31', $details[0]['issues'][0]['received']);
self::assertSame(1, $details[0]['rule_version']);
self::assertSame('Synthetic source', $details[0]['source']['title']);
}
public function test_matching_context_has_no_diagnostic_and_does_not_evaluate_an_expression(): void
{
self::assertSame([], (new SimulationIvaDiagnostics)->explain([$this->version()], '2026-01-01', '2026-12-31', ['operation' => 'internal_general_net', 'currency' => 'MZN']));
}
public function test_an_explicitly_excluded_generic_operation_is_reported_without_inferring_a_domestic_scope(): void
{
$details = (new SimulationIvaDiagnostics)->explain([$this->version()], '2026-01-01', '2026-12-31', ['fiscal_operation' => 'import', 'currency' => 'MZN']);
self::assertCount(1, $details[0]['issues']);
self::assertSame('out_of_scope', $details[0]['issues'][0]['category']);
self::assertSame('import', $details[0]['issues'][0]['received']);
}
}