Files
Investidor-V2/app/Http/Requests/StoreSimulationFormDraftRequest.php
T

352 lines
27 KiB
PHP

<?php
namespace App\Http\Requests;
use App\Services\CountryCatalogue;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Validator;
class StoreSimulationFormDraftRequest extends FormRequest
{
/** @return array{personal_snapshot: array<string,mixed>, economic_snapshot: array<string,mixed>} */
public function snapshots(): array
{
$validated = $this->validated();
$personal = $validated['personal_snapshot'] ?? null;
$economic = $validated['economic_snapshot'] ?? null;
if (! is_array($personal) || ! is_array($economic)) {
throw new \LogicException('Validated simulation snapshots must be arrays.');
}
return ['personal_snapshot' => $personal, 'economic_snapshot' => $economic];
}
public function authorize(): bool
{
return true;
}
/** @return array<string, list<mixed>> */
public function rules(): array
{
$personal = [
'person_type', 'investor_origin', 'tax_residency_country', 'fiscal_residency_country', 'entity_stage', 'legal_form', 'nuit',
'investor_profile_id', 'profile_confirmation', 'legal_name', 'commercial_name', 'registration_number',
'representative_name', 'representative_capacity', 'email', 'phone', 'address', 'contact_country', 'selection_labels',
'contact_province', 'contact_district', 'preferred_channel',
];
$economic = [
'context_opportunity', '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', 'modelo_a_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',
'project_duration_years', 'existing_project_id', 'province_id', 'district_id', 'admin_post_id', 'locality_id',
'site_known', 'latitude', 'longitude', 'area_required', 'area_unit', 'special_zone_id', 'land_status',
'infrastructure_needs', 'currency', 'total_investment', 'own_capital', 'domestic_debt', 'foreign_debt',
'grants', 'secured_amount', 'sought_amount', 'annual_revenue', 'annual_operating_costs', 'expected_profit',
'financing_term_months', 'funds_origin_countries', 'direct_jobs', 'indirect_jobs', 'national_jobs',
'foreign_jobs', 'skills_needed', 'local_content_percentage', 'exports_value', 'import_substitution_value',
'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_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',
'opportunity_value', 'opportunity_currency', 'opportunity_conditions', 'opportunity_deadline',
'required_documents', 'confidentiality', 'publish_requested', 'terms_accepted', 'truth_declaration', 'reuse_simulation',
'contact_authorisation', 'publication_authorisation', 'public_fields_confirmation', 'validation_acknowledged',
'final_declaration_name',
];
$rules = [
// Anonymous clients must send an empty snapshot; `required` rejects
// an empty array before the privacy policy can enforce that shape.
'context' => ['nullable', 'uuid'],
'personal_snapshot' => ['present', 'array:'.implode(',', $personal)],
'economic_snapshot' => ['required', 'array:'.implode(',', $economic)],
'economic_snapshot.purpose' => ['required', 'in:simulate_only,own_investment,seek_investor,seek_partner,present_opportunity,request_facilitation'],
'economic_snapshot.journey_type' => ['nullable', 'in:personal,small_business,company,investor'],
'economic_snapshot.detail_level' => ['nullable', 'in:simple,advanced'],
'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:1000000'],
'economic_snapshot.context_opportunity' => ['nullable', 'string', 'max:100'],
'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'],
// Preserve operation books and explicit history as the draft grows.
'economic_snapshot.modelo_a_snapshot' => ['nullable', 'string', 'json', 'max:4000000'],
'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'],
'economic_snapshot.project_questionnaire_version' => ['nullable', 'string', 'max:100'],
'economic_snapshot.customer_validation_status' => ['nullable', 'in:yes,no,partial,unknown,not_applicable'],
'economic_snapshot.cost_estimate_status' => ['nullable', 'in:yes,no,partial,unknown,not_applicable'],
'economic_snapshot.location_assessment_status' => ['nullable', 'in:yes,no,partial,unknown,not_applicable'],
'economic_snapshot.receivables_timing_status' => ['nullable', 'in:yes,no,partial,unknown,not_applicable'],
'economic_snapshot.funding_status' => ['nullable', 'in:confirmed,in_process,not_started,unknown,not_applicable'],
'economic_snapshot.manifestation_type' => ['nullable', 'in:investment,business'],
'economic_snapshot.preferred_language' => ['nullable', 'in:pt-MZ,en'],
'economic_snapshot.project_type' => ['nullable', 'in:new,expansion,acquisition,partnership,concession,supplier,buyer,other'],
'economic_snapshot.maturity_stage' => ['nullable', 'in:idea,concept,pre_feasibility,feasibility,financing,implementation,operation'],
'economic_snapshot.land_status' => ['nullable', 'in:owned,duat,leased,requested,unknown'],
'economic_snapshot.offer_or_demand' => ['nullable', 'in:offer,demand,both'],
'economic_snapshot.opportunity_type' => ['nullable', 'in:capital,partner,buyer,supplier,land,technology,licence,services,other'],
'economic_snapshot.confidentiality' => ['nullable', 'in:public,registered_users,restricted,confidential'],
'economic_snapshot.site_known' => ['nullable', 'in:yes,no'],
'economic_snapshot.tax_calculation' => ['nullable', 'in:yes,no'],
'economic_snapshot.fiscal_scenario_mode' => ['nullable', 'in:approved_parameters,custom_planning,no_calculation'],
'economic_snapshot.fiscal_period' => ['nullable', 'integer', 'in:'.implode(',', range(2000, today()->year + 1))],
'economic_snapshot.fiscal_periodicity' => ['nullable', 'in:monthly,quarterly,annual,one_off,unknown'],
'economic_snapshot.fiscal_month' => ['nullable', 'regex:/^(0[1-9]|1[0-2])$/'],
'economic_snapshot.fiscal_quarter' => ['nullable', 'in:Q1,Q2,Q3,Q4'],
'economic_snapshot.fiscal_reference_date' => ['nullable', 'date_format:Y-m-d'],
'economic_snapshot.irps_salary_view' => ['nullable', 'in:monthly_net_estimate,monthly_withholding,annual_assessment'],
'economic_snapshot.fiscal_component' => ['nullable', 'in:irpc,iva,ispc,irps,inss,withholding,customs_ice'],
'economic_snapshot.fiscal_income_category' => ['nullable', 'in:employment,rental,digital,capital_gain,business,other,unknown'],
'economic_snapshot.fiscal_residency_assessment' => ['nullable', 'in:self_reported_resident,self_reported_non_resident,unknown'],
'economic_snapshot.fiscal_operation' => ['nullable', 'in:domestic,non_resident,import,export,refund,other,unknown'],
'economic_snapshot.fiscal_documentation' => ['nullable', 'in:documented,partly_documented,not_documented,unknown'],
'economic_snapshot.fiscal_vat_refund_state' => ['nullable', 'in:estimate_only,request_filed,recognized,received,unknown'],
'economic_snapshot.publish_requested' => ['nullable', 'in:yes,no'],
'economic_snapshot.terms_accepted' => ['nullable', 'in:yes,no'],
'economic_snapshot.truth_declaration' => ['nullable', 'in:yes,no'],
'economic_snapshot.reuse_simulation' => ['nullable', 'in:yes,no'],
'economic_snapshot.contact_authorisation' => ['nullable', 'in:yes,no'],
'economic_snapshot.publication_authorisation' => ['nullable', 'in:yes,no'],
'economic_snapshot.public_fields_confirmation' => ['nullable', 'in:yes,no'],
'economic_snapshot.validation_acknowledged' => ['nullable', 'in:yes,no'],
'economic_snapshot.currency' => ['nullable', 'string', 'regex:/^[A-Z]{3}$/'],
'economic_snapshot.opportunity_currency' => ['nullable', 'string', 'regex:/^[A-Z]{3}$/'],
'economic_snapshot.rate' => ['nullable', 'numeric', 'between:0,100'],
'economic_snapshot.reduction' => ['nullable', 'numeric', 'between:0,100'],
'economic_snapshot.local_content_percentage' => ['nullable', 'numeric', 'between:0,100'],
'economic_snapshot.latitude' => ['nullable', 'numeric', 'between:-90,90'],
'economic_snapshot.longitude' => ['nullable', 'numeric', 'between:-180,180'],
'economic_snapshot.expected_start_date' => ['nullable', 'date'],
'economic_snapshot.opportunity_deadline' => ['nullable', 'date'],
'economic_snapshot.reference_date' => ['nullable', 'date'],
'economic_snapshot.*' => ['nullable'],
];
foreach (['base', 'area_required', 'total_investment', 'own_capital', 'domestic_debt', 'foreign_debt', 'grants',
'secured_amount', 'sought_amount', 'annual_revenue', 'annual_operating_costs', 'expected_profit', 'exports_value',
'import_substitution_value', 'sales_taxable', 'sales_exempt', 'sales_zero_rate', 'exports', 'deductible_costs',
'non_deductible_costs', 'carried_losses', 'opportunity_value'] as $field) {
$rules['economic_snapshot.'.$field] = ['nullable', 'numeric', 'min:0'];
}
foreach (['direct_jobs', 'indirect_jobs', 'national_jobs', 'foreign_jobs', 'financing_term_months', 'project_duration_years'] as $field) {
$rules['economic_snapshot.'.$field] = ['nullable', 'integer', 'min:0'];
}
$rules['economic_snapshot.fiscal_establishments'] = ['nullable', 'integer', 'min:1'];
foreach (['fiscal_activity', 'fiscal_customs_code'] as $field) {
$rules['economic_snapshot.'.$field] = ['nullable', 'string', 'max:300'];
}
foreach (['infrastructure_needs', 'funds_origin_countries', 'required_documents'] as $field) {
$rules['economic_snapshot.'.$field] = ['nullable', 'array', 'max:100'];
$rules['economic_snapshot.'.$field.'.*'] = ['nullable', 'string', 'max:3000'];
}
$rules['economic_snapshot.infrastructure_needs.*'] = ['nullable', 'in:energia,água,estrada,porto,ferrovia,telecomunicações,armazenamento,customers,raw_materials,workers,facilities,costs,legal_requirements'];
$rules['economic_snapshot.required_documents.*'] = ['nullable', 'in:identity_document,company_registration,nuit_certificate,company_profile,business_plan,feasibility_study,proof_of_funds,special_zone_certificate,licence,site_map'];
foreach (['skills_needed', 'payroll', 'outbound_payments', 'imports', 'claimed_benefits', 'opportunity_conditions'] as $field) {
$rules['economic_snapshot.'.$field] = ['nullable', 'string', 'max:10000'];
}
$rules['economic_snapshot.selection_labels'] = ['nullable', 'array'];
foreach (['project_summary', 'project_problem', 'target_customers', 'value_proposition', 'market_assumption', 'market_source', 'project_milestones', 'opportunity_description', 'products_services', 'community_benefits', 'competitor_assumption', 'sales_channel_assumption', 'marketing_approach', 'location_rationale'] as $field) {
$rules['economic_snapshot.'.$field] = ['nullable', 'string', 'max:10000'];
}
$rules['personal_snapshot.email'] = ['nullable', 'email:rfc', 'max:254'];
$rules['personal_snapshot.nuit'] = ['nullable', 'string', 'max:32'];
$rules['personal_snapshot.investor_profile_id'] = ['nullable', 'uuid'];
$rules['personal_snapshot.profile_confirmation'] = ['nullable', 'boolean'];
$rules['personal_snapshot.selection_labels'] = ['nullable', 'array'];
$rules['personal_snapshot.person_type'] = ['nullable', 'in:singular,collective,sole_trader,consortium'];
$rules['personal_snapshot.investor_origin'] = ['nullable', 'in:national,foreign,mixed'];
$rules['personal_snapshot.entity_stage'] = ['nullable', 'in:existing,being_formed'];
$rules['economic_snapshot.sector_id'] = ['nullable', 'integer', 'exists:sectors,id'];
$rules['economic_snapshot.activity_id'] = ['nullable', 'uuid', 'exists:economic_activities,id'];
foreach (['existing_project_id', 'special_zone_id'] as $field) {
$rules['economic_snapshot.'.$field] = ['nullable', 'string', 'max:100'];
}
foreach (['province_id', 'district_id', 'admin_post_id', 'locality_id'] as $field) {
$rules['economic_snapshot.'.$field] = ['nullable', 'uuid', 'exists:geo_locations,id'];
}
foreach (['person_type', 'investor_origin', 'tax_residency_country', 'fiscal_residency_country', 'entity_stage', 'legal_form', 'commercial_name',
'registration_number', 'representative_name', 'representative_capacity', 'phone', 'address', 'contact_country',
'contact_province', 'contact_district', 'preferred_channel'] as $field) {
$rules['personal_snapshot.'.$field] = ['nullable', 'string', 'max:3000'];
}
foreach (['tax_residency_country', 'fiscal_residency_country', 'contact_country'] as $field) {
$rules['personal_snapshot.'.$field][] = app(CountryCatalogue::class)->legacyCountryRule();
}
$rules['economic_snapshot.funds_origin_countries.*'][] = app(CountryCatalogue::class)->legacyCountryRule();
return $rules;
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$economic = $this->input('economic_snapshot', []);
if (! is_array($economic)) {
return;
}
foreach ([
'project_team_snapshot' => [
'keys' => ['competency', 'state', 'action', 'estimated_cost', 'due_date'],
'states' => ['available', 'gap', 'unknown'],
'amounts' => ['estimated_cost'],
],
'project_execution_snapshot' => [
'keys' => ['title', 'due_date', 'status', 'planned_amount', 'actual_amount'],
'states' => ['planned', 'in_progress', 'completed'],
'amounts' => ['planned_amount', 'actual_amount'],
],
] as $field => $schema) {
if (! isset($economic[$field]) || $economic[$field] === '') {
continue;
}
$rows = json_decode((string) $economic[$field], true);
$valid = is_array($rows) && count($rows) <= 100;
foreach (is_array($rows) ? $rows : [] as $row) {
if (! is_array($row) || array_diff(array_keys($row), $schema['keys']) !== []
|| array_diff($schema['keys'], array_keys($row)) !== []
|| array_filter($row, fn ($value) => ! is_string($value) || mb_strlen($value) > 500) !== []) {
$valid = false;
break;
}
$stateKey = $field === 'project_team_snapshot' ? 'state' : 'status';
if (! in_array($row[$stateKey], $schema['states'], true)) {
$valid = false;
break;
}
foreach ($schema['amounts'] as $amountKey) {
if ($row[$amountKey] !== '' && (! is_numeric($row[$amountKey]) || (float) $row[$amountKey] < 0)) {
$valid = false;
}
}
foreach (['due_date'] as $dateKey) {
if ($row[$dateKey] !== '' && ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $row[$dateKey])) {
$valid = false;
}
}
}
if (! $valid) {
$validator->errors()->add('economic_snapshot.'.$field, 'O plano estruturado contém linhas ou valores inválidos.');
}
}
if (isset($economic['project_risk_responses_snapshot']) && $economic['project_risk_responses_snapshot'] !== '') {
$responses = json_decode((string) $economic['project_risk_responses_snapshot'], true);
$validResponses = is_array($responses) && array_is_list($responses) && count($responses) <= 30;
foreach (is_array($responses) ? $responses : [] as $response) {
if (! is_array($response)
|| array_diff(array_keys($response), ['risk_id', 'action', 'owner', 'estimated_cost', 'due_date']) !== []
|| array_diff(['risk_id', 'action', 'owner', 'estimated_cost', 'due_date'], array_keys($response)) !== []
|| array_filter($response, fn ($value) => ! is_string($value) || mb_strlen($value) > 500) !== []
|| ! preg_match('/^[a-z0-9-]{1,80}$/', $response['risk_id'])
|| ($response['estimated_cost'] !== '' && (! is_numeric($response['estimated_cost']) || (float) $response['estimated_cost'] < 0))
|| ($response['due_date'] !== '' && ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $response['due_date']))) {
$validResponses = false;
break;
}
}
if (! $validResponses) {
$validator->errors()->add('economic_snapshot.project_risk_responses_snapshot', 'As respostas planeadas para lacunas ou riscos não são válidas.');
}
}
if (isset($economic['fiscal_scenario_mode'])) {
$custom = $economic['fiscal_scenario_mode'] === 'custom_planning';
$providedCount = 0;
foreach (['base', 'rate', 'reduction'] as $field) {
$present = isset($economic[$field]) && $economic[$field] !== '';
$providedCount += $present ? 1 : 0;
if (! $custom && $present) {
$validator->errors()->add('economic_snapshot.'.$field, 'Valores hipotéticos só podem ser enviados no cenário personalizado.');
}
}
if ($custom && $providedCount > 0 && $providedCount < 3) {
foreach (['base', 'rate', 'reduction'] as $field) {
if (! isset($economic[$field]) || $economic[$field] === '') {
$validator->errors()->add('economic_snapshot.'.$field, 'Preencha todos os pressupostos se começar um cenário personalizado.');
}
}
}
}
$allowedLabels = [
'economic_snapshot.selection_labels' => ['journey_type', 'detail_level', 'purpose', 'manifestation_type', 'preferred_language', 'project_type', 'maturity_stage', 'sector_id', 'activity_id',
'province_id', 'district_id', 'admin_post_id', 'locality_id', 'site_known', 'land_status', 'infrastructure_needs',
'area_unit', 'required_documents', 'opportunity_currency',
'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_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'],
];
foreach ($allowedLabels as $path => $allowedFields) {
$snapshot = str_starts_with($path, 'personal_') ? $this->input('personal_snapshot.selection_labels', []) : ($economic['selection_labels'] ?? []);
foreach (is_array($snapshot) ? $snapshot : [] as $field => $label) {
$labels = is_array($label) ? $label : [$label];
if (! is_string($field) || ! in_array($field, $allowedFields, true) || count($labels) > 100
|| collect($labels)->contains(fn ($value) => ! is_string($value) || mb_strlen($value) > 255)) {
$validator->errors()->add($path, 'Os rótulos de selecção não são válidos.');
break 2;
}
}
}
if (isset($economic['activity_id'], $economic['sector_id'])) {
$matchesSector = DB::table('economic_activities as a')
->join('reference_catalog_versions as v', 'v.id', '=', 'a.catalog_version_id')
->where('a.id', $economic['activity_id'])->where('a.sector_id', $economic['sector_id'])
->where('a.status', 'published')->where('v.status', 'published')
->whereDate('a.valid_from', '<=', today())
->where(fn ($query) => $query->whereNull('a.valid_to')->orWhereDate('a.valid_to', '>', today()))->exists();
if (! $matchesSector) {
$validator->errors()->add('economic_snapshot.activity_id', 'A actividade não pertence ao sector seleccionado ou já não está publicada.');
}
} elseif (! empty($economic['activity_id'])) {
$validator->errors()->add('economic_snapshot.sector_id', 'Seleccione o sector antes da actividade económica.');
}
$parents = ['district_id' => ['province_id', 'district'], 'admin_post_id' => ['district_id', 'admin_post'], 'locality_id' => ['admin_post_id', 'locality']];
foreach ($parents as $child => [$parentField, $type]) {
if (empty($economic[$child])) {
continue;
}
$parentType = ['district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'][$type];
$valid = ! empty($economic[$parentField]) && DB::table('geo_locations as child')
->join('geo_locations as parent', 'parent.id', '=', 'child.parent_id')
->where('child.id', $economic[$child])->where('child.location_type', $type)
->where('child.source_system', 'cartografia-workbook')->where('child.data_classification', 'public')->whereNull('child.deleted_at')
->where('parent.id', $economic[$parentField])->where('parent.location_type', $parentType)
->where('parent.source_system', 'cartografia-workbook')->where('parent.data_classification', 'public')->whereNull('parent.deleted_at')->exists();
if (! $valid) {
$validator->errors()->add('economic_snapshot.'.$child, 'Seleccione uma opção publicada dependente do nível territorial anterior.');
}
}
$financeKeys = ['total_investment', 'own_capital', 'domestic_debt', 'foreign_debt', 'grants'];
if (collect($financeKeys)->every(fn (string $key) => array_key_exists($key, $economic) && $economic[$key] !== null && $economic[$key] !== '')) {
$funding = (float) $economic['own_capital'] + (float) $economic['domestic_debt'] + (float) $economic['foreign_debt'] + (float) $economic['grants'];
if (abs($funding - (float) $economic['total_investment']) > 0.01) {
$validator->errors()->add('economic_snapshot.total_investment', 'A soma do capital, dívida e subvenções deve reconciliar com o investimento total.');
}
}
});
}
}