Compare commits

..
50 changed files with 2943 additions and 806 deletions
@@ -40,7 +40,7 @@ class SimulationController extends Controller
$organizationId = $validated['organization_id'] ?? null;
$execution->authorizeOrganization($actorId, $organizationId);
if (($validated['scenario_kind'] ?? null) === 'fiscal_rule_published') {
$input = array_intersect_key($validated, array_flip(['base', 'currency', 'reference_date', 'operation', 'scope_declared']));
$input = array_intersect_key($validated, array_flip(['base', 'currency', 'reference_date', 'operation', 'scope_declared', 'monthly_sales', 'sales_vat_basis']));
$input['scope_declared'] = (bool) $input['scope_declared'];
$result = $execution->createPublishedFiscal($input, $actorId, $organizationId, $token, $key);
} else {
@@ -19,13 +19,15 @@ class SimulationTaxParameterController extends Controller
$input['period_start'],
$input['period_end'],
$input['context'] ?? [],
$input['component'] === 'IVA',
);
return response()->json([
'component' => $input['component'],
'period' => ['start' => $input['period_start'], 'end' => $input['period_end']],
'assumptions' => $input['context'] ?? [],
'parameters' => $data,
'parameters' => $input['component'] === 'IVA' ? $data['parameters'] : $data,
'diagnostics' => $input['component'] === 'IVA' ? $data['diagnostics'] : [],
])->header('Cache-Control', 'no-store');
}
}
@@ -170,8 +170,10 @@ class InvestmentSubmissionController extends Controller
// Binding does not save form edits or assert that the context has been reviewed.
$this->event($row, $request, 'context_bound');
$desk->event($desk->case($request->user(), $case->id), $request->user(), 'manifestation_context_bound', ['submission_id' => $id, 'previous_case_version' => (int) $case->version]);
return ['case_id' => $case->id, 'version' => $row->version, 'case_version' => (int) $case->version + 1, 'replayed' => false];
}, 3);
return response()->json($result);
}
@@ -190,11 +192,12 @@ class InvestmentSubmissionController extends Controller
{
$required = $submitting ? 'required' : 'nullable';
$data = $request->validate(['version' => ['required', 'integer', 'min:1'],
'payload' => ['required', 'array:full_name,email,phone,country,residence_country_code,incorporation_country_code,organization_country_snapshot,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
'payload' => ['required', 'array:context_reuse,full_name,email,phone,country,residence_country_code,incorporation_country_code,organization_country_snapshot,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'],
'payload.organization_country_snapshot' => ['sometimes', 'array', 'max:10'],
'payload.residence_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.incorporation_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()],
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
'payload.other_details' => ['nullable', 'string', 'max:5000'],
'payload.full_name' => [$required, 'string', ...($submitting ? ['min:3'] : []), 'max:150'], 'payload.email' => [$required, $submitting ? 'email:rfc' : 'string', 'max:150'],
'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => [$submitting ? 'required_without:payload.declared_origin_country_code' : 'nullable', 'nullable', 'string', 'max:100'],
@@ -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'],
];
}
@@ -33,7 +33,8 @@ class StoreLandAssistanceCaseRequest extends FormRequest
return [
'version' => [$this->isMethod('post') && ! $this->routeIs('land-assistance.support') ? 'nullable' : 'required', 'integer', 'min:1'],
'context_revision' => ['nullable', 'integer', 'min:0'], 'context_reviewed' => ['nullable', 'boolean'],
'payload' => ['required', 'array:support_kind,request_description,project_reference,investor_path_case_id,applicant_category,full_name,contact_person,email,phone,preferred_contact,country,declared_origin_country_code,nationality,representative_name,representative_role,representation_basis,nuit,constitution_or_residency_note,area_status,parcels,purpose,activity,known_occupants,occupant_description,known_improvements,improvements_description,prior_right_status,prior_right_description,conflict_status,conflict_description,official_process,attachments_to_follow,exploration_plan,consultation,additional_information,truth_declaration,contact_consent'],
'payload' => ['required', 'array:context_reuse,support_kind,request_description,project_reference,investor_path_case_id,applicant_category,full_name,contact_person,email,phone,preferred_contact,country,declared_origin_country_code,nationality,representative_name,representative_role,representation_basis,nuit,constitution_or_residency_note,area_status,parcels,purpose,activity,known_occupants,occupant_description,known_improvements,improvements_description,prior_right_status,prior_right_description,conflict_status,conflict_description,official_process,attachments_to_follow,exploration_plan,consultation,additional_information,truth_declaration,contact_consent'],
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
'payload.support_kind' => ['nullable', 'in:investment,home,family_exploitation,recognition,regularization,delimitation,change_of_use,transfer,existing_process,conflict_guidance,other'],
'payload.request_description' => ['nullable', 'string', 'max:4000'],
'payload.project_reference' => ['nullable', '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',
'context_applicability_snapshot', 'investment_regime', 'context_reuse', 'compatible_fields_snapshot', 'published_iva_base', 'published_iva_currency', 'published_iva_date', 'published_iva_scope', '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,16 @@ 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.context_applicability_snapshot' => ['nullable', 'string', 'max:20000'],
'economic_snapshot.investment_regime' => ['nullable', 'in:general,industrial_park,zee,zfi'],
'economic_snapshot.context_reuse' => ['nullable', 'string', 'max:10000'],
'economic_snapshot.compatible_fields_snapshot' => ['nullable', 'string', 'max:10000'],
'economic_snapshot.published_iva_base' => ['nullable', 'string', 'max:40'],
'economic_snapshot.published_iva_currency' => ['nullable', 'string', 'max:3'],
'economic_snapshot.published_iva_date' => ['nullable', 'string', 'max:10'],
'economic_snapshot.published_iva_scope' => ['nullable', 'string', 'in:yes,no'],
'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 +289,7 @@ class StoreSimulationFormDraftRequest extends FormRequest
'currency', 'funds_origin_countries', 'environmental_category', 'tax_calculation', 'fiscal_scenario_mode', 'offer_or_demand',
'opportunity_type', 'confidentiality', 'publish_requested', 'terms_accepted', 'truth_declaration',
'reuse_simulation', 'contact_authorisation', 'publication_authorisation', 'public_fields_confirmation', 'validation_acknowledged',
'fiscal_period', 'fiscal_periodicity', 'fiscal_month', 'fiscal_quarter', 'fiscal_reference_date', 'irps_salary_view', 'fiscal_component', 'fiscal_income_category', 'fiscal_residency_assessment', 'fiscal_operation',
'fiscal_currency', 'fiscal_period', 'fiscal_periodicity', 'fiscal_month', 'fiscal_quarter', 'fiscal_reference_date', 'irps_salary_view', 'fiscal_component', 'fiscal_income_category', 'fiscal_residency_assessment', 'fiscal_operation',
'fiscal_documentation', 'fiscal_vat_refund_state'],
'personal_snapshot.selection_labels' => ['person_type', 'investor_origin', 'tax_residency_country', 'fiscal_residency_country', 'entity_stage', 'legal_form', 'preferred_channel'],
];
+25 -1
View File
@@ -22,6 +22,12 @@ class StoreSimulationRequest extends FormRequest
'date_format' => 'Indique uma data de exigibilidade válida.',
'boolean' => 'Indique se declara o enquadramento da operação.',
'prohibited' => 'O campo :attribute não pode ser definido pelo cliente neste cálculo.',
'array' => 'Indique uma lista válida em :attribute.',
'monthly_sales.min' => 'Indique pelo menos um mês de vendas.',
'monthly_sales.max' => 'Este lote aceita até 12 meses dentro de 2026.',
'monthly_sales.*.date.regex' => 'Indique o primeiro dia de um mês de 2026.',
'monthly_sales.*.date.distinct' => 'Cada mês deve aparecer apenas uma vez.',
'monthly_sales.*.amount.present' => 'Indique o valor das vendas ou deixe-o explicitamente sem informação.',
];
}
@@ -29,13 +35,31 @@ class StoreSimulationRequest extends FormRequest
{
return ['base' => 'base tributável', 'currency' => 'moeda', 'reference_date' => 'data de exigibilidade',
'operation' => 'operação', 'scope_declared' => 'declaração de enquadramento', 'scenario_kind' => 'tipo de cálculo',
'rule_id' => 'regra', 'rule_version' => 'versão da regra', 'rate' => 'taxa', 'reduction' => 'redução'];
'rule_id' => 'regra', 'rule_version' => 'versão da regra', 'rate' => 'taxa', 'reduction' => 'redução',
'monthly_sales' => 'vendas mensais', 'monthly_sales.*.date' => 'mês da projecção',
'monthly_sales.*.amount' => 'vendas declaradas do mês', 'sales_vat_basis' => 'tratamento do IVA nos preços'];
}
/** @return array<string, list<mixed>> */
public function rules(): array
{
if ($this->input('scenario_kind') === 'fiscal_rule_published') {
if ($this->exists('monthly_sales')) {
return [
'scenario_kind' => ['required', 'in:fiscal_rule_published'],
'monthly_sales' => ['required', 'array', 'min:1', 'max:12'],
'monthly_sales.*' => ['required', 'array:date,amount'],
'monthly_sales.*.date' => ['required', 'date_format:Y-m-d', 'regex:/^2026-\d{2}-01$/D', 'distinct'],
'monthly_sales.*.amount' => ['present', 'nullable', 'string', 'max:40', 'regex:/^\d+(?:\.\d+)?$/D'],
'sales_vat_basis' => ['required', 'in:net,included,unknown'],
'currency' => ['required', 'in:MZN'],
'scope_declared' => ['required', 'boolean'],
'organization_id' => ['nullable', 'uuid', 'exists:organizations,id'],
'base' => ['prohibited'], 'rate' => ['prohibited'], 'reduction' => ['prohibited'],
'rule_id' => ['prohibited'], 'rule_version' => ['prohibited'], 'fiscal_preview' => ['prohibited'],
];
}
return [
'scenario_kind' => ['required', 'in:fiscal_rule_published'],
'base' => ['required', 'string', 'max:40', 'regex:/^\d+(?:\.\d+)?$/'],
+68 -16
View File
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Models\ExistingBusiness;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\SimulationFormDraft;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Arr;
@@ -21,7 +22,7 @@ final class InvestorContext
public const SOURCE = 'investor-context';
public const FIELDS = ['project_title', 'project_summary', 'sector_id', 'sector', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location'];
public const FIELDS = ['project_title', 'project_summary', 'sector_id', 'sector', 'province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location', 'currency', 'expected_start_date', 'fiscal_period', 'fiscal_periodicity', 'fiscal_month', 'investor_origin', 'person_type', 'entity_stage', 'tax_residency_country', 'fiscal_residency_country', 'activity_id', 'project_type', 'maturity_stage', 'special_zone_id', 'investment_regime'];
public function __construct(private readonly VirtualDesk $desk) {}
@@ -34,8 +35,26 @@ final class InvestorContext
{
$payload = $this->desk->decode($case->payload);
return $payload['investor_path']['context'] ?? ['revision' => 0, 'selection' => [],
$context = $payload['investor_path']['context'] ?? ['revision' => 0, 'selection' => [],
'fields' => ['project_title' => $case->title], 'provenance' => ['project_title' => ['kind' => 'dossier', 'id' => $case->id, 'version' => $case->version]], 'review_steps' => []];
// Explicit dossier anchor only: never the account's most recent simulation or another owner's draft.
if (($case->simulation_id ?? null) && request()->user()?->id === (int) $case->user_id) {
$draft = SimulationFormDraft::whereKey($case->simulation_id)->where('owner_user_id', $case->user_id)->whereNull('deleted_at')->first();
if ($draft) {
$values = [...SimulationFormDraft::decryptSnapshot($draft->personal_snapshot), ...SimulationFormDraft::decryptSnapshot($draft->economic_snapshot)];
foreach (Arr::only($values, self::FIELDS) as $key => $value) {
if (is_scalar($value) && (string) $value !== '' && (! array_key_exists($key, $context['fields']) || ($context['provenance'][$key]['kind'] ?? null) === 'linked_simulation')) {
$context['fields'][$key] = (string) $value;
$context['provenance'][$key] = ['kind' => 'linked_simulation', 'id' => $draft->id, 'version' => $draft->version_no, 'sha256' => $draft->snapshot_sha256];
}
}
}
}
$zone = $context['selection']['park'] ?? null;
$context['fields']['investment_regime'] = $zone['fields']['investment_regime'] ?? 'general';
$context['fields']['special_zone_id'] = $zone['id'] ?? '';
return $context;
}
public function dependencyHash(array $selection): string
@@ -53,17 +72,20 @@ final class InvestorContext
$documents = [];
$visibility = 'public';
$state = null;
$zoneAnalysis = null;
if ($kind === 'opportunity') {
abort_unless(ctype_digit($id), 422, 'Identificador de oportunidade inválido.');
$o = Opportunity::with('sector')->findOrFail((int) $id);
$published = Opportunity::published()->whereKey($o->id)->exists();
if (!$published) {
if (! $published) {
abort_unless(request()->user() && $o->status !== 'archived' && app(PortalProfileAccess::class)->canView(request()->user(), $o), 404, 'Ficha privada indisponível neste âmbito.');
$visibility = 'private';
$state = app(PortalProfileAccess::class)->state($o);
}
if (request()->user()) $documents = app(OpportunityLibrarySupport::class)->snapshot(request()->user(), $o);
$draft = !$published ? app(OpportunityManagement::class)->values($o) : [];
if (request()->user()) {
$documents = app(OpportunityLibrarySupport::class)->snapshot(request()->user(), $o);
}
$draft = ! $published ? app(OpportunityManagement::class)->values($o) : [];
$title = $draft['canonical']['official_title'] ?? $o->title;
$href = $published ? '/oportunidades/'.$o->slug : '/minhas-oportunidades/'.$o->id.'/view';
$version = $o->management_version;
@@ -122,19 +144,28 @@ final class InvestorContext
$fields = $this->territoryFields($id);
} elseif ($kind === 'park') {
abort_unless(Str::isUuid($id), 422);
$p = app(InvestmentZones::class)->visible()->where('category', 'industrial_park')->findOrFail($id);
$p = app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->findOrFail($id);
$title = $p->name;
$fields = ['special_zone_id' => (string) $p->id, 'investment_regime' => (string) $p->category];
$version = $p->version_no;
$zoneAnalysis = ['conditions' => app(InvestmentZoneOperationalDetails::class)->publicData($p)['conditions'] ?? [],
'source' => $p->legal_source_id ? DB::table('investment_legal_sources')->where('id', $p->legal_source_id)->first(['title', 'url', 'diploma', 'article', 'valid_from', 'valid_until']) : null,
'recognition' => 'undetermined', 'executable_benefit' => false];
$href = '/zonas/'.$id;
if ($p->primary_territory_id) {
$fields = $this->territoryFields($p->primary_territory_id);
$fields = [...$fields, ...$this->territoryFields($p->primary_territory_id)];
}
} else {
abort(422, 'Tipo de referência inválido.');
}
$fields = array_filter($fields, fn ($v) => is_scalar($v) && trim((string) $v) !== '');
$record = ['key' => $key, 'kind' => $kind, 'id' => $id, 'title' => $title, 'version' => $version, 'href' => $href, 'fields' => $fields];
if ($documents || $visibility === 'private') $record += ['documents' => $documents, 'visibility' => $visibility, 'state' => $state];
if ($zoneAnalysis !== null) {
$record['analysis'] = $zoneAnalysis;
}
if ($documents || $visibility === 'private') {
$record += ['documents' => $documents, 'visibility' => $visibility, 'state' => $state];
}
return [...$record, 'sha256' => hash('sha256', json_encode($record, JSON_THROW_ON_ERROR))];
}
@@ -170,7 +201,11 @@ final class InvestorContext
}
if (request()->user()) {
$private = app(PortalProfileAccess::class)->scope(Opportunity::query(), request()->user())->where('status', '!=', 'published')->where('status', '!=', 'archived')->when($query, fn ($q) => $q->where('title', 'ilike', '%'.$query.'%'))->orderBy('title')->limit(51)->get();
foreach ($private as $o) if (app(PortalProfileAccess::class)->canView(request()->user(), $o)) $items[] = ['key' => 'opportunity:'.$o->id, 'title' => $o->title.' — preparação privada', 'kind' => 'opportunity'];
foreach ($private as $o) {
if (app(PortalProfileAccess::class)->canView(request()->user(), $o)) {
$items[] = ['key' => 'opportunity:'.$o->id, 'title' => $o->title.' — preparação privada', 'kind' => 'opportunity'];
}
}
}
if (config('existing-business.enabled')) {
foreach (ExistingBusiness::published()->when($query, fn ($q) => $q->where('public_title', 'ilike', '%'.$query.'%'))->orderBy('public_title')->limit(51)->get(['id', 'public_title']) as $o) {
@@ -179,7 +214,7 @@ final class InvestorContext
}
return ['opportunities' => $items, 'search_limited' => count($items) > 50, 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']),
'parks' => app(InvestmentZones::class)->visible()->where('category', 'industrial_park')->orderBy('name')->get(['id', 'name', 'version_no'])];
'parks' => app(InvestmentZones::class)->visible()->whereIn('category', ['industrial_park', 'zee', 'zfi'])->orderBy('name')->get(['id', 'name', 'category', 'version_no'])];
}
public function current(User $actor): array
@@ -235,7 +270,8 @@ final class InvestorContext
abort_if(array_diff(array_keys($input), ['version', 'selection', 'declarations', 'replace_fields', 'preview_hash', 'operation_key', 'confirm', '_token']), 422, 'Campo de controlo desconhecido.');
return Validator::make($input, ['version' => 'required|integer|min:1', 'selection' => 'present|array:opportunity,sector,territory,park',
'selection.*' => 'nullable|string|max:100', 'declarations' => 'nullable|array:project_title,project_summary', 'declarations.project_title' => 'nullable|string|max:200', 'declarations.project_summary' => 'nullable|string|max:5000',
'selection.*' => 'nullable|string|max:100', 'declarations' => 'nullable|array:project_title,project_summary,currency,expected_start_date,investor_origin,person_type,entity_stage,tax_residency_country,fiscal_residency_country,project_type,maturity_stage', 'declarations.project_title' => 'nullable|string|max:200', 'declarations.project_summary' => 'nullable|string|max:5000',
'declarations.currency' => 'nullable|regex:/^[A-Z]{3}$/', 'declarations.expected_start_date' => 'nullable|date_format:Y-m-d', 'declarations.investor_origin' => 'nullable|in:national,foreign,mixed', 'declarations.person_type' => 'nullable|in:singular,collective,sole_trader,consortium', 'declarations.entity_stage' => 'nullable|in:existing,being_formed', 'declarations.tax_residency_country' => ['nullable', 'string', app(CountryCatalogue::class)->rule()], 'declarations.fiscal_residency_country' => ['nullable', 'string', app(CountryCatalogue::class)->rule()], 'declarations.project_type' => 'nullable|string|max:100', 'declarations.maturity_stage' => 'nullable|string|max:100',
'replace_fields' => 'nullable|array', 'replace_fields.*' => 'string|in:'.implode(',', self::FIELDS)])->validate();
}
@@ -299,7 +335,7 @@ final class InvestorContext
}
// Remove inherited subordinate geography only when its source was actually replaced.
foreach ($fields as $key => $value) {
if (! array_key_exists($key, $proposed) && in_array($key, ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location', 'sector_id', 'sector'], true) && isset($provenance[$key]['key'])) {
if (! array_key_exists($key, $proposed) && in_array($key, ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'location', 'sector_id', 'sector', 'special_zone_id', 'investment_regime'], true) && isset($provenance[$key]['key'])) {
unset($fields[$key],$provenance[$key]);
}
}
@@ -314,11 +350,16 @@ final class InvestorContext
$changed[] = $slot;
}
}
$dependencyChanged = $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection);
$changedFields = array_column($replaced, 'field');
$conditionsChanged = (bool) array_intersect($changedFields, array_diff(self::FIELDS, ['project_title', 'project_summary']));
$dependencyChanged = $this->dependencyHash($old['selection']) !== $this->dependencyHash($selection) || $conditionsChanged;
$payload = $this->desk->decode($case->payload);
$results = $payload['investor_path']['results'] ?? [];
$requests = DB::table('desk_requests')->where('case_id', $id)->orderBy('id')->get(['id', 'status', 'protocol', 'version'])->all();
$steps = $dependencyChanged ? array_values(array_unique([...(array_intersect($changed, ['opportunity', 'sector']) ? ['S02', 'S03', 'S04', 'S08', 'S12', 'S13', 'S14', 'S15', 'S17'] : []), ...(array_intersect($changed, ['territory', 'park']) ? ['S08', 'S09', 'S10', 'S11', 'S17'] : [])])) : [];
if ($conditionsChanged) {
$steps = array_values(array_unique([...$steps, 'S02', 'S03', 'S08', 'S12', 'S13', 'S17']));
}
$plan = ['case_id' => $id, 'version' => (int) $case->version, 'revision' => (int) $old['revision'], 'selection' => $selection, 'fields' => $fields, 'provenance' => $provenance,
'updated' => $replaced, 'maintained' => $kept, 'changed_dimensions' => $changed, 'dependent_results' => (int) $case->user_id === $actor->id && $dependencyChanged ? array_map(fn ($r) => ['id' => $r['id'], 'engine' => $r['engine']], $results) : [],
'requests' => $requests, 'review_steps' => $steps, 'dependency_changed' => $dependencyChanged, 'documents_preserved' => DB::table('desk_documents')->where('case_id', $id)->count(),
@@ -362,7 +403,7 @@ final class InvestorContext
$review[$step] = ['revision' => $revision, 'reason' => 'O contexto mudou; reveja os pressupostos e a evidência aplicável.'];
}
$context = ['revision' => $revision, 'selection' => $plan['selection'], 'fields' => $plan['fields'], 'provenance' => $plan['provenance'], 'review_steps' => $review,
'dependency_hash' => $this->dependencyHash($plan['selection']), 'updated_at' => now()->toIso8601String(), 'updated_by' => $actor->id];
'dependency_hash' => $this->dependencyHash(['selection' => $plan['selection'], 'fields' => Arr::except($plan['fields'], ['project_title', 'project_summary'])]), 'updated_at' => now()->toIso8601String(), 'updated_by' => $actor->id];
if ($plan['dependency_changed']) {
foreach ($payload['investor_path']['results'] ?? [] as $i => $r) {
$payload['investor_path']['results'][$i] = [...$r, 'context_state' => 'review_required', 'context_invalidated_revision' => $revision, 'context_invalidated_at' => now()->toIso8601String()];
@@ -384,6 +425,8 @@ final class InvestorContext
abort_unless($this->desk->owns($actor, $case, false) || $actor->can('manage-portal'), 403);
$ctx = $this->state($case);
$path = app(InvestorPath::class)->view($actor, $id);
$ctx['path_applicability'] = ['definition_id' => $path['definition_id'], 'definition_version' => $path['definition_version'], 'source_sha256' => $path['source_sha256'],
'steps' => array_values(array_map(fn ($step) => Arr::only($step, ['code', 'title', 'applicability', 'reason', 'state', 'context_review_required']), array_filter($path['steps'], fn ($step) => ! empty($step['branch']))))];
$simulatorRevision = null;
if ((int) $case->user_id === $actor->id && $case->simulation_id) {
@@ -404,10 +447,19 @@ final class InvestorContext
public function prefill(array $ctx): array
{
$f = $ctx['fields'];
$sim = Arr::only($f, ['project_title', 'project_summary', 'sector_id', 'province_id', 'district_id', 'admin_post_id', 'locality_id']);
$sim = Arr::except(Arr::only($f, self::FIELDS), ['sector', 'province', 'district', 'location']);
$zone = $ctx['selection']['park'] ?? null;
$sim['context_applicability_snapshot'] = json_encode([
'version' => 1, 'context_revision' => $ctx['revision'],
'zone' => $zone ? Arr::only($zone, ['id', 'title', 'href', 'version', 'sha256', 'fields', 'analysis']) : null,
'provenance' => $ctx['provenance'],
'path' => $ctx['path_applicability'] ?? null,
'fields' => Arr::only($f, ['investor_origin', 'tax_residency_country', 'entity_stage', 'currency', 'expected_start_date', 'investment_regime']),
'notice' => 'Referências e declarações para preparação; sem reconhecimento automático de elegibilidade ou benefício.',
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
return ['simulator' => $sim, 'land' => array_filter(['project_reference' => $f['project_title'] ?? null, 'activity' => $f['sector'] ?? null, 'purpose' => $f['project_summary'] ?? null, 'parcels' => [Arr::only($f, array_keys(InvestmentGeography::FIELDS))]], fn ($v) => $v !== null),
'desk' => array_filter(['title' => $f['project_title'] ?? null, 'territory_id' => $f['locality_id'] ?? $f['admin_post_id'] ?? $f['district_id'] ?? $f['province_id'] ?? null, 'location_source' => isset($f['province_id']) ? 'Referência consultada no contexto v'.($ctx['revision'] ?? 0).': '.($f['location'] ?? 'território identificado').'. Não confirma disponibilidade.' : null, 'description' => $f['project_summary'] ?? null, 'sector' => $f['sector'] ?? null, ...Arr::only($f, array_keys(InvestmentGeography::FIELDS))], fn ($v) => $v !== null),
'submission' => array_filter(['sector' => $f['sector'] ?? null, 'objective' => $f['project_summary'] ?? null, 'location' => $f['location'] ?? null], fn ($v) => $v !== null)];
'submission' => array_filter(['currency' => $f['currency'] ?? null, 'sector' => $f['sector'] ?? null, 'objective' => $f['project_summary'] ?? null, 'location' => $f['location'] ?? null], fn ($v) => $v !== null)];
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Services;
/** Explains a failed selection; never changes answers, eligibility or calculation. */
final class SimulationIvaDiagnostics
{
public function explain(array $versions, string $start, string $end, array $context): array
{
$diagnostics = [];
foreach ($versions as $version) {
$parameter = $version['parameters']['IVA_GENERAL_RATE'] ?? null;
if (! is_array($parameter)) {
continue;
}
$issues = [];
$add = function (string $category, ?string $field, string $label, mixed $received, string $expected, string $message) use (&$issues): void {
$issues[] = compact('category', 'field', 'label', 'received', 'expected', 'message');
};
if ($start < $version['valid_from'] || ($version['valid_until'] !== null && $end > $version['valid_until'])) {
$add('period_unavailable', 'fiscal_period', 'Período', "$start a $end", $version['valid_from'].' a '.($version['valid_until'] ?? 'sem data final indicada'), 'Esta versão da regra não abrange todo o período seleccionado. Não altere a data real da operação para obter um resultado.');
}
foreach ($parameter['conditions'] ?? [] as $key => $expected) {
$received = $context[$key] ?? null;
if ($received === $expected) {
continue;
}
if ($key === 'currency') {
$category = $received === null || $received === '' ? 'missing_field' : (preg_match('/^[A-Z]{3}$/D', $received) ? 'out_of_scope' : 'invalid_value');
$add($category, 'fiscal_currency', 'Moeda', $received, $expected, $category === 'missing_field' ? 'Indique a moeda da operação.' : 'Esta versão aceita apenas '.$expected.'. Reveja a moeda ou utilize um cálculo adequado à operação; a resposta não foi alterada.');
} elseif ($key === 'operation' && ! array_key_exists('operation', $context) && $expected === 'internal_general_net' && in_array($context['fiscal_operation'] ?? null, ['import', 'export', 'non_resident', 'refund'], true)) {
$add('out_of_scope', 'fiscal_operation', 'Operação', $context['fiscal_operation'], 'Operação interna à taxa geral, sobre base líquida já apurada', 'A operação declarada está excluída desta versão. Utilize um cálculo adequado à operação; não altere a resposta para obter um resultado.');
} elseif ($key === 'operation' && ! array_key_exists('operation', $context)) {
$add('system_configuration', 'fiscal_operation', 'Operação', $context['fiscal_operation'] ?? null, $expected === 'internal_general_net' ? 'Operação interna à taxa geral, sobre base líquida já apurada' : $expected, 'Limitação de integração do sistema: esta consulta não comunica o enquadramento específico exigido pela versão. A resposta genérica não permite inferi-lo. Utilize o formulário IVA — taxa geral, com declaração explícita do âmbito; não altere respostas para forçar compatibilidade.');
} elseif ($key === 'operation') {
$add($received === null || $received === '' ? 'missing_field' : 'out_of_scope', 'fiscal_operation', 'Operação', $received, $expected === 'internal_general_net' ? 'Operação interna à taxa geral, sobre base líquida já apurada' : $expected, 'A operação não está abrangida por esta versão; utilize um cálculo adequado à operação declarada.');
} else {
$add('system_configuration', null, 'Configuração da regra', null, 'Condições publicadas ligadas a campos deste formulário', 'O sistema não consegue relacionar todas as condições desta versão com o formulário. Não é uma falha das suas respostas.');
}
}
if (! SimulationFiscalSourceAcceptance::eligible($version['source'] ?? [], $version['source_sha256'] ?? null)
|| ($version['source_version_no'] ?? null) !== ($version['source']['version_no'] ?? null)
|| ($version['rule_definition']['language'] ?? null) !== 'investmoz.tax.v1') {
$add('system_configuration', null, 'Regra', null, 'Versão publicada com fonte e configuração coerentes', 'A configuração ou a fonte da regra não permite resolver este parâmetro. Não altere as respostas; é necessária correcção do sistema.');
}
if ($issues !== []) {
$source = array_intersect_key($version['source'] ?? [], array_flip(['title', 'diploma', 'article', 'url']));
$diagnostics[] = ['label' => 'IVA — taxa geral', 'rule_version' => $version['rule_version'], 'validity' => ['start' => $version['valid_from'], 'end' => $version['valid_until']], 'source' => $source, 'issues' => $issues];
}
}
return $diagnostics;
}
}
+55 -1
View File
@@ -6,6 +6,7 @@ namespace App\Services;
use App\Models\InvestmentLegalSource;
use App\Models\SimulationTaxRuleVersion;
use Brick\Math\BigRational;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
@@ -35,8 +36,11 @@ final class SimulationPublishedFiscal
->where('scope_key', 'mz.internal-general-net.mzn');
}
public function admit(array $input): array
public function admit(array $input, bool $missingBase = false): array
{
if (isset($input['monthly_sales'])) {
return $this->admitMonthly($input);
}
// Called inside createPayload, after idempotent replay, never by the worker.
abort_unless(DB::transactionLevel() > 0, 500);
abort_unless(config('simulation-fiscal-consumer.enabled'), 503,
@@ -63,6 +67,9 @@ final class SimulationPublishedFiscal
$input['reference_date'], $input['reference_date'], ['currency' => $input['currency'], 'operation' => $input['operation']]);
$rate = collect($resolution)->firstWhere('parameter_code', 'IVA_GENERAL_RATE');
$missing = [];
if ($missingBase) {
$missing[] = 'taxable_sales';
}
$evaluation = null;
if (! ($input['scope_declared'] ?? false)) {
$missing[] = 'operation_scope';
@@ -79,4 +86,51 @@ final class SimulationPublishedFiscal
return app(SimulationFiscalSnapshot::class)->build($version, $input, $resolution, $evaluation, $missing, true);
}
private function admitMonthly(array $input): array
{
$rows = [];
$baseTotal = BigRational::zero();
$vatTotal = BigRational::zero();
$complete = true;
$previous = null;
foreach ($input['monthly_sales'] as $sale) {
abort_unless($previous === null || $sale['date'] === date('Y-m-d', strtotime($previous.' +1 month')), 422, 'A projecção deve conter meses consecutivos, sem duplicações.');
$previous = $sale['date'];
$known = $sale['amount'] !== null && $sale['amount'] !== '';
$net = $input['sales_vat_basis'] === 'net';
$month = $this->admit(['base' => $known && $net ? $sale['amount'] : null,
'currency' => $input['currency'], 'reference_date' => $sale['date'],
'operation' => 'internal_general_net', 'scope_declared' => $input['scope_declared']], ! $known || ! $net);
$snapshot ??= $month;
$rate = collect($month['fiscal_provenance']['resolution'])->firstWhere('parameter_code', 'IVA_GENERAL_RATE');
$vat = $month['result']['value'];
$status = ! $known ? 'missing_sales' : (! $net ? 'unsupported_basis' : ($vat === null ? 'indeterminate' : 'calculated'));
$rows[] = ['date' => $sale['date'], 'sales_declared' => $sale['amount'],
'net_sales' => $known && $net ? $sale['amount'] : null,
'rate' => $vat !== null && ($rate['status'] ?? null) === 'resolved' ? $rate['value'] : null,
'vat' => $vat, 'gross_sales' => $vat === null ? null : (string) BigRational::of($sale['amount'])->plus($vat)->toBigDecimal(),
'status' => $status, 'rule_id' => $month['fiscal_provenance']['rule']['id'],
'rule_version' => $month['fiscal_provenance']['rule']['rule_version']];
if ($vat === null) {
$complete = false;
} else {
$baseTotal = $baseTotal->plus($sale['amount']);
$vatTotal = $vatTotal->plus($vat);
}
}
$value = $complete ? (string) $vatTotal->toBigDecimal() : null;
$snapshot['base'] = $complete ? (string) $baseTotal->toBigDecimal() : null;
$snapshot['indicative_total'] = $value;
$snapshot['result']['value'] = $value;
$snapshot['result']['status'] = $complete ? 'calculated' : 'indeterminate';
$snapshot['result']['missing_inputs'] = $complete ? [] : ['monthly_sales_or_net_basis_or_scope'];
$snapshot['result']['explanation'] = 'IVA mensal sobre vendas declaradas sem IVA, pela versão publicada congelada na admissão. Valores com IVA incluído ou sem informação permanecem indeterminados. Não é imposto final a pagar.';
$snapshot['presentation']['value'] = $value;
$snapshot['intermediate'] = null;
$snapshot['monthly_projection'] = ['input' => $input, 'rows' => $rows,
'cash_treatment' => 'Informação separada da tesouraria: os recebimentos declarados não são aumentados pelo IVA nem o IVA é deduzido como custo ou imposto a pagar. Calendário de cobrança e liquidação fiscal não é inferido.'];
return $snapshot;
}
}
@@ -53,9 +53,9 @@ final class SimulationTaxParameterResolver
* never executes a tax formula and requires each parameter to declare context.
*
* @param array<string,string> $context
* @return list<array<string,mixed>>
* @return list<array<string,mixed>>|array{parameters:list<array<string,mixed>>,diagnostics:list<array<string,mixed>>}
*/
public function resolvePublishedForPeriod(string $component, string $periodStart, string $periodEnd, array $context): array
public function resolvePublishedForPeriod(string $component, string $periodStart, string $periodEnd, array $context, bool $diagnostics = false): array
{
$start = $this->date($periodStart);
$end = $this->date($periodEnd);
@@ -67,14 +67,14 @@ final class SimulationTaxParameterResolver
->join('investment_legal_sources as source', 'source.id', '=', 'rule.legal_source_id')
->where('rule.component', $component)
->where('rule.status', 'published')
->whereDate('rule.valid_from', '<=', $periodEnd)
->where(fn ($query) => $query->whereNull('rule.valid_until')->orWhereDate('rule.valid_until', '>=', $periodStart))
->when(! $diagnostics, fn ($query) => $query->whereDate('rule.valid_from', '<=', $periodEnd)
->where(fn ($query) => $query->whereNull('rule.valid_until')->orWhereDate('rule.valid_until', '>=', $periodStart)))
->where(fn ($query) => $query->where('source.validation_status', 'validated')->orWhere(fn ($fiscal) => $fiscal->where('source.validation_status', 'pending')->where('source.fiscal_acceptance->scope', 'fiscal_parameters')
->where('source.fiscal_acceptance->basis', 'Tempo-official-sources-20261008')
->whereRaw("source.fiscal_acceptance->>'sha256'=rule.source_sha256")))
->whereColumn('source.version_no', 'rule.source_version_no')
->whereDate('source.valid_from', '<=', $periodStart)
->where(fn ($query) => $query->whereNull('source.valid_until')->orWhereDate('source.valid_until', '>=', $periodEnd))
->when(! $diagnostics, fn ($query) => $query->whereDate('source.valid_from', '<=', $periodStart)
->where(fn ($query) => $query->whereNull('source.valid_until')->orWhereDate('source.valid_until', '>=', $periodEnd)))
->select([
'rule.id', 'rule.rule_code', 'rule.rule_version', 'rule.component', 'rule.scope_key',
'rule.valid_from', 'rule.valid_until', 'rule.parameters', 'rule.rule_definition',
@@ -121,7 +121,11 @@ final class SimulationTaxParameterResolver
];
})->all());
return $this->resolveAvailableParameters($versions, $component, $periodStart, $periodEnd, $context);
$parameters = $this->resolveAvailableParameters($versions, $component, $periodStart, $periodEnd, $context);
$resolvedIva = array_filter($parameters, fn (array $parameter): bool => $parameter['parameter_code'] === 'IVA_GENERAL_RATE' && $parameter['status'] === 'resolved');
return $diagnostics ? ['parameters' => $parameters, 'diagnostics' => $resolvedIva !== [] ? [] : (new SimulationIvaDiagnostics)->explain($versions, $periodStart, $periodEnd, $context)] : $parameters;
}
/**
+5 -4
View File
@@ -250,7 +250,8 @@ class VirtualDesk
* @return array<string,mixed> */
public function validateFields(array $payload, array $definition, bool $submitting): array
{
$rules = ['payload' => ['required', 'array:'.implode(',', [...array_keys($definition['fields']), 'country', 'declared_origin_country_code'])],
$rules = ['payload' => ['required', 'array:'.implode(',', [...array_keys($definition['fields']), 'country', 'declared_origin_country_code', 'context_reuse'])],
'payload.context_reuse' => ['nullable', 'string', 'max:10000'],
'payload.country' => ['nullable', 'string', 'max:100', app(CountryCatalogue::class)->legacyCountryRule()],
'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()]];
foreach ($definition['fields'] as $key => $field) {
@@ -388,7 +389,7 @@ class VirtualDesk
abort_if($this->owns($actor, $case, false), 403, 'O proponente não pode analisar ou decidir o seu próprio pedido.');
abort_unless($actor->can('manage-portal') || (int) $request->analyst_id === $actor->id, 403);
if (in_array($data['status'], ['accepted', 'rejected', 'closed'], true)) {
abort_unless(app(ScopedPortalAdministration::class)->deskDecision($actor,$request), 403);
abort_unless(app(ScopedPortalAdministration::class)->deskDecision($actor, $request), 403);
}
abort_unless(in_array($data['status'], config('virtual-desk.transitions.'.$request->status, []), true), 409, 'Transição inválida.');
}
@@ -412,13 +413,13 @@ class VirtualDesk
return DB::transaction(function () use ($actor, $id, $data) {
$case = $this->case($actor, $id, false, true);
abort_unless($this->owns($actor, $case) || app(ScopedPortalAdministration::class)->deskCaseDecision($actor,$case->id), 403);
abort_unless($this->owns($actor, $case) || app(ScopedPortalAdministration::class)->deskCaseDecision($actor, $case->id), 403);
abort_unless((int) $case->version === (int) $data['version'], 409);
$workflow = json_decode($case->workflow, true, 512, JSON_THROW_ON_ERROR);
$current = $workflow[$data['stage']];
$decision = in_array($data['status'], ['completed', 'not_applicable'], true);
if ($decision) {
abort_unless(app(ScopedPortalAdministration::class)->deskCaseDecision($actor,$case->id) && ! $this->owns($actor, $case, false), 403, 'Conclusão documental exige revisão interna por outra pessoa autorizada.');
abort_unless(app(ScopedPortalAdministration::class)->deskCaseDecision($actor, $case->id) && ! $this->owns($actor, $case, false), 403, 'Conclusão documental exige revisão interna por outra pessoa autorizada.');
abort_unless(trim($data['evidence'] ?? '') !== '', 422, 'Identifique a evidência documental.');
foreach ($current['dependencies'] as $dependency) {
abort_unless(in_array($workflow[$dependency]['status'], ['completed', 'not_applicable'], true) || (($data['exception'] ?? false) && ($data['document_id'] ?? null)), 422, 'Dependência '.$dependency.' pendente; uma exceção exige evidência própria.');
@@ -76,3 +76,5 @@
"Cópia privada ou derivado";"C:\laragon\www\portal-investidor\storage\app\private\portal-documents\5c860c48-e8cb-4d8d-ab9b-2e3ea7036ce2";"5c860c48-e8cb-4d8d-ab9b-2e3ea7036ce2";"1834284";"EA1B0DC6F5CF6E595F6D9A19E7042F398999E0087CAEF1785F6E8FAC571B5F53";"5c860c48-e8cb-4d8d-ab9b-2e3ea7036ce2";"";"Private stored copy already represented by its document record";"Acessível em armazenamento privado";"Inventariado por caminho, tamanho e SHA-256; não publicado nem aberto nesta revisão."
"Cópia privada ou derivado";"C:\laragon\www\portal-investidor\storage\app\private\portal-documents\64c46765-96b9-4f43-afc6-f5b730c010c4";"64c46765-96b9-4f43-afc6-f5b730c010c4";"440018";"CCF2A8DEECA820F2DD076B4B2141B1EFE3F6A806568C3A587CA59A8E92F4C251";"64c46765-96b9-4f43-afc6-f5b730c010c4";"";"Private stored copy already represented by its document record";"Acessível em armazenamento privado";"Inventariado por caminho, tamanho e SHA-256; não publicado nem aberto nesta revisão."
"Cópia privada ou derivado";"C:\laragon\www\portal-investidor\storage\app\private\portal-documents\b0ea969a-30ec-477b-8b8b-b7af82a11bfe";"b0ea969a-30ec-477b-8b8b-b7af82a11bfe";"67067";"5C69B6F189A1D3F5307E0CF7DD8224554705158A682637D3105AFAD615DB26E2";"b0ea969a-30ec-477b-8b8b-b7af82a11bfe";"C:\laragon\www\portal-investidor\storage\app\private\geo-ine-review-20260928\original.pdf + C:\laragon\www\portal-investidor\storage\app\private\geo-ine-review-20260928-v2\original.pdf + C:\laragon\www\portal-investidor\storage\app\private\portal-documents\b0ea969a-30ec-477b-8b8b-b7af82a11bfe";"Private stored copy already represented by its document record";"Acessível em armazenamento privado";"Inventariado por caminho, tamanho e SHA-256; não publicado nem aberto nesta revisão."
"Formulario fornecido Tempo";"\\Mac\Home\Documents\TEMPO\Portal Tempo\Servicos Tempo\portal do investidor\Notas\Novo Modelo A.docx";"Novo Modelo A.docx";"532008";"62C198E90D8C6792F7D5CCEFC6386DF2E4B6089573067D3A6E1EAE7FC2B9572B";"IVA Modelo A - exemplar Tempo";"";"Original privado na biblioteca QA; sem publicacao ou activacao";"Acessivel / importado QA / privado / scan limpo";"Imagem incorporada lida visualmente; edicao/vigencia nao certificada. Correspondencia na reconciliacao Modelo A, AUD-SIM-INT02. Variante de bytes preservada, nao duplicado por nome."
"Formulario fornecido Tempo";"\\Mac\Home\Documents\TEMPO\Portal Tempo\Servicos Tempo\portal do investidor\Notas\Novo Modelo A v1.docx";"Novo Modelo A v1.docx";"532100";"63D45FA80DB17A60B43619D55A25813229D670D14C758273F86316285DB8E02B";"IVA Modelo A - exemplar Tempo";"";"Original privado na biblioteca QA; sem publicacao ou activacao";"Acessivel / importado QA / privado / scan limpo";"Imagem incorporada lida visualmente; edicao/vigencia nao certificada. Correspondencia na reconciliacao Modelo A, AUD-SIM-INT02. Variante de bytes preservada, nao duplicado por nome."
1 RecordType Path FileName SizeBytes SHA256 TitleOrSubject DuplicateGroup Disposition Status Notes
76 Cópia privada ou derivado C:\laragon\www\portal-investidor\storage\app\private\portal-documents\5c860c48-e8cb-4d8d-ab9b-2e3ea7036ce2 5c860c48-e8cb-4d8d-ab9b-2e3ea7036ce2 1834284 EA1B0DC6F5CF6E595F6D9A19E7042F398999E0087CAEF1785F6E8FAC571B5F53 5c860c48-e8cb-4d8d-ab9b-2e3ea7036ce2 Private stored copy already represented by its document record Acessível em armazenamento privado Inventariado por caminho, tamanho e SHA-256; não publicado nem aberto nesta revisão.
77 Cópia privada ou derivado C:\laragon\www\portal-investidor\storage\app\private\portal-documents\64c46765-96b9-4f43-afc6-f5b730c010c4 64c46765-96b9-4f43-afc6-f5b730c010c4 440018 CCF2A8DEECA820F2DD076B4B2141B1EFE3F6A806568C3A587CA59A8E92F4C251 64c46765-96b9-4f43-afc6-f5b730c010c4 Private stored copy already represented by its document record Acessível em armazenamento privado Inventariado por caminho, tamanho e SHA-256; não publicado nem aberto nesta revisão.
78 Cópia privada ou derivado C:\laragon\www\portal-investidor\storage\app\private\portal-documents\b0ea969a-30ec-477b-8b8b-b7af82a11bfe b0ea969a-30ec-477b-8b8b-b7af82a11bfe 67067 5C69B6F189A1D3F5307E0CF7DD8224554705158A682637D3105AFAD615DB26E2 b0ea969a-30ec-477b-8b8b-b7af82a11bfe C:\laragon\www\portal-investidor\storage\app\private\geo-ine-review-20260928\original.pdf + C:\laragon\www\portal-investidor\storage\app\private\geo-ine-review-20260928-v2\original.pdf + C:\laragon\www\portal-investidor\storage\app\private\portal-documents\b0ea969a-30ec-477b-8b8b-b7af82a11bfe Private stored copy already represented by its document record Acessível em armazenamento privado Inventariado por caminho, tamanho e SHA-256; não publicado nem aberto nesta revisão.
79 Formulario fornecido Tempo \\Mac\Home\Documents\TEMPO\Portal Tempo\Servicos Tempo\portal do investidor\Notas\Novo Modelo A.docx Novo Modelo A.docx 532008 62C198E90D8C6792F7D5CCEFC6386DF2E4B6089573067D3A6E1EAE7FC2B9572B IVA Modelo A - exemplar Tempo Original privado na biblioteca QA; sem publicacao ou activacao Acessivel / importado QA / privado / scan limpo Imagem incorporada lida visualmente; edicao/vigencia nao certificada. Correspondencia na reconciliacao Modelo A, AUD-SIM-INT02. Variante de bytes preservada, nao duplicado por nome.
80 Formulario fornecido Tempo \\Mac\Home\Documents\TEMPO\Portal Tempo\Servicos Tempo\portal do investidor\Notas\Novo Modelo A v1.docx Novo Modelo A v1.docx 532100 63D45FA80DB17A60B43619D55A25813229D670D14C758273F86316285DB8E02B IVA Modelo A - exemplar Tempo Original privado na biblioteca QA; sem publicacao ou activacao Acessivel / importado QA / privado / scan limpo Imagem incorporada lida visualmente; edicao/vigencia nao certificada. Correspondencia na reconciliacao Modelo A, AUD-SIM-INT02. Variante de bytes preservada, nao duplicado por nome.
@@ -1179,5 +1179,472 @@
"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"
},
"modelo_a_reconciliation_qa_20261021": {
"branch": "codex/iva-modelo-a-reconciliation-QA",
"parent_candidate": "3f5430c3",
"status": "documentary_and_first_interface_increment_verified; full IVA settlement remains incomplete",
"reference": "docs/investmoz/simulations/modelo-a-reconciliacao-20261021.md",
"originals": [
{
"id": "fbfa820d-29d5-4bf8-9f81-534931d80cef",
"file_name": "Novo Modelo A.docx",
"sha256": "62c198e90d8c6792f7d5ccefc6386df2e4b6089573067d3a6e1eae7fc2b9572b",
"status": "active",
"malware_scan_status": "clean",
"malware_scanned_sha256": "62c198e90d8c6792f7d5ccefc6386df2e4b6089573067d3a6e1eae7fc2b9572b"
},
{
"id": "cf8b799e-44de-4497-b2e3-a5c468f2fe0e",
"file_name": "Novo Modelo A v1.docx",
"sha256": "63d45fa80db17a60b43619d55a25813229d670d14c758273f86316285db8e02b",
"status": "active",
"malware_scan_status": "clean",
"malware_scanned_sha256": "63d45fa80db17a60b43619d55a25813229d670d14c758273f86316285db8e02b"
}
],
"library_qa_only": true,
"resources": [
33,
34
],
"published": false,
"inventory": "Existing document corpus inventory extended; private inventory of all seven accessible Notes files preserved",
"read": "OOXML text plus embedded image2.png visually inspected; image identical in both originals; source unchanged; all three LibreOffice reading pages inspected, field25 confirmed visually; conversion layout drift is not a replacement original",
"coverage": {
"fields_mapped": "01–27",
"frames_mapped": "1–13 plus ambiguously numbered prior-credit block",
"calculated": "Existing net general-rate component01/02 only",
"totals14_to18": "indeterminate, not inferred from VAT on sales"
},
"implemented": [
"Consultable field/purpose/data/calculation/basis/status table in normal QA simulator",
"Existing purchase schedules reused independently of sales completeness",
"Net/supported VAT/gross/deductible VAT kept separate and unknown without classification",
"Partial simulation marker in report; historical server snapshots not rewritten"
],
"documentary_discrepancies": [
"Regularization prose11/12 versus boxes12/13",
"Credit note18/19 versus block19/20",
"Refund noteart20 versus art21 amended in2025",
"Ambiguous frame8 remissions; repeated25 was extraction artifact, one box25 confirmed visually"
],
"official_sources_read": [
"Existing Lei22/2022 PDF4 art17-A and entry into force",
"Existing Lei10/2025 BR248 PDF12–15; visualPDF13/14"
],
"unresolved": [
"Exact approved edition of form for period",
"Consolidation of relevant base provisions, Lei3/2025 and regulations for each extension",
"Operation-level scopes, eligibility, documents and effective dates for reduced rate/deduction"
],
"tool_limits": [
"Direct AT form page timed out; indexed page has different frame numbering, not used as replacement",
"Current chat attachment was not exposed as a local attachment; exact-named local Tempo Notes exemplar used with hash disclosed"
],
"tests": {
"new_frontend_tests": 3,
"types": "passed",
"build": "passed",
"browser": "new correspondence/purchases/report annotations only, no new calculation",
"import": "preview/private import/clean hash-matched scan/idempotent replay with zero duplicates"
},
"reused_proofs": [
"published rate selection",
"decimal calculation",
"monthly recovery",
"idempotency/suspension/history",
"CSV/DOCX/PDF exporters"
],
"next_lot_estimate": {
"cadastre_and_consolidation_hours": [
8,
16
],
"code_and_acceptance_hours": [
20,
36
],
"total_hours": [
28,
52
],
"excludes": "prior credits/substitutions/payment/refund and full fiscal coverage"
},
"main_integrated": false,
"homologation_changed": false,
"new_rules_registered": false,
"new_rules_published": false,
"consumer_activation_changed": false,
"proof_directory": "outputs/modelo-a-20261021"
},
"contextual_continuity_qa_20261022": {
"branch": "codex/project-context-continuity-QA",
"base": "57fc914821d0cefa02caa80a74b3d42e663f2742",
"scope": "Primeiro incremento de continuidade e explicação contextual; adaptação jurídica integral não encerrada",
"status": "QA, revisão; sem integração main ou alteração fiscal activa",
"http_tests": {
"tests": 4,
"assertions": 74,
"transaction_rollback": true
},
"frontend_tests": 8,
"typescript": "passed",
"build": "passed",
"visual_evidence_directory": "outputs/context-continuity-20261022",
"same_project_new_session": true,
"deliberate_edit_preserved": true,
"second_project_isolated": true,
"fiscal_execution_id": "ae401465-834f-4761-9406-5af060c499fb",
"new_executions_today": 1,
"reused_proofs": [
"13f68f1b: recuperação/exportadores/PDF",
"83d2f97d e 226fd308: precisão/versões/históricos",
"3f5430c3: cálculo mensal",
"0b0cf761: correspondência parcial Modelo A"
],
"limitations": [
"Catálogo visual QA sintético; ZEE/ZFI apenas controlos HTTP sintéticos revertidos, sem elegibilidade real demonstrada",
"Condições jurídicas por sector/actividade/regime e benefícios executáveis não concluídos",
"Outros três consumidores usam origem/reconciliador comum; não houve demonstração visual integral de cada processo",
"Sem regra fiscal nova, publicação, activação ou integração em main",
"Edge interrompeu a inspecção após navegação de regresso; endereço e gravação confirmados por inventário/serviço, visual final não observada. Limitação da ferramenta."
],
"return_evidence_id": "43317c22-50c8-40cf-98e4-e5d4d108c372",
"B_recovered_context_revision": 2
}
}
@@ -0,0 +1,93 @@
# Reconciliação do IVA com o Novo Modelo A
21/10/2026. Referência AUD-SIM-INT02, sem nova matriz/backlog. Preparação progressiva em QA; não é apuramento completo, submissão ou aceitação pela AT. A componente16% existente continua independente das restantes componentes por cadastrar/desenvolver. A leitura e aceitação documental da Tempo não é apresentada como validação jurídica independente.
## Exemplar e preservação
Exemplar exacto: **Novo Modelo A.docx**, 532008bytes, SHA256 **62c198e90d8c6792f7d5ccefc6386df2e4b6089573067d3a6e1eae7fc2b9572b**. Localizado na pasta de notas do portal indicada no inventário privado desta execução, não substituído por formulário genérico ou pela página da AT. O quadro1–8 é imagem incorporada: word/media/image2.png, SHA2567ae7f02efca96323087d0dfa5f3078b4ae724318edd5b6c4802c863d840bdf66, conferida visualmente sobre fundo branco para tornar legível a transparência. Os blocos seguintes e respectivas instruções foram extraídos do OOXML. Também se conferiram visualmente as três páginas da conversão de leitura em LibreOffice: quadros1–8 na primeira, créditos/quadros9–13 na segunda e apenas o rodapé de distribuição na terceira. A conversão apresenta deslocações de texto; serve para leitura, não é versão corrigida ou reprodução fiel para entrega. Nenhum DOCX original foi alterado, nem se afirmou edição oficial vigente pela simples leitura.
Também inventariado e conservado **Novo Modelo A v1.docx**, 532100bytes, SHA25663d45fa80db17a60b43619d55a25813229d670d14c758273f86316285db8e02b. A mesma imagem é byte a byte igual; a extracção textual difere na segmentação de palavras. São originais distintos, não duplicados por bytes, conservados sem renomear uma revisão como diploma novo. A criação OOXML em21/10/2026 não é data de aprovação ou vigência do formulário.
Biblioteca QA existente, clone simulator_fiscal_delta_20261021_test: recurso33/documento fbfa820d-29d5-4bf8-9f81-534931d80cef (original) e recurso34/documento cf8b799e-44de-4497-b2e3-a5c468f2fe0e (v1). Pré-visualização, hash, conflitos de identidade/bytes, importação privada idempotente, revisão/auditoria e scan reais reutilizados; ambos limpos com o hash analisado igual ao original. **Não publicados**; consulta privada e estado documental não activam cálculo. Actor166 é fixture QA já existente, configurada apenas neste processo de ensaio da administração da biblioteca, sem atribuir função real da Tempo ou alterar permissões persistentes. Nenhuma importação no destino activo. A aceitação oficial da Tempo08/10 é preservada; os estados genéricos do importador não a revogam nem certificam vigência.
## Correspondência dos quadros
| Quadro/bloco no exemplar | Finalidade e dados existentes | Delta/limite |
|---|---|---|
|1 — Tipo de declaração|Inicial/substituição; não corresponde a criar versão de uma projecção|Não simular uma declaração efectiva; futura ligação a declaração anterior separada de versões de planeamento|
|2 — Período/prazo|Meses da projecção disponíveis; não prova de entrega dentro/fora do prazo|Calendário fiscal e entrega efectiva por integrar; nunca marcar submissão pela data da simulação|
|3 — NUIT/sucursal/Área Fiscal|Identificação guardada no dossier quando disponível|Reutilização com confirmação específica; não exigir NUIT para planear nem inferir enquadramento fiscal|
|4 — Nome/designação|Identificação privada existente|Não copiar para documento público; tratar como dado efectivo só se confirmado|
|5 — Actividade/CAE|Actividade/sector existentes; não garantem CAE ou taxa|Correspondência CAE e classificação por operação, sem converter sector em incidência|
|6 — Domicílio fiscal|Localização e contactos do dossier|Domicílio do projecto não prova domicílio fiscal; recolher apenas diferença/confirmar contexto|
|7 — Inexistência de operações|Zero de vendas é conhecido, mas aquisições podem existir|Não assinalar inexistência por vendas zero ou campos vazios; precisa de todas as operações activas/passivas|
|8 — Apuramento|Campos01–18; componente01/02 disponível no âmbito actual|Demais bases, imposto suportado/dedutível e regularizações continuam indeterminados|
|Utilização de créditos anteriores|Campos19/20; o texto remete novamente para quadro8|Registar remissão ambígua; créditos efectivos e comunicados distintos de estimativas/pedidos|
|9 — Substituições|Campo21 e referências17/18 da actual/substituída|Guardar a relação entre declarações efectivas; uma nova simulação não substitui declaração|
|10 — Entrega ao Estado|Campos22–24, meios de pagamento|Sem pagamento ou guia gerada; caixa prevista não prova liquidação|
|11 — Recuperação|Campos25–27, reporte e pedido|Separar crédito, pedido, reconhecimento e recebimento; não gerar reembolso automático|
|12 — Autenticação|Data/nome/assinatura do sujeito passivo|Autenticação do portal não assina nem submete Modelo A|
|13 — Serviços|Entrada/inserção/receita/funcionário/recebedor|Reservado à AT; não preencher ou imitar aceitação administrativa|
## Correspondência campo a campo
Os rótulos de finalidade são explicativos; não corrigem a imagem nem substituem o original. Fórmulas impressas estão mapeadas, não activadas por este registo. Ausência de componente não equivale a zero.
|Campo do Modelo A|Finalidade|Origem dos dados|Cálculo|Fundamento|Já implementado/em falta|
|---|---|---|---|---|---|
|01|Base das operações à taxa geral|Vendas mensais já projectadas, declaradas sem IVA e no âmbito geral|Base líquida do mês|CIVA arts.15 e 17; Lei22/2022; regra publicada existente|Implementado no âmbito declarado|
|02|IVA à taxa geral|Resultado mensal congelado no servidor|Campo01 × taxa da versão publicada /100|CIVA art.17(1); mesma regra/versionamento do consumidor|Implementado; componente do apuramento|
|03|Base de operações específicas à taxa reduzida|Operações separadas e enquadradas; ainda não recolhidas|Base das operações elegíveis|Exemplar e CIVA art.17-A, aditado pela Lei22/2022; enquadramento a cadastrar|Em falta; não assumir zero|
|04|IVA à taxa reduzida|Campo03 e futura versão fundamentada|Campo03 × taxa reduzida aplicável; não activa pelo exemplar|CIVA art.17-A; âmbito/versão temporal a integrar|Em falta; não assumir zero|
|05|Vendas isentas com direito à dedução|Vendas classificadas por operação e fundamento|Soma das bases classificadas; sem taxa presumida|Exemplar; CIVA arts.9–14 e 18–20 a consolidar por operação|Em falta; não assumir zero|
|06|Operações da alínea b), n.º1, art.18, conforme exemplar|Operações classificadas com referência verificada|Soma separada; não inferir do nome do sector|Remissão do exemplar ao art.18(1)(b); cotejo do âmbito necessário|Em falta; não assumir zero|
|07|Isentas sem direito à dedução|Vendas classificadas e uso das aquisições|Soma das bases elegíveis|Exemplar; incidência/isenções e exclusões de dedução a fundamentar|Em falta; não assumir zero|
|08|IVA dedutível — imobilizado|Aquisições/facturas de activos, não o valor global do investimento|Apenas imposto elegível documentado|CIVA arts.18–21; art.18(2), Lei10/2025: documentos legais e fornecedor inscrito|Em falta; não assumir zero|
|09|IVA dedutível — existências|Compras projectadas reutilizáveis; falta IVA, documento e elegibilidade|IVA elegível das existências, não compras × taxa geral|CIVA arts.18–21; condições/exclusões, afectação e período|Em falta; não assumir zero|
|10|IVA dedutível — outros bens/serviços|Aquisições discriminadas; despesas agregadas não provam IVA|IVA elegível; excluir operações sem direito|CIVA arts.18–21; art.19(2), Lei10/2025, inclui exclusão das operações art.17-A|Em falta; não assumir zero|
|11|IVA dedutível — importações|Documento aduaneiro legal e afectação; ainda não recolhidos|Imposto aduaneiro elegível, não taxa sobre orçamento|CIVA art.18(2), Lei10/2025; demais condições/exclusões a cadastrar|Em falta; não assumir zero|
|12|Regularizações a favor do sujeito passivo|Ajustamentos individualizados, documento e motivo|Soma de regularizações elegíveis|Exemplar tem remissão interna 11/12 divergente das caixas12/13; fundamento por motivo por conferir|Em falta; não assumir zero|
|13|Regularizações a favor do Estado|Ajustamentos individualizados, documento e motivo|Soma separada das regularizações elegíveis|Mesma divergência; não corrigir o original nem inventar incidência|Em falta; não assumir zero|
|14|Soma das bases do quadro de apuramento|Campos01,03,05,06,07 completos|01+03+05+06+07|Fórmula impressa no exemplar; não é apenas vendas tributáveis|Estrutura mapeada; total indeterminado|
|15|Total a favor do sujeito passivo|Campos08–12 completos|08+09+10+11+12|Fórmula do exemplar; depende de deduções/regularizações fundamentadas|Estrutura mapeada; total indeterminado|
|16|Total a favor do Estado|Campos02,04,13 completos|02+04+13|Fórmula impressa no exemplar; 02 sozinho não equivale a16|Estrutura mapeada; total indeterminado|
|17|Diferença devedora antes de créditos anteriores|Totais15/16 completos|16−15 quando16>15|Instrução do exemplar; não é automaticamente pagamento|Indeterminado; sem consolidação completa|
|18|Diferença credora antes de créditos anteriores|Totais15/16 completos|15−16 quando15>16|Instrução do exemplar; não constitui reembolso reconhecido|Indeterminado; sem consolidação completa|
|19|Excesso a reportar do período anterior|Histórico fiscal anterior confirmado, separado de estimativas|Crédito anterior utilizável; sem copiar crédito estimado|Quadro de utilização de créditos do exemplar; condições/prazo por conferir|Em falta; não assumir zero|
|20|Créditos comunicados pelos serviços|Comunicação efectiva da Administração Fiscal|Crédito efectivamente comunicado e utilizável|Exemplar; não converter pedido ou crédito estimado em comunicação|Em falta; não assumir zero|
|21|Diferencial de declaração de substituição|Valores17/18 da declaração actual e da substituída|Casos impressos no quadro9, com sinal devedor/credor|Exemplar; ligação às declarações efectivas e regras de substituição em falta|Em falta; não assumir zero|
|22|IVA a entregar no contexto da declaração|Apuramento e créditos/substituição completos|Não derivar apenas do campo02|Quadro10 do exemplar; procedimento aplicável por fundamentar|Em falta; não assumir zero|
|23|Juros compensatórios|Factos e períodos que legalmente os determinem|Sem taxa, mora ou obrigação presumidas|Quadro10; fundamento específico não analisado neste lote|Em falta; não assumir zero|
|24|Importância a pagar|Campos22/23 completos|22+23|Fórmula do exemplar; não é ordem nem execução de pagamento|Em falta; não assumir zero|
|25|Crédito de imposto do quadro11|Apuramento completo e tratamento dos créditos anteriores|Montante recuperável no contexto próprio, por fundamentar|Caixa25 confirmada visualmente no bloco de recuperação; condições por consolidar|Em falta; não assumir zero|
|26|Reporte para período seguinte|Crédito utilizável e opção no quadro11|Separado de pedido e recebimento|Quadro11; cotejo de condições/prazos e articulação19/20 necessário|Em falta; não assumir zero|
|27|Pedido de reembolso|Crédito elegível e pedido, sem presumir reconhecimento/caixa|Pedido distinto de aprovação e recebimento|Exemplar cita art.20; Lei10/2025 altera reembolsos no art.21. Divergência registada.|Em falta; não assumir zero|
## Cotejo documental para o período2026
Reutilizados os exemplares oficiais aceites e arquivados em source-archive, a fichaIVA existente e a análise primária; não se pede nova autenticação genérica das fontes. Leitura dirigida agora: Lei22/2022, art.2/aditamento17-A, PDF4/BR2252-(28), e art.6, eficácia01/01/2023; Lei10/2025 no BR248/suplemento1, PDF12–15, especialmente arts.18,19,21 na PDF13/BR2180-(13), arts.32/34 na PDF14. Páginas13 e14 de2025 e página4 de2022 renderizadas para confronto. Não se afirma revisão integral de todo o CIVA ou de todas as alterações/regulamentos.
- Campo01/02: taxa geral e base líquida já fundamentadas pela ficha existente; não reabrir a prova do motor.
- Campo03/04: o art.17-A aditado pela Lei22/2022 identifica serviços médicos/sanitários de estabelecimentos privados indicados; ensino por estabelecimentos privados integrados e reconhecidos; formação profissional por entidades privadas; lições pessoais escolares/superiores. Esta enumeração não classifica automaticamente uma empresa ou toda a sua facturação. Falta cadastro/integração do âmbito temporal e classificação por linha; não se publica uma regra5% com base apenas no formulário.
- Aquisições: Lei10/2025, art.18(2), exige os documentos legais indicados e inscrição do fornecedor; art.18(6) trata proporcionalidade em certas bases reduzidas. Art.19(2) exclui o direito relativo às operações do art.17-A e às indicadas no art.15(6)(c). Portanto, o valor das compras ou o IVA suportado não podem ser automaticamente dedutíveis. Arts.18(1),19(1),20,21 e22, exclusões, afectação, pro rata e momento de dedução precisam de consolidação para cada futuro lote; os trechos[...] da lei alteradora não substituem o texto-base.
- Reembolso: a nota do exemplar cita art.20. A Lei10/2025 altera no art.21 condições e prazos de reembolso/crédito, incluindo números7/8/10/13/15. Registar a divergência; não transplantar prazos ou limites isolados para regras de elegibilidade. Crédito não é recebimento garantido.
- Preços com IVA incluído: art.34 na redacção de2025 prevê método específico para apurar a base e referência a arredondamento nesse contexto. Não é regra universal para o cálculo líquido, nem autorização para aplicar16% ao bruto. O consumidor actual continua a preservar o bruto e a não o calcular. A extensão requer implementação própria e casos dirigidos; não reabre como bloqueio o arredondamento já resolvido para base líquida.
- Declaração/período: art.32 alterado em2025 distingue situações/prazos. Não se decide entrega fora/dentro do prazo por ser uma projecção futura. O exemplar instrui terminar no quadro8 em certos casos; essa instrução não está activa como regra de software.
**Divergências preservadas:** linha de regularizações diz campo11/12 mas caixas12/13; utilização de créditos cita campos18/19, enquanto as caixas desse bloco são19/20; nota de reembolso cita art.20 e as alterações de2025 tratam a matéria no art.21; remissões de quadro8 são ambíguas; a repetição25 foi um artefacto da extracção textual, não uma duplicação de campo no formulário, cuja caixa25 foi confirmada visualmente. Não se fez correcção silenciosa nem se apresentou uma transcrição com ambiguidades como fórmula legal inequívoca.
Pesquisa oficial21/10/2026: [página AT Modelo A](https://edeclaracao.at.gov.mz/formularios/iva/view/formulario_iva_ma.aspx) tem resultado indexado com apuramento no quadro5, diferente do quadro8 deste exemplar. A abertura directa expirou; não se descarregou formulário substituto nem se certificou qual é a edição vigente. [INM BR248/2025](https://inm.gov.mz/pt-br/content/sum%C3%A1rio-suplemento-n%C2%BA-1-de-291225-pag-2180-1-20-br-n%C2%BA-248-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-1) identifica a Lei10/2025. O [comunicado governamental de30/09/2026](https://portaldogoverno.gov.mz/governo-introduz-alteracoes-nos-regulamentos-fiscais-para-reforcar-eficiencia-tributaria/) anuncia alterações regulamentares; não fornece por si o articulado para cadastrar novos critérios. A edição exacta aplicável do formulário, correspondência da Lei3/2025 referida na Lei10/2025 e regulamentos pertinentes permanecem por localizar/consolidar no lote respectivo. Nenhuma destas pendências é convertida em bloqueio genérico do cálculo líquido16% já aceite.
Inventário complementar sem utilização automática: Downloads/IVA-2025.pdf(83páginas, consolidação com leis listadas na capa, origem oficial do exemplar ainda não identificada), iva.pdf(8páginas sem texto extraível nas duas primeiras) e alteracao CIVA2023(18páginas, BR250/2022, já corresponde ao acervo arquivado) foram localizados. Não se usa a consolidação de programação ou uma cópia sem identificação como substituto do BR. O inventário global anterior e as fontes não analisadas permanecem conservados.
## Delta entregue e execução progressiva restante
**Entregue nesta candidataQA:** correspondência consultável dos27campos, quadros documentados, resumo mensal rotulado como simulação parcial01/02, notas no relatório sem reescrever os snapshots aceites, reutilização das compras/encomendas activas (por produto, calendário ou constante) mantendo zero distinto de falta e base/IVA/dedução separados e indeterminados. Não transforma custo das vendas, depreciação, pagamento de fornecedor, investimento global ou compra no imposto dedutível. Sem taxa nova no formulário, novo motor, migration, regra publicada ou activação.
**Próximo lote implementável delimitado:** classificação fiscal por operação de venda e aquisição, com pressuposto ou dado efectivo identificados; tipos de documento, datas, fornecedor/inscrição quando aplicável, utilização e exclusões; separar net/IVA/gross sem repetir quantidade/preço. Reutilizar planos como fontes, pedir apenas a classificação e o que falta; registar ligações/versionamento do dado de origem, não copiar e divergir. Dados efectivos não substituem previsões históricas nem herdam automaticamente as declarações de âmbito da projecção. No cadastro, configurar versões/âmbitos/condições da taxa5% e das deduções apenas após consolidar os dispositivos pertinentes; testes de operações excluídas e entradas insuficientes antes de publicar. Depois ligar totais14–18 através do avaliador existente, propagando indeterminação até todas as parcelas estarem conhecidas ou explicitamente sem operações. Num lote posterior, créditos anteriores/substituições/pedido e liquidação, preservando comunicação/reconhecimento/caixa separados. A publicação/activação são operações expressas distintas da integração do código.
Estimativa de saldo, não horas medidas nem compromisso de cobertura integral: **cadastro/consolidação8–16h** (classificação5%, deduções do primeiro âmbito, exemplos e correspondências) e **código/aceitação20–36h** (dados por operação8–14h; ligação a versões/dedução e totais8–14h; histórico/relatório/coerência4–8h), **28–52h** para o próximo lote delimitado. Créditos anteriores, substituições, pagamento/reembolso e cobertura integral ficam fora desta estimativa e exigem delimitação própria; não reutilizar as antigas15–26h como estimativa desta ampliação. Perguntas concretas ainda abertas: edição/norma de aprovação do formulário para o período e dispositivos consolidados pertinentes; não pedir à Tempo nova validação da credibilidade dos BR aceites.
Critérios do lote seguinte: divisão de uma mesma venda entre enquadramentos sem duplicação; aquisições elegíveis/não elegíveis/mistas, documento ausente e fornecedor não inscrito; zero diferente de falta; não deduzir17-A; data relevante por componente; totais nulos com parcelas indeterminadas; alteração de origem cria versão e invalida dependentes, sem reescrever efectivos/previstos anteriores; relatório mostra período/fonte/regra e não gera declaração aceita, guia ou promessa de reembolso. Suspensão e históricos reutilizam o mecanismo existente.
Provas deste delta:3testes frontend novos (prioridade dos calendários de compras, zero/falta e ausência de dedução presumida, adenda sem mutar resultados antigos); TypeScript/build; importação privada/hash/scan e repetição idempotente. Conferência Edge apenas da nova correspondência, compras e relatório recuperado, sem calcular novamente nem repetir CSV/PDF aceites. Provas privadas em outputs/modelo-a-20261021. MatrizAUD-SIM-INT02, inventário documental e recibo existentes actualizados; main e homologação intactos.
@@ -1961,3 +1961,83 @@ 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.
### Reconciliação Modelo A — primeiro incremento QA,21/10/2026
Por instrução expressa da Tempo, o **Novo Modelo A.docx** é a referência estrutural, incluindo o quadro incorporado como imagem; não foi substituído por um modelo genérico. A [reconciliação campo a campo](../simulations/modelo-a-reconciliacao-20261021.md) contém todos os quadros, campos01–27, finalidade, origem, cálculo, fundamento e estado; inventário/versões/exemplares e divergências preservados. A componente16% existente representa apenas01/02. O apuramento14–18 não se presume igual ao IVA das vendas; não é saldo final a pagar.
Original532008bytes/SHA25662c198e90d8c6792f7d5ccefc6386df2e4b6089573067d3a6e1eae7fc2b9572b e variantev1 de532100bytes/63d45fa80db17a60b43619d55a25813229d670d14c758273f86316285db8e02b preservados na biblioteca **privada QA**, recursos33/34, documentos e revisões/auditoria existentes. Importador existente, pré-visualização, conflitos/duplicados, scans reais limpos com mesmohash e nova tentativa sem criar documentos ou scans. Actor166 é a fixture administrativa do ensaio isolado, sem papel real ou alteração persistente de permissões atribuída à Tempo. Nenhuma publicação ou importação na homologação. Aceitação das fontes oficiais da Tempo08/10 mantida; edição vigente do formulário não afirmada pela data OOXML nem pela leitura do anexo.
No simulador normal, resumo de simulação parcial e correspondência consultável ligados ao resultado mensal recuperado. Compras/encomendas projectadas reutilizadas conforme agenda activa por produto, calendário ou valor constante; não são custo das vendas ou pagamentos de fornecedor. Zero difere de falta. A indicação semIVA/IVA/comIVA/dedutível fica separada e por determinar quando faltam classificação e documentos; não se considera toda compra dedutível. Relatório acrescenta correspondência/limites e referências dos pressupostos actuais de compras, sem os apresentar como resultado fiscal histórico nem reescrever snapshots. Nenhuma nova fórmula fiscal activada ou taxa fixada no formulário; sem migration ou motor novo.
Cotejo dirigido: Lei22/2022 art.17-A/aditamento e eficácia; Lei10/2025 arts.18/19/21/32/34 nas páginas12–15 do BR248, com leitura visual13/14. Registadas remissões divergentes do exemplar (regularizações11/12 versus caixas12/13; crédito18/19 versus caixas19/20; reembolsoart20 versus matéria noart21; quadro8 ambíguo). Não corrigir original nem aplicar instrução ambígua como regra. AT directa expirou; resultado indexado com numeração distinta é limitação de consulta, não substituto nem prova de edição vigente. Lei3/2025/dispositivos/regulamentos pertinentes por consolidar na extensão concreta, sem reabrir genericamente a base líquida16% aceite.
Provas novas apenas do incremento:3testes frontend (agendas, zero/falta, sem IVA/dedução fabricados e relatório sem mutar resultado), TypeScript/build, Edge de consulta após recuperação e biblioteca idempotente/scan. Avaliador, selecção publicada, precisão, históricos e exportadores aceites reutilizados. DossierQA e fixtureIVA v4 continuam disponíveis para revisão; este incremento não calcula novamente. Provas privadas outputs/modelo-a-20261021; recibo e inventário existentes actualizados. Branch codex/iva-modelo-a-reconciliation-QA, parent3f5430c3, separada de main; homologação/regra real/interruptor inalterados.
Saldo explícito: **reconciliação e primeiro incremento de interface concluídos, apuramento completo não implementado**. Próximo lote: classificação por operação e origem prevista/efectiva, cadastro fundamentado de âmbito/dedução e ligação a totais14–18 propagando indeterminação; 8–16h cadastro/consolidação e20–36h código/aceitação, total28–52h condicionado ao âmbito/consolidação, sem créditos anteriores/substituições/pagamento/reembolso ou simulador integral. O documento contém o delta e critérios concretos. Integração do código, publicação e activação continuam operações distintas; esta preparação não executa nenhuma no destino.
### Continuidade e adaptação contextual — primeiro incremento QA, 22/10/2026
Instrução expressa da Tempo de 22/10: o contexto do investimento é transversal ao percurso, formulários, documentos e simulações, mesmo sem IA. Base Git e revisão servida confirmadas em leitura: `57fc914821d0cefa02caa80a74b3d42e663f2742`. Trabalho isolado em `codex/project-context-continuity-QA`, sem integração ou publicação/activação fiscal no destino.
Causas identificadas no código e reprodução dirigida: `InvestorContext` só distribuía título, descrição, sector e geografia; o preenchimento comum copiava apenas campos vazios sem distinguir herança de edição deliberada; o IVA pontual mantinha moeda/data/base em estado local que desaparecia ao desmontar o ecrã. A projecção empresarial sobrepunha a moeda do projecto à moeda de origem do plano ao apresentar/calcular/exportar. No ensaio anterior, projecto guardado em MZN e selector IVA vazio. A primeira tentativa de abertura encontrou uma sessão expirada, confirmada pela leitura sem autenticação e resolvida pelo início de sessão normal; não foi classificada como perda do dossier.
Correcção comum: consolidar exclusivamente o rascunho explicitamente ligado ao mesmo dossier e proprietário; conservar origem, versão e hash. Não procurar o último projecto da conta. Herança persistida com assinaturas de alteração por campo e âmbito dossier/consumidor; não são tokens de autorização nem prova jurídica. Actualizar valores ainda herdados, preservar alterações deliberadas e campos apagados; manter a hierarquia geográfica e os restantes dados das parcelas. O armazenamento do navegador passa a incluir conta e projecto. Consumidores do padrão: simulador, apoio à terra, Balcão e manifestação de investimento; esta última recebe também a moeda compatível. Campos sem correspondência semântica não são copiados. Nacionalidade, residência e país de constituição não são equiparados.
O IVA pontual guarda as entradas com o rascunho. A exploração fiscal, oportunidade e componente pontual reutilizam a moeda compatível; a projecção mensal reutiliza o plano e o início previsto. Não se infere exigibilidade fiscal a partir da data prevista de início, nem residência jurídica a partir do país. USD é conservado e explicado como não suportado pela versão MZN. Uma divergência entre moeda guardada no plano e moeda do projecto é apresentada, sem conversão ou reclassificação. O plano conserva a moeda de origem também na apresentação e nos exportadores existentes. Vendas, zero/falta e snapshots de IVA continuam a usar o motor publicado e os históricos aceites; nenhum imposto mensal fixo é introduzido.
Adaptação deste incremento: o editor existente passa a permitir moeda, início previsto, origem do investidor, tipo/situação da entidade e residência fiscal declarada, sempre pela prévia/confirmar existente. A prévia continua a identificar dados/documentos conservados, resultados e etapas afectados; alterações apenas ao título/descrição não invalidam a aplicabilidade. O selector territorial reutiliza o catálogo público `InvestmentZones` para parques, ZEE e ZFI, preservando versão/hash, fontes e condições públicas já divulgadas no complemento existente. A mesma síntese aparece no contexto e simulador e segue para o relatório do dossier. Os ramos de terra/financiamento reutilizam a definição publicada, factos, estados e razões de `InvestorPath`; não foi criado outro avaliador nem activado benefício.
Escolher uma zona abre a análise específica: referência consultada, condições documentadas ou em falta, período/fonte cadastrados quando disponíveis e reconhecimento por determinar. O benefício e o cenário condicionado permanecem indeterminados quando não existe ligação executável às condições/reconhecimento. O IVA disponível é identificado como cenário geral sem benefício; não representa automaticamente o regime escolhido nem apuramento final. Reutilizam-se `InvestorContext`, `InvestmentZones`, `InvestmentZoneOperationalDetails`, `InvestorPath`/definição publicada, resolvedor/avaliador fiscal e snapshots existentes. A escolha técnica de exigir uma nova confirmação genérica dos mesmos dados em cada formulário foi retirada por instrução da Tempo; permissões, declarações, submissões formais, revisões independentes aprovadas e campos autorizados de pedidos devolvidos mantêm os controlos existentes.
Provas novas: 4 testes HTTP/74 asserções, em transacções revertidas na base isolada `simulator_fiscal_delta_20261021_test`/loopback55441; 8 testes frontend de herança, edição/apagamento, isolamento, parcelas, moeda divergente e contexto no relatório; TypeScript/build. Percurso Edge: A nacional/MZN e residência fiscal ZA, vendas guardadas de quatro meses → cálculo mensal → gravação → edição deliberada do título → recarregamento → terminar sessão/iniciar sessão → recuperação e relatório pelos dados efectivamente guardados. Resultado `ae401465-834f-4761-9406-5af060c499fb`, uma única execução nova no dia; Janeiro1000.03/IVA160.0048, Fevereiro2000.06/320.0096, Marçozero e Abrilsem informação. O modelo de relatório recuperado conserva os valores exactos e não altera o snapshot. B estrangeiro/USD, primeiro divergência explícita planoMZN/projectoUSD, depois declaração manual USD no plano, IVA recusado sem conversão; selecção de parque pela prévia e análise específica. Os testes de ZEE/ZFI usam apenas metadados sintéticos revertidos: não demonstram elegibilidade real. Provas privadas em `outputs/context-continuity-20261022`; não entregar fixtures ou credenciais.
Limites concretos: este é o primeiro incremento utilizável de continuidade e explicação contextual, não a conclusão da adaptação de todos os sectores/regimes. O catálogo QA consultado contém parques sintéticos; a ligação jurídica completa de requisitos/documentos/licenças e benefícios às condições de cada actividade/regime não está demonstrada. Condições sem estrutura/relação executável continuam explicitamente em falta; nenhuma alegação nova de direito ou benefício é feita. Não houve nova validação jurídica nem revalidação genérica de credibilidade das fontes oficiais aceites em08/10. Não repetir cálculo decimal/exportadores/paginação já aceites. A demonstração visual foi feita no percurso e simulador; os outros três consumidores receberam o reconciliador comum, sem se afirmar um ensaio visual integral de cada processo.
Revisão: abrir o simulador QA com o dossier A `92d0836c-0ffb-4e07-a7f5-0e8ca3f594b9` ou B `1872412f-251a-4f6d-bf18-70388e71a16a`; usar a conta sintética QA existente, nunca actores sintéticos na homologação. O router privado de QA usa a instalação já existente do percurso, activada apenas neste ensaio isolado; não há migration ou nova exigência de funções. Entregar a candidata para revisão pelo processo Git existente, preservando alterações concorrentes. Uma integração futura exige rever o delta relativamente a main e o estado de instalação/configuração do percurso no destino; não publicar regras ou alterar interruptores fiscais como consequência desta correcção. Preservar todos os históricos na suspensão ou reversão de código.
Fecho da observação deste incremento: o regresso A ao dossier gravou a evidência financeira `43317c22-50c8-40cf-98e4-e5d4d108c372`, rascunho `cf4dec46-c96c-4aa6-aa9c-1a8d6ecb1bea` v8, contexto v1, com hash preservado. A ligação de inspecção do Edge interrompeu-se depois da navegação; o inventário mostrou o endereço do mesmo dossier e a leitura autenticada do serviço confirmou a gravação. A parte visual final do regresso não foi observada e fica como limitação da ferramenta, não falha do simulador. B recuperou contexto v2 e gravou rascunho v6, mantendo USD e sem execução fiscal. O relatório gerado destes dados recuperados conserva a versão do parque e benefício indeterminado. O enriquecimento automático do rascunho privado é exclusivo do proprietário autenticado; o acesso de gestão ao dossier não concede por esta alteração acesso ao rascunho privado da conta.
File diff suppressed because one or more lines are too long
+5 -409
View File
@@ -7,413 +7,8 @@
@custom-variant dark (&:is(.dark *));
/* Institutional shell adapted from Tempo; page content keeps its own theme. */
.tempo-header {
position: relative;
z-index: 40;
background: #090909;
color: #fff;
}
.tempo-header-inner {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-rows: 72px;
column-gap: 0.5rem;
align-items: center;
max-width: 1440px;
margin-inline: auto;
padding-inline: 0.75rem;
}
.tempo-logo-link {
grid-area: 1 / 1;
display: inline-flex;
width: fit-content;
align-items: center;
flex-shrink: 0;
border-radius: 0.25rem;
}
.tempo-header-logo {
width: auto;
height: 36px;
object-fit: contain;
}
.tempo-primary-navigation {
display: none;
grid-area: 2 / 1 / auto / -1;
min-width: 0;
border-top: 1px solid #ffffff1a;
}
.tempo-primary-navigation > ul {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.25rem 0.5rem;
padding-block: 0.75rem;
}
.tempo-primary-navigation .tempo-nav-trigger {
font-size: 0.9375rem;
padding-inline: 0.5rem;
}
.tempo-nav-trigger {
display: flex;
width: 100%;
min-height: 44px;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
padding: 0.5rem 0.75rem;
border-radius: 0.4rem;
font-size: 1rem;
line-height: 1.5;
font-weight: 600;
white-space: nowrap;
color: #eee;
transition:
color 0.2s,
background 0.2s;
}
.tempo-nav-trigger:hover,
.tempo-nav-trigger[data-state='open'] {
background: #ffffff0d;
color: #fff;
}
.tempo-nav-trigger[data-current-group] {
color: #fff;
box-shadow: inset 0 -2px #ed1c24;
}
.tempo-nav-trigger[data-state='open'] > svg {
transform: rotate(180deg);
}
.tempo-primary-navigation .tempo-nav-trigger > svg {
transform: none;
flex-shrink: 0;
}
.tempo-section-bar {
border-top: 1px solid #ffffff14;
background: inherit;
}
.tempo-section-inner {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.25rem 0.75rem;
max-width: 1440px;
margin-inline: auto;
padding: 0 0.75rem 0.5rem;
}
.tempo-section-navigation {
grid-area: 1 / 1 / auto / -1;
min-width: 0;
}
.tempo-section-navigation > ul {
display: flex;
justify-content: center;
gap: 0.25rem;
}
.tempo-section-navigation .tempo-nav-trigger {
justify-content: center;
min-height: 44px;
gap: 0.25rem;
padding: 0.5rem 0.375rem;
border-radius: 0;
font-size: 0.8125rem;
letter-spacing: normal;
}
@media (min-width: 640px) {
.tempo-section-navigation .tempo-nav-trigger {
min-height: 48px;
gap: 0.5rem;
padding-inline: 0.75rem;
font-size: 1rem;
}
}
@media (min-width: 768px) {
.tempo-section-inner {
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
padding: 0.25rem 1.25rem;
}
.tempo-section-navigation {
grid-area: 1 / 2;
}
.tempo-section-inner .tempo-panel-summary {
grid-area: 1 / 1;
}
.tempo-section-inner .tempo-interest-button {
grid-area: 1 / 3;
}
}
.tempo-header-extras {
display: none;
grid-area: 3 / 1 / auto / -1;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
padding-block: 0.75rem;
border-top: 1px solid #ffffff1a;
}
.tempo-header[data-expanded] .tempo-primary-navigation {
display: block;
}
.tempo-header[data-expanded] .tempo-header-extras {
display: flex;
}
.tempo-header-utilities {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.tempo-utility-button {
display: flex;
min-height: 40px;
align-items: center;
gap: 0.4rem;
padding: 0.4rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
color: #e2e8f0;
}
.tempo-utility-button:hover {
background: #ffffff10;
}
.tempo-header-social {
display: flex;
width: 30px;
height: 40px;
align-items: center;
justify-content: center;
color: var(--social-color);
transition:
transform 0.2s,
opacity 0.2s;
border-radius: 0.4rem;
}
.tempo-header-social:hover {
transform: translateY(-2px);
opacity: 0.8;
}
.tempo-login-button {
display: inline-flex;
min-height: 40px;
align-items: center;
gap: 0.5rem;
border: 1px solid #ffffff50;
border-radius: 0.4rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 600;
white-space: nowrap;
color: #fff;
transition:
background 0.2s,
border-color 0.2s;
}
.tempo-login-button:hover {
background: #ed1c24;
border-color: #ed1c24;
}
.tempo-login-button > span {
max-width: clamp(2rem, calc(100vw - 18rem), 6rem);
}
.tempo-header-actions {
grid-area: 1 / 2;
display: flex;
min-width: 0;
align-items: center;
}
.tempo-interest-button {
display: inline-flex;
grid-area: 2 / 2;
justify-self: end;
align-items: center;
justify-content: center;
min-height: 40px;
padding: 0.5rem 0.65rem;
border: 1px solid #ed1c24;
border-radius: 0.4rem;
background: #ed1c24;
color: white;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
transition: background 0.2s;
}
.tempo-interest-button:hover {
background: #c9151c;
}
.tempo-panel-summary {
grid-area: 2 / 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 0.1rem;
min-height: 44px;
font-size: 0.8125rem;
line-height: 1.4;
color: #fff;
text-underline-offset: 3px;
}
.tempo-panel-summary:hover {
text-decoration: underline;
}
.tempo-panel-count {
color: #cbd5e1;
font-size: 0.6875rem;
}
.tempo-header-toggle {
grid-area: 1 / 3;
display: inline-flex;
width: 44px;
height: 44px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 1px solid #ffffff33;
border-radius: 0.4rem;
color: #fff;
cursor: pointer;
}
.tempo-header-toggle:hover,
.tempo-header-toggle[aria-expanded='true'] {
background: #ffffff10;
}
@media (min-width: 640px) {
.tempo-header-inner {
padding-inline: 1.25rem;
}
.tempo-header-logo {
height: 44px;
}
.tempo-primary-navigation > ul {
display: flex;
justify-content: space-between;
}
}
@media (min-width: 1024px) {
.tempo-header-inner {
grid-template-rows: 88px;
padding-inline: 1.5rem;
}
.tempo-header-logo {
height: 56px;
}
}
@media (min-width: 1280px) {
/* Shared columns keep each portal area below its institutional link. */
.tempo-header-layout {
display: grid;
grid-template-columns: auto repeat(4, max-content) minmax(0, 1fr) auto;
column-gap: 0.25rem;
max-width: 1440px;
margin-inline: auto;
padding-inline: 2rem;
}
.tempo-header-inner {
grid-column: 1 / -1;
grid-template-columns: subgrid;
min-height: 88px;
column-gap: normal;
max-width: none;
margin: 0;
padding: 0;
}
.tempo-logo-link {
margin-right: 0.75rem;
}
.tempo-header-logo {
height: 64px;
}
.tempo-header[data-expanded] .tempo-primary-navigation,
.tempo-primary-navigation {
display: grid;
grid-area: 1 / 2 / 2 / 6;
grid-template-columns: subgrid;
border: 0;
}
.tempo-primary-navigation > ul {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
gap: normal;
padding: 0;
}
.tempo-nav-trigger {
justify-content: center;
padding-inline: 0.875rem;
font-size: 1.125rem;
letter-spacing: 0.025em;
}
.tempo-primary-navigation .tempo-nav-trigger {
padding-inline: 0.625rem;
font-size: 1.125rem;
}
.tempo-header-extras {
display: flex;
grid-area: 1 / 6;
justify-self: end;
flex-shrink: 0;
padding: 0 0.75rem;
border-top: 0;
border-inline: 1px solid #ffffff1a;
}
.tempo-header-utilities {
flex-wrap: nowrap;
gap: 0.5rem;
}
.tempo-header-social {
width: 26px;
}
.tempo-header-toggle {
display: none;
}
.tempo-header-actions {
grid-area: 1 / 7;
}
.tempo-section-bar {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
.tempo-section-inner {
grid-column: 1 / -1;
grid-template-columns: subgrid;
gap: normal;
max-width: none;
margin: 0;
padding-inline: 0;
}
.tempo-section-navigation {
display: grid;
grid-area: 1 / 2 / 2 / 5;
grid-template-columns: subgrid;
}
.tempo-section-navigation > ul {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
gap: normal;
}
.tempo-section-navigation .tempo-nav-trigger {
padding-inline: 0.625rem;
}
.tempo-primary-navigation .tempo-nav-trigger,
.tempo-section-navigation .tempo-nav-trigger {
justify-content: flex-start;
}
.tempo-interest-button {
padding-inline: 0.85rem;
font-size: 0.8125rem;
}
.tempo-section-inner .tempo-interest-button {
grid-area: 1 / 6 / 2 / -1;
}
}
.tempo-header :is(a, button):focus-visible {
outline: 2px solid #fff;
outline-offset: 4px;
}
@import './tempo-header.css';
.tempo-dropdown {
z-index: 60;
border: 1px solid #e2e8f0;
@@ -441,7 +36,7 @@
outline: none;
}
.tempo-footer {
margin-top: 2rem;
margin-top: 1.5rem;
background: #050505;
color: #fff;
}
@@ -475,7 +70,8 @@
.tempo-contact-link {
display: flex;
align-items: center;
gap: 1rem;
gap: 0.75rem;
min-height: 40px;
transition: color 0.2s;
}
.tempo-contact-link:hover {
+321
View File
@@ -0,0 +1,321 @@
/* Presentation choice for Tempo's 21/10/2026 request: one shared navigation. */
.tempo-header {
position: relative;
z-index: 40;
background: #090909;
color: #fff;
border-bottom: 1px solid #ffffff14;
}
.tempo-header-inner {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-rows: 71px;
align-items: center;
column-gap: 0.5rem;
max-width: 1600px;
margin-inline: auto;
padding-inline: 0.75rem;
}
.tempo-logo-link {
grid-area: 1 / 1;
display: inline-flex;
align-items: center;
width: 112px;
max-width: 100%;
border-radius: 0.25rem;
}
.tempo-header-logo {
width: 100%;
height: auto;
object-fit: contain;
}
.tempo-primary-navigation {
display: none;
grid-area: 2 / 1 / auto / -1;
min-width: 0;
border-top: 1px solid #ffffff1a;
padding-block: 0.75rem;
}
.tempo-primary-navigation > ul {
display: grid;
gap: 0.25rem;
}
.tempo-nav-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
width: 100%;
min-height: 44px;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
color: #e2e8f0;
font-size: 1rem;
line-height: 1.5;
font-weight: 600;
white-space: nowrap;
transition:
color 0.2s,
background 0.2s;
}
.tempo-nav-trigger:hover,
.tempo-nav-trigger[data-state='open'] {
background: #ffffff0d;
color: #fff;
}
.tempo-nav-trigger[data-current-group] {
color: #fff;
background: #ffffff08;
box-shadow: inset 0 -2px #ed1c24;
}
.tempo-nav-trigger > svg {
flex-shrink: 0;
transition: transform 0.2s;
}
.tempo-nav-trigger[data-state='open'] > svg {
transform: rotate(180deg);
}
.tempo-header-extras {
display: none;
grid-area: 3 / 1 / auto / -1;
min-width: 0;
padding-block: 0.75rem;
border-top: 1px solid #ffffff1a;
}
.tempo-header-utilities {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.tempo-utility-button {
display: flex;
align-items: center;
gap: 0.4rem;
min-height: 44px;
padding: 0.4rem;
border-radius: 0.5rem;
color: #e2e8f0;
font-size: 0.8125rem;
}
.tempo-utility-button:hover {
background: #ffffff10;
}
.tempo-header-actions {
grid-area: 1 / 2;
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.tempo-login-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-height: 44px;
padding: 0.5rem 0.625rem;
border: 1px solid #ffffff40;
border-radius: 0.5rem;
color: #fff;
font-size: 0.875rem;
font-weight: 600;
white-space: nowrap;
transition:
background 0.2s,
border-color 0.2s;
}
.tempo-login-button:hover {
background: #ffffff10;
border-color: #ffffff80;
}
.tempo-login-button > span {
max-width: clamp(2rem, calc(100vw - 18rem), 6rem);
}
.tempo-panel-summary {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
flex-shrink: 0;
border-radius: 0.5rem;
color: #e2e8f0;
}
.tempo-panel-summary:hover {
background: #ffffff10;
color: #fff;
}
.tempo-panel-count {
position: absolute;
top: 0;
right: -0.125rem;
min-width: 17px;
padding-inline: 0.2rem;
border: 2px solid #090909;
border-radius: 999px;
background: #ed1c24;
color: #fff;
font-size: 0.5625rem;
line-height: 1.4;
text-align: center;
}
.tempo-interest-button {
display: none;
grid-area: 4 / 1 / auto / -1;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 44px;
margin-bottom: 1rem;
padding: 0.5rem 0.875rem;
border: 1px solid #ed1c24;
border-radius: 0.5rem;
background: #ed1c24;
color: #fff;
font-size: 0.8125rem;
font-weight: 600;
white-space: nowrap;
transition: background 0.2s;
}
.tempo-interest-button:hover {
background: #c9151c;
}
.tempo-header-toggle {
grid-area: 1 / 3;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: 1px solid #ffffff33;
border-radius: 0.5rem;
color: #fff;
cursor: pointer;
}
.tempo-header-toggle:hover,
.tempo-header-toggle[aria-expanded='true'] {
background: #ffffff10;
}
.tempo-header[data-expanded] .tempo-primary-navigation {
display: block;
}
.tempo-header[data-expanded] .tempo-header-extras {
display: flex;
}
.tempo-header[data-expanded] .tempo-interest-button {
display: inline-flex;
}
.tempo-header :is(a, button):focus-visible {
outline: 2px solid #fff;
outline-offset: 3px;
}
.tempo-submenu {
max-width: min(
calc(100vw - 1.5rem),
var(--radix-dropdown-menu-content-available-width)
);
}
@media (min-width: 640px) {
.tempo-header-inner {
grid-template-columns: minmax(0, 1fr) auto auto auto;
column-gap: 0.75rem;
padding-inline: 1.5rem;
}
.tempo-logo-link {
width: 132px;
}
.tempo-interest-button {
display: inline-flex;
grid-area: 1 / 3;
margin-bottom: 0;
}
.tempo-header-toggle {
grid-area: 1 / 4;
}
}
@media (min-width: 1280px) {
.tempo-header-inner {
grid-template-columns: auto minmax(0, 1fr) auto auto auto;
column-gap: 0.25rem;
padding-inline: 1.25rem;
}
.tempo-logo-link {
width: 100px;
}
.tempo-login-button > span {
max-width: 3rem;
}
.tempo-primary-navigation,
.tempo-header[data-expanded] .tempo-primary-navigation {
display: block;
grid-area: 1 / 2;
border: 0;
padding: 0;
justify-self: center;
}
.tempo-primary-navigation > ul {
display: flex;
align-items: center;
gap: 0;
}
.tempo-nav-trigger {
justify-content: center;
padding-inline: 0.25rem;
font-size: 1.125rem;
letter-spacing: 0.025em;
}
.tempo-header-extras,
.tempo-header[data-expanded] .tempo-header-extras {
display: flex;
grid-area: 1 / 3;
padding: 0 0.5rem 0 0;
border: 0;
border-right: 1px solid #ffffff1a;
}
.tempo-header-utilities {
flex-wrap: nowrap;
}
.tempo-header-actions {
grid-area: 1 / 4;
}
.tempo-interest-button {
grid-area: 1 / 5;
}
.tempo-header-toggle {
display: none;
}
}
@media (min-width: 1440px) {
.tempo-header-inner {
column-gap: 0.75rem;
padding-inline: 2rem;
}
.tempo-nav-trigger {
padding-inline: 0.375rem;
font-size: 1.125rem;
}
.tempo-logo-link {
width: 132px;
}
.tempo-login-button > span {
max-width: 5rem;
}
.tempo-header-extras {
padding-right: 0.75rem;
}
.tempo-primary-navigation > ul {
gap: 0.125rem;
}
}
@media (min-width: 1536px) {
.tempo-header-inner {
column-gap: 1rem;
}
.tempo-nav-trigger {
padding-inline: 0.75rem;
}
.tempo-login-button > span {
max-width: 6rem;
}
}
+27 -58
View File
@@ -1,10 +1,12 @@
import { useEffect, useRef, useState } from 'react';
import { InvestmentContextAssessment } from './investment-context-assessment';
import { Link } from '@inertiajs/react';
import {
contextApi,
contextFieldNames,
type ContextView,
} from '@/lib/investor-context';
import { inheritCompatibleFields } from '@/lib/context-inheritance';
type Props = {
contextId?: string | null;
consumer: 'simulator' | 'land' | 'desk' | 'submission';
@@ -12,6 +14,7 @@ type Props = {
enabled?: boolean;
readOnly?: boolean;
allowedFields?: string[];
persistInheritance?: boolean;
boundRevision?: number;
onApply: (patch: Record<string, unknown>, replace: boolean) => void;
onReview?: (revision: number, reviewed: boolean) => void;
@@ -40,17 +43,16 @@ export function DossierPrefill(props: Props) {
const [selected, setSelected] = useState<string[]>([]);
const [reviewed, setReviewed] = useState(false);
const [message, setMessage] = useState('');
const filled = useRef('');
useEffect(() => {
if (!contextId) return;
let stopped = false;
setView(null);
setError('');
void contextApi<ContextView>(`/investimentos/${contextId}/contexto`)
.then((v) => {
if (!stopped) {
setView(v);
setReviewed(false);
latest.current.onReview?.(v.context.revision, false);
}
})
.catch((e) => {
@@ -61,44 +63,20 @@ export function DossierPrefill(props: Props) {
};
}, [contextId, retry]);
useEffect(() => {
if (!view || !enabled || readOnly || !view.can_write) return;
const generation = `${contextId}:${view.context.revision}:${consumer}`;
if (filled.current === generation) return;
if (!view || view.case.id !== contextId || !enabled || readOnly || !view.can_write) return;
const source = view.prefill[consumer] ?? {};
const now = latest.current;
const patch: Record<string, unknown> = {};
const conflict = geo.some(
(k) =>
!empty(now.values[k]) &&
String(now.values[k]) !== text(source[k]),
);
for (const [k, v] of Object.entries(source)) {
if (
(now.allowedFields && !now.allowedFields.includes(k)) ||
(geo.includes(k) && conflict)
)
continue;
if (k === 'parcels') {
const old = now.values.parcels as
| Record<string, unknown>[]
| undefined;
if (
(!old?.length || geo.every((g) => empty(old[0]?.[g]))) &&
Array.isArray(v) &&
v[0] &&
Object.keys(v[0]).length
)
patch[k] = v;
} else if (empty(now.values[k]) && !empty(v)) patch[k] = v;
}
filled.current = generation;
if (Object.keys(patch).length) {
now.onApply(patch, false);
const inherited = inheritCompatibleFields(`dossier:${contextId}:${consumer}`, now.values, source, now.values.context_reuse, now.allowedFields);
if (Object.keys(inherited.patch).length || (now.persistInheritance !== false && now.values.context_reuse !== inherited.metadata)) {
// The shared reconciliation has already selected safe fields; metadata travels with the same saved form.
now.onApply({...inherited.patch, ...(now.persistInheritance === false ? {} : {context_reuse:inherited.metadata})}, true);
setMessage(
'Campos vazios preenchidos a partir do dossier. Reveja-os; os campos já preenchidos foram preservados.',
'Dados compatíveis reutilizados do mesmo dossier. Os valores herdados acompanham o contexto; as suas alterações locais foram preservadas.',
);
}
}, [view, enabled, readOnly, contextId, consumer]);
setReviewed(true);
now.onReview?.(view.context.revision, true);
}, [view, enabled, readOnly, contextId, consumer, props.values]);
if (!contextId) return null;
if (error)
return (
@@ -126,7 +104,7 @@ export function DossierPrefill(props: Props) {
),
);
const differences = Object.keys(source).filter(
(k) => text(props.values[k]) !== text(source[k]),
(k) => k !== 'context_applicability_snapshot' && text(props.values[k]) !== text(source[k]),
);
const linkedRevision =
consumer === 'simulator'
@@ -152,6 +130,7 @@ export function DossierPrefill(props: Props) {
. Mestres, declarações deste formulário e conteúdo já submetido
mantêm-se separados.
</p>
<InvestmentContextAssessment contextId={contextId} raw={view.prefill.simulator?.context_applicability_snapshot} />
{stale && (
<p className="font-semibold" role="status">
{linkedRevision > 0
@@ -254,7 +233,17 @@ export function DossierPrefill(props: Props) {
const patch: Record<string, unknown> = {};
for (const k of selected)
patch[k] = source[k] ?? '';
props.onApply(patch, true);
const scope = `dossier:${contextId}:${consumer}`;
let raw = String(props.values.context_reuse ?? '');
try {
const metadata = JSON.parse(raw);
if(metadata.scope === scope) {
for(const key of selected) delete metadata.inherited?.[key];
raw=JSON.stringify(metadata);
}
} catch { /* Legacy metadata does not grant ownership of other fields. */ }
const adopted=inheritCompatibleFields(scope,{...props.values,...Object.fromEntries(selected.map(key=>[key,undefined]))},patch,raw,props.allowedFields);
props.onApply({...patch,...(props.persistInheritance === false ? {} : {context_reuse:adopted.metadata})}, true);
setSelected([]);
setReviewed(false);
props.onReview?.(view.context.revision, false);
@@ -267,27 +256,7 @@ export function DossierPrefill(props: Props) {
</button>
</details>
)}
{!readOnly &&
view.can_write &&
props.onReview &&
view.context.revision > 0 && (
<label className="block">
<input
type="checkbox"
checked={reviewed}
onChange={(e) => {
setReviewed(e.target.checked);
props.onReview?.(
view.context.revision,
e.target.checked,
);
}}
/>{' '}
Conferi os dados e pressupostos deste formulário para o
contexto v{view.context.revision}; os valores diferentes
que conservei são declarações deste processo.
</label>
)}
{!readOnly && reviewed && <p>Contexto v{view.context.revision} recuperado neste formulário, sem nova confirmação dos dados compatíveis. As edições locais conservam-se; as declarações e submissões formais mantêm os seus controlos próprios.</p>}
<p className="text-xs">
Associar uma referência não confirma incentivos, licenças, terra
disponível, reserva ou autorização.
@@ -0,0 +1,24 @@
import { Link } from '@inertiajs/react';
type Snapshot = {context_revision:number;path?:{definition_version:number;steps:Array<{code:string;title:string;applicability:string;reason:string;state:string;context_review_required?:boolean}>}; zone?:{title:string;href:string;version:number|string;fields?:Record<string,string>;analysis?:{conditions?:Array<{id:string;label:string;knowledge:string;source_reference?:string;valid_from?:string;valid_until?:string}>;source?:{title:string;url:string;diploma?:string;article?:string;valid_from?:string;valid_until?:string}|null}}|null;fields?:Record<string,string>};
export function InvestmentContextAssessment({raw,contextId}:{raw:unknown;contextId?:string|null}) {
let snapshot:Snapshot|null=null;
try {const value=JSON.parse(String(raw ?? ''));if(value.version===1) snapshot=value;} catch { /* Missing context remains explicit. */ }
if(!snapshot) return null;
const fields=snapshot.fields ?? {};const zone=snapshot.zone;
const regime=zone?.fields?.investment_regime ?? fields.investment_regime;
const names:Record<string,string>={zee:'ZEE',zfi:'ZFI',industrial_park:'Parque industrial'};
return <section className="space-y-2 rounded border border-amber-400 p-3" aria-label="Aplicabilidade ao investimento">
<h3 className="font-semibold">Aplicabilidade ao investimento · contexto v{snapshot.context_revision}</h3>
<p>Origem do investidor: {{national:'nacional',foreign:'estrangeiro',mixed:'misto'}[fields.investor_origin] ?? 'informação em falta'}. Residência fiscal declarada: {fields.tax_residency_country || 'informação em falta'}. Estas condições são distintas; uma não determina a outra.</p>
{zone ? <><p><strong>Potencialmente aplicável — análise específica de {names[regime ?? ''] ?? 'zona seleccionada'}.</strong> <Link className="underline" href={zone.href}>{zone.title} · versão {zone.version} · consultar ficha e fundamentos</Link>.</p>
<p>A selecção identifica a referência consultada; não confirma o reconhecimento da entidade, actividade autorizada, licença ou acesso a benefícios. Consulte as condições documentadas na ficha e reúna a autorização ou evidência correspondente no dossier.</p>
{zone.analysis?.source && <p>Fundamento cadastrado: <a className="underline" href={zone.analysis.source.url}>{zone.analysis.source.diploma || zone.analysis.source.title} {zone.analysis.source.article}</a>. Período cadastrado: {zone.analysis.source.valid_from || 'início por indicar'} a {zone.analysis.source.valid_until || 'sem termo indicado'}. A cobertura do investimento exige correspondência com estas condições.</p>}
{zone.analysis?.conditions?.length ? <ul className="list-disc pl-5">{zone.analysis.conditions.map(condition=><li key={condition.id}><strong>{condition.label}</strong>: {condition.knowledge==='documented'?'condição documentada; cumprimento pelo investimento por confirmar':condition.knowledge==='disputed'?'condição com informação divergente':'informação em falta'}. {condition.source_reference && <>Fundamento: {condition.source_reference}.</>} {condition.valid_from && <>Desde {condition.valid_from}.</>} {condition.valid_until && <>Até {condition.valid_until}.</>}</li>)}</ul> : <p>Condições específicas: informação estruturada em falta nesta referência. A ficha e os documentos continuam consultáveis; não se presume cumprimento.</p>}
<p role="status">Benefício fiscal deste enquadramento: indeterminado. Não existe neste consumidor uma regra executável ligada ao reconhecimento e condições da zona. No simulador, o IVA disponível corresponde apenas ao cenário geral sem benefício, sobre vendas declaradas compatíveis; não simula o regime especial. O cenário com benefício e o apuramento final estão indisponíveis.</p></>
: <p>Enquadramento geral de preparação: sem ZEE, ZFI ou parque seleccionado. Isso não confirma a elegibilidade para uma operação fiscal. O cálculo mantém o âmbito e as exclusões da versão publicada.</p>}
<p>Confirmado neste quadro significa apenas dado recuperado do dossier; declarações e referências não são decisões jurídicas. Requisitos específicos não documentados permanecem indeterminados.</p>
{snapshot.path && <details><summary>Passos adaptados pelo percurso existente · versão {snapshot.path.definition_version}</summary><ul className="list-disc pl-5">{snapshot.path.steps.map(step=><li key={step.code}>{step.code} — {step.title}: {step.context_review_required?'informação alterada; rever fundamento':step.applicability==='not_needed_for_preparation'?'não aplicável à preparação declarada':step.applicability==='undetermined'?'informação em falta':['completed','approved'].includes(step.state)?'preparação confirmada no percurso; não é decisão fiscal':'potencialmente aplicável'}. {step.reason}</li>)}</ul></details>}
{contextId && <Link className="underline" href={`/investimentos/${contextId}/contexto/editar`}>Alterar contexto e comparar impacto antes de guardar</Link>}
</section>;
}
+308 -235
View File
@@ -3,14 +3,16 @@ import { Link, router, usePage } from '@inertiajs/react';
import {
ChevronDown,
ArrowUpRight,
LayoutDashboard,
LogOut,
Menu,
UserRound,
X,
} from 'lucide-react';
import { useEffect, useId, useRef, useState } from 'react';
import { Fragment, useEffect, useId, useRef, useState } from 'react';
import { useIsMobile } from '@/hooks/use-mobile';
import { useNavigationContext } from '@/components/navigation-context';
import { TempoSocialLinks, useTempoUi } from '@/components/tempo-brand';
import { tempoSocialLinks, useTempoUi } from '@/components/tempo-brand';
import {
TempoLanguageSelector,
TempoWeather,
@@ -52,6 +54,7 @@ export function PortalGroupedMenu() {
scopedAdministration?: {label:string;href:string}[];
}>();
const { t, language, requestCount } = useTempoUi();
const mobile = useIsMobile();
const navigation = useNavigationContext();
const groups = portalNavigation({
context: navigation.view
@@ -72,24 +75,34 @@ export function PortalGroupedMenu() {
const currentItem = currentNavigationItem(groups, url);
const [loggingOut, setLoggingOut] = useState(false);
const [expanded, setExpanded] = useState(false);
const institutionalId = useId();
const navigationId = useId();
const toolsId = useId();
const interestId = useId();
const toggle = useRef<HTMLButtonElement>(null);
useEffect(() => router.on('navigate', () => setExpanded(false)), []);
const items = (children: PortalNavItem[]) =>
children.map((item) =>
item.children ? (
<DropdownMenuSub key={item.id}>
<DropdownMenuSubTrigger className="tempo-menu-item font-semibold">
{t(item.label)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
collisionPadding={12}
className="tempo-dropdown max-h-[min(70vh,34rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
mobile ? (
<Fragment key={item.id}>
<DropdownMenuLabel className="mt-2 border-t px-3 pt-3 text-xs text-slate-500">
{t(item.label)}
</DropdownMenuLabel>
{items(item.children)}
</DropdownMenuSubContent>
</DropdownMenuSub>
</Fragment>
) : (
<DropdownMenuSub key={item.id}>
<DropdownMenuSubTrigger className="tempo-menu-item font-semibold">
{t(item.label)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
collisionPadding={12}
className="tempo-dropdown tempo-submenu max-h-[min(70vh,34rem)] w-72 overflow-y-auto p-2"
>
{items(item.children)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
) : (
<DropdownMenuItem
key={item.id}
@@ -111,6 +124,8 @@ export function PortalGroupedMenu() {
(group) =>
!['home', 'discovery', 'realization', 'support'].includes(group.id),
);
const panelLabel = `${t('O meu painel')} — ${requestCount} ${t(requestCount === 1 ? 'pedido recebido' : 'pedidos recebidos')}`;
const institutionalCurrent = url.split('?')[0] === '/contacto';
return (
<header
className="tempo-header"
@@ -120,245 +135,303 @@ export function PortalGroupedMenu() {
if (
event.key === 'Escape' &&
expanded &&
!event.defaultPrevented
!event.defaultPrevented &&
!(
event.target instanceof Element &&
event.target.closest('[role="menu"]')
)
) {
setExpanded(false);
toggle.current?.focus();
}
}}
>
<div className="tempo-header-layout">
<div className="tempo-header-inner">
<Link
href="/"
aria-label="InvesteMoz — início"
className="tempo-logo-link"
>
<PortalProductIdentity />
</Link>
<nav
id={institutionalId}
aria-label="Navegação institucional"
className="tempo-primary-navigation"
>
<ul>
<li>
<Link
href="/"
className="tempo-nav-trigger"
aria-current={
url.split('?')[0] === '/'
? 'page'
: undefined
}
data-current-group={
url.split('?')[0] === '/' || undefined
}
>
{t('Início')}
</Link>
</li>
<li>
<a
href="https://revista.tempo.co.mz/"
target="_blank"
rel="noopener noreferrer"
className="tempo-nav-trigger"
aria-label={`${t('Revista Tempo')} — ${t('Abre num novo separador')}`}
>
{t('Revista Tempo')}
<ArrowUpRight
size={14}
aria-hidden="true"
/>
</a>
</li>
{[
['/sobre', 'Sobre nós'],
['/contacto', 'Contacto'],
].map(([href, label]) => (
<li key={href}>
<Link
href={href}
className="tempo-nav-trigger"
aria-current={
url.split('?')[0] === href
? 'page'
: undefined
}
data-current-group={
url.split('?')[0] === href ||
undefined
}
>
{t(label)}
</Link>
</li>
))}
</ul>
</nav>
<div id={toolsId} className="tempo-header-extras">
<div className="tempo-header-utilities">
<div className="flex items-center gap-1">
<TempoWeather />
<TempoLanguageSelector />
</div>
<TempoSocialLinks />
</div>
</div>
<div className="tempo-header-actions">
{props.auth?.user ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger className="tempo-login-button">
<UserRound size={16} aria-hidden="true" />
<span className="max-w-24 truncate">
{props.auth.user.name ||
t('A minha área')}
</span>
<ChevronDown size={12} aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={12}
collisionPadding={12}
className="tempo-dropdown max-h-[75vh] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
<DropdownMenuLabel className="truncate text-xs text-slate-500">
{props.auth.user.email}
</DropdownMenuLabel>
{items(accountGroups)}
<DropdownMenuSeparator />
<DropdownMenuItem
asChild
className="tempo-menu-item text-red-700"
>
<Link
href={logout()}
as="button"
type="button"
disabled={loggingOut}
data-test="portal-logout-button"
onClick={() => router.flushAll()}
onStart={() => setLoggingOut(true)}
onFinish={() =>
setLoggingOut(false)
<div className="tempo-header-inner">
<Link
href="/"
aria-label="Tempo — início"
className="tempo-logo-link"
>
<img
src="/images/logo/logo-vermelho.png"
alt="Tempo"
width="1787"
height="564"
className="tempo-header-logo"
/>
</Link>
<nav
id={navigationId}
aria-label="Áreas do portal"
className="tempo-primary-navigation"
>
<ul>
<li>
<Link
href="/"
className="tempo-nav-trigger"
aria-current={
url.split('?')[0] === '/'
? 'page'
: undefined
}
data-current-group={
url.split('?')[0] === '/' || undefined
}
>
{t('Início')}
</Link>
</li>
{groups
.filter((group) =>
[
'discovery',
'realization',
'support',
].includes(group.id),
)
.map((group) => (
<li key={group.id}>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className="tempo-nav-trigger"
data-current-group={
current === group.id ||
undefined
}
>
<LogOut
size={16}
{t(group.label)}
<ChevronDown
size={14}
aria-hidden="true"
/>
{loggingOut
? 'A terminar sessão…'
: t('Terminar sessão')}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Link href="/login" className="tempo-login-button">
<UserRound size={15} aria-hidden="true" />
{t('Entrar')}
</Link>
)}
</div>
<button
ref={toggle}
type="button"
className="tempo-header-toggle"
aria-label={
expanded
? 'Fechar menu e ferramentas'
: 'Abrir menu e ferramentas'
}
aria-expanded={expanded}
aria-controls={`${institutionalId} ${toolsId}`}
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<X size={22} aria-hidden="true" />
) : (
<Menu size={22} aria-hidden="true" />
)}
</button>
</div>
<div className="tempo-section-bar">
<div className="tempo-section-inner">
<Link
href="/investidor/manifestacoes"
className="tempo-panel-summary"
>
<span>{t('O meu painel')}</span>
<span
className="tempo-panel-count"
aria-live="polite"
aria-atomic="true"
{current === group.id && (
<span className="sr-only">
Grupo actual
</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={12}
collisionPadding={12}
className="tempo-dropdown max-h-[min(75vh,36rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
{items(group.children ?? [])}
</DropdownMenuContent>
</DropdownMenu>
</li>
))}
<li>
<Link
href="/sobre"
className="tempo-nav-trigger"
aria-current={
url.split('?')[0] === '/sobre'
? 'page'
: undefined
}
data-current-group={
url.split('?')[0] === '/sobre' || undefined
}
>
{requestCount}{' '}
{t(
requestCount === 1
? 'pedido recebido'
: 'pedidos recebidos',
)}
</span>
</Link>
<nav
aria-label="Áreas do portal"
className="tempo-section-navigation"
>
<ul>
{groups
.filter((group) =>
[
'discovery',
'realization',
'support',
].includes(group.id),
)
.map((group) => (
<li key={group.id}>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className="tempo-nav-trigger"
data-current-group={
current === group.id ||
undefined
{t('Sobre nós')}
</Link>
</li>
<li>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className="tempo-nav-trigger"
data-current-group={
institutionalCurrent || undefined
}
>
Tempo
<ChevronDown size={14} aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={12}
collisionPadding={12}
className="tempo-dropdown max-h-[75vh] w-64 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
<DropdownMenuItem
asChild
className="tempo-menu-item"
>
<a
href="https://revista.tempo.co.mz/"
target="_blank"
rel="noopener noreferrer"
aria-label={`${t('Revista Tempo')} — ${t('Abre num novo separador')}`}
>
{t('Revista Tempo')}
<ArrowUpRight
size={14}
aria-hidden="true"
/>
</a>
</DropdownMenuItem>
{[['/contacto', 'Contacto']].map(
([href, label]) => (
<DropdownMenuItem
key={href}
asChild
className="tempo-menu-item"
>
<Link
href={href}
aria-current={
url.split('?')[0] ===
href
? 'page'
: undefined
}
>
{t(group.label)}
<ChevronDown
size={14}
{t(label)}
</Link>
</DropdownMenuItem>
),
)}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-slate-500">
Revista Tempo
</DropdownMenuLabel>
{tempoSocialLinks.map(
({ name, href, icon: Icon }) => (
<DropdownMenuItem
key={name}
asChild
className="tempo-menu-item"
>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={`Revista Tempo — ${name}`}
>
<Icon
size={16}
aria-hidden="true"
/>
{current === group.id && (
<span className="sr-only">
Grupo actual
</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={8}
collisionPadding={12}
className="tempo-dropdown max-h-[min(75vh,36rem)] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
{items(
group.children ?? [],
)}
</DropdownMenuContent>
</DropdownMenu>
</li>
))}
</ul>
</nav>
<Link
href="/manifestar-interesse"
className="tempo-interest-button"
>
{t('Manifestar interesse')}
</Link>
{name}
<ArrowUpRight
size={12}
aria-hidden="true"
/>
</a>
</DropdownMenuItem>
),
)}
</DropdownMenuContent>
</DropdownMenu>
</li>
</ul>
</nav>
<div id={toolsId} className="tempo-header-extras">
<div className="tempo-header-utilities">
<div className="flex items-center gap-1">
<TempoWeather />
<TempoLanguageSelector />
</div>
</div>
</div>
<div className="tempo-header-actions">
<Link
href="/investidor/manifestacoes"
className="tempo-panel-summary"
aria-label={panelLabel}
title={panelLabel}
>
<LayoutDashboard size={19} aria-hidden="true" />
<span className="tempo-panel-count" aria-hidden="true">
{requestCount}
</span>
</Link>
<span
className="sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>
{panelLabel}
</span>
{props.auth?.user ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger className="tempo-login-button">
<UserRound size={16} aria-hidden="true" />
<span className="max-w-24 truncate">
{props.auth.user.name || t('A minha área')}
</span>
<ChevronDown size={12} aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={12}
collisionPadding={12}
className="tempo-dropdown max-h-[75vh] w-72 max-w-[calc(100vw-1.5rem)] overflow-y-auto p-2"
>
<DropdownMenuLabel className="truncate text-xs text-slate-500">
{props.auth.user.email}
</DropdownMenuLabel>
{items(accountGroups)}
<DropdownMenuSeparator />
<DropdownMenuItem
asChild
className="tempo-menu-item text-red-700"
>
<Link
href={logout()}
as="button"
type="button"
disabled={loggingOut}
data-test="portal-logout-button"
onClick={() => router.flushAll()}
onStart={() => setLoggingOut(true)}
onFinish={() => setLoggingOut(false)}
>
<LogOut size={16} aria-hidden="true" />
{loggingOut
? 'A terminar sessão…'
: t('Terminar sessão')}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Link href="/login" className="tempo-login-button">
<UserRound size={15} aria-hidden="true" />
{t('Entrar')}
</Link>
)}
</div>
<Link
id={interestId}
href="/manifestar-interesse"
className="tempo-interest-button"
>
{t('Manifestar interesse')}
<ArrowUpRight size={15} aria-hidden="true" />
</Link>
<button
ref={toggle}
type="button"
className="tempo-header-toggle"
aria-label={
expanded
? 'Fechar menu e ferramentas'
: 'Abrir menu e ferramentas'
}
aria-expanded={expanded}
aria-controls={`${navigationId} ${toolsId} ${interestId}`}
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<X size={22} aria-hidden="true" />
) : (
<Menu size={22} aria-hidden="true" />
)}
</button>
</div>
</header>
);
@@ -95,7 +95,7 @@ function MoneyInput({
}
export function SimulationBusinessPlan({
currency,
currency: projectCurrency,
snapshot,
onCurrencyChange,
onSnapshotChange,
@@ -253,8 +253,10 @@ export function SimulationBusinessPlan({
const plan = workingPlan ??
restoreBusinessPlanSnapshot(snapshot) ?? {
...emptyPlan,
currency,
currency: projectCurrency,
};
// A project edit is not a conversion or permission to relabel stored sales.
const currency = plan.currency || projectCurrency;
const update = (
key: keyof BusinessFinancialPlanInput,
value:
@@ -858,6 +860,7 @@ export function SimulationBusinessPlan({
<summary className="cursor-pointer text-lg font-semibold">
Plano financeiro empresarial (pressupostos editáveis)
</summary>
{plan.currency && projectCurrency && plan.currency !== projectCurrency && <p role="alert">A projecção guardada está em {plan.currency}; o projecto está em {projectCurrency}. Os valores e a moeda de origem foram preservados. Reveja o enquadramento monetário antes de os reutilizar; não foi feita conversão.</p>}
{snapshotError && (
<p
role="alert"
@@ -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>
)}
+26 -7
View File
@@ -1,4 +1,6 @@
import { SimulationPublishedIva, type PublishedIvaInput } from './simulation-published-iva';
import { SimulationMonthlyIva } from './simulation-monthly-iva';
import { inheritSimulatorFields } from '@/lib/context-inheritance';
import {
PublicDocumentContent,
type PublicDocument,
@@ -56,6 +58,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,
@@ -776,7 +779,7 @@ function SearchChoice({
export function SimulationForm() {
const { auth } = usePage<{
auth: { user: { email_verified_at?: string | null } | null };
auth: { user: { id: number; email_verified_at?: string | null } | null };
}>().props;
const pageUrl = usePage().url;
const draftContext = useMemo(() => {
@@ -788,11 +791,12 @@ export function SimulationForm() {
};
}, [pageUrl]);
const contextId = draftContext.context;
const storageScope = contextId
const projectScope = contextId
? `:case:${contextId}`
: draftContext.requestedDraft
? `:draft:${draftContext.requestedDraft}`
: '';
const storageScope = `${auth?.user ? `:account:${auth.user.id}` : ':anonymous'}${projectScope}`;
const draftKey = legacyDraftKey + storageScope;
const draftCreateKey = legacyDraftCreateKey + storageScope;
const draftIdKey = legacyDraftIdKey + storageScope;
@@ -804,12 +808,14 @@ export function SimulationForm() {
const canAssociateDraft = Boolean(auth?.user?.email_verified_at);
const [draft, setDraft] = useState<SimulationDraft>({});
const [ready, setReady] = useState(false);
useEffect(() => { if (ready) setDraft(current => inheritSimulatorFields(current)); }, [draft, ready]);
const [contextReview, setContextReview] = useState({
revision: 0,
reviewed: false,
});
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 +1942,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 +2315,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 +3294,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 +3341,7 @@ export function SimulationForm() {
}
/>
<FieldGuidance fieldKey={field.key} />
{ivaNotes}
</div>
);
}
@@ -3405,6 +3416,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 +3469,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') &&
@@ -3601,7 +3614,7 @@ export function SimulationForm() {
}
/>
)}
{current.id === 'tax' && <SimulationPublishedIva busy={isCalculating} onCalculate={runSimulation} />}
{current.id === 'tax' && <SimulationPublishedIva draft={draft} onChange={change} busy={isCalculating} onCalculate={runSimulation} />}
{current.id === 'tax' && (
<div
id="draft-tool-fiscal"
@@ -3609,6 +3622,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 +3637,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 +3654,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,24 @@
import { modeloAFields, modeloAPurchaseReferences, modeloAReference } from '@/lib/iva-modelo-a';
export function SimulationModeloA({draft}: {draft:Record<string,unknown>}) {
let purchases: ReturnType<typeof modeloAPurchaseReferences> = [];
let error = '';
try { purchases = modeloAPurchaseReferences(draft); }
catch (cause) { error = cause instanceof Error ? cause.message : 'Não foi possível consultar as aquisições projectadas.'; }
return <section aria-label="Correspondência com o Modelo A" className="space-y-3 rounded border p-3">
<h4 className="font-semibold">Modelo A — correspondência da simulação</h4>
<p>{modeloAReference.status}</p>
<p>Os campos 14–18 do apuramento permanecem indeterminados. O IVA das vendas não é imposto final a pagar. Os dados deste percurso são projecções, separados de facturas e declarações efectivas.</p>
<details><summary>Aquisições já projectadas — sem dedução presumida</summary>
<p>Reutilizam-se os valores das compras/encomendas para existências do plano. O mês da encomenda não prova a data da factura, recepção ou pagamento. Valores declarados ainda sem indicação de base líquida/IVA incluído não são tratados como base tributável. IVA suportado e IVA dedutível continuam por determinar; zero de compras não prova inexistência de outras aquisições.</p>
{error && <p role="alert">{error}</p>}
{!purchases.length && !error && <p>Sem agenda de compras recuperável do plano empresarial.</p>}
{!!purchases.length && <div className="overflow-x-auto"><table className="min-w-full text-sm"><caption>Pressupostos actuais de compras; sem resultado fiscal guardado</caption><thead><tr>{['Mês do plano','Compras declaradas','Sem IVA','IVA suportado','Com IVA','IVA dedutível'].map((name)=><th className="p-2 text-left" key={name}>{name}</th>)}</tr></thead><tbody>{purchases.map((row)=><tr key={row.month}><th className="p-2" scope="row">{row.month}</th><td>{row.declared ?? 'Sem informação'}</td><td>Por classificar</td><td>Indeterminado</td><td>Por classificar</td><td>Indeterminado</td></tr>)}</tbody></table></div>}
</details>
<details><summary>Ver campos, cálculos, fundamentos e cobertura do Modelo A</summary>
<p>{modeloAReference.file}. {modeloAReference.edition} SHA256: {modeloAReference.sha256}.</p>
<p>O exemplar contém divergências de numeração/remissões, registadas no confronto documental. Não foram corrigidas no original. A existência de um campo não activa uma regra fiscal.</p>
<div className="overflow-x-auto"><table className="min-w-full text-sm"><caption>Campo do Modelo A → finalidade → origem → cálculo → fundamento → cobertura</caption><thead><tr>{['Campo','Finalidade','Origem dos dados','Cálculo','Fundamento','Estado'].map((name)=><th className="p-2 text-left" key={name}>{name}</th>)}</tr></thead><tbody>{modeloAFields.map((field)=><tr key={field.field}>{[field.field,field.purpose,field.origin,field.calculation,field.basis,field.status].map((value,index)=><td className="border-t p-2 align-top" key={index}>{value}</td>)}</tr>)}</tbody></table></div>
</details>
</section>;
}
@@ -0,0 +1,83 @@
import { InvestmentContextAssessment } from './investment-context-assessment';
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';
import { SimulationModeloA } from './simulation-modelo-a';
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 || input.currency !== 'MZN' || 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">
<InvestmentContextAssessment raw={draft.context_applicability_snapshot} />
<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>
{!!draft.currency && draft.currency !== "MZN" && <p role="alert">Moeda do projecto: {String(draft.currency)}. A regra disponível aceita apenas MZN. As vendas e a moeda são preservadas, sem conversão; o IVA mensal não pode ser determinado por esta versão.</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>}
<SimulationModeloA draft={draft} />
</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';
@@ -9,16 +10,22 @@ export type PublishedIvaInput = {
scope_declared: boolean;
};
export function SimulationPublishedIva({ busy, onCalculate }: {
export function SimulationPublishedIva({ busy, onCalculate, draft, onChange }: {
draft: Record<string, unknown>;
onChange: (field: string, value: string) => void;
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…');
const [base, setBase] = useState('');
const [date, setDate] = useState('');
const [currency, setCurrency] = useState('');
const [declared, setDeclared] = useState(false);
const base = String(draft.published_iva_base ?? '');
const date = String(draft.published_iva_date ?? '');
const currency = String(draft.published_iva_currency ?? '');
const declared = draft.published_iva_scope === 'yes';
const setBase = (value: string) => onChange('published_iva_base', value);
const setDate = (value: string) => onChange('published_iva_date', value);
const setCurrency = (value: string) => onChange('published_iva_currency', value);
const setDeclared = (value: boolean) => onChange('published_iva_scope', value ? 'yes' : 'no');
const [error, setError] = useState('');
useEffect(() => {
let active = true;
@@ -51,18 +58,19 @@ 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">
<label className="block">Base tributável líquida (decimal, com ponto)<input aria-label="Base tributável líquida" inputMode="decimal" value={base} onChange={(e) => setBase(e.target.value)} className="block rounded border p-2" /></label>
<label className="block">Moeda da base<select aria-label="Moeda da base IVA" value={currency} onChange={(e) => setCurrency(e.target.value)} className="block rounded border p-2"><option value="">Seleccione</option><option value="MZN">MZN</option></select></label>
<label className="block">Moeda da base<select aria-label="Moeda da base IVA" value={currency} onChange={(e) => setCurrency(e.target.value)} className="block rounded border p-2"><option value="">Seleccione</option><option value="MZN">MZN</option>{currency && currency !== "MZN" && <option value={currency}>{currency} — moeda declarada</option>}</select></label>
<label className="block">Data de exigibilidade declarada<input aria-label="Data de exigibilidade IVA" type="date" min="2026-01-01" max="2026-12-31" value={date} onChange={(e) => setDate(e.target.value)} className="block rounded border p-2" /></label>
<label className="block"><input type="checkbox" checked={declared} onChange={(e) => setDeclared(e.target.checked)} /> Declaro operação interna à taxa geral, base líquida já apurada e ausência das excepções acima.</label>
<p className="text-sm">Sem esta declaração, o resultado fica indeterminado. O valor calculado conserva a precisão decimal; não é saldo a pagar.</p>
<button type="button" disabled={!available || busy} onClick={() => void calculate()} className="rounded bg-emerald-800 px-4 py-2 text-white disabled:opacity-50">{busy ? 'A calcular…' : 'Calcular IVA e gravar'}</button>
</fieldset>
{currency && currency !== "MZN" && <p role="alert">Moeda: {currency}. A versão disponível aceita apenas MZN. O valor declarado foi preservado; não foi feita conversão. Este cálculo permanece indisponível para esta moeda.</p>}
{error && <p role="alert">{error}</p>}
</section>;
}
+22 -15
View File
@@ -10,14 +10,16 @@ const navigation = [
export function TempoFooter() {
const { t, language } = useTempoUi();
const { portalInstitutionalContactEmail } = usePage<{ portalInstitutionalContactEmail?: string | null }>().props;
const { portalInstitutionalContactEmail } = usePage<{
portalInstitutionalContactEmail?: string | null;
}>().props;
return (
<footer
className="tempo-footer border-t border-white/5"
lang={language}
>
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-12 px-6 py-10 md:grid-cols-2 lg:grid-cols-5 lg:gap-16 lg:px-12">
<div className="space-y-8">
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-6 px-6 py-6 md:grid-cols-2 lg:grid-cols-5 lg:gap-8 lg:px-12">
<div className="space-y-4">
<Link
href="/"
aria-label="InvesteMoz — início"
@@ -37,9 +39,9 @@ export function TempoFooter() {
</address>
</div>
</div>
<div className="space-y-8">
<div className="space-y-4">
<h2 className="tempo-footer-heading">{t('Contactos')}</h2>
<div className="space-y-5">
<div className="space-y-2">
{[
['+258868010880', '+258 86 801 0880'],
['+258833219644', '+258 83 321 9644'],
@@ -58,21 +60,26 @@ export function TempoFooter() {
</a>
))}
<a
href={portalInstitutionalContactEmail ? `mailto:${portalInstitutionalContactEmail}` : '/apoio'}
href={
portalInstitutionalContactEmail
? `mailto:${portalInstitutionalContactEmail}`
: '/apoio'
}
className="tempo-contact-link"
>
<span className="tempo-contact-icon">
<Mail size={16} aria-hidden="true" />
</span>
<strong className="min-w-0 text-sm font-medium [overflow-wrap:anywhere]">
{portalInstitutionalContactEmail ?? t('Apoio ao investidor')}
<strong className="min-w-0 text-[13px] leading-5 font-medium [overflow-wrap:anywhere]">
{portalInstitutionalContactEmail ??
t('Apoio ao investidor')}
</strong>
</a>
</div>
</div>
<nav aria-label="Navegação do rodapé" className="space-y-8">
<nav aria-label="Navegação do rodapé" className="space-y-4">
<h2 className="tempo-footer-heading">{t('Navegação')}</h2>
<ul className="space-y-4">
<ul className="space-y-2">
{navigation.map(([href, label]) => (
<li key={href}>
<Link href={href} className="tempo-footer-link">
@@ -86,9 +93,9 @@ export function TempoFooter() {
))}
</ul>
</nav>
<nav aria-label="Informação legal" className="space-y-8">
<nav aria-label="Informação legal" className="space-y-4">
<h2 className="tempo-footer-heading">Legal</h2>
<ul className="space-y-4">
<ul className="space-y-2">
{[
[
'politica-de-privacidade',
@@ -111,7 +118,7 @@ export function TempoFooter() {
))}
</ul>
</nav>
<div className="space-y-8">
<div className="space-y-4">
<h2 className="tempo-footer-heading">Social</h2>
<p className="text-xs leading-relaxed font-medium text-slate-300">
{t(
@@ -121,8 +128,8 @@ export function TempoFooter() {
<TempoSocialLinks footer />
</div>
</div>
<div className="border-t border-white/10 py-2">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-6 px-6 text-center text-[10px] tracking-[0.2em] text-slate-400 md:flex-row md:text-left md:text-xs">
<div className="border-t border-white/10 py-1.5">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-1 px-6 text-center text-[10px] tracking-[0.1em] text-slate-400 md:flex-row md:text-left md:text-[11px]">
<p>
© {new Date().getFullYear()}{' '}
<span className="text-[#ed1c24]">Tempo</span>.{' '}
@@ -776,6 +776,37 @@ 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);
});
}
/** Existing purchase orders are planning references, never a proxy for deductible VAT or invoice dates. */
export function monthlyProjectedPurchaseOrders(input: BusinessFinancialPlanInput): Array<string | null> {
const periods = input.periods ?? 12;
if (!Number.isInteger(periods) || periods < 1 || periods > 60) throw new Error('Horizonte de compras inválido.');
return Array.from({length: periods}, (_, month) => {
const amounts = input.useProductInventorySchedule
? input.products.map((product) => product.productInventoryPurchasesByPeriod?.[month]?.amount)
: [input.useInventoryPurchaseSchedule
? input.inventoryPurchaseSchedule?.[month]?.amount : input.monthlyInventoryPurchases];
if (!amounts.length || amounts.some((amount) => typeof amount !== 'string' || amount.trim() === '')) return null;
return format(amounts.reduce((total, amount) => total + cents(amount!, 'Compras projectadas'), 0n));
});
}
function format(centsValue: bigint): string {
const absolute = centsValue < 0n ? -centsValue : centsValue;
return `${centsValue < 0n ? '-' : ''}${absolute / 100n}.${String(absolute % 100n).padStart(2, '0')}`;
+66
View File
@@ -0,0 +1,66 @@
import { restoreBusinessPlanSnapshot } from './business-financial-plan.ts';
const empty = (value: unknown) => value == null || value === '' || (Array.isArray(value) && value.length === 0);
function canonical(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).sort(([a],[b])=>a.localeCompare(b)).map(([key,item])=>[key,canonical(item)]));
return value;
}
// Change tracking only, never an authorization, integrity or legal-acceptance token. No personal values in metadata.
function signature(value: unknown): string {
const text=JSON.stringify(canonical(value)) ?? 'undefined';let hash=14695981039346656037n;
for(let index=0;index<text.length;index++) hash=BigInt.asUintN(64,(hash^BigInt(text.charCodeAt(index)))*1099511628211n);
return `${text.length}:${hash.toString(16)}`;
}
export function inheritCompatibleFields(scope:string, values:Record<string,unknown>, source:Record<string,unknown>, raw:unknown, allowed?:string[]): {patch:Record<string,unknown>; metadata:string; inherited:string[]; conflicts:string[]} {
let previous:Record<string,string>={};
try { const saved=JSON.parse(String(raw ?? ''));if(saved.version===1 && saved.scope===scope && saved.inherited && typeof saved.inherited==='object') previous=saved.inherited; } catch { /* Legacy inputs are retained, not overwritten. */ }
const next={...previous};const patch:Record<string,unknown>={};const inherited:string[]=[];const conflicts:string[]=[];
const geo=['province_id','district_id','admin_post_id','locality_id'];
const geoConflict=geo.some(key=>(!empty(values[key]) || key in previous) && signature(values[key])!==signature(source[key]) && previous[key]!==signature(values[key]));
for(const [key,value] of Object.entries(source)) {
if ((allowed && !allowed.includes(key)) || (empty(value) && !(key in previous))) continue;
const current=values[key];
const track = (item:unknown) => key==='parcels' ? Object.fromEntries(geo.map(field=>[field,(item as Array<Record<string,unknown>>)?.[0]?.[field] ?? ''])) : item;
const owned=previous[key]===signature(track(current));
const vacant=key==='parcels' ? geo.every(field=>empty((current as Array<Record<string,unknown>>)?.[0]?.[field])) : empty(current);
if ((geo.includes(key) && geoConflict) || (!owned && (!vacant || key in previous))) {
if(signature(current)!==signature(value)) conflicts.push(key);
continue;
}
if(key==='parcels') {
const parcels=current as Array<Record<string,unknown>>|undefined;
if(!owned && parcels?.length && geo.some(field=>!empty(parcels[0]?.[field]))) {conflicts.push(key);continue;}
}
if(signature(track(current))!==signature(track(value))) {
if(key==='parcels') {
const parcels=(current as Array<Record<string,unknown>>) ?? [];
patch[key]=[{...parcels[0],...Object.fromEntries(geo.map(field=>[field,(value as Array<Record<string,unknown>>)[0]?.[field] ?? '']))},...parcels.slice(1)];
} else patch[key]=value;
}
next[key]=signature(track(value));inherited.push(key);
}
return {patch,metadata:JSON.stringify({version:1,scope,inherited:next}),inherited,conflicts};
}
export function inheritSimulatorFields<T extends Record<string,unknown>>(draft:T): T {
const source:Record<string,unknown>={};
const plan=draft.business_plan_snapshot ? restoreBusinessPlanSnapshot(String(draft.business_plan_snapshot)) : null;
if(plan?.currency) source.currency=plan.currency;
const currency=draft.currency || source.currency;
if(currency) {source.fiscal_currency=currency;source.opportunity_currency=currency;source.published_iva_currency=currency;}
if(draft.tax_residency_country) source.fiscal_residency_country=draft.tax_residency_country;
if(draft.fiscal_reference_date) source.published_iva_date=draft.fiscal_reference_date;
let settings:Record<string,unknown>={start:'',basis:'unknown',declared:false,auto:false};
let settingsValid=!draft.monthly_iva_settings;
try {const saved=JSON.parse(String(draft.monthly_iva_settings ?? ''));if(saved && !Array.isArray(saved) && typeof saved.start==='string' && ['net','included','unknown'].includes(saved.basis) && typeof saved.declared==='boolean' && typeof saved.auto==='boolean') {settings=saved;settingsValid=true;}} catch { /* Invalid stored inputs remain intact. */ }
const start=String(draft.expected_start_date ?? '');
if(/^\d{4}-\d{2}-\d{2}$/.test(start)) source.monthly_iva_start=start.slice(0,7);
else if(/^\d{4}$/.test(String(draft.fiscal_period)) && draft.fiscal_periodicity==='monthly' && /^(0?[1-9]|1[0-2])$/.test(String(draft.fiscal_month))) source.monthly_iva_start=`${draft.fiscal_period}-${String(draft.fiscal_month).padStart(2,'0')}`;
if(!settingsValid) delete source.monthly_iva_start;
const values={...draft,monthly_iva_start:settings.start ?? ''};
const patch=inheritCompatibleFields('simulator-compatible-fields',values,source,draft.compatible_fields_snapshot);
const next:Record<string,unknown>={...draft,...patch.patch,compatible_fields_snapshot:patch.metadata};
if('monthly_iva_start' in patch.patch) next.monthly_iva_settings=JSON.stringify({...settings,start:patch.patch.monthly_iva_start});
delete next.monthly_iva_start;
return JSON.stringify(next)===JSON.stringify(draft) ? draft : next as T;
}
+9 -1
View File
@@ -1,4 +1,5 @@
import { isApiSimulationResultSnapshotV1 } from "./simulation-contract.ts";
import { modeloAComponentRows } from './iva-modelo-a.ts';
export function fiscalQaRows(value: unknown): Array<[string, string]> {
if (!isApiSimulationResultSnapshotV1(value)) return [];
@@ -19,7 +20,14 @@ 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 [
...modeloAComponentRows(snapshot),
...(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 +37,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",
+15
View File
@@ -134,4 +134,19 @@ export const contextFieldNames: Record<string, string> = {
province: 'Província',
district: 'Distrito',
location: 'Localização',
currency: 'Moeda do projecto',
person_type: 'Tipo de pessoa',
fiscal_period: 'Ano fiscal',
fiscal_periodicity: 'Periodicidade',
fiscal_month: 'Mês fiscal',
fiscal_residency_country: 'País de residência fiscal declarado',
activity_id: 'Actividade',
project_type: 'Modalidade do investimento',
maturity_stage: 'Fase do investimento',
expected_start_date: 'Início previsto',
investor_origin: 'Origem do investidor',
entity_stage: 'Situação da entidade',
tax_residency_country: 'Residência fiscal declarada',
investment_regime: 'Enquadramento territorial consultado',
special_zone_id: 'ZEE, ZFI ou parque',
};
+26
View File
@@ -0,0 +1,26 @@
export type IvaIssue = {
category: 'missing_field' | 'invalid_value' | 'out_of_scope' | 'period_unavailable' | 'system_configuration';
field: string | null;
label: string;
received: unknown;
expected: string;
message: string;
};
export type IvaDiagnostic = {
label: string;
rule_version: number;
validity: { start: string; end: string | null };
source: { title?: string; diploma?: string; article?: string; url?: string };
issues: IvaIssue[];
};
export const ivaIssueCategories = {
missing_field: 'Campo em falta', invalid_value: 'Valor inválido', out_of_scope: 'Fora do âmbito',
period_unavailable: 'Regra indisponível para o período', system_configuration: 'Configuração do sistema',
};
export function ivaReceived(value: unknown): string {
const labels: Record<string, string> = { domestic: 'Operação interna', internal_general_net: 'Operação interna à taxa geral, sobre base líquida', import: 'Importação', export: 'Exportação', refund: 'Reembolso', unknown: 'Não sei', non_resident: 'Operação com não residente', other: 'Outra operação' };
return typeof value === 'string' && value !== '' ? (labels[value] ?? value) : 'Não recebido';
}
export function ivaIssueText(issue: IvaIssue): string {
return `${issue.label}: ${ivaIssueCategories[issue.category]}. Recebido: ${ivaReceived(issue.received)}. Esperado: ${issue.expected}. ${issue.message}`;
}
+59
View File
@@ -0,0 +1,59 @@
import { monthlyProjectedPurchaseOrders, restoreBusinessPlanSnapshot } from './business-financial-plan.ts';
/** Correspondence to the Tempo exemplar, not an electronic AT declaration or a new tax ruleset. */
export const modeloAReference = {
file: 'Novo Modelo A.docx',
sha256: '62c198e90d8c6792f7d5ccefc6386df2e4b6089573067d3a6e1eae7fc2b9572b',
edition: 'Edição/vigência do formulário não estabelecida; original preservado na biblioteca QA.',
status: 'Simulação parcial: campos 01/02. Não é declaração submetida ou aceite pela Autoridade Tributária.',
};
export type ModeloAField = { field: string; purpose: string; origin: string; calculation: string; basis: string; status: string };
const pending = 'Em falta; não assumir zero';
export const modeloAFields: ModeloAField[] = [
{field:'01',purpose:'Base das operações à taxa geral',origin:'Vendas mensais já projectadas, declaradas sem IVA e no âmbito geral',calculation:'Base líquida do mês',basis:'CIVA arts.15 e 17; Lei22/2022; regra publicada existente',status:'Implementado no âmbito declarado'},
{field:'02',purpose:'IVA à taxa geral',origin:'Resultado mensal congelado no servidor',calculation:'Campo01 × taxa da versão publicada /100',basis:'CIVA art.17(1); mesma regra/versionamento do consumidor',status:'Implementado; componente do apuramento'},
{field:'03',purpose:'Base de operações específicas à taxa reduzida',origin:'Operações separadas e enquadradas; ainda não recolhidas',calculation:'Base das operações elegíveis',basis:'Exemplar e CIVA art.17-A, aditado pela Lei22/2022; enquadramento a cadastrar',status:pending},
{field:'04',purpose:'IVA à taxa reduzida',origin:'Campo03 e futura versão fundamentada',calculation:'Campo03 × taxa reduzida aplicável; não activa pelo exemplar',basis:'CIVA art.17-A; âmbito/versão temporal a integrar',status:pending},
{field:'05',purpose:'Vendas isentas com direito à dedução',origin:'Vendas classificadas por operação e fundamento',calculation:'Soma das bases classificadas; sem taxa presumida',basis:'Exemplar; CIVA arts.9–14 e 18–20 a consolidar por operação',status:pending},
{field:'06',purpose:'Operações da alínea b), n.º1, art.18, conforme exemplar',origin:'Operações classificadas com referência verificada',calculation:'Soma separada; não inferir do nome do sector',basis:'Remissão do exemplar ao art.18(1)(b); cotejo do âmbito necessário',status:pending},
{field:'07',purpose:'Isentas sem direito à dedução',origin:'Vendas classificadas e uso das aquisições',calculation:'Soma das bases elegíveis',basis:'Exemplar; incidência/isenções e exclusões de dedução a fundamentar',status:pending},
{field:'08',purpose:'IVA dedutível — imobilizado',origin:'Aquisições/facturas de activos, não o valor global do investimento',calculation:'Apenas imposto elegível documentado',basis:'CIVA arts.18–21; art.18(2), Lei10/2025: documentos legais e fornecedor inscrito',status:pending},
{field:'09',purpose:'IVA dedutível — existências',origin:'Compras projectadas reutilizáveis; falta IVA, documento e elegibilidade',calculation:'IVA elegível das existências, não compras × taxa geral',basis:'CIVA arts.18–21; condições/exclusões, afectação e período',status:pending},
{field:'10',purpose:'IVA dedutível — outros bens/serviços',origin:'Aquisições discriminadas; despesas agregadas não provam IVA',calculation:'IVA elegível; excluir operações sem direito',basis:'CIVA arts.18–21; art.19(2), Lei10/2025, inclui exclusão das operações art.17-A',status:pending},
{field:'11',purpose:'IVA dedutível — importações',origin:'Documento aduaneiro legal e afectação; ainda não recolhidos',calculation:'Imposto aduaneiro elegível, não taxa sobre orçamento',basis:'CIVA art.18(2), Lei10/2025; demais condições/exclusões a cadastrar',status:pending},
{field:'12',purpose:'Regularizações a favor do sujeito passivo',origin:'Ajustamentos individualizados, documento e motivo',calculation:'Soma de regularizações elegíveis',basis:'Exemplar tem remissão interna 11/12 divergente das caixas12/13; fundamento por motivo por conferir',status:pending},
{field:'13',purpose:'Regularizações a favor do Estado',origin:'Ajustamentos individualizados, documento e motivo',calculation:'Soma separada das regularizações elegíveis',basis:'Mesma divergência; não corrigir o original nem inventar incidência',status:pending},
{field:'14',purpose:'Soma das bases do quadro de apuramento',origin:'Campos01,03,05,06,07 completos',calculation:'01+03+05+06+07',basis:'Fórmula impressa no exemplar; não é apenas vendas tributáveis',status:'Estrutura mapeada; total indeterminado'},
{field:'15',purpose:'Total a favor do sujeito passivo',origin:'Campos08–12 completos',calculation:'08+09+10+11+12',basis:'Fórmula do exemplar; depende de deduções/regularizações fundamentadas',status:'Estrutura mapeada; total indeterminado'},
{field:'16',purpose:'Total a favor do Estado',origin:'Campos02,04,13 completos',calculation:'02+04+13',basis:'Fórmula impressa no exemplar; 02 sozinho não equivale a16',status:'Estrutura mapeada; total indeterminado'},
{field:'17',purpose:'Diferença devedora antes de créditos anteriores',origin:'Totais15/16 completos',calculation:'16−15 quando16>15',basis:'Instrução do exemplar; não é automaticamente pagamento',status:'Indeterminado; sem consolidação completa'},
{field:'18',purpose:'Diferença credora antes de créditos anteriores',origin:'Totais15/16 completos',calculation:'15−16 quando15>16',basis:'Instrução do exemplar; não constitui reembolso reconhecido',status:'Indeterminado; sem consolidação completa'},
{field:'19',purpose:'Excesso a reportar do período anterior',origin:'Histórico fiscal anterior confirmado, separado de estimativas',calculation:'Crédito anterior utilizável; sem copiar crédito estimado',basis:'Quadro de utilização de créditos do exemplar; condições/prazo por conferir',status:pending},
{field:'20',purpose:'Créditos comunicados pelos serviços',origin:'Comunicação efectiva da Administração Fiscal',calculation:'Crédito efectivamente comunicado e utilizável',basis:'Exemplar; não converter pedido ou crédito estimado em comunicação',status:pending},
{field:'21',purpose:'Diferencial de declaração de substituição',origin:'Valores17/18 da declaração actual e da substituída',calculation:'Casos impressos no quadro9, com sinal devedor/credor',basis:'Exemplar; ligação às declarações efectivas e regras de substituição em falta',status:pending},
{field:'22',purpose:'IVA a entregar no contexto da declaração',origin:'Apuramento e créditos/substituição completos',calculation:'Não derivar apenas do campo02',basis:'Quadro10 do exemplar; procedimento aplicável por fundamentar',status:pending},
{field:'23',purpose:'Juros compensatórios',origin:'Factos e períodos que legalmente os determinem',calculation:'Sem taxa, mora ou obrigação presumidas',basis:'Quadro10; fundamento específico não analisado neste lote',status:pending},
{field:'24',purpose:'Importância a pagar',origin:'Campos22/23 completos',calculation:'22+23',basis:'Fórmula do exemplar; não é ordem nem execução de pagamento',status:pending},
{field:'25',purpose:'Crédito de imposto do quadro11',origin:'Apuramento completo e tratamento dos créditos anteriores',calculation:'Montante recuperável no contexto próprio, por fundamentar',basis:'Caixa25 confirmada visualmente no bloco de recuperação; condições por consolidar',status:pending},
{field:'26',purpose:'Reporte para período seguinte',origin:'Crédito utilizável e opção no quadro11',calculation:'Separado de pedido e recebimento',basis:'Quadro11; cotejo de condições/prazos e articulação19/20 necessário',status:pending},
{field:'27',purpose:'Pedido de reembolso',origin:'Crédito elegível e pedido, sem presumir reconhecimento/caixa',calculation:'Pedido distinto de aprovação e recebimento',basis:'Exemplar cita art.20; Lei10/2025 altera reembolsos no art.21. Divergência registada.',status:pending},
];
export function modeloAComponentRows(snapshot: Record<string, unknown>): Array<[string,string]> {
if (!snapshot.monthly_projection) return [];
return [
['Correspondência Modelo A',modeloAReference.status],
['Campos01/02', 'Base líquida e IVA mensal da versão publicada. Os restantes campos não foram presumidos iguais a zero.'],
['Apuramento do período — campos14–18', 'Indeterminado: faltam componentes e enquadramentos do Modelo A. IVA das vendas não é imposto final a pagar.'],
['Origem dos dados','Projecção do negócio; não são operações efectivas nem declaração fiscal.'],
['Exemplar de referência',`${modeloAReference.file}; SHA256 ${modeloAReference.sha256}. ${modeloAReference.edition}`],
];
}
export function modeloAPurchaseReferences(draft: Record<string, unknown>): Array<{month:number; declared:string|null; net:null; vat:null; gross:null; deductible:null}> {
if (!draft.business_plan_snapshot) return [];
const plan = restoreBusinessPlanSnapshot(String(draft.business_plan_snapshot));
if (!plan) throw new Error('Não foi possível recuperar as aquisições projectadas. O original guardado foi preservado.');
return monthlyProjectedPurchaseOrders(plan).map((amount, index) => ({month:index+1, declared:amount, net:null, vat:null, gross:null, deductible:null}));
}
+51
View File
@@ -0,0 +1,51 @@
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.');
if (draft.currency && plan.currency && draft.currency !== plan.currency) throw new Error(`A projecção de vendas está em ${plan.currency}, mas o projecto está em ${String(draft.currency)}. Reveja a moeda da projecção; os valores não foram convertidos nem reclassificados.`);
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.';
-1
View File
@@ -444,7 +444,6 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
id: 'discovery',
label: 'Descoberta',
children: [
link('home', 'Início', '/'),
link(
'idea-entry',
'Partir de uma ideia',
@@ -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',
@@ -276,6 +277,19 @@ function makeSections(
}
}
if (draft.context_applicability_snapshot) {
try {
const context = JSON.parse(String(draft.context_applicability_snapshot));
if (context.version === 1) {
const section = sections.get('sources');
const zone = context.zone;
section?.rows.push({field:'Contexto do investimento',value:`Revisão ${context.context_revision}; ${zone ? `${zone.title}, versão ${zone.version}` : 'sem ZEE, ZFI ou parque seleccionado'}`,origin:'Referência do mesmo dossier no momento da gravação',classification:'Contexto versionado; não é reconhecimento jurídico'});
section?.paragraphs.push(zone ? 'Benefício fiscal da zona indeterminado: falta regra executável ligada às condições e reconhecimento. O IVA apresentado é apenas cenário geral sem benefício; não representa o regime especial nem o apuramento final.' : 'Cenário geral de preparação; sem confirmação automática de elegibilidade fiscal.');
if(zone?.href) section?.rows.push({field:'Ficha e fundamentos da zona',value:zone.href,origin:`Versão consultada ${zone.version}`,classification:'Referência consultável; condições por verificar'});
}
} catch { sections.get('sources')?.paragraphs.push('Não foi possível recuperar o contexto guardado; o enquadramento territorial permanece indeterminado.'); }
}
for (const [sectionName, field, value, origin] of rows.slice(1)) {
// Questionnaire schema identifiers remain stored; they do not help the
// reader assess the project and are not a business report field.
+16
View File
@@ -1,4 +1,7 @@
import { displayValue } from './display-value.ts';
import { monthlyIvaInput, monthlyIvaKey, readMonthlyIvaRecord, readMonthlyIvaSettings } from './monthly-iva.ts';
import { modeloAPurchaseReferences } from './iva-modelo-a.ts';
import { fiscalQaRows } from './fiscal-qa-preview.ts';
import { csvCellWithDelimiter } from './csv.js';
import {
restoreCashflowDraftSnapshot,
@@ -608,6 +611,19 @@ 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']);
try {
for (const purchase of modeloAPurchaseReferences(draft)) rows.push(['IVA mensal', `Compras/encomendas — mês ${purchase.month}`, purchase.declared ?? 'Sem informação', 'Pressuposto actual do plano, não resultado fiscal histórico; base/IVA incluído por classificar; IVA suportado e dedutível indeterminados; não é data de factura ou pagamento']);
} catch {
rows.push(['IVA mensal','Aquisições projectadas','Não foi possível recuperar; sem substituição por zero','Pressupostos guardados preservados; não calcula deduções']);
}
}
if (draft.cashflow_snapshot) {
const saved = restoreCashflowDraftSnapshot(
String(draft.cashflow_snapshot),
+8 -1
View File
@@ -1,3 +1,4 @@
import { inheritSimulatorFields } from './context-inheritance.ts';
import { csvCell } from './csv.js';
import { fiscalQaRows } from './fiscal-qa-preview.ts';
import { isApiSimulationResultSnapshotV1 } from './simulation-contract.ts';
@@ -965,6 +966,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',
@@ -1524,7 +1531,7 @@ export function restoreSimulationDraft(
}
}
}
return draft as SimulationDraft;
return inheritSimulatorFields(draft as SimulationDraft);
}
export function simulationDraftFingerprint(draft: SimulationDraft): string {
+1
View File
@@ -135,6 +135,7 @@ export default function RequestPage({
<DossierPrefill
contextId={dossier.id}
consumer="desk"
persistInheritance={request.status !== 'returned'}
values={form.data.payload}
boundRevision={boundContextRevision}
readOnly={!canEdit}
+27 -3
View File
@@ -1,3 +1,4 @@
import { InvestmentContextAssessment } from '@/components/investment-context-assessment';
import ContextDocuments from '@/components/context-documents';
import { Link } from '@inertiajs/react';
import { useState } from 'react';
@@ -131,6 +132,7 @@ export default function ContextEditor({
}
return (
<Frame title="Contexto do investimento">
<InvestmentContextAssessment contextId={view.case.id} raw={view.prefill.simulator?.context_applicability_snapshot} />
<ContextDocuments source={view.context.selection.opportunity} />
<div className="space-y-5 [overflow-wrap:anywhere]">
<Link
@@ -286,7 +288,7 @@ export default function ContextEditor({
</select>
</label>
<label className="grid min-w-0 gap-1">
Parque consultado
ZEE, ZFI ou parque consultado
<select
className={input + ' max-w-full min-w-0'}
disabled={busy}
@@ -294,7 +296,7 @@ export default function ContextEditor({
onChange={(e) => change('park', e.target.value)}
>
<option value="">
Sem parque no contexto atual
Sem ZEE, ZFI ou parque seleccionado
</option>
{options.parks.map((p) => (
<option key={p.id} value={'park:' + p.id}>
@@ -335,6 +337,17 @@ export default function ContextEditor({
Estes campos pertencem ao processo. Alterá-los não muda
o cadastro da oportunidade nem o perfil da organização.
</p>
<p>Origem do investidor, residência fiscal e constituição da entidade são declarações distintas. A localização não confirma benefícios. A prévia identifica o impacto antes de guardar.</p>
{[
['currency','Moeda do projecto',null],
['expected_start_date','Data prevista de início',null],
['investor_origin','Origem do investidor',[['national','Nacional'],['foreign','Estrangeiro'],['mixed','Misto']]],
['person_type','Tipo de entidade',[['singular','Pessoa singular'],['collective','Pessoa colectiva'],['consortium','Consórcio']]],
['entity_stage','Situação da entidade',[['existing','Existente'],['being_formed','Em constituição']]],
['tax_residency_country','País de residência fiscal declarado (ISO de duas letras)',null],
].map(([key,label,choices]) => <label className="grid gap-1" key={String(key)}>{String(label)}{Array.isArray(choices)
? <select className={input} value={declarations[String(key)] ?? view.context.fields[String(key)] ?? ''} onChange={e=>{setDeclarations({...declarations,[String(key)]:e.target.value});setPlan(null);}}><option value="">Por indicar</option>{choices.map(([value,text])=><option key={value} value={value}>{text}</option>)}</select>
: <input className={input} type={key==='expected_start_date'?'date':'text'} maxLength={key==='currency'?3:key==='tax_residency_country'?2:10} value={declarations[String(key)] ?? view.context.fields[String(key)] ?? ''} onChange={e=>{setDeclarations({...declarations,[String(key)]:e.target.value});setPlan(null);}} />}</label>)}
{['project_title', 'project_summary'].map((k) => (
<label className="grid gap-1" key={k}>
{contextFieldNames[k]}
@@ -366,7 +379,18 @@ export default function ContextEditor({
declarações do utilizador. Autorize somente os
campos que pretende substituir pelo cadastro.
</p>
{['project_title', 'project_summary'].map((k) => (
<p>Origem do investidor, residência fiscal e constituição da entidade são declarações distintas. A localização não confirma benefícios. A prévia identifica o impacto antes de guardar.</p>
{[
['currency','Moeda do projecto',null],
['expected_start_date','Data prevista de início',null],
['investor_origin','Origem do investidor',[['national','Nacional'],['foreign','Estrangeiro'],['mixed','Misto']]],
['person_type','Tipo de entidade',[['singular','Pessoa singular'],['collective','Pessoa colectiva'],['consortium','Consórcio']]],
['entity_stage','Situação da entidade',[['existing','Existente'],['being_formed','Em constituição']]],
['tax_residency_country','País de residência fiscal declarado (ISO de duas letras)',null],
].map(([key,label,choices]) => <label className="grid gap-1" key={String(key)}>{String(label)}{Array.isArray(choices)
? <select className={input} value={declarations[String(key)] ?? view.context.fields[String(key)] ?? ''} onChange={e=>{setDeclarations({...declarations,[String(key)]:e.target.value});setPlan(null);}}><option value="">Por indicar</option>{choices.map(([value,text])=><option key={value} value={value}>{text}</option>)}</select>
: <input className={input} type={key==='expected_start_date'?'date':'text'} maxLength={key==='currency'?3:key==='tax_residency_country'?2:10} value={declarations[String(key)] ?? view.context.fields[String(key)] ?? ''} onChange={e=>{setDeclarations({...declarations,[String(key)]:e.target.value});setPlan(null);}} />}</label>)}
{['project_title', 'project_summary'].map((k) => (
<label className="block" key={k}>
<input
type="checkbox"
@@ -0,0 +1,103 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\InvestmentZones;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
class InvestmentContextContinuityTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$db = DB::selectOne('select current_database() as name, inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $db->name);
$this->assertSame(55441, (int) $db->port);
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
}
private function dossier(User $user, string $title): string
{
return $this->actingAs($user)->postJson('/investimentos', ['title' => $title, 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
}
public function test_same_dossier_context_reuses_linked_draft_and_preserves_account_and_project_boundaries(): void
{
$owner = User::factory()->create();
$a = $this->dossier($owner, 'A');
$b = $this->dossier($owner, 'B');
foreach ([[$a, 'MZN', 'national'], [$b, 'USD', 'foreign']] as [$id,$currency,$origin]) {
$this->actingAs($owner)->postJson('/api/v1/simulation-drafts', ['context' => $id, 'personal_snapshot' => ['investor_origin' => $origin, 'tax_residency_country' => 'ZA', 'person_type' => 'collective'], 'economic_snapshot' => ['purpose' => 'simulate_only', 'project_title' => $id, 'currency' => $currency, 'expected_start_date' => '2026-01-01']], ['Idempotency-Key' => (string) Str::uuid()])->assertCreated();
$v = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk();
$v->assertJsonPath('prefill.simulator.currency', $currency)->assertJsonPath('prefill.simulator.investor_origin', $origin)->assertJsonPath('prefill.simulator.tax_residency_country', 'ZA');
$this->assertSame('linked_simulation', $v->json('context.provenance.currency.kind'));
}
$this->actingAs(User::factory()->create())->getJson('/investimentos/'.$a.'/contexto')->assertForbidden();
$this->actingAs($owner)->getJson('/investimentos/'.$a.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.currency', 'MZN');
}
public function test_context_change_is_previewed_versioned_and_idempotent_without_changing_the_source_draft(): void
{
$owner = User::factory()->create();
$id = $this->dossier($owner, 'Impacto');
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$body = ['version' => $view['case']['version'], 'selection' => [], 'declarations' => ['currency' => 'USD', 'investor_origin' => 'foreign', 'tax_residency_country' => 'MZ'], 'replace_fields' => []];
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
$this->assertNotEmpty($plan['updated']);
$this->assertTrue($plan['dependency_changed']);
$control = [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true];
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', $control)->assertOk();
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', $control)->assertOk()->assertJsonPath('replayed', true);
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.currency', 'USD')->assertJsonPath('prefill.simulator.tax_residency_country', 'MZ');
}
public function test_zone_selection_reuses_existing_reference_and_impact_workflow_without_granting_a_benefit(): void
{
$owner = User::factory()->create();
$id = $this->dossier($owner, 'Zonas QA');
$template = app(InvestmentZones::class)->visible()->where('category', 'industrial_park')->firstOrFail();
foreach (['zee', 'zfi', 'industrial_park'] as $category) {
// Synthetic catalogue metadata, rolled back. Never a legal source or published rule in homologation.
$zone = $template->replicate();
$zone->id = (string) Str::uuid();
$zone->code = 'SINT-CONTEXT-'.Str::random(12);
$zone->name = 'SINTÉTICO QA '.$category;
$zone->category = $category;
$zone->legal_regime = $category === 'industrial_park' ? 'pending' : $category;
$zone->source_evidence = [['source_type' => 'government', 'url' => 'https://example.test/synthetic-zone', 'supports' => 'Synthetic test only; no legal or eligibility conclusion']];
$zone->save();
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$body = ['version' => $view['case']['version'], 'selection' => ['park' => 'park:'.$zone->id]];
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
$this->assertSame($category, $plan['fields']['investment_regime']);
$this->assertContains('S17', $plan['review_steps']);
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$after = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk();
$after->assertJsonPath('prefill.simulator.investment_regime', $category);
$snapshot = json_decode($after->json('prefill.simulator.context_applicability_snapshot'), true);
$this->assertSame($zone->id, $snapshot['zone']['id']);
$this->assertArrayNotHasKey('benefit_rate', $snapshot);
}
$view = $this->getJson('/investimentos/'.$id.'/contexto')->json();
$body = ['version' => $view['case']['version'], 'selection' => ['park' => null]];
$plan = $this->postJson('/investimentos/'.$id.'/contexto/previa', $body)->assertOk()->json();
$this->postJson('/investimentos/'.$id.'/contexto/confirmar', [...$body, 'preview_hash' => $plan['preview_hash'], 'operation_key' => (string) Str::uuid(), 'confirm' => true])->assertOk();
$this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->assertJsonPath('prefill.simulator.investment_regime', 'general')->assertJsonPath('prefill.simulator.special_zone_id', '');
}
public function test_a_title_edit_does_not_invalidate_applicability_or_ask_for_irrelevant_reviews(): void
{
$owner = User::factory()->create();
$id = $this->dossier($owner, 'Título original');
$view = $this->getJson('/investimentos/'.$id.'/contexto')->assertOk()->json();
$this->postJson('/investimentos/'.$id.'/contexto/previa', ['version' => $view['case']['version'], 'selection' => [], 'declarations' => ['project_title' => 'Título revisto']])
->assertOk()->assertJsonPath('dependency_changed', false)->assertJsonPath('review_steps', []);
}
}
@@ -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,63 @@
import { createProjectDossierReport } from '../../resources/js/lib/project-dossier-report.ts';
import assert from 'node:assert/strict';
import test from 'node:test';
import { inheritCompatibleFields, inheritSimulatorFields } from '../../resources/js/lib/context-inheritance.ts';
import { serializeBusinessPlanSnapshot } from '../../resources/js/lib/business-financial-plan.ts';
import { monthlySalesFromDraft } from '../../resources/js/lib/monthly-iva.ts';
import { restoreSimulationDraft, partitionSimulationDraft } from '../../resources/js/lib/simulation-form.ts';
test('inherited source updates follow through recovery while local edits and deliberate clears survive',()=>{
const first=inheritCompatibleFields('dossier:A:simulator',{}, {currency:'MZN',project_title:'A'}, null);
const saved={...first.patch,context_reuse:first.metadata};
const changed=inheritCompatibleFields('dossier:A:simulator',saved,{currency:'USD',project_title:'A2'},saved.context_reuse);
assert.deepEqual(changed.patch,{currency:'USD',project_title:'A2'});
const edited=inheritCompatibleFields('dossier:A:simulator',{...saved,currency:'EUR',project_title:''},{currency:'USD',project_title:'A2'},saved.context_reuse);
assert.deepEqual(edited.patch,{});assert.deepEqual(edited.conflicts,['currency','project_title']);
const recovered=JSON.parse(JSON.stringify({...saved,currency:'EUR',project_title:''}));
assert.deepEqual(inheritCompatibleFields('dossier:A:simulator',recovered,{currency:'USD',project_title:'A2'},recovered.context_reuse).patch,{});
assert.ok(!first.metadata.includes('MZN'));assert.ok(!first.metadata.includes('project_title":"A'));
});
test('a different dossier cannot take ownership of existing values using another dossier metadata',()=>{
const first=inheritCompatibleFields('dossier:A:simulator',{}, {currency:'MZN'}, null);
const second=inheritCompatibleFields('dossier:B:simulator',first.patch,{currency:'USD'},first.metadata);
assert.deepEqual(second.patch,{});assert.deepEqual(second.conflicts,['currency']);
});
test('a local geographic override protects the entire hierarchy',()=>{
const first=inheritCompatibleFields('A',{}, {province_id:'p1',district_id:'d1'},null);
const update=inheritCompatibleFields('A',{...first.patch,province_id:'p-local'},{province_id:'p2',district_id:'d2'},first.metadata);
assert.deepEqual(update.patch,{});
});
test('compatible currency and date reuse survives backend partition and new session recovery without invented tax scope',()=>{
const initial=inheritSimulatorFields({currency:'USD',expected_start_date:'2026-03-01',purpose:'simulate_only'});
assert.equal(initial.fiscal_currency,'USD');assert.equal(initial.published_iva_currency,'USD');assert.equal(initial.opportunity_currency,'USD');
assert.equal(JSON.parse(initial.monthly_iva_settings).start,'2026-03');assert.equal(JSON.parse(initial.monthly_iva_settings).declared,false);
assert.equal(initial.published_iva_date,undefined);
assert.equal(inheritSimulatorFields({expected_start_date:'2026-01-01',monthly_iva_settings:'[invalid stored input'}).monthly_iva_settings,'[invalid stored input');
const saved=partitionSimulationDraft(initial,true);const recovered=restoreSimulationDraft(saved.economic_snapshot,saved.personal_snapshot);
assert.equal(recovered.published_iva_currency,'USD');assert.strictEqual(inheritSimulatorFields(recovered),recovered);
const changed=inheritSimulatorFields({...recovered,currency:'EUR'});assert.equal(changed.fiscal_currency,'EUR');
const override=inheritSimulatorFields({...changed,fiscal_currency:'MZN',published_iva_currency:''});assert.equal(override.fiscal_currency,'MZN');assert.equal(override.published_iva_currency,'');
});
test('land consumer reuses geography inside an empty parcel and preserves all other parcel data',()=>{
const first=inheritCompatibleFields('land', {parcels:[{area:'12',province_id:''},{area:'20',province_id:'other'}]}, {parcels:[{province_id:'P',district_id:'D'}]},null);
assert.equal(first.patch.parcels[0].area,'12');assert.equal(first.patch.parcels[0].district_id,'D');assert.equal(first.patch.parcels[1].province_id,'other');
const changed=inheritCompatibleFields('land',{parcels:[{...first.patch.parcels[0],area:'13'},first.patch.parcels[1]]},{parcels:[{province_id:'P2'}]},first.metadata);
assert.equal(changed.patch.parcels[0].area,'13');assert.equal(changed.patch.parcels[0].district_id,'');
});
test('clearing an inherited zone reference clears only the inherited value, preserving a deliberate override',()=>{
const first=inheritCompatibleFields('A',{}, {special_zone_id:'zone'},null);
assert.equal(inheritCompatibleFields('A',first.patch,{special_zone_id:''},first.metadata).patch.special_zone_id,'');
assert.deepEqual(inheritCompatibleFields('A',{special_zone_id:'local'},{special_zone_id:''},first.metadata).patch,{});
});
test('conflicting saved sales currency is explained instead of relabelled or converted',()=>{
const fields=['openingCash','monthlyCollections','monthlyVariableCostPayments','monthlyInventoryPurchases','monthlyOperatingExpenses','monthlyOperatingPayments','monthlyPayrollExpense','monthlyPayrollPayments','assetInvestment','inventory','receivables','payables','equity','shareholderLoans','bankLoanProceeds','grants','assetDepreciation','monthlyInterestExpense','monthlyInterestPayment','monthlyPrincipalRepayment'];
const plan={...Object.fromEntries(fields.map(key=>[key,'0'])),currency:'MZN',periods:4,depreciationLifeMonths:60,products:[{label:'QA',monthlyUnits:'1',unitPrice:'100',unitVariableCost:'0'}]};
assert.throws(()=>monthlySalesFromDraft({currency:'USD',business_plan_snapshot:serializeBusinessPlanSnapshot(plan)}),/projecção de vendas está em MZN.*projecto está em USD/);
});
test('report preserves consulted zone version and makes unsupported benefit explicit without inventing a fiscal result',()=>{
const context={version:1,context_revision:3,zone:{title:'Referência QA',version:7,href:'/zonas/qa'},fields:{currency:'USD'}};
const draft={project_title:'QA',currency:'USD',context_applicability_snapshot:JSON.stringify(context)};
const saved=JSON.stringify(draft);const report=createProjectDossierReport(draft,{selectedSections:new Set(['sources'])});
assert.match(JSON.stringify(report),/Referência QA, versão 7/);assert.match(JSON.stringify(report),/Benefício fiscal da zona indeterminado/);assert.equal(JSON.stringify(draft),saved);
});
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { monthlyProjectedPurchaseOrders, serializeBusinessPlanSnapshot } from '../../resources/js/lib/business-financial-plan.ts';
import { modeloAComponentRows, modeloAPurchaseReferences } from '../../resources/js/lib/iva-modelo-a.ts';
import { createProjectDossierReport } from '../../resources/js/lib/project-dossier-report.ts';
test('purchase references use active existing purchase schedules, not cost of sales or cash payments', () => {
const plan={periods:3,products:[{label:'A',monthlyUnits:'8',unitPrice:'30',unitVariableCost:'12',productInventoryPurchasesByPeriod:[{amount:'100',leadMonths:2},{amount:'0',leadMonths:0},{amount:'',leadMonths:1}]}],useProductInventorySchedule:true,monthlyInventoryPurchases:'900',monthlyVariableCostPayments:'700',useInventoryPurchaseSchedule:true,inventoryPurchaseSchedule:[{amount:'400',leadMonths:0},{amount:'500',leadMonths:0},{amount:'600',leadMonths:0}]};
assert.deepEqual(monthlyProjectedPurchaseOrders(plan),['100.00','0.00',null]);
plan.useProductInventorySchedule=false;
assert.deepEqual(monthlyProjectedPurchaseOrders(plan),['400.00','500.00','600.00']);
plan.useInventoryPurchaseSchedule=false;
assert.deepEqual(monthlyProjectedPurchaseOrders(plan),['900.00','900.00','900.00']);
});
test('missing and zero purchase amounts are preserved without manufacturing net VAT or deduction', () => {
const plan={currency:'MZN',periods:2,depreciationLifeMonths:60,products:[{label:'A',monthlyUnits:'1',unitPrice:'10',unitVariableCost:'0'}],useInventoryPurchaseSchedule:true,inventoryPurchaseSchedule:[{amount:'0',leadMonths:0},{amount:'',leadMonths:0}]};
for(const key of ['openingCash','monthlyCollections','monthlyVariableCostPayments','monthlyOperatingExpenses','monthlyOperatingPayments','monthlyPayrollExpense','monthlyPayrollPayments','assetInvestment','inventory','receivables','payables','equity','shareholderLoans','bankLoanProceeds','grants','assetDepreciation','monthlyInterestExpense','monthlyInterestPayment','monthlyPrincipalRepayment']) plan[key]='0';
const rows=modeloAPurchaseReferences({business_plan_snapshot:serializeBusinessPlanSnapshot(plan)});
assert.equal(rows[0].declared,'0.00');assert.equal(rows[1].declared,null);
for(const row of rows) for(const field of ['net','vat','gross','deductible']) assert.equal(row[field],null);
assert.deepEqual(modeloAPurchaseReferences({}),[]);
assert.throws(()=>modeloAPurchaseReferences({business_plan_snapshot:'broken'}),/preservado/);
const report = createProjectDossierReport({currency:'MZN',business_plan_snapshot:serializeBusinessPlanSnapshot(plan),monthly_iva_snapshot:'{}'},{selectedSections:new Set(['finance'])});
const text=JSON.stringify(report);
assert.match(text,/Compras\/encomendas/);assert.match(text,/0\.00/);
assert.match(text,/base\/IVA incluído por classificar/);assert.match(text,/IVA suportado e dedutível indeterminados/);
});
test('Modelo A report adds partial coverage without recalculating or rewriting accepted server history', () => {
const snapshot={monthly_projection:{rows:[{date:'2026-01-01',net_sales:'1000.03',vat:'160.0048'}]},result:{value:'160.0048'}};
const before=JSON.stringify(snapshot);const rows=modeloAComponentRows(snapshot);
assert.equal(JSON.stringify(snapshot),before);
assert.match(rows.map(row=>row.join(' ')).join('\n'),/Indeterminado/);
assert.match(rows.map(row=>row.join(' ')).join('\n'),/não é imposto final/);
assert.deepEqual(modeloAComponentRows({scenario_kind:'custom_planning'}),[]);
});
+57
View File
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { monthlyProjectedSales, forecastBusinessPlan, serializeBusinessPlanSnapshot } from '../../resources/js/lib/business-financial-plan.ts';
import { monthlyIvaInput, monthlyIvaKey, monthlySalesFromDraft, readMonthlyIvaRecord, monthlyIvaCashTreatment } from '../../resources/js/lib/monthly-iva.ts';
import { partitionSimulationDraft, restoreSimulationDraft, readArithmeticSnapshot } from '../../resources/js/lib/simulation-form.ts';
import { createProjectDossierReport } from '../../resources/js/lib/project-dossier-report.ts';
function plan() {
const fields = ['openingCash','monthlyCollections','monthlyVariableCostPayments','monthlyInventoryPurchases','monthlyOperatingExpenses','monthlyOperatingPayments','monthlyPayrollExpense','monthlyPayrollPayments','assetInvestment','inventory','receivables','payables','equity','shareholderLoans','bankLoanProceeds','grants','assetDepreciation','monthlyInterestExpense','monthlyInterestPayment','monthlyPrincipalRepayment'];
return {...Object.fromEntries(fields.map((key) => [key,'0'])), currency:'MZN', periods:4, depreciationLifeMonths:60,
products:[{label:'Produto QA',monthlyUnits:'1',unitPrice:'1000.03',unitVariableCost:'0',monthlyUnitsByPeriod:['1','2','0','3']}]};
}
const settings = {start:'2026-01',basis:'net',declared:true,auto:true};
function draft(value=plan()) {return {currency:'MZN',business_plan_snapshot:serializeBusinessPlanSnapshot(value),monthly_iva_settings:JSON.stringify(settings)};}
function snapshot(input) {
return {contract_version:1,scenario_kind:'fiscal_rule_published',base:null,currency:'MZN',indicative_total:null,legal_interpretation:false,formula_version:'investmoz.tax.v1',
context:{reference_date:'2026-01-01',currency:'MZN',currency_fraction_digits:2,scenario_id:'61000100-0000-4000-8000-000000000099',scenario_version:1,engine_version:'fiscal-published-v1',ruleset_version:'IVA-GERAL-BASE-LIQUIDA:1'},
result:{status:'indeterminate',value:null,unit:'MZN',explanation:'IVA mensal; não é imposto a pagar.',missing_inputs:['monthly_sales'],source_ids:['qa-source']},presentation:{value:null,rounding_applied:false},
fiscal_provenance:{rule:{rule_code:'IVA-GERAL-BASE-LIQUIDA',rule_version:1,valid_from:'2023-01-01',valid_until:null,source:{diploma:'Fonte sintética QA',article:'Teste'}},scope_status:'user_declared'},
monthly_projection:{input,cash_treatment:monthlyIvaCashTreatment,rows:[{date:'2026-01-01',sales_declared:'1000.03',net_sales:'1000.03',rate:'16',vat:'160.0048',gross_sales:'1160.0348',status:'calculated'},{date:'2026-02-01',sales_declared:'0',net_sales:'0',rate:'16',vat:'0',gross_sales:'0',status:'calculated'},{date:'2026-03-01',sales_declared:null,net_sales:null,rate:null,vat:null,gross_sales:null,status:'missing_sales'}]}};
}
test('monthly IVA reuses exactly the existing per-product sales projection without repeating entry', () => {
const value=plan();
assert.deepEqual(monthlyProjectedSales(value), forecastBusinessPlan(value).rows.map((row) => row.revenue));
assert.deepEqual(monthlySalesFromDraft(draft()).amounts,['1000.03','2000.06','0.00','3000.09']);
});
test('a missing month remains unknown while an explicit zero and other months stay available', () => {
const value=plan(); value.products[0].monthlyUnitsByPeriod=['1','','0','2'];
assert.deepEqual(monthlyProjectedSales(value),['1000.03',null,'0.00','2000.06']);
const input=monthlyIvaInput(draft(value), settings);
assert.equal(input.monthly_sales[1].amount,null); assert.equal(input.monthly_sales[2].amount,'0.00');
assert.equal('rate' in input,false); assert.equal('monthlyTaxAssessed' in input,false);
});
test('gross basis is preserved and old fiscal results become stale when sales, currency or basis change', () => {
const value=draft(); const initial=monthlyIvaInput(value, settings); const frozen=JSON.stringify(initial);
const gross=monthlyIvaInput(value,{...settings,basis:'included'});
assert.equal(gross.monthly_sales[0].amount,'1000.03'); assert.notEqual(monthlyIvaKey(gross),monthlyIvaKey(initial));
const changed=plan();changed.products[0].monthlyUnitsByPeriod[0]='2';
assert.notEqual(monthlyIvaKey(monthlyIvaInput(draft(changed),settings)),monthlyIvaKey(initial));
assert.equal(JSON.stringify(initial),frozen);
assert.throws(() => monthlyIvaInput(value,{...settings,start:'2026-12'}),/cobertura/);
});
test('monthly recovery and finance report preserve exact values and mark stale history without recomputing VAT', () => {
const value=plan();value.products[0].monthlyUnitsByPeriod=['1','0','','2'];
const original=draft(value); const input=monthlyIvaInput(original,settings); const result=snapshot(input);
original.monthly_iva_snapshot=JSON.stringify({input,execution:{id:'61000100-0000-4000-8000-000000000077',status:'completed',version_no:2,result_snapshot:result}});
const partition=partitionSimulationDraft(original); const recovered=restoreSimulationDraft(partition.economic_snapshot,partition.personal_snapshot);
assert.equal(recovered.monthly_iva_snapshot,original.monthly_iva_snapshot);
assert.equal(readMonthlyIvaRecord(recovered.monthly_iva_snapshot).execution.result_snapshot.monthly_projection.rows[0].vat,'160.0048');
assert.equal(readArithmeticSnapshot(result).integrity,'versioned');
let report=createProjectDossierReport(recovered,{selectedSections:new Set(['finance'])});
let text=JSON.stringify(report); assert.match(text,/160\.0048/);assert.match(text,/1160\.0348/);assert.match(text,/sem informação de vendas/);assert.match(text,/não é imposto a pagar/);
value.products[0].monthlyUnitsByPeriod[0]='2';recovered.business_plan_snapshot=serializeBusinessPlanSnapshot(value);
report=createProjectDossierReport(recovered,{selectedSections:new Set(['finance'])});text=JSON.stringify(report);
assert.match(text,/Histórico desactualizado/);assert.match(text,/160\.0048/);
assert.match(monthlyIvaCashTreatment,/não é somado novamente/);assert.match(monthlyIvaCashTreatment,/nem deduzido como custo/);
});
+389
View File
@@ -0,0 +1,389 @@
// Isolated UI fixture: real header/providers, synthetic Inertia props, no database.
// Requires the local Vite server. No requests to Laravel or external services.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { portalNavigation } from '../resources/js/lib/portal-navigation.ts';
const root = process.cwd();
const dir = path.join(root, 'outputs/header-navbar-20261021');
fs.mkdirSync(dir, { recursive: true });
const require = createRequire(import.meta.url);
const { chromium } = require(process.env.HEADER_QA_PLAYWRIGHT || 'playwright');
const { expect } = require(
(process.env.HEADER_QA_PLAYWRIGHT || 'playwright') + '/test',
);
const base = process.env.HEADER_QA_VITE || 'http://127.0.0.1:5173';
assert.equal(new URL(base).hostname, '127.0.0.1');
const before = process.argv.includes('--before');
if (before && fs.existsSync(path.join(dir, 'before-results.json'))) {
throw new Error(
'The original baseline already exists and must be preserved.',
);
}
fs.writeFileSync(
path.join(dir, 'fixture.html'),
`<!doctype html>
<html lang="pt"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>InvesteMoz · revisão do header</title></head><body><div id="app"></div>
<script type="module" src="/outputs/header-navbar-20261021/fixture.tsx"></script></body></html>`,
);
fs.writeFileSync(
path.join(dir, 'fixture.tsx'),
`
import React from 'react';
import { createRoot } from 'react-dom/client';
import { createInertiaApp } from '@inertiajs/react';
import { PortalGroupedMenu } from '../../resources/js/components/portal-grouped-menu';
import { TempoUiProvider, useTempoUi } from '../../resources/js/components/tempo-brand';
import '../../resources/css/app.css';
const role = new URLSearchParams(location.search).get('role') || 'guest';
const props = {
auth: { user: role === 'guest' ? null : { id: 1, name: 'Investidor de demonstração com nome extenso', email: 'qa@example.invalid' }, canManagePortal: role === 'manager' },
existingBusinessEnabled: true, businessReviewUrl: role === 'manager' ? '/management/negocios' : null,
educationEnabled: true, aiEnabled: true, legacyContentEnabled: true,
};
function Fixture() {
const ui = useTempoUi();
return <><PortalGroupedMenu /><main style={{padding:'48px 24px',maxWidth:1200,margin:'auto'}}>
<p style={{color:'#b91c1c',fontSize:12,fontWeight:600,letterSpacing:2}}>INVESTEMOZ · REVISÃO LOCAL</p>
<h1 style={{fontSize:36,fontWeight:600,margin:'16px 0'}}>O seu próximo investimento começa aqui</h1>
<p style={{color:'#64748b'}}>Navegação real do portal com dados fictícios para testar a apresentação.</p>
<button data-test="increment" onClick={ui.registerSubmittedInterest}>Simular pedido no contador</button>
</main></>;
}
await createInertiaApp({page:{component:'fixture',props,url:'/',version:null},resolve:()=>Fixture,
setup({el,App,props}) {createRoot(el).render(<TempoUiProvider><App {...props}/></TempoUiProvider>);}});
`,
);
const browser = await chromium.launch({
headless: true,
...(process.env.HEADER_QA_CHROMIUM
? { executablePath: process.env.HEADER_QA_CHROMIUM }
: {}),
});
const results = [];
const errors = [];
try {
for (const [width, role, language] of before
? [
[1440, 'guest', 'pt'],
[390, 'guest', 'pt'],
]
: [
[1280, 'manager', 'fr'],
[1280, 'manager', 'pt'],
[1440, 'manager', 'fr'],
[320, 'guest', 'pt'],
[320, 'investor', 'pt'],
[390, 'guest', 'pt'],
[390, 'manager', 'pt'],
[768, 'investor', 'pt'],
[1024, 'manager', 'pt'],
[1440, 'guest', 'pt'],
[1920, 'investor', 'en'],
]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
});
await context.addInitScript(
(lang) => localStorage.setItem('tempo-navigation-language', lang),
language,
);
await context.route('**/*', (route) => {
const url = new URL(route.request().url());
if (url.origin === base && url.pathname.startsWith('/images/')) {
const asset = path.resolve(root, 'public', `.${url.pathname}`);
if (
asset.startsWith(
path.join(root, 'public', 'images') + path.sep,
) &&
fs.existsSync(asset)
) {
return route.fulfill({ path: asset });
}
}
if (url.origin === base || url.protocol === 'data:')
return route.continue();
return route.abort();
});
const page = await context.newPage();
page.on('pageerror', (e) => {
errors.push(e.message);
console.error(e.message);
});
await page.goto(
`${base}/outputs/header-navbar-20261021/fixture.html?role=${role}`,
{ waitUntil: 'domcontentloaded', timeout: 60000 },
);
const header = page.locator('.tempo-header');
await header.waitFor();
await page.screenshot({
path: path.join(
dir,
`${before ? 'before' : 'after'}-${width}-${role}-${language}.png`,
),
});
if (before) {
results.push({
width,
height: await header.evaluate(
(el) => el.getBoundingClientRect().height,
),
});
await context.close();
continue;
}
assert.equal(
await header.locator('nav').count(),
1,
'One navigation landmark in header',
);
assert.equal(await header.locator('.tempo-section-bar').count(), 0);
const collapsedHeight = await header.evaluate(
(el) => el.getBoundingClientRect().height,
);
assert.ok(collapsedHeight <= 80, `Header height ${collapsedHeight}`);
if (width < 1280) {
const toggle = header.locator('.tempo-header-toggle');
await toggle.click();
assert.equal(await toggle.getAttribute('aria-expanded'), 'true');
await page.screenshot({
path: path.join(
dir,
`expanded-${width}-${role}-${language}.png`,
),
});
await page.keyboard.press('Escape');
assert.equal(await toggle.getAttribute('aria-expanded'), 'false');
await expect(toggle).toBeFocused();
await toggle.click();
}
const nav = header.locator('nav');
if (width >= 1280) {
const boxes = await Promise.all(
[
'.tempo-logo-link',
'.tempo-primary-navigation',
'.tempo-header-extras',
'.tempo-header-actions',
'.tempo-interest-button',
].map((selector) => header.locator(selector).boundingBox()),
);
for (let i = 1; i < boxes.length; i++)
assert.ok(
boxes[i - 1].x + boxes[i - 1].width <= boxes[i].x + 1,
`Header elements overlap at ${width}px: ${JSON.stringify(boxes)}`,
);
}
assert.deepEqual(
await nav
.locator(':scope > ul > li')
.evaluateAll((nodes) =>
nodes.map(
(node) =>
node
.querySelector('a,button')
?.getAttribute('href') ||
node.textContent.trim(),
),
),
language === 'pt'
? [
'/',
'Descoberta',
'Realização',
'Suporte',
'/sobre',
'Tempo',
]
: language === 'fr'
? [
'/',
'Découvrir',
'Investir',
'Assistance',
'/sobre',
'Tempo',
]
: ['/', 'Discover', 'Invest', 'Support', '/sobre', 'Tempo'],
);
assert.equal(await nav.locator('a[href="/sobre"]').count(), 1);
assert.equal(
await nav
.locator('a')
.first()
.evaluate((el) => getComputedStyle(el).fontSize),
width >= 1280 ? '18px' : '16px',
);
const destinations = new Set(
await header
.locator('a[href]')
.evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute('href')),
),
);
async function inspectMenu(menu) {
await menu.waitFor();
menu = page.locator(`[id="${await menu.getAttribute('id')}"]`);
try {
await expect
.poll(async () => {
const box = await menu.boundingBox();
return (
!!box &&
box.x >= 0 &&
box.x + box.width <= width + 1
);
})
.toBe(true);
} catch (error) {
console.error(
JSON.stringify({
width,
role,
menu: await menu.getAttribute('aria-labelledby'),
box: await menu.boundingBox(),
}),
);
await page.screenshot({
path: path.join(dir, 'submenu-failure.png'),
});
throw error;
}
for (const href of await menu
.locator('a[href]')
.evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute('href')),
))
destinations.add(href);
const subIds = await menu
.locator('[role="menuitem"][aria-haspopup="menu"]')
.evaluateAll((nodes) => nodes.map((node) => node.id));
for (const subId of subIds) {
const sub = page.locator(`[id="${subId}"]`);
await sub.focus();
await page.keyboard.press('ArrowRight');
await expect(sub).toHaveAttribute('aria-expanded', 'true');
await inspectMenu(page.locator('[role="menu"]').last());
await page.keyboard.press('ArrowLeft');
await expect(sub).toBeFocused();
}
}
for (const trigger of await nav
.locator(':scope > ul > li > button')
.all()) {
await trigger.focus();
await page.keyboard.press('Enter');
assert.equal(await trigger.getAttribute('aria-expanded'), 'true');
const menu = page.locator('[role="menu"]').last();
await inspectMenu(menu);
await page.keyboard.press('Escape');
await expect(trigger).toBeFocused();
}
await header
.getByRole('button', { name: 'Tempo', exact: true })
.click();
for (const href of [
'/contacto',
'https://revista.tempo.co.mz/',
'https://www.facebook.com/revistatempo.mz/',
'https://www.instagram.com/revistatempo.mz/',
'https://www.linkedin.com/company/revistatempo/',
'https://www.youtube.com/@RevistaTempoMz/',
]) {
assert.equal(
await page.locator(`[role="menu"] a[href="${href}"]`).count(),
1,
href,
);
}
await page.keyboard.press('Escape');
assert.ok(
await header
.getByRole('button', { name: 'Idioma da navegação' })
.isVisible(),
);
assert.ok(
await header
.getByRole('button', { name: 'Temperatura em Maputo' })
.isVisible(),
);
assert.ok(
await header.locator('a[href="/manifestar-interesse"]').isVisible(),
);
const dashboard = header.locator('a[href="/investidor/manifestacoes"]');
assert.ok(await dashboard.isVisible());
await page.locator('[data-test="increment"]').click();
assert.match(await dashboard.getAttribute('aria-label'), /1 /);
if (role !== 'guest') {
await header.locator('.tempo-login-button').click();
await inspectMenu(page.locator('[role="menu"]').last());
assert.ok(
await page
.locator('[data-test="portal-logout-button"]')
.isVisible(),
);
const hasManagement = await page
.getByRole('menu')
.getByText('Gestão', { exact: true })
.count();
assert.equal(hasManagement > 0, role === 'manager');
await page.keyboard.press('Escape');
} else {
assert.ok(await header.locator('a[href="/login"]').isVisible());
}
const catalog = portalNavigation({
authenticated: role !== 'guest',
manager: role === 'manager',
businesses: true,
businessReviewUrl:
role === 'manager' ? '/management/negocios' : null,
education: true,
ai: true,
legacyContent: true,
});
function checkDestinations(groups) {
for (const group of groups) {
if (group.children) checkDestinations(group.children);
else if (group.href)
assert.ok(
destinations.has(group.href),
`Preserved destination: ${group.href}`,
);
}
}
checkDestinations(
role === 'guest'
? catalog.filter((group) => group.id !== 'account')
: catalog,
);
const overflow = await header.evaluate((el) =>
Array.from(el.querySelectorAll('a,button,img'))
.filter((n) => {
const r = n.getBoundingClientRect();
return r.width && (r.x < -1 || r.right > innerWidth + 1);
})
.map((n) => n.textContent),
);
assert.deepEqual(overflow, []);
results.push({
width,
role,
language,
collapsedHeight,
destinations: destinations.size,
result: 'passed',
});
await context.close();
}
assert.deepEqual(errors, []);
fs.writeFileSync(
path.join(dir, before ? 'before-results.json' : 'qa-results.json'),
JSON.stringify({ results, errors }, null, 2),
);
console.log(JSON.stringify({ results, errors }));
} finally {
await browser.close();
}