Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f5430c3ce | ||
|
|
0d7b70459d |
@@ -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'],
|
||||
];
|
||||
|
||||
@@ -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+)?$/'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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')}`;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user