diff --git a/Http/Controllers/TaxAssociationController.php b/Http/Controllers/TaxAssociationController.php new file mode 100644 index 00000000..0ce8d2f4 --- /dev/null +++ b/Http/Controllers/TaxAssociationController.php @@ -0,0 +1,129 @@ +companyId(); + + return view('nfse::settings.tax-associations', [ + 'taxes' => DB::table('taxes') + ->where('company_id', $companyId) + ->whereNull('deleted_at') + ->orderBy('name') + ->orderBy('id') + ->get(['id', 'name', 'type', 'rate', 'enabled']), + 'associationsByTax' => TaxFiscalAssociation::query() + ->where('company_id', $companyId) + ->orderBy('effective_from') + ->get() + ->groupBy('tax_id'), + 'identities' => FiscalTaxIdentity::cases(), + 'roles' => FiscalTaxRole::cases(), + ]); + } + + public function store(Request $request, TaxFiscalAssociationStore $store): RedirectResponse + { + $companyId = $this->companyId(); + $actorId = (int) auth()->id(); + abort_if($actorId <= 0, 403, 'An authenticated confirming operator is required.'); + + $values = $request->validate([ + 'tax_id' => ['required', 'integer', 'min:1'], + 'fiscal_identity' => ['required', Rule::in(array_column(FiscalTaxIdentity::cases(), 'value'))], + 'fiscal_role' => ['required', Rule::in(array_column(FiscalTaxRole::cases(), 'value'))], + 'effective_from' => ['required', 'date_format:Y-m-d'], + 'effective_until' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_from'], + 'source_reference' => ['required', 'string', 'min:5', 'max:255'], + 'acknowledge' => ['required', 'accepted'], + ]); + $values['source_reference'] = trim($values['source_reference']); + $values['effective_until'] ??= null; + + try { + if (!$this->identicalRevisionExists($companyId, $actorId, $values)) { + $store->confirm( + $companyId, + (int) $values['tax_id'], + FiscalTaxIdentity::from($values['fiscal_identity']), + FiscalTaxRole::from($values['fiscal_role']), + $values['effective_from'], + $values['effective_until'], + $values['source_reference'], + $actorId, + ); + } + } catch (\DomainException|\InvalidArgumentException $exception) { + // An identical concurrent submission may have won the row lock. + if (!$this->identicalRevisionExists($companyId, $actorId, $values)) { + return redirect()->back()->withInput()->withErrors([ + 'tax_id' => $exception->getMessage(), + ]); + } + } + + return redirect()->route('nfse.settings.tax-associations.index') + ->with('success', 'Associação fiscal confirmada. Nenhum imposto ou documento financeiro foi alterado.'); + } + + /** + * An exact retry is idempotent. Different revisions, sources or actors must + * go through the store's inclusive-window overlap validation. + * + * @param array $values + */ + private function identicalRevisionExists(int $companyId, int $actorId, array $values): bool + { + // An old confirmation cannot authorize a retry if ownership has changed. + $stillOwned = DB::table('taxes') + ->where('id', (int) $values['tax_id']) + ->where('company_id', $companyId) + ->whereNull('deleted_at') + ->exists(); + if (!$stillOwned) { + return false; + } + + return TaxFiscalAssociation::query() + ->where('company_id', $companyId) + ->where('tax_id', (int) $values['tax_id']) + ->whereDate('effective_from', $values['effective_from']) + ->where('effective_until', $values['effective_until']) + ->where('fiscal_identity', $values['fiscal_identity']) + ->where('fiscal_role', $values['fiscal_role']) + ->where('source_reference', $values['source_reference']) + ->where('confirmation_kind', 'operator_confirmed') + ->where('confirmed_by', $actorId) + ->exists(); + } + + private function companyId(): int + { + $companyId = function_exists('company_id') ? (int) company_id() : 0; + abort_if($companyId <= 0, 403, 'An active company is required for fiscal tax associations.'); + + return $companyId; + } +} diff --git a/Resources/views/settings/edit.blade.php b/Resources/views/settings/edit.blade.php index 47a2abc4..d830de29 100644 --- a/Resources/views/settings/edit.blade.php +++ b/Resources/views/settings/edit.blade.php @@ -18,6 +18,9 @@

{{ trans('nfse::general.settings.title') }}

A empresa do Akaunting continua sendo o cadastro principal. O perfil NFS-e apenas acrescenta certificado e parâmetros fiscais.

+ @can('read-nfse-settings') +

Revisar associações fiscais dos impostos existentes

+ @endcan
@if(session('success')) diff --git a/Resources/views/settings/tax-associations.blade.php b/Resources/views/settings/tax-associations.blade.php new file mode 100644 index 00000000..b5e7facf --- /dev/null +++ b/Resources/views/settings/tax-associations.blade.php @@ -0,0 +1,137 @@ +{{-- SPDX-FileCopyrightText: 2026 LibreCode coop and contributors --}} +{{-- SPDX-License-Identifier: AGPL-3.0-or-later --}} + + Associações fiscais de impostos + +
+ ← Voltar às configurações NFS-e +
+

Associações fiscais dos impostos existentes

+

+ Identidade e papel fiscais exigem confirmação do responsável tributário. A classificação não calcula + incidência, não altera impostos ou faturas e não comprova conformidade legal. As revisões confirmadas + permanecem no histórico, com vigência e fonte documental. +

+
+ @if(session('success')) +

{{ session('success') }}

+ @endif + @if($errors->any()) + + @endif +
+
+

Impostos cadastrados nesta empresa

+ Abrir cadastro de impostos do Akaunting +
+
+ + + + + + + + + + @forelse($taxes as $tax) + + + + + + @empty + + @endforelse + +
Imposto / IDTipo / alíquota contábilRevisões fiscais confirmadas
{{ $tax->name }} (#{{ $tax->id }}){{ $tax->type }} / {{ $tax->rate }}% + @if(!isset($associationsByTax[$tax->id]) || $associationsByTax[$tax->id]->isEmpty()) + Sem associação confirmada — identidade desconhecida + @else +
    + @foreach($associationsByTax[$tax->id] as $association) +
  • + {{ $association->fiscal_identity }} / {{ $association->fiscal_role }} + · {{ $association->effective_from }} a {{ $association->effective_until ?: 'sem término' }} + + Fonte: {{ $association->source_reference }} · Confirmado por #{{ $association->confirmed_by }} + @if($association->confirmed_at) em {{ $association->confirmed_at->format('Y-m-d') }} @endif + + @if(($tax->type === 'withholding') !== ($association->fiscal_role === 'withheld_by_payer')) + Revisar compatibilidade entre tipo contábil e papel fiscal. + @endif +
  • + @endforeach +
+ @endif +
Nenhum imposto cadastrado nesta empresa. Não será criado imposto automaticamente.
+
+
+ @can('update-nfse-settings') +
+

Confirmar uma associação fiscal

+

+ A revisão será adicionada sem modificar períodos existentes. Vigências sobrepostas ou mudanças + de enquadramento de um período já confirmado exigem revisão administrativa separada; não substitua + silenciosamente associações históricas. +

+ @if($taxes->isNotEmpty()) +
+ @csrf + +
+ + +
+
+ + +
+ + + +
+ @endif +
+ @endcan +
+
+
diff --git a/Routes/admin.php b/Routes/admin.php index e671fa9e..5c5367a1 100644 --- a/Routes/admin.php +++ b/Routes/admin.php @@ -16,6 +16,7 @@ use Modules\Nfse\Http\Controllers\InvoiceController; use Modules\Nfse\Http\Controllers\LegacyInvoiceController; use Modules\Nfse\Http\Controllers\SettingsController; +use Modules\Nfse\Http\Controllers\TaxAssociationController; use Modules\Nfse\Http\Controllers\Modals\InvoiceEmails; Route::admin('nfse', function () { @@ -39,6 +40,10 @@ ->middleware('permission:read-nfse-settings')->name('approximate-taxes'); Route::post('/approximate-taxes', [SettingsController::class, 'storeApproximateTaxes']) ->middleware('permission:update-nfse-settings')->name('approximate-taxes.store'); + Route::get('/tax-associations', [TaxAssociationController::class, 'index']) + ->middleware('permission:read-nfse-settings')->name('tax-associations.index'); + Route::post('/tax-associations', [TaxAssociationController::class, 'store']) + ->middleware('permission:update-nfse-settings')->name('tax-associations.store'); Route::patch('/artifacts', [SettingsController::class, 'updateArtifacts']) ->middleware('permission:update-nfse-settings')->name('artifacts'); diff --git a/Tests/Feature/AdminRouteRegistrationTest.php b/Tests/Feature/AdminRouteRegistrationTest.php index 3aaa5540..d4477ab8 100644 --- a/Tests/Feature/AdminRouteRegistrationTest.php +++ b/Tests/Feature/AdminRouteRegistrationTest.php @@ -22,6 +22,8 @@ public static function registeredRoutes(): array 'dashboard' => ['nfse.dashboard.index', 'GET'], 'settings edit' => ['nfse.settings.edit', 'GET'], 'settings update' => ['nfse.settings.update', 'PATCH'], + 'tax association list' => ['nfse.settings.tax-associations.index', 'GET'], + 'tax association confirmation' => ['nfse.settings.tax-associations.store', 'POST'], 'certificate upload' => ['nfse.certificate.upload', 'POST'], 'certificate destroy' => ['nfse.certificate.destroy', 'DELETE'], 'certificate parse' => ['nfse.certificate.parse', 'POST'], @@ -50,6 +52,8 @@ public function testSensitiveFiscalRoutesEnforcePermissions(): void $permissions = [ 'nfse.settings.edit' => 'permission:read-nfse-settings', 'nfse.settings.update' => 'permission:update-nfse-settings', + 'nfse.settings.tax-associations.index' => 'permission:read-nfse-settings', + 'nfse.settings.tax-associations.store' => 'permission:update-nfse-settings', 'nfse.settings.vault' => 'permission:update-nfse-settings', 'nfse.settings.fiscal' => 'permission:update-nfse-settings', 'nfse.settings.federal' => 'permission:update-nfse-settings', diff --git a/Tests/Feature/TaxAssociationAdminTest.php b/Tests/Feature/TaxAssociationAdminTest.php new file mode 100644 index 00000000..d89358b1 --- /dev/null +++ b/Tests/Feature/TaxAssociationAdminTest.php @@ -0,0 +1,136 @@ +loginAs(); + $tax = $this->tax((int) company_id(), 'Synthetic confirmed PIS'); + $initial = DB::table('taxes')->where('id', $tax)->first(); + + $this->get(route('nfse.settings.tax-associations.index')) + ->assertOk() + ->assertSee('Synthetic confirmed PIS') + ->assertSee('Sem associação confirmada'); + + $data = $this->confirmation($tax); + $url = route('nfse.settings.tax-associations.store'); + $this->post($url, $data)->assertRedirect(route('nfse.settings.tax-associations.index')); + $this->post($url, $data)->assertRedirect(route('nfse.settings.tax-associations.index')); + + $stored = TaxFiscalAssociation::query()->where('tax_id', $tax)->get(); + self::assertCount(1, $stored); + self::assertSame('pis', $stored[0]->fiscal_identity); + self::assertSame('provider_due', $stored[0]->fiscal_role); + self::assertSame('synthetic-fiscal-assessment', $stored[0]->source_reference); + self::assertSame((int) auth()->id(), (int) $stored[0]->confirmed_by); + $after = DB::table('taxes')->where('id', $tax)->first(); + self::assertSame($initial->name, $after->name); + self::assertSame($initial->rate, $after->rate); + self::assertSame($initial->type, $after->type); + } + + public function testForeignCompanyTaxCannotBeListedOrConfirmed(): void + { + $this->loginAs(); + $foreign = $this->tax((int) company_id() + 1000, 'Foreign synthetic secret tax'); + + $this->get(route('nfse.settings.tax-associations.index')) + ->assertOk() + ->assertDontSee('Foreign synthetic secret tax'); + + $this->post(route('nfse.settings.tax-associations.store'), $this->confirmation($foreign)) + ->assertSessionHasErrors('tax_id'); + + self::assertSame(0, TaxFiscalAssociation::query()->where('tax_id', $foreign)->count()); + } + + public function testExactRetryMustStillProveThatTheTaxBelongsToTheCompany(): void + { + $this->loginAs(); + $owner = (int) company_id(); + $tax = $this->tax($owner, 'Synthetic reassigned tax'); + $url = route('nfse.settings.tax-associations.store'); + $data = $this->confirmation($tax); + + $this->post($url, $data)->assertSessionHasNoErrors(); + DB::table('taxes')->where('id', $tax)->update(['company_id' => $owner + 1000]); + $this->post($url, $data)->assertSessionHasErrors('tax_id'); + + self::assertSame(1, TaxFiscalAssociation::query()->where('tax_id', $tax)->count()); + } + + public function testOverlappingOrUnacknowledgedRevisionsAreRejected(): void + { + $this->withExceptionHandling(); + $this->loginAs(); + $tax = $this->tax((int) company_id(), 'Synthetic tax'); + + $url = route('nfse.settings.tax-associations.store'); + $this->post($url, $this->confirmation($tax))->assertSessionHasNoErrors(); + + $other = $this->confirmation($tax); + $other['fiscal_identity'] = 'cofins'; + $other['effective_from'] = '2026-06-01'; + $this->post($url, $other)->assertSessionHasErrors('tax_id'); + + $unconfirmed = $this->confirmation($tax); + unset($unconfirmed['acknowledge']); + $this->post($url, $unconfirmed)->assertSessionHasErrors('acknowledge'); + + self::assertSame(1, TaxFiscalAssociation::query()->where('tax_id', $tax)->count()); + } + + public function testMissingCompanyContextFailsClosed(): void + { + $this->loginAs(); + $active = Company::getCurrent(); + self::assertNotNull($active); + app()->forgetInstance(Company::class); + try { + $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class); + (new \Modules\Nfse\Http\Controllers\TaxAssociationController())->index(); + } finally { + app()->instance(Company::class, $active); + } + } + + private function tax(int $companyId, string $name): int + { + return (int) DB::table('taxes')->insertGetId([ + 'company_id' => $companyId, + 'name' => $name, + 'rate' => '0.65', + 'type' => 'normal', + 'enabled' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + /** @return array */ + private function confirmation(int $tax): array + { + return [ + 'tax_id' => $tax, + 'fiscal_identity' => 'pis', + 'fiscal_role' => 'provider_due', + 'effective_from' => '2026-01-01', + 'effective_until' => null, + 'source_reference' => 'synthetic-fiscal-assessment', + 'acknowledge' => '1', + ]; + } +} diff --git a/docs/operacao-e-configuracao.md b/docs/operacao-e-configuracao.md index 2075d84e..330a82d9 100644 --- a/docs/operacao-e-configuracao.md +++ b/docs/operacao-e-configuracao.md @@ -155,10 +155,34 @@ por CNPJ retornam HTTP 410 (`Gone`). O endpoint de inspeção PFX (somente leitura) permanece disponível. A configuração WebDAV usa as preferências nativas da empresa Akaunting ativa, separada dos parâmetros do emissor. -A nova interface pertence à #420 e o consumo do perfil pelos emissores, -filas, recuperação, ADN e WebDAV pertence à #421. **Não interprete um -perfil `active` como autorização para transmitir NFS-e** enquanto -esses consumidores não tiverem sido integrados e testados. +A infraestrutura e os perfis fiscais são isolados por empresa. Um perfil +`active` não autoriza transmissão por si só: emissão, recuperação e filas +permanecem condicionadas à validação fiscal, certificado, Vault e identidade +DPS conforme o fluxo e o ambiente em uso. + +## Associações fiscais de impostos existentes + +Em **Configurações NFS-e → Revisar associações fiscais dos impostos existentes**, +o operador pode consultar os impostos nativos da empresa ativa e confirmar +explicitamente identidade fiscal (ISSQN, PIS, COFINS, CSLL, IRRF, CP, IBS ou CBS), +papel (devido, retido ou estimativa de transparência), vigência e referência +documental. A ação exige permissão `update-nfse-settings`; leitura requer +`read-nfse-settings`. Cada confirmação registra seu autor e não altera +`taxes`, itens, valores financeiros nem documentos fiscais autorizados. + +Associações são revisões datadas: não podem sobrepor o período de outra +classificação para o mesmo `tax_id`; repetir a mesma confirmação por seu +autor é idempotente. Se uma vigência já confirmada precisar ser revista, +a alteração deve seguir revisão administrativa explícita: esta interface +não sobrescreve registros históricos. Escolha períodos finitos quando +mudanças futuras forem esperadas. + +Uma associação comprova somente que um operador atribuiu identidade e papel +ao imposto existente. Não comprova incidência, alíquota, base, retenção ou +obrigação legal. Impostos homônimos não são mapeados automaticamente, +e IBS/CBS não são inseridos nas linhas de faturas por conta da exigência +documental na NFS-e. Para alteração da alíquota ou criação de imposto, use +o cadastro nativo de impostos do Akaunting com orientação contábil. ## Dados fiscais e validação @@ -170,11 +194,16 @@ emissão. A ausência de classificação não autoriza substituir códigos fisca por valores de outra empresa. Isso inclui os métodos administrativos de emissão manual, que não devem consultar `nfse.item_lista_servico`, `nfse.codigo_tributacao_nacional` nem `nfse.aliquota` descontinuados. -A classificação dos impostos federais dos itens considera nomes e -identificadores textuais configurados no Akaunting: PIS, COFINS, IRRF, CSLL e -contribuição previdenciária. Use nomes explícitos, por exemplo `IRRF - Serviços` -ou `cod:pis`; códigos numéricos isolados podem ser ambíguos. -Confira o enquadramento tributário com o responsável fiscal. +Para os impostos **existentes** no Akaunting, confirme em separado o +vínculo fiscal por `company_id + tax_id`, papel e vigência, sem inferir +identidade a partir de nome ou percentual. Uma associação confirmada +não comprova a incidência, o cálculo ou a retenção legal na competência. + +Alguns caminhos legados de agregação ainda interpretam nomes textuais +de impostos; **isso não constitui evidência tributária válida** e está +sob revisão de #428/#429. Não altere nomes ou alíquotas para forçar +uma classificação silenciosa. Confira o enquadramento com o responsável +fiscal antes da transmissão. Na aba fiscal de cada empresa, selecione explicitamente a situação do prestador no Simples Nacional (não optante, MEI ou ME/EPP). Ausência ou código inválido diff --git a/e2e/nfse-settings.spec.ts b/e2e/nfse-settings.spec.ts index e4b958c7..28824220 100644 --- a/e2e/nfse-settings.spec.ts +++ b/e2e/nfse-settings.spec.ts @@ -117,3 +117,18 @@ test('administrative UI synthetic screenshot', async ({ page }, info) => { contentType: 'image/png', }); }); + +test('fiscal tax associations are reviewed through native settings, without automatic provisioning', async ({ page }, info) => { + await loginToAkaunting(page, info); + await page.goto('/1/nfse/settings', { waitUntil: 'domcontentloaded' }); + + const link = page.getByRole('link', { name: 'Revisar associações fiscais dos impostos existentes' }); + await expect(link).toBeVisible(); + await link.click(); + + await expect(page).toHaveURL(/\/1\/nfse\/settings\/tax-associations$/); + await expect(page.getByRole('heading', { name: 'Associações fiscais dos impostos existentes' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Impostos cadastrados nesta empresa' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Abrir cadastro de impostos do Akaunting' })).toBeVisible(); + await expect(page.getByText('A classificação não calcula', { exact: false })).toBeVisible(); +});