Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions Http/Controllers/TaxAssociationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace Modules\Nfse\Http\Controllers;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Modules\Nfse\Contracts\FiscalTaxIdentity;
use Modules\Nfse\Contracts\FiscalTaxRole;
use Modules\Nfse\Models\TaxFiscalAssociation;
use Modules\Nfse\Support\TaxFiscalAssociationStore;

/**
* An operator confirms existing Akaunting tax identities; this is not tax provisioning.
* Company ownership, including that of the selected tax, is enforced server-side.
*/
final class TaxAssociationController extends Controller
{
public function index(): View
{
$companyId = $this->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<string, mixed> $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;
}
}
3 changes: 3 additions & 0 deletions Resources/views/settings/edit.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
<div>
<h1 class="text-xl font-semibold">{{ trans('nfse::general.settings.title') }}</h1>
<p class="mt-1 text-sm text-gray-600">A empresa do Akaunting continua sendo o cadastro principal. O perfil NFS-e apenas acrescenta certificado e parâmetros fiscais.</p>
@can('read-nfse-settings')
<p class="mt-2 text-sm"><a class="underline" href="{{ route('nfse.settings.tax-associations.index') }}">Revisar associações fiscais dos impostos existentes</a></p>
@endcan
</div>

@if(session('success'))
Expand Down
137 changes: 137 additions & 0 deletions Resources/views/settings/tax-associations.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
{{-- SPDX-FileCopyrightText: 2026 LibreCode coop and contributors --}}
{{-- SPDX-License-Identifier: AGPL-3.0-or-later --}}
<x-layouts.admin>
<x-slot name="title">Associações fiscais de impostos</x-slot>
<x-slot name="content">
<div class="max-w-5xl space-y-5">
<a href="{{ route('nfse.settings.edit') }}" class="text-sm underline">&larr; Voltar às configurações NFS-e</a>
<header>
<h1 class="text-xl font-semibold">Associações fiscais dos impostos existentes</h1>
<p class="mt-2 text-sm text-gray-600">
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.
</p>
</header>
@if(session('success'))
<p role="status" class="rounded border border-green-300 p-3">{{ session('success') }}</p>
@endif
@if($errors->any())
<ul role="alert" class="rounded border border-red-300 p-3 text-sm text-red-800">
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
@endif
<section class="rounded border bg-white p-4 space-y-3" aria-labelledby="existing-taxes-title">
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 id="existing-taxes-title" class="font-semibold">Impostos cadastrados nesta empresa</h2>
<a href="{{ route('taxes.index') }}" class="text-sm underline">Abrir cadastro de impostos do Akaunting</a>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="text-left px-3 py-2">Imposto / ID</th>
<th class="text-left px-3 py-2">Tipo / alíquota contábil</th>
<th class="text-left px-3 py-2">Revisões fiscais confirmadas</th>
</tr>
</thead>
<tbody>
@forelse($taxes as $tax)
<tr class="border-t">
<td class="px-3 py-2">{{ $tax->name }} (#{{ $tax->id }})</td>
<td class="px-3 py-2">{{ $tax->type }} / {{ $tax->rate }}%</td>
<td class="px-3 py-2">
@if(!isset($associationsByTax[$tax->id]) || $associationsByTax[$tax->id]->isEmpty())
<span class="text-amber-800">Sem associação confirmada — identidade desconhecida</span>
@else
<ul class="space-y-2">
@foreach($associationsByTax[$tax->id] as $association)
<li>
<strong>{{ $association->fiscal_identity }} / {{ $association->fiscal_role }}</strong>
· {{ $association->effective_from }} a {{ $association->effective_until ?: 'sem término' }}
<span class="block text-xs text-gray-600">
Fonte: {{ $association->source_reference }} · Confirmado por #{{ $association->confirmed_by }}
@if($association->confirmed_at) em {{ $association->confirmed_at->format('Y-m-d') }} @endif
</span>
@if(($tax->type === 'withholding') !== ($association->fiscal_role === 'withheld_by_payer'))
<span class="block text-xs text-amber-800">Revisar compatibilidade entre tipo contábil e papel fiscal.</span>
@endif
</li>
@endforeach
</ul>
@endif
</td>
</tr>
@empty
<tr><td colspan="3" class="px-3 py-4 text-gray-600">Nenhum imposto cadastrado nesta empresa. Não será criado imposto automaticamente.</td></tr>
@endforelse
</tbody>
</table>
</div>
</section>
@can('update-nfse-settings')
<section class="rounded border bg-white p-4 space-y-3" aria-labelledby="confirm-tax-title">
<h2 id="confirm-tax-title" class="font-semibold">Confirmar uma associação fiscal</h2>
<p class="text-sm text-gray-600">
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.
</p>
@if($taxes->isNotEmpty())
<form method="POST" action="{{ route('nfse.settings.tax-associations.store') }}" class="space-y-4">
@csrf
<label class="block text-sm">Imposto existente
<select name="tax_id" required class="mt-1 block w-full rounded border p-2">
<option value="">Selecione um imposto desta empresa</option>
@foreach($taxes as $tax)
<option value="{{ $tax->id }}" @selected((string) old('tax_id') === (string) $tax->id)>
{{ $tax->name }} (#{{ $tax->id }}) — {{ $tax->type }} / {{ $tax->rate }}%
</option>
@endforeach
</select>
</label>
<div class="grid gap-3 md:grid-cols-2">
<label class="block text-sm">Identidade fiscal (revisada pelo responsável tributário)
<select name="fiscal_identity" required class="mt-1 block w-full rounded border p-2">
<option value="">Selecione</option>
@foreach($identities as $identity)
<option value="{{ $identity->value }}" @selected(old('fiscal_identity') === $identity->value)>{{ strtoupper($identity->value) }}</option>
@endforeach
</select>
</label>
<label class="block text-sm">Papel fiscal
<select name="fiscal_role" required class="mt-1 block w-full rounded border p-2">
<option value="">Selecione</option>
@foreach($roles as $role)
<option value="{{ $role->value }}" @selected(old('fiscal_role') === $role->value)>{{ $role->value }}</option>
@endforeach
</select>
</label>
</div>
<div class="grid gap-3 md:grid-cols-2">
<label class="block text-sm">Vigência inicial
<input type="date" name="effective_from" required value="{{ old('effective_from') }}" class="mt-1 block w-full rounded border p-2">
</label>
<label class="block text-sm">Vigência final (opcional)
<input type="date" name="effective_until" value="{{ old('effective_until') }}" class="mt-1 block w-full rounded border p-2">
</label>
</div>
<label class="block text-sm">Referência documental da classificação
<input type="text" name="source_reference" maxlength="255" minlength="5" required
value="{{ old('source_reference') }}" class="mt-1 block w-full rounded border p-2"
placeholder="Parecer, documento fiscal interno ou fonte normativa">
</label>
<label class="flex items-start gap-2 text-sm">
<input type="checkbox" name="acknowledge" value="1" required class="mt-1">
<span>Confirmo que revisei a identidade, papel, vigência e fonte; esta ação não cria tributos, altera valores nem substitui análise jurídico-contábil.</span>
</label>
<button type="submit" class="rounded bg-green-700 px-4 py-2 text-white">Registrar confirmação</button>
</form>
@endif
</section>
@endcan
</div>
</x-slot>
</x-layouts.admin>
5 changes: 5 additions & 0 deletions Routes/admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand All @@ -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');

Expand Down
4 changes: 4 additions & 0 deletions Tests/Feature/AdminRouteRegistrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading