Compare commits

..
26 changed files with 1016 additions and 35 deletions
@@ -40,7 +40,7 @@ class SimulationController extends Controller
$organizationId = $validated['organization_id'] ?? null;
$execution->authorizeOrganization($actorId, $organizationId);
if (($validated['scenario_kind'] ?? null) === 'fiscal_rule_published') {
$input = array_intersect_key($validated, array_flip(['base', 'currency', 'reference_date', 'operation', 'scope_declared']));
$input = array_intersect_key($validated, array_flip(['base', 'currency', 'reference_date', 'operation', 'scope_declared', 'monthly_sales', 'sales_vat_basis']));
$input['scope_declared'] = (bool) $input['scope_declared'];
$result = $execution->createPublishedFiscal($input, $actorId, $organizationId, $token, $key);
} else {
@@ -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'],
];
}
@@ -37,7 +37,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
'contact_province', 'contact_district', 'preferred_channel',
];
$economic = [
'journey_type', 'detail_level', 'business_plan_snapshot', 'financial_tools_snapshot', 'cashflow_snapshot', 'project_team_snapshot', 'project_execution_snapshot', 'purpose', 'manifestation_type', 'preferred_language', 'project_title', 'project_summary', 'project_problem', 'target_customers', 'value_proposition', 'market_assumption', 'market_source', 'project_milestones', 'selection_labels', 'project_type',
'monthly_iva_settings', 'monthly_iva_snapshot', 'journey_type', 'detail_level', 'business_plan_snapshot', 'financial_tools_snapshot', 'cashflow_snapshot', 'project_team_snapshot', 'project_execution_snapshot', 'purpose', 'manifestation_type', 'preferred_language', 'project_title', 'project_summary', 'project_problem', 'target_customers', 'value_proposition', 'market_assumption', 'market_source', 'project_milestones', 'selection_labels', 'project_type',
'competitor_assumption', 'sales_channel_assumption', 'marketing_approach', 'location_rationale', 'project_risk_responses_snapshot', 'project_questionnaire_version',
'customer_validation_status', 'cost_estimate_status', 'location_assessment_status', 'funding_status', 'receivables_timing_status',
'project_type_other', 'sector_id', 'activity_id', 'products_services', 'maturity_stage', 'expected_start_date',
@@ -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',
@@ -72,6 +72,8 @@ class StoreSimulationFormDraftRequest extends FormRequest
'economic_snapshot.business_plan_snapshot' => ['nullable', 'string', 'max:60000'],
'economic_snapshot.financial_tools_snapshot' => ['nullable', 'string', 'max:30000'],
'economic_snapshot.cashflow_snapshot' => ['nullable', 'string', 'max:20000'],
'economic_snapshot.monthly_iva_settings' => ['nullable', 'string', 'max:2000'],
'economic_snapshot.monthly_iva_snapshot' => ['nullable', 'string', 'max:60000'],
'economic_snapshot.project_team_snapshot' => ['nullable', 'string', 'max:30000'],
'economic_snapshot.project_execution_snapshot' => ['nullable', 'string', 'max:30000'],
'economic_snapshot.project_risk_responses_snapshot' => ['nullable', 'string', 'max:30000'],
@@ -279,7 +281,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'],
];
+25 -1
View File
@@ -22,6 +22,12 @@ class StoreSimulationRequest extends FormRequest
'date_format' => 'Indique uma data de exigibilidade válida.',
'boolean' => 'Indique se declara o enquadramento da operação.',
'prohibited' => 'O campo :attribute não pode ser definido pelo cliente neste cálculo.',
'array' => 'Indique uma lista válida em :attribute.',
'monthly_sales.min' => 'Indique pelo menos um mês de vendas.',
'monthly_sales.max' => 'Este lote aceita até 12 meses dentro de 2026.',
'monthly_sales.*.date.regex' => 'Indique o primeiro dia de um mês de 2026.',
'monthly_sales.*.date.distinct' => 'Cada mês deve aparecer apenas uma vez.',
'monthly_sales.*.amount.present' => 'Indique o valor das vendas ou deixe-o explicitamente sem informação.',
];
}
@@ -29,13 +35,31 @@ class StoreSimulationRequest extends FormRequest
{
return ['base' => 'base tributável', 'currency' => 'moeda', 'reference_date' => 'data de exigibilidade',
'operation' => 'operação', 'scope_declared' => 'declaração de enquadramento', 'scenario_kind' => 'tipo de cálculo',
'rule_id' => 'regra', 'rule_version' => 'versão da regra', 'rate' => 'taxa', 'reduction' => 'redução'];
'rule_id' => 'regra', 'rule_version' => 'versão da regra', 'rate' => 'taxa', 'reduction' => 'redução',
'monthly_sales' => 'vendas mensais', 'monthly_sales.*.date' => 'mês da projecção',
'monthly_sales.*.amount' => 'vendas declaradas do mês', 'sales_vat_basis' => 'tratamento do IVA nos preços'];
}
/** @return array<string, list<mixed>> */
public function rules(): array
{
if ($this->input('scenario_kind') === 'fiscal_rule_published') {
if ($this->exists('monthly_sales')) {
return [
'scenario_kind' => ['required', 'in:fiscal_rule_published'],
'monthly_sales' => ['required', 'array', 'min:1', 'max:12'],
'monthly_sales.*' => ['required', 'array:date,amount'],
'monthly_sales.*.date' => ['required', 'date_format:Y-m-d', 'regex:/^2026-\d{2}-01$/D', 'distinct'],
'monthly_sales.*.amount' => ['present', 'nullable', 'string', 'max:40', 'regex:/^\d+(?:\.\d+)?$/D'],
'sales_vat_basis' => ['required', 'in:net,included,unknown'],
'currency' => ['required', 'in:MZN'],
'scope_declared' => ['required', 'boolean'],
'organization_id' => ['nullable', 'uuid', 'exists:organizations,id'],
'base' => ['prohibited'], 'rate' => ['prohibited'], 'reduction' => ['prohibited'],
'rule_id' => ['prohibited'], 'rule_version' => ['prohibited'], 'fiscal_preview' => ['prohibited'],
];
}
return [
'scenario_kind' => ['required', 'in:fiscal_rule_published'],
'base' => ['required', 'string', 'max:40', 'regex:/^\d+(?:\.\d+)?$/'],
+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;
}
}
+55 -1
View File
@@ -6,6 +6,7 @@ namespace App\Services;
use App\Models\InvestmentLegalSource;
use App\Models\SimulationTaxRuleVersion;
use Brick\Math\BigRational;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
@@ -35,8 +36,11 @@ final class SimulationPublishedFiscal
->where('scope_key', 'mz.internal-general-net.mzn');
}
public function admit(array $input): array
public function admit(array $input, bool $missingBase = false): array
{
if (isset($input['monthly_sales'])) {
return $this->admitMonthly($input);
}
// Called inside createPayload, after idempotent replay, never by the worker.
abort_unless(DB::transactionLevel() > 0, 500);
abort_unless(config('simulation-fiscal-consumer.enabled'), 503,
@@ -63,6 +67,9 @@ final class SimulationPublishedFiscal
$input['reference_date'], $input['reference_date'], ['currency' => $input['currency'], 'operation' => $input['operation']]);
$rate = collect($resolution)->firstWhere('parameter_code', 'IVA_GENERAL_RATE');
$missing = [];
if ($missingBase) {
$missing[] = 'taxable_sales';
}
$evaluation = null;
if (! ($input['scope_declared'] ?? false)) {
$missing[] = 'operation_scope';
@@ -79,4 +86,51 @@ final class SimulationPublishedFiscal
return app(SimulationFiscalSnapshot::class)->build($version, $input, $resolution, $evaluation, $missing, true);
}
private function admitMonthly(array $input): array
{
$rows = [];
$baseTotal = BigRational::zero();
$vatTotal = BigRational::zero();
$complete = true;
$previous = null;
foreach ($input['monthly_sales'] as $sale) {
abort_unless($previous === null || $sale['date'] === date('Y-m-d', strtotime($previous.' +1 month')), 422, 'A projecção deve conter meses consecutivos, sem duplicações.');
$previous = $sale['date'];
$known = $sale['amount'] !== null && $sale['amount'] !== '';
$net = $input['sales_vat_basis'] === 'net';
$month = $this->admit(['base' => $known && $net ? $sale['amount'] : null,
'currency' => $input['currency'], 'reference_date' => $sale['date'],
'operation' => 'internal_general_net', 'scope_declared' => $input['scope_declared']], ! $known || ! $net);
$snapshot ??= $month;
$rate = collect($month['fiscal_provenance']['resolution'])->firstWhere('parameter_code', 'IVA_GENERAL_RATE');
$vat = $month['result']['value'];
$status = ! $known ? 'missing_sales' : (! $net ? 'unsupported_basis' : ($vat === null ? 'indeterminate' : 'calculated'));
$rows[] = ['date' => $sale['date'], 'sales_declared' => $sale['amount'],
'net_sales' => $known && $net ? $sale['amount'] : null,
'rate' => $vat !== null && ($rate['status'] ?? null) === 'resolved' ? $rate['value'] : null,
'vat' => $vat, 'gross_sales' => $vat === null ? null : (string) BigRational::of($sale['amount'])->plus($vat)->toBigDecimal(),
'status' => $status, 'rule_id' => $month['fiscal_provenance']['rule']['id'],
'rule_version' => $month['fiscal_provenance']['rule']['rule_version']];
if ($vat === null) {
$complete = false;
} else {
$baseTotal = $baseTotal->plus($sale['amount']);
$vatTotal = $vatTotal->plus($vat);
}
}
$value = $complete ? (string) $vatTotal->toBigDecimal() : null;
$snapshot['base'] = $complete ? (string) $baseTotal->toBigDecimal() : null;
$snapshot['indicative_total'] = $value;
$snapshot['result']['value'] = $value;
$snapshot['result']['status'] = $complete ? 'calculated' : 'indeterminate';
$snapshot['result']['missing_inputs'] = $complete ? [] : ['monthly_sales_or_net_basis_or_scope'];
$snapshot['result']['explanation'] = 'IVA mensal sobre vendas declaradas sem IVA, pela versão publicada congelada na admissão. Valores com IVA incluído ou sem informação permanecem indeterminados. Não é imposto final a pagar.';
$snapshot['presentation']['value'] = $value;
$snapshot['intermediate'] = null;
$snapshot['monthly_projection'] = ['input' => $input, 'rows' => $rows,
'cash_treatment' => 'Informação separada da tesouraria: os recebimentos declarados não são aumentados pelo IVA nem o IVA é deduzido como custo ou imposto a pagar. Calendário de cobrança e liquidação fiscal não é inferido.'];
return $snapshot;
}
}
@@ -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,339 @@
"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
},
"monthly_iva_projection_qa_20261021": {
"branch": "codex/iva-monthly-projection-QA",
"status": "candidate_verified_in_isolated_QA_only",
"source": "existing monthly product sales; saved constant cashflow revenue only when no business plan exists",
"basis": "explicit net/included/unknown; only net calculated; no silent conversion",
"selection": "published version selected by server; frozen at admission; same resolver/evaluator",
"cash_treatment": "separate IVA memorandum; declared receipts/payments unchanged; not cost, no inferred collection or final settlement",
"manual_tax": "custom planning only; never input into monthly IVA",
"unknown_vs_zero": "missing month indeterminate; explicit zero calculated as zero; aggregate indeterminate if any month unknown",
"tests": {
"api_tests": 3,
"api_assertions": 46,
"frontend_tests": 4,
"types": "passed",
"build": "passed",
"pint": "passed",
"accepted_calculation_and_exporter_proofs": "reused, not repeated"
},
"qa_fixture": {
"database": "simulator_fiscal_delta_20261021_test",
"rule_id": "01a15e50-7972-72e0-8991-82ca28df9b49",
"rule_version": 4,
"consumer_enabled": true,
"synthetic": true,
"left_available_for_review": true,
"draft_id": "e0201d19-286e-4d5c-9585-2c1e3bd1ad1e"
},
"live_history": [
{
"id": "06566910-9672-4176-804d-aad8350417cb",
"status": "completed",
"created_at": "2026-10-21T11:57:11.000000Z",
"basis": "net",
"rows": [
{
"vat": "160.0048",
"date": "2026-01-01",
"rate": "16",
"status": "calculated",
"rule_id": "01a15e50-7972-72e0-8991-82ca28df9b49",
"net_sales": "1000.03",
"gross_sales": "1160.0348",
"rule_version": 4,
"sales_declared": "1000.03"
},
{
"vat": "320.0096",
"date": "2026-02-01",
"rate": "16",
"status": "calculated",
"rule_id": "01a15e50-7972-72e0-8991-82ca28df9b49",
"net_sales": "2000.06",
"gross_sales": "2320.0696",
"rule_version": 4,
"sales_declared": "2000.06"
},
{
"vat": "0",
"date": "2026-03-01",
"rate": "16",
"status": "calculated",
"rule_id": "01a15e50-7972-72e0-8991-82ca28df9b49",
"net_sales": "0.00",
"gross_sales": "0",
"rule_version": 4,
"sales_declared": "0.00"
},
{
"vat": null,
"date": "2026-04-01",
"rate": null,
"status": "missing_sales",
"rule_id": "01a15e50-7972-72e0-8991-82ca28df9b49",
"net_sales": null,
"gross_sales": null,
"rule_version": 4,
"sales_declared": null
}
],
"total": null,
"reports": [
{
"id": "42b95158-6c4c-43c6-801d-28e973a087a6",
"same_snapshot": true,
"sha256": "b6d10565f82f7e83952fbdbb15a357ca3a7fee550e53c80a0ef5b9e314ccfa68"
}
]
},
{
"id": "098900ed-2f5f-4141-98c8-08f892912a5b",
"status": "completed",
"created_at": "2026-10-21T11:57:34.000000Z",
"basis": "net",
"rows": [
{
"vat": "480.0144",
"date": "2026-01-01",
"rate": "16",
"status": "calculated",
"rule_id": "01a15e50-7972-72e0-8991-82ca28df9b49",
"net_sales": "3000.09",
"gross_sales": "3480.1044",
"rule_version": 4,
"sales_declared": "3000.09"
},
{
"vat": "320.0096",
"date": "2026-02-01",
"rate": "16",
"status": "calculated",
"rule_id": "01a15e50-7972-72e0-8991-82ca28df9b49",
"net_sales": "2000.06",
"gross_sales": "2320.0696",
"rule_version": 4,
"sales_declared": "2000.06"
},
{
"vat": "0",
"date": "2026-03-01",
"rate": "16",
"status": "calculated",
"rule_id": "01a15e50-7972-72e0-8991-82ca28df9b49",
"net_sales": "0.00",
"gross_sales": "0",
"rule_version": 4,
"sales_declared": "0.00"
},
{
"vat": null,
"date": "2026-04-01",
"rate": null,
"status": "missing_sales",
"rule_id": "01a15e50-7972-72e0-8991-82ca28df9b49",
"net_sales": null,
"gross_sales": null,
"rule_version": 4,
"sales_declared": null
}
],
"total": null,
"reports": [
{
"id": "44211195-0a53-4f4c-93ef-6ff0e439082b",
"same_snapshot": true,
"sha256": "5de00868029b7a688585e180bcd8fbef1e46e289b19ab766c58f7406fb2af972"
}
]
},
{
"id": "96eec0d8-effa-4286-b465-6af50f756db4",
"status": "completed",
"created_at": "2026-10-21T11:58:35.000000Z",
"basis": "included",
"rows": [
{
"vat": null,
"date": "2026-01-01",
"rate": null,
"status": "unsupported_basis",
"rule_id": "01a15e50-7972-72e0-8991-82ca28df9b49",
"net_sales": null,
"gross_sales": null,
"rule_version": 4,
"sales_declared": "3000.09"
},
{
"vat": null,
"date": "2026-02-01",
"rate": null,
"status": "unsupported_basis",
"rule_id": "01a15e50-7972-72e0-8991-82ca28df9b49",
"net_sales": null,
"gross_sales": null,
"rule_version": 4,
"sales_declared": "2000.06"
},
{
"vat": null,
"date": "2026-03-01",
"rate": null,
"status": "unsupported_basis",
"rule_id": "01a15e50-7972-72e0-8991-82ca28df9b49",
"net_sales": null,
"gross_sales": null,
"rule_version": 4,
"sales_declared": "0.00"
},
{
"vat": null,
"date": "2026-04-01",
"rate": null,
"status": "missing_sales",
"rule_id": "01a15e50-7972-72e0-8991-82ca28df9b49",
"net_sales": null,
"gross_sales": null,
"rule_version": 4,
"sales_declared": null
}
],
"total": null,
"reports": [
{
"id": "821e6347-680c-4029-9756-c3169d79f85e",
"same_snapshot": true,
"sha256": "06abadc593e65cfff64d208fac6cac3a8f98db4bef92795a27ce637fe7958cec"
}
]
},
{
"id": "e88aaaea-4482-4b7d-869f-f0648a430651",
"status": "completed",
"created_at": "2026-10-21T12:00:32.000000Z",
"basis": "net",
"rows": [
{
"vat": "480.0144",
"date": "2026-01-01",
"rate": "16",
"status": "calculated",
"rule_id": "01a15e50-7972-72e0-8991-82ca28df9b49",
"net_sales": "3000.09",
"gross_sales": "3480.1044",
"rule_version": 4,
"sales_declared": "3000.09"
},
{
"vat": "320.0096",
"date": "2026-02-01",
"rate": "16",
"status": "calculated",
"rule_id": "01a15e50-7972-72e0-8991-82ca28df9b49",
"net_sales": "2000.06",
"gross_sales": "2320.0696",
"rule_version": 4,
"sales_declared": "2000.06"
},
{
"vat": "0",
"date": "2026-03-01",
"rate": "16",
"status": "calculated",
"rule_id": "01a15e50-7972-72e0-8991-82ca28df9b49",
"net_sales": "0.00",
"gross_sales": "0",
"rule_version": 4,
"sales_declared": "0.00"
},
{
"vat": null,
"date": "2026-04-01",
"rate": null,
"status": "missing_sales",
"rule_id": "01a15e50-7972-72e0-8991-82ca28df9b49",
"net_sales": null,
"gross_sales": null,
"rule_version": 4,
"sales_declared": null
}
],
"total": null,
"reports": [
{
"id": "bc204cc7-1f0c-4468-80fa-e093642a5eac",
"same_snapshot": true,
"sha256": "4ed224f7de50b533f144f7f411d240eb1ccca6f3e5bff7a64b1eca6f6d7971dc"
}
]
}
],
"main_integrated": false,
"homologation_changed": false,
"rule_publication_on_destination": false,
"coverage_expanded": false,
"limitations": [
"IVA included prices unsupported in current net lot",
"2026 only, max 12 consecutive months",
"no final payable, deductions, refunds, special/reduced/exempt regimes",
"examples prove arithmetic/integration, not legal eligibility of every transaction"
],
"remaining": "Tempo review/decision for code integration; no further generic preparation identified; keep real rule/publication/activation separate",
"proof_directory": "outputs/simulator-continuation-20261020"
}
}
@@ -1961,3 +1961,45 @@ 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.
### IVA mensal ligado às vendas — candidata QA, 21/10/2026
Pedido da Tempo: o formulário recolhe pressupostos do negócio e calcula o IVA correspondente, sem repetir vendas nem tomar o imposto manual como entrada fiscal. A falta de ligação entre a projecção empresarial e o consumidor publicado era uma escolha de implementação; não uma nova obrigação do utilizador. Alteração na branch `codex/iva-monthly-projection-QA`, separada de main. Não altera a instalação, regra ou activação da homologação.
**Ligação:** o simulador normal reutiliza quantidades e preços por produto/mês do plano empresarial, com a mesma representação monetária das vendas já usada nesse plano. Se não existir plano, admite a receita mensal constante já guardada na projecção personalizada, identificando essa origem e exigindo declaração do enquadramento. Não usa os valores demonstrativos de um widget ainda não guardado. A indicação sem IVA/com IVA incluído fica explícita, sem a presumir. Neste lote só a base líquida declarada permite calcular: preços com IVA incluído, base desconhecida e meses sem vendas informadas permanecem indeterminados. Zero explícito calcula IVA zero; não se preenche um mês vazio com zero.
O cliente envia meses, vendas, moeda e declaração; não envia taxa, versão ou imposto manual. O servidor selecciona a versão publicada ligada ao consumidor e reutiliza o resolvedor temporal e avaliador decimal existentes em cada mês, dentro da mesma admissão transaccional. Nenhuma taxa foi fixada no formulário, nem introduzido arredondamento fiscal. A versão é congelada antes da fila; mantém-se a autorização/idempotência/histórico/relatório existentes. Sem migration ou reconstrução dos mecanismos. Cobertura permanece 2026, no máximo 12 meses consecutivos; um horizonte que atravessa o limite é explicado, sem truncar a projecção.
**Ecrã e dependências:** cada mês apresenta vendas declaradas, vendas tributáveis sem IVA, taxa efectivamente aplicada, IVA calculado e total com IVA. Após a primeira execução, a actualização automática fica ligada e pode ser pausada. Alterar vendas, moeda, período ou tratamento do IVA assinala imediatamente o resultado anterior como histórico desactualizado. A nova execução guarda outra versão sem modificar a anterior. Repetir sem alterações recupera a execução concluída; uma execução pendente é consultada pelo mesmo ID. Falhas conservam os pressupostos e permitem tentar novamente. O rascunho conserva configurações e resultado; o relatório distingue resultado actual e histórico desactualizado e não recalcula o IVA na exportação. Se algum mês estiver indeterminado, o total fiscal fica indeterminado, conservando os meses conhecidos.
**Tesouraria adoptada:** o IVA é um memorando fiscal separado; não se soma novamente aos recebimentos introduzidos, não se deduz como custo e não se apresenta como imposto final a pagar. Conserva-se o calendário de recebimentos/pagamentos declarado, sem inferir cobrança integral no mesmo mês nem liquidação fiscal. O imposto manual permanece exclusivamente no painel recolhido «Cenário personalizado de tesouraria — imposto manual, separado do IVA calculado», com rótulos de cenário personalizado. Não alimenta a base nem o resultado do consumidor IVA. Não foram acrescentados deduções, créditos, reembolsos, importações, isenções, taxas reduzidas ou regimes especiais ao cálculo fiscal; as hipóteses antigas de tesouraria continuam personalizadas e separadas.
**Provas apenas do delta:** 3 testes API novos/46 asserções, transacções no clone `simulator_fiscal_checks_20261021_test` com guarda QA; 4 testes frontend novos para reutilização das vendas, zero/informação em falta, invalidação e recuperação/relatório. Incluem taxa proibida no cliente, âmbito não declarado, preços brutos, período excluído, repetição sem duplicações, versão congelada antes da suspensão e conclusão depois dela. TypeScript/build/Pint/diff-check verificados. Reutilizadas as provas aceites do avaliador decimal, exportadores e PDF; não se voltou a testar paginação nem a ampliar cobertura legal.
**Percurso Edge no simulador normal:** dossier sintético privado «QA IVA mensal - vendas projectadas», ID `e0201d19-286e-4d5c-9585-2c1e3bd1ad1e`, QA127.0.0.1:18580. Vendas Janeiro1000.03/ Fevereiro2000.06/ Março0/ Abril vazio produziram IVA160.0048/320.0096/0/indeterminado. Alterar apenas a quantidade de Janeiro de1 para3 actualizou vendas3000.09, IVA480.0144 e total3480.1044. Assinalou-se resultado desactualizado durante a mudança. Seleccionar preços com IVA incluído conservou vendas e tornou base/IVA/total indeterminados; voltar manualmente à base líquida gerou nova execução. Após recarregar e recuperar o mesmo dossier, os valores permaneceram iguais e o fundamento mostrou a versão congelada. Quatro execuções mensais intencionais (inicial, venda alterada, base incluída, regresso à líquida), quatro relatórios com snapshots iguais às respectivas execuções; recuperação não criou a quinta. Não se atribui aplicabilidade fiscal universal a estes exemplos aritméticos.
A fixture sintética **v4**, ID `01a15e50-7972-72e0-8991-82ca28df9b49`, está publicada e o consumidor ligado **apenas no clone QA** `simulator_fiscal_delta_20261021_test`, deixados disponíveis para esta revisão. Isto substitui o estado local suspenso/off da prova diagnóstica anterior; não é indisponibilidade na homologação. Não importar esta fixture, os seus actores ou os scripts privados. A regra real de homologação v1 mantém-se fora desta execução.
**Revisão e disponibilização:** rever esta candidata QA e integrar somente o código autorizado, mantendo a ligação ao ID/versão reais do destino. Este delta não precisa de publicar outra regra nem de mudar a taxa/âmbito ou de reactivar um consumidor já activo. Conferir apenas revisão servida e ligação mensal no destino após autorização própria; conservar o procedimento separado de publicação e activação se alguma dessas operações vier a ser expressamente pedida. Suspender novas admissões pelo interruptor existente ou estado da regra preserva as já admitidas e os históricos. Não apagar snapshots para suspender ou reverter código. Sem integração em main, escrita activa, validação jurídica atribuída à Tempo ou nova ronda de preparação identificada nesta entrega QA.
Provas privadas em `outputs/simulator-continuation-20261020`: monthly-iva-api-test.txt, monthly-iva-build-final.txt, monthly-iva-initial.txt, monthly-iva-updated.txt, monthly-iva-stale.txt, monthly-iva-included.txt, monthly-iva-recovered.txt, monthly-iva-live-history.json e monthly-iva-proof.png. O recibo existente conserva o saldo e os IDs; AUD-SIM-INT02 da matriz existente recebe esta referência, sem outra matriz/backlog.
File diff suppressed because one or more lines are too long
@@ -156,7 +156,7 @@ export function SimulationCashflowPlanner({
</label>
{taxMode === 'fixed' ? (
<label className="block max-w-xs text-sm font-medium">
Imposto assumido por mês
Imposto manual do cenário personalizado por mês
<input
type="number"
min="0"
@@ -175,7 +175,7 @@ export function SimulationCashflowPlanner({
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{inputs.monthlyTaxSchedule.map((value, index) => (
<label key={index} className="text-sm font-medium">
Imposto assumido — mês {index + 1}
Imposto manual do cenário personalizado — mês {index + 1}
<input
type="number"
min="0"
@@ -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>
)}
+20 -4
View File
@@ -1,4 +1,5 @@
import { SimulationPublishedIva, type PublishedIvaInput } from './simulation-published-iva';
import { SimulationMonthlyIva } from './simulation-monthly-iva';
import {
PublicDocumentContent,
type PublicDocument,
@@ -56,6 +57,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 +812,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 +1939,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];
@@ -2308,12 +2312,14 @@ export function SimulationForm() {
);
}
if (!publishedInput) sessionStorage.removeItem(idempotencyStorageKey);
return simulation ?? null;
} catch (cause) {
setServerError(
cause instanceof Error
? cause.message
: 'Erro de ligação. Tente novamente.',
);
return null;
} finally {
calculationInFlightRef.current = false;
setIsCalculating(false);
@@ -3285,6 +3291,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 +3338,7 @@ export function SimulationForm() {
}
/>
<FieldGuidance fieldKey={field.key} />
{ivaNotes}
</div>
);
}
@@ -3405,6 +3413,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
@@ -3457,15 +3466,16 @@ export function SimulationForm() {
{(current.id === 'financial' ||
(draft.purpose === 'simulate_only' &&
current.id === 'tax')) && (
<SimulationCashflowPlanner
<details className="rounded border p-3"><summary>Cenário personalizado de tesouraria — imposto manual, separado do IVA calculado</summary><SimulationCashflowPlanner
snapshot={String(draft.cashflow_snapshot ?? '')}
onSnapshotChange={(value) =>
change('cashflow_snapshot', value)
}
currency={String(draft.currency ?? '')}
onCurrencyChange={(value) => change('currency', value)}
/>
/></details>
)}
{(current.id === 'financial' || current.id === 'tax') && <SimulationMonthlyIva draft={draft} busy={isCalculating} onCalculate={runSimulation} onChange={change} />}
{current.id === 'financial' &&
(draft.detail_level === 'advanced' ||
draft.journey_type === 'small_business') &&
@@ -3609,6 +3619,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 +3634,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 +3651,7 @@ export function SimulationForm() {
? [[key, draft[key] as string]]
: [],
),
)}
), ...(typeof draft.fiscal_currency === 'string' && draft.fiscal_currency !== '' ? {currency: draft.fiscal_currency} : {}) }}
/>
</div>
)}
@@ -0,0 +1,78 @@
import { useEffect, useRef, useState } from 'react';
import { monthlyIvaInput, monthlyIvaKey, monthlySalesFromDraft, readMonthlyIvaRecord, readMonthlyIvaSettings, monthlyIvaCashTreatment, type MonthlyIvaInput, type MonthlyIvaSettings } from '@/lib/monthly-iva';
import { fiscalQaRows } from '@/lib/fiscal-qa-preview';
import { isApiSimulationReadResponseV1, type ApiSimulationExecutionV1 } from '@/lib/simulation-contract';
export function SimulationMonthlyIva({draft, busy, onCalculate, onChange}: {
draft: Record<string, unknown>; busy: boolean;
onCalculate: (input: MonthlyIvaInput) => Promise<ApiSimulationExecutionV1 | null | undefined>;
onChange: (field: string, value: string) => void;
}) {
const settings = readMonthlyIvaSettings(String(draft.monthly_iva_settings ?? ''));
const record = readMonthlyIvaRecord(String(draft.monthly_iva_snapshot ?? ''));
const [retry, setRetry] = useState(0);
const [error, setError] = useState('');
const [available, setAvailable] = useState<boolean | null>(null);
const attempt = useRef('');
const callbacks = useRef({onCalculate, onChange}); callbacks.current = {onCalculate, onChange};
let input: MonthlyIvaInput | null = null; let inputError = ''; let source = '';
try { source = monthlySalesFromDraft(draft).source; input = monthlyIvaInput(draft, settings); }
catch (cause) { inputError = cause instanceof Error ? cause.message : 'Reveja a projecção de vendas.'; }
const key = input ? monthlyIvaKey(input) : '';
const current = !!(input && record && monthlyIvaKey(record.input) === key);
const completed = current && record?.execution.status === 'completed';
const saveSettings = (patch: Partial<MonthlyIvaSettings>) => onChange('monthly_iva_settings', JSON.stringify({...settings, ...patch}));
useEffect(() => {
let active = true;
fetch('/api/v1/fiscal-consumer', {credentials: 'same-origin', headers: {Accept:'application/json'}}).then(async (response) => {
if (!response.ok) throw new Error(); const payload = await response.json(); if (active) setAvailable(payload?.data?.enabled === true);
}).catch(() => {if (active) setError('Não foi possível consultar a disponibilidade da regra. As vendas foram preservadas.');});
return () => { active = false; };
}, [retry]);
useEffect(() => {
if (!settings.auto || !input || busy || !available || completed || attempt.current === key) return;
const request = input;
const pending = current && record && ['queued','running'].includes(record.execution.status) ? record.execution.id : null;
const timer = window.setTimeout(() => {
attempt.current = key; setError('');
void (async () => {
try {
let execution;
if (pending) {
const response = await fetch(`/api/v1/simulations/${pending}`, {credentials:'same-origin', headers:{Accept:'application/json'}});
const value: unknown = await response.json();
if (!response.ok || !isApiSimulationReadResponseV1(value)) throw new Error('Não foi possível recuperar a execução mensal guardada. Não foi criada uma execução alternativa.');
execution = value.data;
} else execution = await callbacks.current.onCalculate(request);
if (!execution) throw new Error('O cálculo mensal não foi concluído. As vendas e a versão anterior foram preservadas; tente novamente.');
callbacks.current.onChange('monthly_iva_snapshot', JSON.stringify({input:request, execution}));
} catch (cause) {setError(cause instanceof Error ? cause.message : 'Não foi possível calcular o IVA mensal.');}
})();
}, 700);
return () => window.clearTimeout(timer);
}, [key, settings.auto, busy, available, completed, retry]);
const snapshot = record?.execution.result_snapshot;
const projection = snapshot?.monthly_projection as {rows?: Array<{date:string; sales_declared:string|null; net_sales:string|null; rate:string|null; vat:string|null; gross_sales:string|null; status:string}>} | undefined;
return <section className="space-y-3 rounded border border-emerald-700 p-4" aria-label="IVA mensal das vendas projectadas">
<h3 className="font-semibold">IVA mensal das vendas projectadas</h3>
<p>{source}</p><p>Declare se os preços projectados são sem IVA ou com IVA incluído. Nenhuma taxa é fixada neste formulário; o servidor selecciona a versão publicada.</p>
<label className="block">Primeiro mês da projecção<input type="month" value={settings.start} min="2026-01" max="2026-12" onChange={(event) => saveSettings({start:event.target.value})} className="block rounded border p-2" /></label>
<label className="block">Tratamento do IVA nos preços projectados<select value={settings.basis} onChange={(event) => saveSettings({basis:event.target.value as MonthlyIvaSettings['basis']})} className="block rounded border p-2"><option value="unknown">Por indicar</option><option value="net">Vendas sem IVA (base líquida)</option><option value="included">Vendas com IVA incluído</option></select></label>
<label className="block"><input type="checkbox" checked={settings.declared} onChange={(event) => saveSettings({declared:event.target.checked})} /> Declaro que as vendas projectadas são tributáveis à taxa geral interna e não incluem as operações excluídas deste lote.</label>
<p className="text-sm">Sem importações, taxas reduzidas, isenções, regimes especiais, deduções, reembolsos ou apuramento final. Valores com IVA incluído permanecem indeterminados neste lote; não se aplica a taxa ao preço bruto.</p>
<p className="text-sm">{monthlyIvaCashTreatment}</p>
{inputError && <p role="alert">{inputError}</p>}
{available === null && !error && <p role="status">A consultar a disponibilidade da versão publicada…</p>}
{available === false && <p role="status">A versão publicada de IVA está indisponível neste ambiente para novas execuções. Os pressupostos e históricos mantêm-se.</p>}
{record && !current && <p role="alert">As vendas ou os pressupostos mudaram. Os resultados abaixo são históricos e estão desactualizados; não são usados como resultados da projecção actual.</p>}
{settings.auto && !completed && current && <p role="status">Execução mensal guardada: {record?.execution.status === 'queued' ? 'em fila' : record?.execution.status === 'running' ? 'em processamento' : 'não concluída'}. Pode voltar a consultar sem duplicar.</p>}
{busy && <p role="status">A actualizar os resultados dependentes das vendas…</p>}
{error && <p role="alert">{error}</p>}
<button type="button" disabled={busy || !available || !input || input.currency !== 'MZN'} onClick={() => {attempt.current=''; setRetry((value) => value+1); saveSettings({auto:true});}} className="rounded bg-emerald-800 p-2 text-white disabled:opacity-50">Calcular / actualizar IVA mensal e guardar versão</button>
{settings.auto && <button type="button" className="ml-3 underline" onClick={() => saveSettings({auto:false})}>Pausar actualização automática</button>}
{input && <p className="text-sm">{input.monthly_sales.length} meses; moeda {input.currency || 'por indicar'}. Campo vazio significa informação em falta; zero declarado permanece zero. Depois da primeira execução, alterações das vendas actualizam o cálculo enquanto a actualização automática estiver ligada.</p>}
{projection?.rows && <div className="overflow-x-auto" tabIndex={0}><table className="min-w-full text-sm"><caption>IVA mensal — {current ? 'versão correspondente às entradas' : 'versão histórica'}</caption><thead><tr>{['Mês','Vendas declaradas','Vendas tributáveis sem IVA','Taxa aplicada (%)','IVA calculado','Total com IVA','Estado'].map((label) => <th key={label} className="p-2 text-left">{label}</th>)}</tr></thead><tbody>{projection.rows.map((row) => <tr key={row.date}><th scope="row" className="p-2">{row.date.slice(0,7)}</th><td>{row.sales_declared ?? 'Sem informação'}</td><td>{row.net_sales ?? 'Indeterminado'}</td><td>{row.rate ?? 'Indeterminada'}</td><td>{row.vat ?? 'Indeterminado'}</td><td>{row.gross_sales ?? 'Indeterminado'}</td><td>{{calculated:'Calculado',missing_sales:'Sem informação de vendas',unsupported_basis:'Base líquida não determinada',indeterminate:'Enquadramento ou regra indeterminados'}[row.status] ?? 'Indeterminado'}</td></tr>)}</tbody></table></div>}
{snapshot && <details><summary>Ver fundamento e relatório da versão guardada</summary><dl>{fiscalQaRows(snapshot).map(([label,value], index) => <div key={index}><dt className="font-semibold">{label}</dt><dd>{value}</dd></div>)}</dl></details>}
</section>;
}
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import type { MonthlyIvaInput } from '@/lib/monthly-iva';
export type PublishedIvaInput = {
export type PublishedIvaInput = MonthlyIvaInput | {
scenario_kind: 'fiscal_rule_published';
base: string;
currency: 'MZN';
@@ -11,7 +12,7 @@ export type PublishedIvaInput = {
export function SimulationPublishedIva({ busy, onCalculate }: {
busy: boolean;
onCalculate: (input: PublishedIvaInput) => Promise<void>;
onCalculate: (input: PublishedIvaInput) => Promise<unknown>;
}) {
const [available, setAvailable] = useState(false);
const [message, setMessage] = useState('A consultar disponibilidade do cálculo de IVA…');
@@ -51,8 +52,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">
@@ -776,6 +776,23 @@ function multiplyQuantity(
return (quantityMilli * unitPriceCents + 500n) / 1000n;
}
/** Reuses the sales model, without requiring unrelated cost/cash assumptions. Blank is never zero. */
export function monthlyProjectedSales(input: BusinessFinancialPlanInput): Array<string | null> {
const periods = input.periods ?? 12;
if (!Number.isInteger(periods) || periods < 1 || periods > 60) throw new Error('Horizonte de vendas inválido.');
return Array.from({ length: periods }, (_, month) => {
if (!input.products.length) return null;
let total = 0n;
for (const product of input.products) {
const units = product.monthlyUnitsByPeriod === undefined ? product.monthlyUnits : product.monthlyUnitsByPeriod[month];
const price = product.unitPriceByPeriod === undefined ? product.unitPrice : product.unitPriceByPeriod[month];
if (typeof units !== 'string' || typeof price !== 'string' || units.trim() === '' || price.trim() === '') return null;
total += multiplyQuantity(quantity(units), cents(price, `Preço de ${product.label}`));
}
return format(total);
});
}
function format(centsValue: bigint): string {
const absolute = centsValue < 0n ? -centsValue : centsValue;
return `${centsValue < 0n ? '-' : ''}${absolute / 100n}.${String(absolute % 100n).padStart(2, '0')}`;
+7 -1
View File
@@ -19,7 +19,13 @@ export function fiscalQaRows(value: unknown): Array<[string, string]> {
typeof item === "string" || typeof item === "number"
? String(item)
: "não disponível";
const monthly = snapshot.monthly_projection as {rows?: Array<Record<string,unknown>>; cash_treatment?: string} | undefined;
return [
...(monthly?.rows ? [
["Projecção mensal", "Vendas tributáveis sem IVA → taxa aplicada → IVA calculado → total com IVA; não é apuramento final"],
...monthly.rows.map((row) => [`Mês ${text(row.date)}`, `Vendas declaradas: ${text(row.sales_declared)}; base líquida: ${text(row.net_sales)}; taxa: ${row.rate == null ? 'não aplicada' : `${text(row.rate)}%`}; IVA: ${text(row.vat)}; total com IVA: ${text(row.gross_sales)}; ${row.status === 'calculated' ? 'calculado' : row.status === 'missing_sales' ? 'sem informação de vendas' : row.status === 'unsupported_basis' ? 'base líquida não determinada' : 'enquadramento ou regra indeterminados'}`]),
["Tratamento na tesouraria", monthly.cash_treatment ?? "IVA separado; não é custo nem imposto a pagar"],
] as Array<[string,string]> : []),
["Relatório", published ? "IVA pela versão publicada; ver enquadramento abaixo" : "Pré-visualização fiscal QA de rascunho — sem activação"],
[
"IVA calculado (exacto; não é saldo a pagar)",
@@ -29,7 +35,7 @@ export function fiscalQaRows(value: unknown): Array<[string, string]> {
["Base tributável declarada", text(snapshot.base)],
["Taxa cadastrada (%)", text(parameters?.IVA_GENERAL_RATE?.value)],
["Moeda explícita", text(value.context.currency)],
["Data de exigibilidade declarada", value.context.reference_date],
[monthly ? "Início da projecção mensal" : "Data de exigibilidade declarada", value.context.reference_date],
[
"Enquadramento",
provenance?.scope_status === "undetermined" ? "Indeterminado: falta declaração do enquadramento" : "Declarado pelo utilizador; não validado juridicamente",
+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}`;
}
+50
View File
@@ -0,0 +1,50 @@
import { monthlyProjectedSales, restoreBusinessPlanSnapshot } from './business-financial-plan.ts';
import { restoreCashflowDraftSnapshot } from './monthly-cashflow.ts';
import { isApiSimulationReadResponseV1, type ApiSimulationExecutionV1 } from './simulation-contract.ts';
export type MonthlyIvaInput = {
scenario_kind: 'fiscal_rule_published';
monthly_sales: Array<{ date: string; amount: string | null }>;
sales_vat_basis: 'net' | 'included' | 'unknown';
currency: string;
scope_declared: boolean;
};
export type MonthlyIvaSettings = { start: string; basis: MonthlyIvaInput['sales_vat_basis']; declared: boolean; auto: boolean };
export type MonthlyIvaRecord = { input: MonthlyIvaInput; execution: ApiSimulationExecutionV1 };
export function readMonthlyIvaSettings(raw?: string): MonthlyIvaSettings {
try { const value = JSON.parse(raw ?? '');
if (typeof value.start === 'string' && ['net', 'included', 'unknown'].includes(value.basis) && typeof value.declared === 'boolean' && typeof value.auto === 'boolean') return value;
} catch { /* An invalid setting is not an invented business assumption. */ }
return {start: '', basis: 'unknown', declared: false, auto: false};
}
export function monthlySalesFromDraft(draft: Record<string, unknown>): { amounts: Array<string | null>; source: string } {
if (draft.business_plan_snapshot) {
const plan = restoreBusinessPlanSnapshot(String(draft.business_plan_snapshot));
if (!plan) throw new Error('Não foi possível recuperar a projecção de vendas. Os dados guardados não foram substituídos.');
return {amounts: monthlyProjectedSales(plan), source: 'Vendas por produto e mês do plano empresarial; mesmos pressupostos e representação monetária da projecção de vendas.'};
}
if (draft.cashflow_snapshot) {
const saved = restoreCashflowDraftSnapshot(String(draft.cashflow_snapshot));
if (!saved) throw new Error('Não foi possível recuperar a receita projectada.');
return {amounts: Array.from({length: 12}, () => saved.inputs.monthlyRevenue.trim() === '' ? null : saved.inputs.monthlyRevenue), source: 'Receita mensal constante já declarada na projecção de tesouraria; confirme que corresponde a vendas tributáveis.'};
}
return {amounts: Array.from({length: 12}, () => null), source: 'Sem projecção de vendas guardada. Prepare as vendas no plano empresarial; não é necessário voltar a introduzi-las aqui.'};
}
export function monthlyIvaInput(draft: Record<string, unknown>, settings: MonthlyIvaSettings): MonthlyIvaInput {
const sales = monthlySalesFromDraft(draft);
if (!/^2026-(0[1-9]|1[0-2])$/.test(settings.start)) throw new Error('Indique o primeiro mês real da projecção. Este lote abrange apenas 2026.');
const start = Number(settings.start.slice(5));
if (sales.amounts.length > 12 || start + sales.amounts.length > 13) throw new Error('O horizonte ultrapassa a cobertura de 2026. Esta versão não calcula os meses fora desse período.');
return {scenario_kind: 'fiscal_rule_published', monthly_sales: sales.amounts.map((amount, index) => ({date: `2026-${String(start + index).padStart(2, '0')}-01`, amount})),
sales_vat_basis: settings.basis, currency: String(draft.currency ?? ''), scope_declared: settings.declared};
}
export function monthlyIvaKey(input: Omit<MonthlyIvaInput, 'scenario_kind'>): string {
return JSON.stringify([input.currency, input.sales_vat_basis, input.scope_declared, input.monthly_sales.map((row) => [row.date, row.amount])]);
}
export function readMonthlyIvaRecord(raw?: string): MonthlyIvaRecord | null {
try { const value = JSON.parse(raw ?? '');
if (value?.input?.scenario_kind === 'fiscal_rule_published' && Array.isArray(value.input.monthly_sales) && isApiSimulationReadResponseV1({data: value.execution})) return value;
} catch { /* Keep the stored value intact; no fallback fiscal result. */ }
return null;
}
export const monthlyIvaCashTreatment = 'IVA apresentado separadamente: não é somado novamente aos recebimentos declarados nem deduzido como custo ou imposto final a pagar. A tesouraria conserva os recebimentos e pagamentos introduzidos, antes de uma liquidação fiscal não apurada; não se presume cobrança integral das vendas no mesmo mês.';
@@ -173,6 +173,7 @@ const rowSection: Record<string, DossierReportSectionId> = {
'Ponto de equilíbrio': 'finance',
Orçamento: 'finance',
Tesouraria: 'finance',
'IVA mensal': 'finance',
Equipa: 'team',
'Necessidade de negócio': 'resources',
Financiamento: 'resources',
+10
View File
@@ -1,4 +1,6 @@
import { displayValue } from './display-value.ts';
import { monthlyIvaInput, monthlyIvaKey, readMonthlyIvaRecord, readMonthlyIvaSettings } from './monthly-iva.ts';
import { fiscalQaRows } from './fiscal-qa-preview.ts';
import { csvCellWithDelimiter } from './csv.js';
import {
restoreCashflowDraftSnapshot,
@@ -608,6 +610,14 @@ export function buildProjectDossierRows(
'Planeado/realizado declarado pelo utilizador',
]);
}
if (draft.monthly_iva_snapshot) {
const saved = readMonthlyIvaRecord(String(draft.monthly_iva_snapshot));
let current = false;
try { current = !!saved && monthlyIvaKey(monthlyIvaInput(draft, readMonthlyIvaSettings(String(draft.monthly_iva_settings ?? '')))) === monthlyIvaKey(saved.input); } catch { /* An old fiscal version is never silently made current. */ }
rows.push(['IVA mensal', 'Estado da versão', current ? 'Entradas correspondentes à projecção guardada' : 'Histórico desactualizado face às vendas ou pressupostos actuais; não aplicar como resultado actual', 'Execução versionada no servidor; sem apuramento final']);
if (saved?.execution.result_snapshot) for (const [label,value] of fiscalQaRows(saved.execution.result_snapshot)) rows.push(['IVA mensal', label, value, 'Snapshot fiscal preservado; sem recálculo na exportação']);
else rows.push(['IVA mensal', 'Resultado', 'Indeterminado ou execução ainda não concluída', 'Sem substituição por zero']);
}
if (draft.cashflow_snapshot) {
const saved = restoreCashflowDraftSnapshot(
String(draft.cashflow_snapshot),
+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,102 @@ class SimulationPublishedFiscalTest extends TestCase
return Simulation::with('reports')->findOrFail($id);
}
private function monthlyInput(array $override = []): array
{
return ['scenario_kind' => 'fiscal_rule_published', 'currency' => 'MZN', 'scope_declared' => true, 'sales_vat_basis' => 'net',
'monthly_sales' => [['date' => '2026-01-01', 'amount' => '1000.03'], ['date' => '2026-02-01', 'amount' => '0'], ['date' => '2026-03-01', 'amount' => null]], ...$override];
}
public function test_monthly_sales_preserve_zero_missing_values_recovery_and_report_with_no_double_cash_effect(): void
{
$key = (string) Str::uuid();
$input = $this->monthlyInput();
$id = $this->actingAs($this->consumer)->postJson('/api/v1/simulations', $input, ['Idempotency-Key' => $key])->assertAccepted()->json('data.id');
$snapshot = $this->finish($id)->result_snapshot;
$rows = $snapshot['monthly_projection']['rows'];
self::assertSame('160.0048', $rows[0]['vat']);
self::assertSame('1160.0348', $rows[0]['gross_sales']);
self::assertSame('16', $rows[0]['rate']);
self::assertSame('0', $rows[1]['vat']);
self::assertSame('0', $rows[1]['gross_sales']);
self::assertNull($rows[2]['vat']);
self::assertNull($rows[2]['net_sales']);
self::assertSame('missing_sales', $rows[2]['status']);
self::assertNull($snapshot['result']['value']);
self::assertStringContainsString('nem o IVA é deduzido como custo', $snapshot['monthly_projection']['cash_treatment']);
$this->postJson('/api/v1/simulations', $input, ['Idempotency-Key' => $key])->assertOk()->assertJsonPath('data.id', $id);
$this->getJson('/api/v1/simulations/'.$id)->assertOk()->assertJsonPath('data.result_snapshot', $snapshot);
$report = Simulation::with('reports')->findOrFail($id)->reports->first();
$this->getJson('/api/v1/simulations/'.$id.'/reports/'.$report->id)->assertOk()->assertJsonPath('data.report_snapshot', $snapshot);
$input['monthly_sales'][0]['amount'] = '2000';
$input['monthly_sales'][2]['amount'] = '3000';
$newId = $this->postJson('/api/v1/simulations', $input, ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted()->json('data.id');
$new = $this->finish($newId)->result_snapshot;
self::assertSame('320', $new['monthly_projection']['rows'][0]['vat']);
self::assertSame('480', $new['monthly_projection']['rows'][2]['vat']);
self::assertSame('800', $new['result']['value']);
self::assertSame($snapshot, Simulation::findOrFail($id)->result_snapshot);
self::assertSame(2, Simulation::where('owner_user_id', $this->consumer->id)->count());
}
public function test_monthly_gross_unknown_scope_and_server_rate_protection_do_not_invent_net_sales(): void
{
foreach (['included', 'unknown'] as $basis) {
$id = $this->actingAs($this->consumer)->postJson('/api/v1/simulations', $this->monthlyInput(['sales_vat_basis' => $basis]), ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted()->json('data.id');
$row = $this->finish($id)->result_snapshot['monthly_projection']['rows'][0];
self::assertSame('1000.03', $row['sales_declared']);
self::assertSame('unsupported_basis', $row['status']);
self::assertNull($row['net_sales']);
self::assertNull($row['vat']);
self::assertNull($row['rate']);
}
$id = $this->postJson('/api/v1/simulations', $this->monthlyInput(['scope_declared' => false]), ['Idempotency-Key' => (string) Str::uuid()])->assertAccepted()->json('data.id');
self::assertNull($this->finish($id)->result_snapshot['monthly_projection']['rows'][0]['vat']);
$this->postJson('/api/v1/simulations', $this->monthlyInput(['rate' => '17']), ['Idempotency-Key' => (string) Str::uuid()])->assertUnprocessable();
$this->postJson('/api/v1/simulations', $this->monthlyInput(['monthly_sales' => [['date' => '2027-01-01', 'amount' => '100']]]), ['Idempotency-Key' => (string) Str::uuid()])->assertUnprocessable();
}
public function test_monthly_frozen_admission_survives_suspension_and_does_not_duplicate_on_retry(): void
{
$key = (string) Str::uuid();
$input = $this->monthlyInput();
$id = $this->actingAs($this->consumer)->postJson('/api/v1/simulations', $input, ['Idempotency-Key' => $key])->assertAccepted()->json('data.id');
app(SimulationTaxRuleGovernance::class)->transition($this->publisher, $this->rule->id, $this->rule->version_no, 'suspend', 'QA: suspend monthly consumer after admission, preserve frozen version.');
$this->postJson('/api/v1/simulations', $input, ['Idempotency-Key' => (string) Str::uuid()])->assertStatus(503);
$this->postJson('/api/v1/simulations', $input, ['Idempotency-Key' => $key])->assertOk()->assertJsonPath('data.id', $id);
$snapshot = $this->finish($id)->result_snapshot;
self::assertSame('160.0048', $snapshot['monthly_projection']['rows'][0]['vat']);
self::assertSame($this->rule->id, $snapshot['monthly_projection']['rows'][0]['rule_id']);
self::assertSame('published', $snapshot['fiscal_provenance']['rule']['status']);
}
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']);
}
}
+57
View File
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { monthlyProjectedSales, forecastBusinessPlan, serializeBusinessPlanSnapshot } from '../../resources/js/lib/business-financial-plan.ts';
import { monthlyIvaInput, monthlyIvaKey, monthlySalesFromDraft, readMonthlyIvaRecord, monthlyIvaCashTreatment } from '../../resources/js/lib/monthly-iva.ts';
import { partitionSimulationDraft, restoreSimulationDraft, readArithmeticSnapshot } from '../../resources/js/lib/simulation-form.ts';
import { createProjectDossierReport } from '../../resources/js/lib/project-dossier-report.ts';
function plan() {
const fields = ['openingCash','monthlyCollections','monthlyVariableCostPayments','monthlyInventoryPurchases','monthlyOperatingExpenses','monthlyOperatingPayments','monthlyPayrollExpense','monthlyPayrollPayments','assetInvestment','inventory','receivables','payables','equity','shareholderLoans','bankLoanProceeds','grants','assetDepreciation','monthlyInterestExpense','monthlyInterestPayment','monthlyPrincipalRepayment'];
return {...Object.fromEntries(fields.map((key) => [key,'0'])), currency:'MZN', periods:4, depreciationLifeMonths:60,
products:[{label:'Produto QA',monthlyUnits:'1',unitPrice:'1000.03',unitVariableCost:'0',monthlyUnitsByPeriod:['1','2','0','3']}]};
}
const settings = {start:'2026-01',basis:'net',declared:true,auto:true};
function draft(value=plan()) {return {currency:'MZN',business_plan_snapshot:serializeBusinessPlanSnapshot(value),monthly_iva_settings:JSON.stringify(settings)};}
function snapshot(input) {
return {contract_version:1,scenario_kind:'fiscal_rule_published',base:null,currency:'MZN',indicative_total:null,legal_interpretation:false,formula_version:'investmoz.tax.v1',
context:{reference_date:'2026-01-01',currency:'MZN',currency_fraction_digits:2,scenario_id:'61000100-0000-4000-8000-000000000099',scenario_version:1,engine_version:'fiscal-published-v1',ruleset_version:'IVA-GERAL-BASE-LIQUIDA:1'},
result:{status:'indeterminate',value:null,unit:'MZN',explanation:'IVA mensal; não é imposto a pagar.',missing_inputs:['monthly_sales'],source_ids:['qa-source']},presentation:{value:null,rounding_applied:false},
fiscal_provenance:{rule:{rule_code:'IVA-GERAL-BASE-LIQUIDA',rule_version:1,valid_from:'2023-01-01',valid_until:null,source:{diploma:'Fonte sintética QA',article:'Teste'}},scope_status:'user_declared'},
monthly_projection:{input,cash_treatment:monthlyIvaCashTreatment,rows:[{date:'2026-01-01',sales_declared:'1000.03',net_sales:'1000.03',rate:'16',vat:'160.0048',gross_sales:'1160.0348',status:'calculated'},{date:'2026-02-01',sales_declared:'0',net_sales:'0',rate:'16',vat:'0',gross_sales:'0',status:'calculated'},{date:'2026-03-01',sales_declared:null,net_sales:null,rate:null,vat:null,gross_sales:null,status:'missing_sales'}]}};
}
test('monthly IVA reuses exactly the existing per-product sales projection without repeating entry', () => {
const value=plan();
assert.deepEqual(monthlyProjectedSales(value), forecastBusinessPlan(value).rows.map((row) => row.revenue));
assert.deepEqual(monthlySalesFromDraft(draft()).amounts,['1000.03','2000.06','0.00','3000.09']);
});
test('a missing month remains unknown while an explicit zero and other months stay available', () => {
const value=plan(); value.products[0].monthlyUnitsByPeriod=['1','','0','2'];
assert.deepEqual(monthlyProjectedSales(value),['1000.03',null,'0.00','2000.06']);
const input=monthlyIvaInput(draft(value), settings);
assert.equal(input.monthly_sales[1].amount,null); assert.equal(input.monthly_sales[2].amount,'0.00');
assert.equal('rate' in input,false); assert.equal('monthlyTaxAssessed' in input,false);
});
test('gross basis is preserved and old fiscal results become stale when sales, currency or basis change', () => {
const value=draft(); const initial=monthlyIvaInput(value, settings); const frozen=JSON.stringify(initial);
const gross=monthlyIvaInput(value,{...settings,basis:'included'});
assert.equal(gross.monthly_sales[0].amount,'1000.03'); assert.notEqual(monthlyIvaKey(gross),monthlyIvaKey(initial));
const changed=plan();changed.products[0].monthlyUnitsByPeriod[0]='2';
assert.notEqual(monthlyIvaKey(monthlyIvaInput(draft(changed),settings)),monthlyIvaKey(initial));
assert.equal(JSON.stringify(initial),frozen);
assert.throws(() => monthlyIvaInput(value,{...settings,start:'2026-12'}),/cobertura/);
});
test('monthly recovery and finance report preserve exact values and mark stale history without recomputing VAT', () => {
const value=plan();value.products[0].monthlyUnitsByPeriod=['1','0','','2'];
const original=draft(value); const input=monthlyIvaInput(original,settings); const result=snapshot(input);
original.monthly_iva_snapshot=JSON.stringify({input,execution:{id:'61000100-0000-4000-8000-000000000077',status:'completed',version_no:2,result_snapshot:result}});
const partition=partitionSimulationDraft(original); const recovered=restoreSimulationDraft(partition.economic_snapshot,partition.personal_snapshot);
assert.equal(recovered.monthly_iva_snapshot,original.monthly_iva_snapshot);
assert.equal(readMonthlyIvaRecord(recovered.monthly_iva_snapshot).execution.result_snapshot.monthly_projection.rows[0].vat,'160.0048');
assert.equal(readArithmeticSnapshot(result).integrity,'versioned');
let report=createProjectDossierReport(recovered,{selectedSections:new Set(['finance'])});
let text=JSON.stringify(report); assert.match(text,/160\.0048/);assert.match(text,/1160\.0348/);assert.match(text,/sem informação de vendas/);assert.match(text,/não é imposto a pagar/);
value.products[0].monthlyUnitsByPeriod[0]='2';recovered.business_plan_snapshot=serializeBusinessPlanSnapshot(value);
report=createProjectDossierReport(recovered,{selectedSections:new Set(['finance'])});text=JSON.stringify(report);
assert.match(text,/Histórico desactualizado/);assert.match(text,/160\.0048/);
assert.match(monthlyIvaCashTreatment,/não é somado novamente/);assert.match(monthlyIvaCashTreatment,/nem deduzido como custo/);
});