diff --git a/Http/Controllers/InvoiceController.php b/Http/Controllers/InvoiceController.php index 1cc06bb6..018bc0c5 100644 --- a/Http/Controllers/InvoiceController.php +++ b/Http/Controllers/InvoiceController.php @@ -21,6 +21,7 @@ use LibreCodeCoop\NfsePHP\Exception\SecretStoreException; use LibreCodeCoop\NfsePHP\Http\NfseClient; use LibreCodeCoop\NfsePHP\SecretStore\OpenBaoSecretStore; +use LibreCodeCoop\NfsePHP\Xml\XmlBuilder; use Modules\Nfse\Models\CompanyService; use Modules\Nfse\Models\NfseReceipt; use Modules\Nfse\Support\VaultConfig; @@ -99,6 +100,9 @@ public function emit(Invoice $invoice): RedirectResponse numeroDps: $this->dpsNumber($invoice), dataCompetencia: $this->competenceDate($invoice), indicadorTributacao: $federalPayload['indicadorTributacao'], + totalTributosPercentualFederal: $federalPayload['totalTributosPercentualFederal'], + totalTributosPercentualEstadual: $federalPayload['totalTributosPercentualEstadual'], + totalTributosPercentualMunicipal: $federalPayload['totalTributosPercentualMunicipal'], federalPiscofinsSituacaoTributaria: $federalPayload['federalPiscofinsSituacaoTributaria'], federalPiscofinsTipoRetencao: $federalPayload['federalPiscofinsTipoRetencao'], federalPiscofinsBaseCalculo: $federalPayload['federalPiscofinsBaseCalculo'], @@ -115,6 +119,22 @@ public function emit(Invoice $invoice): RedirectResponse 'invoice_id' => $invoice->id, 'opSimpNac' => $dps->opcaoSimplesNacional, 'aliquota' => $dps->aliquota, + 'tipoAmbiente' => $dps->tipoAmbiente, + 'indicador_tributacao' => $dps->indicadorTributacao, + 'tributacao_federal_mode' => (string) setting('nfse.tributacao_federal_mode', 'per_invoice_amounts'), + 'federal_piscofins_situacao_tributaria' => $dps->federalPiscofinsSituacaoTributaria, + 'federal_piscofins_tipo_retencao' => $dps->federalPiscofinsTipoRetencao, + 'federal_piscofins_base_calculo' => $dps->federalPiscofinsBaseCalculo, + 'federal_piscofins_aliquota_pis' => $dps->federalPiscofinsAliquotaPis, + 'federal_piscofins_valor_pis' => $dps->federalPiscofinsValorPis, + 'federal_piscofins_aliquota_cofins' => $dps->federalPiscofinsAliquotaCofins, + 'federal_piscofins_valor_cofins' => $dps->federalPiscofinsValorCofins, + 'federal_valor_irrf' => $dps->federalValorIrrf, + 'federal_valor_csll' => $dps->federalValorCsll, + 'federal_valor_cp' => $dps->federalValorCp, + 'tributos_fed_p' => (string) setting('nfse.tributos_fed_p', ''), + 'tributos_est_p' => (string) setting('nfse.tributos_est_p', ''), + 'tributos_mun_p' => (string) setting('nfse.tributos_mun_p', ''), ]); $client = $this->makeClient($sandbox); @@ -126,12 +146,14 @@ public function emit(Invoice $invoice): RedirectResponse ->with('error', trans('nfse::general.nfse_secret_store_failed')); } catch (GatewayException $e) { $gatewayDetail = $this->gatewayErrorDetail($e); + $xmlOrderDebug = $this->dpsXmlOrderDebug($dps); $this->safeLogError('NFS-e issuance rejected by SEFIN', [ 'invoice_id' => $invoice->id, 'http_status' => $e->httpStatus, 'upstream_payload' => $e->upstreamPayload, 'gateway_detail' => $gatewayDetail, + 'xml_order_debug' => $xmlOrderDebug, ]); return redirect()->route('nfse.invoices.pending') @@ -275,6 +297,9 @@ public function reemit(Invoice $invoice): RedirectResponse numeroDps: $this->dpsNumber($invoice), dataCompetencia: $this->competenceDate($invoice), indicadorTributacao: $federalPayload['indicadorTributacao'], + totalTributosPercentualFederal: $federalPayload['totalTributosPercentualFederal'], + totalTributosPercentualEstadual: $federalPayload['totalTributosPercentualEstadual'], + totalTributosPercentualMunicipal: $federalPayload['totalTributosPercentualMunicipal'], federalPiscofinsSituacaoTributaria: $federalPayload['federalPiscofinsSituacaoTributaria'], federalPiscofinsTipoRetencao: $federalPayload['federalPiscofinsTipoRetencao'], federalPiscofinsBaseCalculo: $federalPayload['federalPiscofinsBaseCalculo'], @@ -296,12 +321,14 @@ public function reemit(Invoice $invoice): RedirectResponse ->with('error', trans('nfse::general.nfse_secret_store_failed')); } catch (GatewayException $e) { $gatewayDetail = $this->gatewayErrorDetail($e); + $xmlOrderDebug = $this->dpsXmlOrderDebug($dps); $this->safeLogError('NFS-e reissuance rejected by SEFIN', [ 'invoice_id' => $invoice->id, 'http_status' => $e->httpStatus, 'upstream_payload' => $e->upstreamPayload, 'gateway_detail' => $gatewayDetail, + 'xml_order_debug' => $xmlOrderDebug, ]); return redirect()->route('nfse.invoices.show', $invoice) @@ -658,7 +685,11 @@ protected function supportsCompanyServiceSelection(): bool protected function resolveCompanyId(): int { if (function_exists('company_id')) { - $companyId = (int) (company_id() ?? 0); + try { + $companyId = (int) (company_id() ?? 0); + } catch (\Throwable) { + $companyId = 0; + } if ($companyId > 0) { return $companyId; @@ -687,17 +718,18 @@ protected function normalizedOpcaoSimplesNacional(): int protected function federalPayloadValues(float $invoiceAmount): array { - $mode = (string) setting('nfse.tributacao_federal_mode', 'per_invoice_amounts'); $situacaoTributaria = $this->normalizedFederalSelectValue(setting('nfse.federal_piscofins_situacao_tributaria', '')); $tipoRetencao = $this->normalizedFederalSelectValue(setting('nfse.federal_piscofins_tipo_retencao', '')); + $isSimplesNacionalOptant = $this->normalizedOpcaoSimplesNacional() === 2; + + $totalTributosPercentualFederal = $this->normalizedFederalDecimal(setting($isSimplesNacionalOptant ? 'nfse.tributos_fed_sn' : 'nfse.tributos_fed_p', '')); + $totalTributosPercentualEstadual = $this->normalizedFederalDecimal(setting($isSimplesNacionalOptant ? 'nfse.tributos_est_sn' : 'nfse.tributos_est_p', '')); + $totalTributosPercentualMunicipal = $this->normalizedFederalDecimal(setting($isSimplesNacionalOptant ? 'nfse.tributos_mun_sn' : 'nfse.tributos_mun_p', '')); $indicadorTributacao = ( - setting('nfse.tributos_fed_p', '') !== '' || - setting('nfse.tributos_est_p', '') !== '' || - setting('nfse.tributos_mun_p', '') !== '' || - setting('nfse.tributos_fed_sn', '') !== '' || - setting('nfse.tributos_est_sn', '') !== '' || - setting('nfse.tributos_mun_sn', '') !== '' + $totalTributosPercentualFederal !== '' || + $totalTributosPercentualEstadual !== '' || + $totalTributosPercentualMunicipal !== '' ) ? 2 : 0; if ($situacaoTributaria === '' || $situacaoTributaria === '0') { @@ -709,55 +741,66 @@ protected function federalPayloadValues(float $invoiceAmount): array 'federalPiscofinsValorPis' => '', 'federalPiscofinsAliquotaCofins' => '', 'federalPiscofinsValorCofins' => '', - 'federalValorIrrf' => $this->normalizedFederalDecimal(setting('nfse.federal_valor_irrf', '')), - 'federalValorCsll' => '', - 'federalValorCp' => $this->normalizedFederalDecimal(setting('nfse.federal_valor_cp', '')), + 'federalValorIrrf' => $this->calculateFederalRetentionValue($invoiceAmount, 'federal_valor_irrf'), + 'federalValorCsll' => $this->calculateFederalRetentionValue($invoiceAmount, 'federal_valor_csll'), + // Produção restrita currently rejects vRetCP (RNG6110), so keep CP as UI/config only. + 'federalValorCp' => '', 'indicadorTributacao' => $indicadorTributacao, + 'totalTributosPercentualFederal' => $totalTributosPercentualFederal, + 'totalTributosPercentualEstadual' => $totalTributosPercentualEstadual, + 'totalTributosPercentualMunicipal' => $totalTributosPercentualMunicipal, ]; } - $valorCsll = ($tipoRetencao !== '' && $tipoRetencao !== '0') - ? $this->normalizedFederalDecimal(setting('nfse.federal_valor_csll', '')) - : ''; - - if ($mode === 'percentage_profile') { - $aliquotaPis = $this->normalizedFederalDecimal(setting('nfse.federal_piscofins_aliquota_pis', '')); - $aliquotaCofins = $this->normalizedFederalDecimal(setting('nfse.federal_piscofins_aliquota_cofins', '')); - - return [ - 'federalPiscofinsSituacaoTributaria' => $situacaoTributaria, - 'federalPiscofinsTipoRetencao' => $tipoRetencao, - 'federalPiscofinsBaseCalculo' => number_format($invoiceAmount, 2, '.', ''), - 'federalPiscofinsAliquotaPis' => $aliquotaPis, - 'federalPiscofinsValorPis' => $aliquotaPis !== '' - ? number_format($invoiceAmount * (float) $aliquotaPis / 100, 2, '.', '') - : '', - 'federalPiscofinsAliquotaCofins' => $aliquotaCofins, - 'federalPiscofinsValorCofins' => $aliquotaCofins !== '' - ? number_format($invoiceAmount * (float) $aliquotaCofins / 100, 2, '.', '') - : '', - 'federalValorIrrf' => $this->normalizedFederalDecimal(setting('nfse.federal_valor_irrf', '')), - 'federalValorCsll' => $valorCsll, - 'federalValorCp' => $this->normalizedFederalDecimal(setting('nfse.federal_valor_cp', '')), - 'indicadorTributacao' => $indicadorTributacao, - ]; - } + $aliquotaPis = $this->normalizedFederalDecimal(setting('nfse.federal_piscofins_aliquota_pis', '')); + $aliquotaCofins = $this->normalizedFederalDecimal(setting('nfse.federal_piscofins_aliquota_cofins', '')); return [ 'federalPiscofinsSituacaoTributaria' => $situacaoTributaria, 'federalPiscofinsTipoRetencao' => $tipoRetencao, - 'federalPiscofinsBaseCalculo' => $this->normalizedFederalDecimal(setting('nfse.federal_piscofins_base_calculo', '')), - 'federalPiscofinsAliquotaPis' => $this->normalizedFederalDecimal(setting('nfse.federal_piscofins_aliquota_pis', '')), - 'federalPiscofinsValorPis' => $this->normalizedFederalDecimal(setting('nfse.federal_piscofins_valor_pis', '')), - 'federalPiscofinsAliquotaCofins' => $this->normalizedFederalDecimal(setting('nfse.federal_piscofins_aliquota_cofins', '')), - 'federalPiscofinsValorCofins' => $this->normalizedFederalDecimal(setting('nfse.federal_piscofins_valor_cofins', '')), - 'federalValorIrrf' => $this->normalizedFederalDecimal(setting('nfse.federal_valor_irrf', '')), - 'federalValorCsll' => $valorCsll, - 'federalValorCp' => $this->normalizedFederalDecimal(setting('nfse.federal_valor_cp', '')), + 'federalPiscofinsBaseCalculo' => number_format($invoiceAmount, 2, '.', ''), + 'federalPiscofinsAliquotaPis' => $aliquotaPis, + 'federalPiscofinsValorPis' => $aliquotaPis !== '' + ? number_format($invoiceAmount * (float) $aliquotaPis / 100, 2, '.', '') + : '', + 'federalPiscofinsAliquotaCofins' => $aliquotaCofins, + 'federalPiscofinsValorCofins' => $aliquotaCofins !== '' + ? number_format($invoiceAmount * (float) $aliquotaCofins / 100, 2, '.', '') + : '', + 'federalValorIrrf' => $this->calculateFederalRetentionValue($invoiceAmount, 'federal_valor_irrf'), + 'federalValorCsll' => $tipoRetencao !== '0' + ? $this->calculateFederalRetentionValue($invoiceAmount, 'federal_valor_csll') + : '', + // Produção restrita currently rejects vRetCP (RNG6110), so keep CP as UI/config only. + 'federalValorCp' => '', 'indicadorTributacao' => $indicadorTributacao, + 'totalTributosPercentualFederal' => $totalTributosPercentualFederal, + 'totalTributosPercentualEstadual' => $totalTributosPercentualEstadual, + 'totalTributosPercentualMunicipal' => $totalTributosPercentualMunicipal, ]; } + /** + * Calculate federal retention value in reais based on percentage setting + */ + protected function calculateFederalRetentionValue(float $invoiceAmount, string $settingKey): string + { + $percentageStr = $this->normalizedFederalDecimal(setting('nfse.' . $settingKey, '')); + + if ($percentageStr === '') { + return ''; + } + + $percentage = (float) $percentageStr; + if ($percentage <= 0) { + return ''; + } + + $calculatedValue = $invoiceAmount * $percentage / 100; + + return number_format($calculatedValue, 2, '.', ''); + } + protected function normalizedFederalSelectValue(mixed $value): string { $normalized = trim((string) $value); @@ -780,6 +823,31 @@ protected function normalizedFederalDecimal(mixed $value): string return number_format((float) $normalized, 2, '.', ''); } + /** + * @return array + */ + protected function dpsXmlOrderDebug(DpsData $dps): array + { + try { + $xml = (new XmlBuilder())->buildDps($dps); + $normalized = str_replace(["\r", "\n", "\t"], '', $xml); + $tpAmbIndex = strpos($normalized, ''); + $cMunIndex = strpos($normalized, ''); + + return [ + 'xml_builder_file' => (new \ReflectionClass(XmlBuilder::class))->getFileName(), + 'tpAmb_index' => $tpAmbIndex, + 'cMun_index' => $cMunIndex, + 'tpAmb_before_cMun' => $tpAmbIndex !== false && $cMunIndex !== false && $tpAmbIndex < $cMunIndex, + 'xml_prefix' => substr($normalized, 0, 260), + ]; + } catch (\Throwable $throwable) { + return [ + 'debug_error' => $throwable->getMessage(), + ]; + } + } + protected function hasCertificateSecret(string $cnpj): bool { if ($cnpj === '') { diff --git a/Http/Controllers/SettingsController.php b/Http/Controllers/SettingsController.php index a420a874..e712e073 100644 --- a/Http/Controllers/SettingsController.php +++ b/Http/Controllers/SettingsController.php @@ -23,6 +23,7 @@ class SettingsController extends Controller { private const IBGE_BASE_URL = 'https://servicodados.ibge.gov.br/api/v1/localidades'; + private const BRASIL_API_BASE_URL = 'https://brasilapi.com.br/api'; public function edit(?Request $request = null): \Illuminate\View\View { @@ -200,17 +201,14 @@ public function updateFederal(Request $request): RedirectResponse } $request->validate([ - 'nfse.tributacao_federal_mode' => 'required|in:per_invoice_amounts,percentage_profile', + 'nfse.tributacao_federal_mode' => 'nullable|in:percentage_profile', 'nfse.federal_piscofins_situacao_tributaria' => 'nullable|regex:/^\d+$/', 'nfse.federal_piscofins_tipo_retencao' => 'nullable|regex:/^\d+$/', - 'nfse.federal_piscofins_base_calculo' => 'nullable|numeric|min:0', 'nfse.federal_piscofins_aliquota_pis' => 'nullable|numeric|min:0|max:100', - 'nfse.federal_piscofins_valor_pis' => 'nullable|numeric|min:0', 'nfse.federal_piscofins_aliquota_cofins' => 'nullable|numeric|min:0|max:100', - 'nfse.federal_piscofins_valor_cofins' => 'nullable|numeric|min:0', - 'nfse.federal_valor_irrf' => 'nullable|numeric|min:0', - 'nfse.federal_valor_csll' => 'nullable|numeric|min:0', - 'nfse.federal_valor_cp' => 'nullable|numeric|min:0', + 'nfse.federal_valor_irrf' => 'nullable|numeric|min:0|max:100', + 'nfse.federal_valor_csll' => 'nullable|numeric|min:0|max:100', + 'nfse.federal_valor_cp' => 'nullable|numeric|min:0|max:100', 'nfse.tributos_fed_p' => 'nullable|numeric|min:0|max:100', 'nfse.tributos_est_p' => 'nullable|numeric|min:0|max:100', 'nfse.tributos_mun_p' => 'nullable|numeric|min:0|max:100', @@ -221,16 +219,14 @@ public function updateFederal(Request $request): RedirectResponse $rawNfseInput = $request->input('nfse', []); $rawNfseInput = is_array($rawNfseInput) ? $rawNfseInput : []; + $rawNfseInput['tributacao_federal_mode'] = 'percentage_profile'; $keys = [ 'tributacao_federal_mode', 'federal_piscofins_situacao_tributaria', 'federal_piscofins_tipo_retencao', - 'federal_piscofins_base_calculo', 'federal_piscofins_aliquota_pis', - 'federal_piscofins_valor_pis', 'federal_piscofins_aliquota_cofins', - 'federal_piscofins_valor_cofins', 'federal_valor_irrf', 'federal_valor_csll', 'federal_valor_cp', @@ -265,6 +261,10 @@ public function updateFederal(Request $request): RedirectResponse setting(['nfse.' . $key => $value]); } + foreach (['federal_piscofins_base_calculo', 'federal_piscofins_valor_pis', 'federal_piscofins_valor_cofins'] as $deprecatedKey) { + setting()->forget('nfse.' . $deprecatedKey); + } + setting()->save(); return redirect()->route('nfse.settings.edit', ['tab' => 'federal']) @@ -304,10 +304,20 @@ public function municipalities(string $uf, IbgeLocalities $ibgeLocalities): Json 'data' => $ibgeLocalities->mapMunicipalities(is_array($rows) ? $rows : []), ]); } catch (Throwable) { - return $this->jsonResponse([ - 'data' => [], - 'message' => 'Failed to load municipalities from IBGE.', - ], 502); + try { + $rows = $this->fetchMunicipalitiesRowsFallback($normalizedUf); + + return $this->jsonResponse([ + 'data' => $ibgeLocalities->mapMunicipalities(is_array($rows) ? $rows : []), + 'message' => 'Using fallback municipalities source because IBGE is unavailable.', + ]); + } catch (Throwable) { + // Keep endpoint stable for UI even if all providers are unavailable. + return $this->jsonResponse([ + 'data' => [], + 'message' => 'Failed to load municipalities from IBGE and fallback source.', + ]); + } } } @@ -571,6 +581,7 @@ protected function fetchUfsRows(): array protected function fetchMunicipalitiesRows(string $normalizedUf): array { $rows = Http::timeout(8) + ->retry(2, 150) ->acceptJson() ->get(self::IBGE_BASE_URL . '/estados/' . $normalizedUf . '/municipios') ->throw() @@ -579,6 +590,37 @@ protected function fetchMunicipalitiesRows(string $normalizedUf): array return is_array($rows) ? $rows : []; } + protected function fetchMunicipalitiesRowsFallback(string $normalizedUf): array + { + $rows = Http::timeout(8) + ->retry(2, 150) + ->acceptJson() + ->get(self::BRASIL_API_BASE_URL . '/ibge/municipios/v1/' . $normalizedUf, [ + 'providers' => 'dados-abertos-br,gov', + ]) + ->throw() + ->json(); + + if (!is_array($rows)) { + return []; + } + + $normalizedRows = []; + + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + $normalizedRows[] = [ + 'id' => $row['codigo_ibge'] ?? '', + 'nome' => $row['nome'] ?? '', + ]; + } + + return $normalizedRows; + } + protected function jsonResponse(array $payload, int $status = 200): JsonResponse { return response()->json($payload, $status); diff --git a/Resources/lang/en-GB/general.php b/Resources/lang/en-GB/general.php index 9d9d73d1..a709bbbd 100644 --- a/Resources/lang/en-GB/general.php +++ b/Resources/lang/en-GB/general.php @@ -194,14 +194,15 @@ 'select_placeholder' => 'Select...', 'piscofins_situacao_tributaria' => 'PIS/COFINS Tax Situation', 'piscofins_tipo_retencao' => 'PIS/COFINS/CSLL retention type', - 'piscofins_base_calculo' => 'PIS/COFINS Tax Base', + 'piscofins_preview_note' => 'The PIS/COFINS tax base, PIS/COFINS own-assessment debits, and withheld social contributions are calculated during issuance from the invoice amount; define only the applicable rates and fixed withholdings here.', 'piscofins_aliquota_pis' => 'PIS - Rate', - 'piscofins_valor_pis' => 'PIS - Own Assessment Debit', 'piscofins_aliquota_cofins' => 'COFINS - Rate', - 'piscofins_valor_cofins' => 'COFINS - Own Assessment Debit', - 'valor_irrf' => 'IRRF', - 'valor_csll' => 'Social Contributions - Withheld', - 'valor_cp' => 'Social Security Contribution - Withheld', + 'valor_irrf' => 'IRRF - Percentage', + 'valor_irrf_hint' => 'Percentage to withhold from the invoice amount. Calculated automatically during issuance.', + 'valor_csll' => 'CSLL - Percentage', + 'valor_csll_hint' => 'Percentage to withhold from the invoice amount. Calculated automatically during issuance. Linked to PIS/COFINS retention type.', + 'valor_cp' => 'Social Security Contribution - Percentage', + 'valor_cp_hint' => 'Percentage to withhold from the invoice amount. Calculated automatically during issuance.', 'tributos_fed_p' => 'Federal taxes (%) - Default profile', 'tributos_est_p' => 'State taxes (%) - Default profile', 'tributos_mun_p' => 'Municipal taxes (%) - Default profile', diff --git a/Resources/lang/pt-BR/general.php b/Resources/lang/pt-BR/general.php index 76a37040..1ef2f846 100644 --- a/Resources/lang/pt-BR/general.php +++ b/Resources/lang/pt-BR/general.php @@ -194,14 +194,15 @@ 'select_placeholder' => 'Selecione...', 'piscofins_situacao_tributaria' => 'Situação Tributária do PIS/COFINS', 'piscofins_tipo_retencao' => 'Tipo de retenção do PIS/COFINS/CSLL', - 'piscofins_base_calculo' => 'BC PIS/COFINS', + 'piscofins_preview_note' => 'BC PIS/COFINS, débitos de PIS/COFINS e contribuições sociais retidas são calculados na emissão a partir do valor da nota; aqui você define apenas as alíquotas e retenções fixas aplicáveis.', 'piscofins_aliquota_pis' => 'PIS - Alíquota', - 'piscofins_valor_pis' => 'PIS - Débito Apuração Própria', 'piscofins_aliquota_cofins' => 'COFINS - Alíquota', - 'piscofins_valor_cofins' => 'COFINS - Débito Apuração Própria', - 'valor_irrf' => 'IRRF', - 'valor_csll' => 'Contribuições Sociais - Retidas', - 'valor_cp' => 'Contribuição Previdenciária - Retida', + 'valor_irrf' => 'IRRF - Percentual', + 'valor_irrf_hint' => 'Percentual a reter sobre o valor da nota. Calculado automaticamente na emissão.', + 'valor_csll' => 'CSLL - Percentual', + 'valor_csll_hint' => 'Percentual a reter sobre o valor da nota. Calculado automaticamente na emissão. Vinculado ao tipo de retenção PIS/COFINS.', + 'valor_cp' => 'Contribuição Previdenciária - Percentual', + 'valor_cp_hint' => 'Percentual a reter sobre o valor da nota. Calculado automaticamente na emissão.', 'tributos_fed_p' => 'Tributos federais (%) - Perfil padrão', 'tributos_est_p' => 'Tributos estaduais (%) - Perfil padrão', 'tributos_mun_p' => 'Tributos municipais (%) - Perfil padrão', diff --git a/Resources/views/settings/edit.blade.php b/Resources/views/settings/edit.blade.php index 8b70a9e5..a5a4ee23 100644 --- a/Resources/views/settings/edit.blade.php +++ b/Resources/views/settings/edit.blade.php @@ -34,10 +34,6 @@ 'federal' => ['label' => trans('nfse::general.settings.federal.tab_title'), 'enabled' => $hasSavedSettings], ]; - $selectedFederalMode = old('nfse.tributacao_federal_mode', setting('nfse.tributacao_federal_mode', 'per_invoice_amounts')); - if (! in_array($selectedFederalMode, ['per_invoice_amounts', 'percentage_profile'], true)) { - $selectedFederalMode = 'per_invoice_amounts'; - } @endphp {{-- ── Tab navigation ──────────────────────────────────────── --}} @@ -450,30 +446,9 @@ class="absolute inset-y-0 right-0 px-3 text-gray-500 hover:text-gray-700" -
+
- - R$ + + %
+

{{ trans('nfse::general.settings.federal.valor_irrf_hint') }}

- +
- - R$ + + %
+

{{ trans('nfse::general.settings.federal.valor_cp_hint') }}

+
+ + -
- {{ trans('nfse::general.settings.federal.behavior_label') }} -

{{ trans('nfse::general.settings.federal.behavior_hint') }}

- -
- - -
-
+
-
+
@@ -558,7 +526,7 @@ class="absolute inset-y-0 right-0 px-3 text-gray-500 hover:text-gray-700"
-
+
@@ -759,8 +727,19 @@ class="absolute inset-y-0 right-0 px-3 text-gray-500 hover:text-gray-700" const federalSituacao = document.getElementById('federal-piscofins-situacao'); const federalTipoRetencao = document.getElementById('federal-piscofins-tipo-retencao'); const federalPanel = document.getElementById('federal-piscofins-panel'); - const federalCsll = document.getElementById('federal_valor_csll'); + const federalValorCsllRow = document.getElementById('federal-valor-csll-row'); + const federalTributosProfileP = document.getElementById('federal-tributos-profile-p'); + const federalTributosProfileSn = document.getElementById('federal-tributos-profile-sn'); const federalFields = Array.from(document.querySelectorAll('.federal-piscofins-field')); + const selectedOpcaoSimplesNacional = String(@json(old('nfse.opcao_simples_nacional', setting('nfse.opcao_simples_nacional', 2)))); + + const syncFederalTributosProfileVisibility = () => { + // Option 2 means Simples Nacional optant. + const isSimplesNacionalOptant = selectedOpcaoSimplesNacional === '2'; + + federalTributosProfileP?.classList.toggle('hidden', isSimplesNacionalOptant); + federalTributosProfileSn?.classList.toggle('hidden', !isSimplesNacionalOptant); + }; const blockPiscofinsFields = (blockAndZero) => { federalFields.forEach((field) => { @@ -782,6 +761,21 @@ class="absolute inset-y-0 right-0 px-3 text-gray-500 hover:text-gray-700" }); }; + const syncFederalCsllVisibility = () => { + if (!(federalTipoRetencao instanceof HTMLSelectElement)) { + return; + } + + const tipoRetencao = federalTipoRetencao.value; + // Follow retention-type semantics in UI: + // show CSLL only when retention type includes CSLL. + const showCsll = ['3', '7', '8', '9'].includes(tipoRetencao); + + if (federalValorCsllRow) { + federalValorCsllRow.classList.toggle('hidden', !showCsll); + } + }; + const syncFederalPanel = () => { if (!(federalSituacao instanceof HTMLSelectElement)) { return; @@ -806,63 +800,24 @@ class="absolute inset-y-0 right-0 px-3 text-gray-500 hover:text-gray-700" field.classList.remove('bg-gray-50'); } }); - } - blockPiscofinsFields(situacao === '4' || situacao === '6'); - }; - - const syncFederalRetencao = () => { - if (!(federalTipoRetencao instanceof HTMLSelectElement) || !(federalCsll instanceof HTMLInputElement)) { - return; + syncFederalCsllVisibility(); } - const hasCsllRetention = federalTipoRetencao.value !== '' && federalTipoRetencao.value !== '0'; - - federalCsll.readOnly = !hasCsllRetention; - federalCsll.classList.toggle('bg-gray-50', !hasCsllRetention); - - if (!hasCsllRetention) { - federalCsll.value = ''; - } + blockPiscofinsFields(situacao === '4' || situacao === '6'); }; federalSituacao?.addEventListener('change', () => { syncFederalPanel(); - syncFederalRetencao(); }); federalTipoRetencao?.addEventListener('change', () => { - syncFederalRetencao(); + syncFederalCsllVisibility(); }); syncFederalPanel(); - syncFederalRetencao(); - - // ── Federal mode toggle: per_invoice_amounts ↔ percentage_profile ── - const federalModeRadios = document.querySelectorAll('input[name="nfse[tributacao_federal_mode]"]'); - const federalBcRow = document.getElementById('federal-piscofins-bc-row'); - const federalPisValorCol = document.getElementById('federal-piscofins-pis-valor-col'); - const federalCofinsValorCol = document.getElementById('federal-piscofins-cofins-valor-col'); - const tributosPercentRows = document.getElementById('federal-tributos-percent-rows'); - - const syncFederalMode = () => { - const mode = document.querySelector('input[name="nfse[tributacao_federal_mode]"]:checked')?.value ?? 'per_invoice_amounts'; - const isPercentage = mode === 'percentage_profile'; - - // In percentage_profile mode: BC and valor fields are auto-calculated at emission time - federalBcRow?.classList.toggle('hidden', isPercentage); - federalPisValorCol?.classList.toggle('hidden', isPercentage); - federalCofinsValorCol?.classList.toggle('hidden', isPercentage); - - // Percentage profile rows only relevant in percentage_profile mode - tributosPercentRows?.classList.toggle('hidden', !isPercentage); - }; - - federalModeRadios.forEach((radio) => { - radio.addEventListener('change', syncFederalMode); - }); - - syncFederalMode(); + syncFederalCsllVisibility(); + syncFederalTributosProfileVisibility(); // ── Fiscal tab: UF / municipality / LC116 ─────────────────── const ufSelect = document.getElementById('uf'); @@ -893,6 +848,10 @@ class="absolute inset-y-0 right-0 px-3 text-gray-500 hover:text-gray-700" placeholder.value = ''; placeholder.textContent = 'Selecione...'; municipalitySelect.appendChild(placeholder); + ibgeHidden.value = ''; + ibgeDisplay.value = ''; + + let hasPreselectedMunicipality = false; municipalities.forEach((city) => { const option = document.createElement('option'); @@ -904,11 +863,16 @@ class="absolute inset-y-0 right-0 px-3 text-gray-500 hover:text-gray-700" option.selected = true; ibgeHidden.value = city.ibge_code; ibgeDisplay.value = city.ibge_code; + hasPreselectedMunicipality = true; } municipalitySelect.appendChild(option); }); + if (!hasPreselectedMunicipality) { + municipalitySelect.value = ''; + } + municipalitySelect.disabled = false; }; @@ -923,6 +887,8 @@ class="absolute inset-y-0 right-0 px-3 text-gray-500 hover:text-gray-700" municipalitySelect.disabled = true; municipalitySelect.innerHTML = ''; + ibgeHidden.value = ''; + ibgeDisplay.value = ''; const url = municipalitiesUrlTemplate.replace('__UF__', encodeURIComponent(uf)); const municipalities = await fetchJson(url); diff --git a/e2e/nfse-emission.spec.ts b/e2e/nfse-emission.spec.ts index 249cdfa0..fd9c9512 100644 --- a/e2e/nfse-emission.spec.ts +++ b/e2e/nfse-emission.spec.ts @@ -1,15 +1,152 @@ // SPDX-FileCopyrightText: 2026 LibreCode coop and contributors // SPDX-License-Identifier: AGPL-3.0-or-later +import fs from 'fs'; +import path from 'path'; import { expect, test } from '@playwright/test'; import { loginToAkaunting } from './support/auth'; const REAL_EMIT_FLOW_ENABLED = process.env.NFSE_E2E_REAL_EMIT_FLOW === '1'; +const KNOWN_INVALID_CUSTOMERS = new Set(['librecode']); +const RETRYABLE_GATEWAY_CODES = /(E0084|E0202|E0700)/i; +const EXAMPLE_FEDERAL_PROFILE = { + federalPiscofinsSituacaoTributaria: '1', + federalPiscofinsTipoRetencao: '4', + federalPiscofinsAliquotaPis: '0.65', + federalPiscofinsAliquotaCofins: '3.00', + federalValorIrrf: '2.00', + federalValorCsll: '1.00', + federalValorCp: '0.50', + tributosFedP: '3.65', + tributosEstP: '0.00', + tributosMunP: '2.00', +}; test.use({ serviceWorkers: 'block' }); const emitFormsSelector = "form[action*='/nfse/invoices/'][action$='/emit']"; +function currentLaravelLogPath(): string { + const now = new Date(); + const year = String(now.getFullYear()); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + + return path.resolve(__dirname, '../../../storage/logs', `laravel-${year}-${month}-${day}.log`); +} + +function normalizeDecimal(value: string | null | undefined): string { + const raw = (value ?? '').trim(); + + if (raw === '') { + return ''; + } + + const cleaned = raw.replace(/\s+/g, '').replace(/R\$/g, '').replace(/%/g, ''); + + if (cleaned.includes(',') && cleaned.includes('.')) { + return Number(cleaned.replace(/\./g, '').replace(',', '.')).toFixed(2); + } + + if (cleaned.includes(',')) { + return Number(cleaned.replace(',', '.')).toFixed(2); + } + + return Number(cleaned).toFixed(2); +} + +function calculatePercentageValue(baseAmount: string, aliquota: string): string { + const calculated = Number(baseAmount) * Number(aliquota) / 100; + const cents = Math.round((calculated + Number.EPSILON) * 100); + + return (cents / 100).toFixed(2); +} + +function isEligiblePendingInvoice(customerName: string, invoiceAmount: string): boolean { + const normalizedCustomer = customerName.trim().toLowerCase(); + + if (KNOWN_INVALID_CUSTOMERS.has(normalizedCustomer)) { + return false; + } + + // Ensure invoice amount is enough to generate meaningful retention values + const minAmount = 50.00; // Minimum R$50 to test retentions + + return Number(invoiceAmount) >= minAmount; +} + +function extractLatestEmissionPayload(logContents: string, invoiceId: string): Record | null { + const lines = logContents.split(/\r?\n/).reverse(); + + for (const line of lines) { + if (!line.includes('NFS-e emission payload')) { + continue; + } + + const jsonStart = line.indexOf('{'); + + if (jsonStart === -1) { + continue; + } + + try { + const payload = JSON.parse(line.slice(jsonStart)) as Record; + + if (String(payload.invoice_id ?? '') === invoiceId) { + return payload; + } + } catch { + // Ignore incomplete lines while the logger is still flushing. + } + } + + return null; +} + +async function waitForEmissionPayload(logPath: string, invoiceId: string): Promise> { + for (let attempt = 0; attempt < 80; attempt += 1) { + const logContents = fs.existsSync(logPath) + ? fs.readFileSync(logPath, 'utf8') + : ''; + const payload = extractLatestEmissionPayload(logContents, invoiceId); + + if (payload !== null) { + return payload; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + throw new Error(`Could not find NFS-e emission payload log for invoice ${invoiceId}.`); +} + +async function applyExampleFederalProfile(page): Promise { + await page.goto('/1/nfse/settings?tab=federal', { waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('#tab-panel-federal')).toBeVisible(); + + await page.locator('#federal-piscofins-situacao').selectOption(EXAMPLE_FEDERAL_PROFILE.federalPiscofinsSituacaoTributaria); + await page.locator('#federal-piscofins-tipo-retencao').selectOption(EXAMPLE_FEDERAL_PROFILE.federalPiscofinsTipoRetencao); + await page.locator('#federal_piscofins_aliquota_pis').fill(EXAMPLE_FEDERAL_PROFILE.federalPiscofinsAliquotaPis); + await page.locator('#federal_piscofins_aliquota_cofins').fill(EXAMPLE_FEDERAL_PROFILE.federalPiscofinsAliquotaCofins); + await page.locator('#federal_valor_irrf').fill(EXAMPLE_FEDERAL_PROFILE.federalValorIrrf); + if (await page.locator('#federal-valor-csll-row').isVisible()) { + await page.locator('#federal_valor_csll').fill(EXAMPLE_FEDERAL_PROFILE.federalValorCsll); + } + await page.locator('#federal_valor_cp').fill(EXAMPLE_FEDERAL_PROFILE.federalValorCp); + await page.locator('#tributos_fed_p').fill(EXAMPLE_FEDERAL_PROFILE.tributosFedP); + await page.locator('#tributos_est_p').fill(EXAMPLE_FEDERAL_PROFILE.tributosEstP); + await page.locator('#tributos_mun_p').fill(EXAMPLE_FEDERAL_PROFILE.tributosMunP); + await page.locator('#tributos_fed_sn').fill('0.00'); + await page.locator('#tributos_est_sn').fill('0.00'); + await page.locator('#tributos_mun_sn').fill('0.00'); + + await page.locator('#tab-panel-federal button[type="submit"]').click(); + await page.waitForLoadState('networkidle'); + await expect(page).toHaveURL(/\/1\/nfse\/settings/); +} + test('pending invoices page exposes emission CTA when authenticated', async ({ page }, testInfo) => { await loginToAkaunting(page, testInfo); @@ -35,6 +172,9 @@ test('real happy path emits NFS-e from pending list', async ({ page }, testInfo) await loginToAkaunting(page, testInfo); + await applyExampleFederalProfile(page); + + const logPath = currentLaravelLogPath(); await page.goto('/1/nfse/invoices/pending', { waitUntil: 'domcontentloaded' }); await page.waitForLoadState('networkidle'); @@ -45,26 +185,111 @@ test('real happy path emits NFS-e from pending list', async ({ page }, testInfo) test.skip(true, 'No pending invoices available to execute real emission happy path.'); } - const firstEmitButton = emitButtons.first(); + const attemptedInvoices = new Set(); + let emittedSuccessfully = false; + let lastGatewayDetail = ''; - await expect(firstEmitButton).toBeVisible(); + for (let attempt = 0; attempt < emitButtonsCount; attempt += 1) { + const emitForms = page.locator(emitFormsSelector); + const currentButtons = page.locator(`${emitFormsSelector} button[type='submit']`); + const currentCount = await currentButtons.count(); - if (!(await firstEmitButton.isEnabled())) { - const readinessItems = await page - .locator('li') - .filter({ hasText: /configured|configurado|ready|pront/i }) - .allInnerTexts(); + let selectedIndex = -1; + let invoiceId = ''; + let invoiceAmount = ''; - const details = readinessItems.length > 0 ? readinessItems.join('; ') : 'unknown configuration prerequisite'; + for (let index = 0; index < currentCount; index += 1) { + const candidateForm = emitForms.nth(index); + const emitAction = await candidateForm.getAttribute('action'); + const invoiceIdMatch = emitAction?.match(/\/invoices\/(\d+)\/emit$/); + const candidateInvoiceId = invoiceIdMatch?.[1] ?? ''; - throw new Error(`Real emission blocked by pending settings: ${details}`); - } + if (candidateInvoiceId === '' || attemptedInvoices.has(candidateInvoiceId)) { + continue; + } - await firstEmitButton.click(); + const candidateRow = candidateForm.locator('xpath=ancestor::tr[1]'); + const customerName = (await candidateRow.locator('td').nth(1).innerText()).trim(); + const candidateAmount = normalizeDecimal(await candidateRow.locator('td').nth(2).innerText()); - await page.waitForLoadState('networkidle'); + if (!isEligiblePendingInvoice(customerName, candidateAmount)) { + continue; + } + + selectedIndex = index; + invoiceId = candidateInvoiceId; + invoiceAmount = candidateAmount; + break; + } + + if (selectedIndex === -1) { + break; + } + + attemptedInvoices.add(invoiceId); - await expect(page).toHaveURL(/\/1\/nfse\/invoices\/\d+$/); - await expect(page.locator('body')).toContainText(/(emitida com sucesso|successfully emitted)/i); - await expect(page.locator('body')).toContainText(/(dados da nfs-e|nfs-e data)/i); + const emitButton = currentButtons.nth(selectedIndex); + await expect(emitButton).toBeVisible(); + + if (!(await emitButton.isEnabled())) { + const readinessItems = await page + .locator('li') + .filter({ hasText: /configured|configurado|ready|pront/i }) + .allInnerTexts(); + + const details = readinessItems.length > 0 ? readinessItems.join('; ') : 'unknown configuration prerequisite'; + + throw new Error(`Real emission blocked by pending settings: ${details}`); + } + + await emitButton.click(); + await page.waitForLoadState('networkidle'); + + const emissionPayload = await waitForEmissionPayload(logPath, invoiceId); + + expect(String(emissionPayload.tipoAmbiente ?? '')).toBe('2'); + expect(String(emissionPayload.tributacao_federal_mode ?? '')).toBe('percentage_profile'); + expect(String(emissionPayload.federal_piscofins_situacao_tributaria ?? '')).toBe(EXAMPLE_FEDERAL_PROFILE.federalPiscofinsSituacaoTributaria); + expect(String(emissionPayload.federal_piscofins_tipo_retencao ?? '')).toBe(EXAMPLE_FEDERAL_PROFILE.federalPiscofinsTipoRetencao); + expect(String(emissionPayload.federal_piscofins_aliquota_pis ?? '')).toBe(EXAMPLE_FEDERAL_PROFILE.federalPiscofinsAliquotaPis); + expect(String(emissionPayload.federal_piscofins_aliquota_cofins ?? '')).toBe(EXAMPLE_FEDERAL_PROFILE.federalPiscofinsAliquotaCofins); + expect(String(emissionPayload.federal_piscofins_base_calculo ?? '')).toBe(invoiceAmount); + expect(String(emissionPayload.federal_piscofins_valor_pis ?? '')).toBe(calculatePercentageValue(invoiceAmount, EXAMPLE_FEDERAL_PROFILE.federalPiscofinsAliquotaPis)); + expect(String(emissionPayload.federal_piscofins_valor_cofins ?? '')).toBe(calculatePercentageValue(invoiceAmount, EXAMPLE_FEDERAL_PROFILE.federalPiscofinsAliquotaCofins)); + expect(String(emissionPayload.federal_valor_irrf ?? '')).toBe(calculatePercentageValue(invoiceAmount, EXAMPLE_FEDERAL_PROFILE.federalValorIrrf)); + expect(String(emissionPayload.federal_valor_csll ?? '')).toBe(calculatePercentageValue(invoiceAmount, EXAMPLE_FEDERAL_PROFILE.federalValorCsll)); + expect(String(emissionPayload.federal_valor_cp ?? '')).toBe(''); + expect(String(emissionPayload.indicador_tributacao ?? '')).toBe('2'); + expect(String(emissionPayload.tributos_fed_p ?? '')).toBe(EXAMPLE_FEDERAL_PROFILE.tributosFedP); + expect(String(emissionPayload.tributos_est_p ?? '')).toBe(EXAMPLE_FEDERAL_PROFILE.tributosEstP); + expect(String(emissionPayload.tributos_mun_p ?? '')).toBe(EXAMPLE_FEDERAL_PROFILE.tributosMunP); + + if (/\/1\/nfse\/invoices\/pending$/.test(page.url())) { + const bodyText = await page.locator('body').innerText(); + const errorDetail = bodyText.match(/Detalhe SEFIN:[\s\S]*/i)?.[0] ?? 'unknown gateway detail'; + lastGatewayDetail = errorDetail; + + if (RETRYABLE_GATEWAY_CODES.test(errorDetail)) { + await page.goto('/1/nfse/invoices/pending', { waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle'); + continue; + } + + throw new Error(`Real emission remained on pending list after using expected federal payload: ${errorDetail}`); + } + + await expect(page).toHaveURL(/\/1\/nfse\/invoices\/\d+$/); + await expect(page.locator('body')).toContainText(/(emitida com sucesso|emitted successfully)/i); + await expect(page.locator('body')).toContainText(/(dados da nfs-e|nfs-e data)/i); + emittedSuccessfully = true; + break; + } + + if (!emittedSuccessfully) { + if (lastGatewayDetail === '' || RETRYABLE_GATEWAY_CODES.test(lastGatewayDetail)) { + test.skip(true, `Emission blocked by external SEFIN business constraint: ${lastGatewayDetail || 'no detail returned'}`); + } + + throw new Error(`Could not emit any eligible pending invoice. Last gateway detail: ${lastGatewayDetail || 'none'}`); + } }); diff --git a/e2e/nfse-settings.spec.ts b/e2e/nfse-settings.spec.ts index e1157b83..fed9cef6 100644 --- a/e2e/nfse-settings.spec.ts +++ b/e2e/nfse-settings.spec.ts @@ -1,12 +1,12 @@ // SPDX-FileCopyrightText: 2026 LibreCode coop and contributors // SPDX-License-Identifier: AGPL-3.0-or-later -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; import { loginToAkaunting } from './support/auth'; test.use({ serviceWorkers: 'block' }); -async function openTab(page, tabId, panelId) { +async function openTab(page: Page, tabId: string, panelId: string) { const tab = page.locator(tabId); await expect(tab).toBeVisible(); await expect(tab).toBeEnabled(); @@ -146,6 +146,113 @@ test('vault status summary shows certificate secret checklist row', async ({ pag await expect(page.locator('text=Segredo do certificado no Vault')).toBeVisible(); }); +test('federal tab visibility matrix matches situacao tributaria and tipo retencao', async ({ page }, testInfo) => { + await loginToAkaunting(page, testInfo); + + await page.goto('/1/nfse/settings?tab=federal', { waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('#tab-panel-federal')).toBeVisible(); + + const situacaoSelect = page.locator('#federal-piscofins-situacao'); + const tipoRetencaoSelect = page.locator('#federal-piscofins-tipo-retencao'); + const piscofinsPanel = page.locator('#federal-piscofins-panel'); + const csllRow = page.locator('#federal-valor-csll-row'); + + // Situacao 0/empty: panel and CSLL row must be hidden regardless of retention type. + for (const situacao of ['', '0']) { + await situacaoSelect.selectOption(situacao); + await expect(piscofinsPanel).toBeHidden(); + await expect(csllRow).toBeHidden(); + } + + // Situacao with tributacao enabled: panel visible, CSLL controlled by retention type. + await situacaoSelect.selectOption('1'); + await expect(piscofinsPanel).toBeVisible(); + + const matrix = [ + { tipoRetencao: '0', shouldShowCsll: false }, + { tipoRetencao: '3', shouldShowCsll: true }, + { tipoRetencao: '4', shouldShowCsll: false }, + { tipoRetencao: '5', shouldShowCsll: false }, + { tipoRetencao: '6', shouldShowCsll: false }, + { tipoRetencao: '7', shouldShowCsll: true }, + { tipoRetencao: '8', shouldShowCsll: true }, + { tipoRetencao: '9', shouldShowCsll: true }, + ]; + + for (const entry of matrix) { + await tipoRetencaoSelect.selectOption(entry.tipoRetencao); + + if (entry.shouldShowCsll) { + await expect(csllRow).toBeVisible(); + } else { + await expect(csllRow).toBeHidden(); + } + } +}); + +test('fiscal tab resets municipality IBGE when UF changes and updates after new selection', async ({ page }, testInfo) => { + await loginToAkaunting(page, testInfo); + + await page.route('**/nfse/ibge/ufs', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: [ + { uf: 'SP', name: 'Sao Paulo' }, + { uf: 'RJ', name: 'Rio de Janeiro' }, + ], + }), + }); + }); + + await page.route('**/nfse/ibge/municipalities/SP', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: [ + { ibge_code: '3550308', name: 'Sao Paulo' }, + { ibge_code: '3509502', name: 'Campinas' }, + ], + }), + }); + }); + + await page.route('**/nfse/ibge/municipalities/RJ', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: [ + { ibge_code: '3303302', name: 'Niteroi' }, + { ibge_code: '3304557', name: 'Rio de Janeiro' }, + ], + }), + }); + }); + + await page.goto('/1/nfse/settings?tab=fiscal', { waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('#tab-panel-fiscal')).toBeVisible(); + + await page.locator('select[name="nfse[uf]"]').selectOption('SP'); + await page.locator('select[name="nfse[municipio_nome]"]').selectOption('Sao Paulo'); + await expect(page.locator('input[name="nfse[municipio_ibge]"]')).toHaveValue('3550308'); + + await page.locator('select[name="nfse[uf]"]').selectOption('RJ'); + + // Changing UF must clear stale municipality/IBGE until the user selects a city from the new UF. + await expect(page.locator('select[name="nfse[municipio_nome]"]')).toHaveValue(''); + await expect(page.locator('input[name="nfse[municipio_ibge]"]')).toHaveValue(''); + + await page.locator('select[name="nfse[municipio_nome]"]').selectOption('Niteroi'); + await expect(page.locator('input[name="nfse[municipio_ibge]"]')).toHaveValue('3303302'); +}); + test('full dependent setup flow covers vault, certificate, fiscal and services steps', async ({ page }, testInfo) => { await loginToAkaunting(page, testInfo); diff --git a/tests/Unit/Http/Controllers/InvoiceControllerTest.php b/tests/Unit/Http/Controllers/InvoiceControllerTest.php index a72dc0c0..c9e9882c 100644 --- a/tests/Unit/Http/Controllers/InvoiceControllerTest.php +++ b/tests/Unit/Http/Controllers/InvoiceControllerTest.php @@ -87,14 +87,14 @@ protected function setUp(): void 'nfse.aliquota' => '4.50', 'nfse.federal_piscofins_situacao_tributaria' => '1', 'nfse.federal_piscofins_tipo_retencao' => '3', - 'nfse.federal_piscofins_base_calculo' => '1500.25', 'nfse.federal_piscofins_aliquota_pis' => '1.65', - 'nfse.federal_piscofins_valor_pis' => '24.75', 'nfse.federal_piscofins_aliquota_cofins' => '7.60', - 'nfse.federal_piscofins_valor_cofins' => '114.02', - 'nfse.federal_valor_irrf' => '15.00', - 'nfse.federal_valor_csll' => '10.00', - 'nfse.federal_valor_cp' => '5.00', + 'nfse.federal_piscofins_base_calculo' => '999.99', + 'nfse.federal_piscofins_valor_pis' => '999.99', + 'nfse.federal_piscofins_valor_cofins' => '999.99', + 'nfse.federal_valor_irrf' => '1.00', + 'nfse.federal_valor_csll' => '1.00', + 'nfse.federal_valor_cp' => '1.00', 'nfse.tributacao_federal_mode' => 'per_invoice_amounts', 'nfse.sandbox_mode' => false, ]; @@ -191,9 +191,12 @@ protected function hasCertificateSecret(string $cnpj): bool self::assertSame('24.75', $client->capturedDps?->federalPiscofinsValorPis); self::assertSame('7.60', $client->capturedDps?->federalPiscofinsAliquotaCofins); self::assertSame('114.02', $client->capturedDps?->federalPiscofinsValorCofins); + // IRRF = 1.00% × 1500.25 = 15.0025 → '15.00' self::assertSame('15.00', $client->capturedDps?->federalValorIrrf); - self::assertSame('10.00', $client->capturedDps?->federalValorCsll); - self::assertSame('5.00', $client->capturedDps?->federalValorCp); + // CSLL = 1.00% × 1500.25 = 15.0025 → '15.00' (tipoRetencao '3' ≠ '0') + self::assertSame('15.00', $client->capturedDps?->federalValorCsll); + // CP always '' (RNG6110 reject in produção restrita) + self::assertSame('', $client->capturedDps?->federalValorCp); self::assertSame(0, $client->capturedDps?->indicadorTributacao); self::assertSame('00001', $client->capturedDps?->serie); self::assertSame('42', $client->capturedDps?->numeroDps); @@ -271,26 +274,30 @@ protected function hasCertificateSecret(string $cnpj): bool $controller->emit($invoice); - // Base = invoice amount, not stored setting '999.00' + // Base = invoice amount, not any stored helper value self::assertSame('1500.25', $client->capturedDps?->federalPiscofinsBaseCalculo); - // PIS valor = 1500.25 × 1.65 / 100 = 24.75 (not stored '999.00') + // PIS valor = 1500.25 × 1.65 / 100 = 24.75 self::assertSame('24.75', $client->capturedDps?->federalPiscofinsValorPis); - // COFINS valor = 1500.25 × 7.60 / 100 = 114.02 (not stored '999.00') + // COFINS valor = 1500.25 × 7.60 / 100 = 114.02 self::assertSame('114.02', $client->capturedDps?->federalPiscofinsValorCofins); // Aliquotas are still from settings self::assertSame('1.65', $client->capturedDps?->federalPiscofinsAliquotaPis); self::assertSame('7.60', $client->capturedDps?->federalPiscofinsAliquotaCofins); - // Retention amounts still from settings (not percentage-calculated) + // IRRF and CSLL are calculated from percentage settings (1.00% × 1500.25 = 15.00) self::assertSame('15.00', $client->capturedDps?->federalValorIrrf); - self::assertSame('10.00', $client->capturedDps?->federalValorCsll); - self::assertSame('5.00', $client->capturedDps?->federalValorCp); + self::assertSame('15.00', $client->capturedDps?->federalValorCsll); + // CP always '' (RNG6110 reject in produção restrita) + self::assertSame('', $client->capturedDps?->federalValorCp); // No tributos_* percent configured → indicadorTributacao = 0 self::assertSame(0, $client->capturedDps?->indicadorTributacao); } public function testEmitSetsIndicadorTributacaoTwoWhenTributosPercentConfigured(): void { + ControllerIsolationState::$settings['nfse.opcao_simples_nacional'] = 1; ControllerIsolationState::$settings['nfse.tributos_fed_p'] = '10.50'; + ControllerIsolationState::$settings['nfse.tributos_est_p'] = '0.00'; + ControllerIsolationState::$settings['nfse.tributos_mun_p'] = '2.00'; $invoice = InvoiceControllerIsolationState::makeInvoice( id: 44, @@ -340,6 +347,114 @@ protected function hasCertificateSecret(string $cnpj): bool $controller->emit($invoice); self::assertSame(2, $client->capturedDps?->indicadorTributacao); + self::assertSame('10.50', $client->capturedDps?->totalTributosPercentualFederal); + self::assertSame('0.00', $client->capturedDps?->totalTributosPercentualEstadual); + self::assertSame('2.00', $client->capturedDps?->totalTributosPercentualMunicipal); + } + + public function testEmitUsesSimplesNacionalTributosProfileWhenCompanyIsOptant(): void + { + ControllerIsolationState::$settings['nfse.opcao_simples_nacional'] = 2; + ControllerIsolationState::$settings['nfse.tributos_fed_p'] = '99.99'; + ControllerIsolationState::$settings['nfse.tributos_est_p'] = '99.99'; + ControllerIsolationState::$settings['nfse.tributos_mun_p'] = '99.99'; + ControllerIsolationState::$settings['nfse.tributos_fed_sn'] = '4.44'; + ControllerIsolationState::$settings['nfse.tributos_est_sn'] = '1.11'; + ControllerIsolationState::$settings['nfse.tributos_mun_sn'] = '0.55'; + + $invoice = InvoiceControllerIsolationState::makeInvoice( + id: 45, + amount: 500.00, + items: [['name' => 'Servico E']], + contactTaxNumber: '99887766000155', + ); + $invoice->issued_at = '2026-02-04 08:37:53'; + + $client = new class () implements NfseClientInterface { + public ?DpsData $capturedDps = null; + + public function emit(DpsData $dps): ReceiptData + { + $this->capturedDps = $dps; + + return new ReceiptData('NF-45', 'CHAVE-45', '2026-03-21T10:30:00-03:00'); + } + + public function query(string $chaveAcesso): ReceiptData + { + throw new \BadMethodCallException('Not used in this test.'); + } + + public function cancel(string $chaveAcesso, string $motivo): bool + { + throw new \BadMethodCallException('Not used in this test.'); + } + }; + + $controller = new class ($client) extends InvoiceController { + public function __construct(private readonly NfseClientInterface $client) + { + } + + protected function makeClient(bool $sandboxMode): NfseClientInterface + { + return $this->client; + } + + protected function hasCertificateSecret(string $cnpj): bool + { + return true; + } + }; + + $controller->emit($invoice); + + self::assertSame(2, $client->capturedDps?->indicadorTributacao); + self::assertSame('4.44', $client->capturedDps?->totalTributosPercentualFederal); + self::assertSame('1.11', $client->capturedDps?->totalTributosPercentualEstadual); + self::assertSame('0.55', $client->capturedDps?->totalTributosPercentualMunicipal); + } + + public function testRuntimeXmlBuilderStartsInfDpsWithTpAmbBeforeMunicipalityFields(): void + { + $builder = new \LibreCodeCoop\NfsePHP\Xml\XmlBuilder(); + $xml = $builder->buildDps(new DpsData( + cnpjPrestador: '12345678000195', + municipioIbge: '3303302', + itemListaServico: '0107', + valorServico: '31500.00', + aliquota: '2.00', + discriminacao: 'Servico de teste E2E', + tipoAmbiente: 2, + codigoTributacaoNacional: '010101', + documentoTomador: '12345678000195', + nomeTomador: 'Cliente de Teste', + opcaoSimplesNacional: 1, + totalTributosPercentualFederal: '3.65', + totalTributosPercentualEstadual: '0.00', + totalTributosPercentualMunicipal: '2.00', + federalPiscofinsSituacaoTributaria: '1', + federalPiscofinsTipoRetencao: '4', + federalPiscofinsBaseCalculo: '31500.00', + federalPiscofinsAliquotaPis: '0.65', + federalPiscofinsValorPis: '204.75', + federalPiscofinsAliquotaCofins: '3.00', + federalPiscofinsValorCofins: '945.00', + federalValorIrrf: '472.50', + federalValorCsll: '0.00', + federalValorCp: '0.00', + )); + + $normalizedXml = str_replace(["\n", ' '], '', $xml); + self::assertStringContainsString('2', $normalizedXml); + self::assertStringContainsString('akaunting-nfse000011', $normalizedXml); + self::assertStringContainsString('33033020101010107', $normalizedXml); + self::assertStringContainsString('0131500.000.653.00204.75945.004', $normalizedXml); + self::assertStringContainsString('472.50', $normalizedXml); + self::assertStringNotContainsString('', $normalizedXml); + self::assertStringNotContainsString('', $normalizedXml); + self::assertStringContainsString('3.650.002.00', $normalizedXml); + self::assertStringNotContainsString('3303302', $normalizedXml); } public function testEmitPrefersDefaultCompanyServiceOverLegacyFiscalSettings(): void diff --git a/tests/Unit/Http/Controllers/SettingsControllerTest.php b/tests/Unit/Http/Controllers/SettingsControllerTest.php index 3ae3a2a9..b6fb34f1 100644 --- a/tests/Unit/Http/Controllers/SettingsControllerTest.php +++ b/tests/Unit/Http/Controllers/SettingsControllerTest.php @@ -340,7 +340,7 @@ protected function jsonResponse(array $payload, int $status = 200): JsonResponse ], $response->getData(true)); } - public function testMunicipalitiesReturnsFallbackWhenIbgeRequestFails(): void + public function testMunicipalitiesReturnsFallbackDataWhenIbgeRequestFails(): void { $controller = new class () extends SettingsController { protected function fetchMunicipalitiesRows(string $normalizedUf): array @@ -348,6 +348,14 @@ protected function fetchMunicipalitiesRows(string $normalizedUf): array throw new \RuntimeException('ibge unavailable'); } + protected function fetchMunicipalitiesRowsFallback(string $normalizedUf): array + { + return [ + ['id' => '3303302', 'nome' => 'Niteroi'], + ['id' => '3304557', 'nome' => 'Rio de Janeiro'], + ]; + } + protected function jsonResponse(array $payload, int $status = 200): JsonResponse { return new JsonResponse($payload, $status); @@ -356,10 +364,41 @@ protected function jsonResponse(array $payload, int $status = 200): JsonResponse $response = $controller->municipalities('rj', new IbgeLocalities()); - self::assertSame(502, $response->getStatusCode()); + self::assertSame(200, $response->getStatusCode()); + self::assertSame([ + 'data' => [ + ['ibge_code' => '3303302', 'name' => 'Niteroi'], + ['ibge_code' => '3304557', 'name' => 'Rio de Janeiro'], + ], + 'message' => 'Using fallback municipalities source because IBGE is unavailable.', + ], $response->getData(true)); + } + + public function testMunicipalitiesReturnsEmptyDataWhenIbgeAndFallbackFail(): void + { + $controller = new class () extends SettingsController { + protected function fetchMunicipalitiesRows(string $normalizedUf): array + { + throw new \RuntimeException('ibge unavailable'); + } + + protected function fetchMunicipalitiesRowsFallback(string $normalizedUf): array + { + throw new \RuntimeException('fallback unavailable'); + } + + protected function jsonResponse(array $payload, int $status = 200): JsonResponse + { + return new JsonResponse($payload, $status); + } + }; + + $response = $controller->municipalities('rj', new IbgeLocalities()); + + self::assertSame(200, $response->getStatusCode()); self::assertSame([ 'data' => [], - 'message' => 'Failed to load municipalities from IBGE.', + 'message' => 'Failed to load municipalities from IBGE and fallback source.', ], $response->getData(true)); } @@ -1199,16 +1238,12 @@ public function testUpdateFederalSavesFederalTaxationSettingsAndRedirectsToFeder $request = new Request( inputs: [ 'nfse' => [ - 'tributacao_federal_mode' => 'percentage_profile', 'federal_piscofins_situacao_tributaria' => '4', 'federal_piscofins_tipo_retencao' => '3', - 'federal_piscofins_base_calculo' => '1000,00', 'federal_piscofins_aliquota_pis' => '1,65', - 'federal_piscofins_valor_pis' => '16,50', 'federal_piscofins_aliquota_cofins' => '7,60', - 'federal_piscofins_valor_cofins' => '76,00', 'federal_valor_irrf' => '15,00', - 'federal_valor_csll' => '10,00', + 'federal_valor_csll' => '12,50', 'federal_valor_cp' => '5,00', 'tributos_fed_p' => '8,55', 'tributos_est_p' => '2.10', @@ -1230,14 +1265,14 @@ public function testUpdateFederalSavesFederalTaxationSettingsAndRedirectsToFeder self::assertSame('percentage_profile', ControllerIsolationState::$settings['nfse.tributacao_federal_mode'] ?? null); self::assertSame('4', ControllerIsolationState::$settings['nfse.federal_piscofins_situacao_tributaria'] ?? null); self::assertSame('3', ControllerIsolationState::$settings['nfse.federal_piscofins_tipo_retencao'] ?? null); - self::assertSame('1000.00', ControllerIsolationState::$settings['nfse.federal_piscofins_base_calculo'] ?? null); self::assertSame('1.65', ControllerIsolationState::$settings['nfse.federal_piscofins_aliquota_pis'] ?? null); - self::assertSame('16.50', ControllerIsolationState::$settings['nfse.federal_piscofins_valor_pis'] ?? null); self::assertSame('7.60', ControllerIsolationState::$settings['nfse.federal_piscofins_aliquota_cofins'] ?? null); - self::assertSame('76.00', ControllerIsolationState::$settings['nfse.federal_piscofins_valor_cofins'] ?? null); self::assertSame('15.00', ControllerIsolationState::$settings['nfse.federal_valor_irrf'] ?? null); - self::assertSame('10.00', ControllerIsolationState::$settings['nfse.federal_valor_csll'] ?? null); + self::assertSame('12.50', ControllerIsolationState::$settings['nfse.federal_valor_csll'] ?? null); self::assertSame('5.00', ControllerIsolationState::$settings['nfse.federal_valor_cp'] ?? null); + self::assertArrayNotHasKey('nfse.federal_piscofins_base_calculo', ControllerIsolationState::$settings); + self::assertArrayNotHasKey('nfse.federal_piscofins_valor_pis', ControllerIsolationState::$settings); + self::assertArrayNotHasKey('nfse.federal_piscofins_valor_cofins', ControllerIsolationState::$settings); self::assertSame('8.55', ControllerIsolationState::$settings['nfse.tributos_fed_p'] ?? null); self::assertSame('2.10', ControllerIsolationState::$settings['nfse.tributos_est_p'] ?? null); self::assertSame('1.35', ControllerIsolationState::$settings['nfse.tributos_mun_p'] ?? null); @@ -1253,7 +1288,6 @@ public function testUpdateFederalRedirectsWithErrorWhenVaultIsNotReady(): void $request = new Request( inputs: [ 'nfse' => [ - 'tributacao_federal_mode' => 'per_invoice_amounts', ], ], ); diff --git a/tests/Unit/Views/OperationalViewsExistenceTest.php b/tests/Unit/Views/OperationalViewsExistenceTest.php index 934fe88d..b87734eb 100644 --- a/tests/Unit/Views/OperationalViewsExistenceTest.php +++ b/tests/Unit/Views/OperationalViewsExistenceTest.php @@ -168,24 +168,28 @@ public function testSettingsViewKeepsServiceSelectionOnlyInServicesTab(): void self::assertStringNotContainsString("document.getElementById('item_lista_servico_display')", $content); self::assertStringContainsString("trans('nfse::general.settings.federal.tab_title')", $content); self::assertStringContainsString("route('nfse.settings.federal')", $content); - self::assertStringContainsString('name="nfse[tributacao_federal_mode]"', $content); + self::assertStringContainsString('name="nfse[tributacao_federal_mode]" type="hidden" value="percentage_profile"', $content); + self::assertStringNotContainsString('value="per_invoice_amounts"', $content); self::assertStringContainsString('name="nfse[federal_piscofins_situacao_tributaria]"', $content); self::assertStringContainsString('name="nfse[federal_piscofins_tipo_retencao]"', $content); - self::assertStringContainsString('name="nfse[federal_piscofins_base_calculo]"', $content); - self::assertStringContainsString('name="nfse[federal_piscofins_valor_cofins]"', $content); + self::assertStringContainsString('id="federal-piscofins-preview-note"', $content); + self::assertStringContainsString("trans('nfse::general.settings.federal.piscofins_preview_note')", $content); + self::assertStringNotContainsString('name="nfse[federal_piscofins_base_calculo]"', $content); + self::assertStringNotContainsString('name="nfse[federal_piscofins_valor_pis]"', $content); + self::assertStringNotContainsString('name="nfse[federal_piscofins_valor_cofins]"', $content); self::assertStringContainsString('name="nfse[federal_valor_csll]"', $content); self::assertStringContainsString('id="federal-piscofins-panel"', $content); self::assertStringContainsString('id="federal-piscofins-situacao"', $content); self::assertStringContainsString('id="federal-piscofins-tipo-retencao"', $content); self::assertStringContainsString('name="nfse[tributos_fed_p]"', $content); self::assertStringContainsString('name="nfse[tributos_mun_sn]"', $content); + self::assertStringContainsString('id="federal-tributos-profile-p"', $content); + self::assertStringContainsString('id="federal-tributos-profile-sn"', $content); self::assertStringContainsString('id="federal-save-button"', $content); self::assertStringContainsString('bg-green-50', $content); self::assertStringNotContainsString('id="federal_opcao_simples_status"', $content); self::assertStringNotContainsString("trans('nfse::general.settings.federal.current_simples_status')", $content); - self::assertStringContainsString('data-tax-affix="money"', $content); self::assertStringContainsString('data-tax-affix="percent"', $content); - self::assertStringContainsString('pointer-events-none absolute inset-y-0 left-0', $content); self::assertStringContainsString('pointer-events-none absolute inset-y-0 right-0', $content); self::assertStringNotContainsString('R$ = valor monetario', $content); }