Просмотр исходного кода

feat(financial): add manual accounts payable and receivable management

ebagabee 3 недель назад
Родитель
Сommit
f4ce90a209

+ 72 - 0
app/Http/Controllers/CompanyAccountPayableController.php

@@ -0,0 +1,72 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\SettlePayableRequest;
+use App\Http\Requests\StoreCompanyPayableRequest;
+use App\Http\Resources\CompanyAccountPayableResource;
+use App\Services\CompanyAccountPayableService;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+
+/**
+ * Contas a Pagar da matriz (franqueadora).
+ */
+class CompanyAccountPayableController extends Controller
+{
+    public function __construct(
+        protected CompanyAccountPayableService $service,
+    ) {}
+
+    public function index(Request $request): JsonResponse
+    {
+        $filters = array_filter($request->only(['status']), fn ($v) => $v !== null);
+
+        return $this->successResponse(
+            payload: CompanyAccountPayableResource::collection($this->service->list($filters)),
+        );
+    }
+
+    public function store(StoreCompanyPayableRequest $request): JsonResponse
+    {
+        $item = $this->service->createManual($request->validated());
+
+        return $this->successResponse(
+            payload: new CompanyAccountPayableResource($item),
+            message: __('messages.created'),
+            code: 201,
+        );
+    }
+
+    public function settle(SettlePayableRequest $request, int $id): JsonResponse
+    {
+        $payable = $this->service->findById($id);
+
+        if (!$payable) {
+            return $this->errorResponse(message: 'Conta a pagar não encontrada', code: 404);
+        }
+
+        $item = $this->service->settle($payable, $request->validated());
+
+        return $this->successResponse(
+            payload: new CompanyAccountPayableResource($item),
+            message: 'Baixa realizada com sucesso',
+        );
+    }
+
+    public function reopen(int $id): JsonResponse
+    {
+        $payable = $this->service->findById($id);
+
+        if (!$payable) {
+            return $this->errorResponse(message: 'Conta a pagar não encontrada', code: 404);
+        }
+
+        $item = $this->service->reopen($payable);
+
+        return $this->successResponse(
+            payload: new CompanyAccountPayableResource($item),
+            message: 'Conta reaberta',
+        );
+    }
+}

+ 72 - 0
app/Http/Controllers/CompanyAccountReceivableController.php

@@ -0,0 +1,72 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\SettleReceivableRequest;
+use App\Http\Requests\StoreCompanyReceivableRequest;
+use App\Http\Resources\CompanyAccountReceivableResource;
+use App\Services\CompanyAccountReceivableService;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+
+/**
+ * Contas a Receber avulsas da matriz (franqueadora).
+ */
+class CompanyAccountReceivableController extends Controller
+{
+    public function __construct(
+        protected CompanyAccountReceivableService $service,
+    ) {}
+
+    public function index(Request $request): JsonResponse
+    {
+        $filters = array_filter($request->only(['status']), fn ($v) => $v !== null);
+
+        return $this->successResponse(
+            payload: CompanyAccountReceivableResource::collection($this->service->list($filters)),
+        );
+    }
+
+    public function store(StoreCompanyReceivableRequest $request): JsonResponse
+    {
+        $item = $this->service->createManual($request->validated());
+
+        return $this->successResponse(
+            payload: new CompanyAccountReceivableResource($item),
+            message: __('messages.created'),
+            code: 201,
+        );
+    }
+
+    public function settle(SettleReceivableRequest $request, int $id): JsonResponse
+    {
+        $receivable = $this->service->findById($id);
+
+        if (!$receivable) {
+            return $this->errorResponse(message: 'Recebível não encontrado', code: 404);
+        }
+
+        $item = $this->service->settle($receivable, $request->validated());
+
+        return $this->successResponse(
+            payload: new CompanyAccountReceivableResource($item),
+            message: 'Baixa realizada com sucesso',
+        );
+    }
+
+    public function reopen(int $id): JsonResponse
+    {
+        $receivable = $this->service->findById($id);
+
+        if (!$receivable) {
+            return $this->errorResponse(message: 'Recebível não encontrado', code: 404);
+        }
+
+        $item = $this->service->reopen($receivable);
+
+        return $this->successResponse(
+            payload: new CompanyAccountReceivableResource($item),
+            message: 'Recebível reaberto',
+        );
+    }
+}

+ 19 - 0
app/Http/Requests/StoreCompanyPayableRequest.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class StoreCompanyPayableRequest extends FormRequest
+{
+    public function rules(): array
+    {
+        return [
+            'history'                   => 'required|string|max:255',
+            'value'                     => 'required|numeric|min:0.01',
+            'due_date'                  => 'required|date',
+            'financial_plan_account_id' => 'nullable|integer|exists:financial_plan_accounts,id',
+            'obs'                       => 'nullable|string|max:1000',
+        ];
+    }
+}

+ 19 - 0
app/Http/Requests/StoreCompanyReceivableRequest.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class StoreCompanyReceivableRequest extends FormRequest
+{
+    public function rules(): array
+    {
+        return [
+            'history'                   => 'required|string|max:255',
+            'value'                     => 'required|numeric|min:0.01',
+            'due_date'                  => 'required|date',
+            'financial_plan_account_id' => 'nullable|integer|exists:financial_plan_accounts,id',
+            'obs'                       => 'nullable|string|max:1000',
+        ];
+    }
+}

+ 32 - 0
app/Http/Resources/CompanyAccountPayableResource.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class CompanyAccountPayableResource extends JsonResource
+{
+    public function toArray(Request $request): array
+    {
+        return [
+            'id'                        => $this->id,
+            'origin'                    => $this->origin,
+            'financial_plan_account_id' => $this->financial_plan_account_id,
+            'plan_account'              => $this->whenLoaded('planAccount', fn () => $this->planAccount ? [
+                'id'          => $this->planAccount->id,
+                'code'        => $this->planAccount->code,
+                'description' => $this->planAccount->description,
+            ] : null),
+            'history'                   => $this->history,
+            'value'                     => (float) $this->value,
+            'paid_value'                => (float) $this->paid_value,
+            'discount'                  => (float) $this->discount,
+            'fine'                      => (float) $this->fine,
+            'due_date'                  => $this->due_date?->format('Y-m-d'),
+            'payment_date'              => $this->payment_date?->format('Y-m-d'),
+            'status'                    => $this->status,
+            'obs'                       => $this->obs,
+        ];
+    }
+}

+ 32 - 0
app/Http/Resources/CompanyAccountReceivableResource.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
+
+class CompanyAccountReceivableResource extends JsonResource
+{
+    public function toArray(Request $request): array
+    {
+        return [
+            'id'                        => $this->id,
+            'origin'                    => $this->origin,
+            'financial_plan_account_id' => $this->financial_plan_account_id,
+            'plan_account'              => $this->whenLoaded('planAccount', fn () => $this->planAccount ? [
+                'id'          => $this->planAccount->id,
+                'code'        => $this->planAccount->code,
+                'description' => $this->planAccount->description,
+            ] : null),
+            'history'                   => $this->history,
+            'value'                     => (float) $this->value,
+            'paid_value'                => (float) $this->paid_value,
+            'discount'                  => (float) $this->discount,
+            'fine'                      => (float) $this->fine,
+            'due_date'                  => $this->due_date?->format('Y-m-d'),
+            'payment_date'              => $this->payment_date?->format('Y-m-d'),
+            'status'                    => $this->status,
+            'obs'                       => $this->obs,
+        ];
+    }
+}

+ 52 - 0
app/Models/CompanyAccountPayable.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+/**
+ * Contas a Pagar da matriz (franqueadora) — lançamentos manuais.
+ *
+ * @property int $id
+ * @property int|null $financial_plan_account_id
+ * @property string $origin
+ * @property string $history
+ * @property numeric $value
+ * @property numeric $paid_value
+ * @property numeric $discount
+ * @property numeric $fine
+ * @property \Illuminate\Support\Carbon $due_date
+ * @property \Illuminate\Support\Carbon|null $payment_date
+ * @property string $status
+ * @property string|null $obs
+ */
+class CompanyAccountPayable extends Model
+{
+    use HasFactory, SoftDeletes;
+
+    public const ORIGIN_MANUAL = 'manual';
+
+    protected $table = 'company_account_payables';
+
+    protected $guarded = ['id'];
+
+    protected $casts = [
+        'value'        => 'decimal:2',
+        'paid_value'   => 'decimal:2',
+        'discount'     => 'decimal:2',
+        'fine'         => 'decimal:2',
+        'due_date'     => 'date',
+        'payment_date' => 'date',
+        'created_at'   => 'datetime',
+        'updated_at'   => 'datetime',
+        'deleted_at'   => 'datetime',
+    ];
+
+    public function planAccount(): BelongsTo
+    {
+        return $this->belongsTo(FinancialPlanAccount::class, 'financial_plan_account_id');
+    }
+}

+ 52 - 0
app/Models/CompanyAccountReceivable.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+/**
+ * Contas a Receber avulsas da matriz (franqueadora) — lançamentos manuais.
+ *
+ * @property int $id
+ * @property int|null $financial_plan_account_id
+ * @property string $origin
+ * @property string $history
+ * @property numeric $value
+ * @property numeric $paid_value
+ * @property numeric $discount
+ * @property numeric $fine
+ * @property \Illuminate\Support\Carbon $due_date
+ * @property \Illuminate\Support\Carbon|null $payment_date
+ * @property string $status
+ * @property string|null $obs
+ */
+class CompanyAccountReceivable extends Model
+{
+    use HasFactory, SoftDeletes;
+
+    public const ORIGIN_MANUAL = 'manual';
+
+    protected $table = 'company_account_receivables';
+
+    protected $guarded = ['id'];
+
+    protected $casts = [
+        'value'        => 'decimal:2',
+        'paid_value'   => 'decimal:2',
+        'discount'     => 'decimal:2',
+        'fine'         => 'decimal:2',
+        'due_date'     => 'date',
+        'payment_date' => 'date',
+        'created_at'   => 'datetime',
+        'updated_at'   => 'datetime',
+        'deleted_at'   => 'datetime',
+    ];
+
+    public function planAccount(): BelongsTo
+    {
+        return $this->belongsTo(FinancialPlanAccount::class, 'financial_plan_account_id');
+    }
+}

+ 93 - 0
app/Services/CompanyAccountPayableService.php

@@ -0,0 +1,93 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\CompanyAccountPayable;
+use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Validation\ValidationException;
+
+/**
+ * Contas a Pagar da matriz (franqueadora). Lançamentos manuais com baixa manual.
+ */
+class CompanyAccountPayableService
+{
+    public function list(array $filters = []): Collection
+    {
+        return CompanyAccountPayable::with('planAccount:id,code,description')
+            ->when(
+                isset($filters['status']),
+                fn ($q) => $q->where('status', $filters['status']),
+            )
+            ->orderBy('due_date')
+            ->get();
+    }
+
+    public function findById(int $id): ?CompanyAccountPayable
+    {
+        return CompanyAccountPayable::find($id);
+    }
+
+    public function createManual(array $data): CompanyAccountPayable
+    {
+        return CompanyAccountPayable::create([
+            'origin'                    => CompanyAccountPayable::ORIGIN_MANUAL,
+            'financial_plan_account_id' => $data['financial_plan_account_id'] ?? null,
+            'history'                   => $data['history'],
+            'value'                     => $data['value'],
+            'due_date'                  => $data['due_date'],
+            'obs'                       => $data['obs'] ?? null,
+            'paid_value'                => 0,
+            'discount'                  => 0,
+            'fine'                      => 0,
+            'status'                    => 'pending',
+        ])->load('planAccount:id,code,description');
+    }
+
+    public function settle(CompanyAccountPayable $payable, array $data): CompanyAccountPayable
+    {
+        if ($payable->status === 'paid') {
+            throw ValidationException::withMessages(['status' => 'Esta conta já está paga.']);
+        }
+
+        if ($payable->status === 'cancelled') {
+            throw ValidationException::withMessages([
+                'status' => 'Esta conta está cancelada e não pode receber baixa.',
+            ]);
+        }
+
+        $discount = isset($data['discount']) ? (float) $data['discount'] : (float) $payable->discount;
+        $fine     = isset($data['fine']) ? (float) $data['fine'] : (float) $payable->fine;
+
+        $paidValue = isset($data['paid_value'])
+            ? (float) $data['paid_value']
+            : round((float) $payable->value - $discount + $fine, 2);
+
+        $payable->update([
+            'status'       => 'paid',
+            'discount'     => $discount,
+            'fine'         => $fine,
+            'paid_value'   => $paidValue,
+            'payment_date' => $data['payment_date'] ?? Carbon::today()->format('Y-m-d'),
+        ]);
+
+        return $payable->fresh('planAccount:id,code,description');
+    }
+
+    public function reopen(CompanyAccountPayable $payable): CompanyAccountPayable
+    {
+        if ($payable->status !== 'paid') {
+            throw ValidationException::withMessages([
+                'status' => 'Só é possível reabrir contas que estão pagas.',
+            ]);
+        }
+
+        $payable->update([
+            'status'       => 'pending',
+            'paid_value'   => 0,
+            'payment_date' => null,
+        ]);
+
+        return $payable->fresh('planAccount:id,code,description');
+    }
+}

+ 93 - 0
app/Services/CompanyAccountReceivableService.php

@@ -0,0 +1,93 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\CompanyAccountReceivable;
+use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Validation\ValidationException;
+
+/**
+ * Contas a Receber avulsas da matriz (franqueadora). Baixa manual.
+ */
+class CompanyAccountReceivableService
+{
+    public function list(array $filters = []): Collection
+    {
+        return CompanyAccountReceivable::with('planAccount:id,code,description')
+            ->when(
+                isset($filters['status']),
+                fn ($q) => $q->where('status', $filters['status']),
+            )
+            ->orderBy('due_date')
+            ->get();
+    }
+
+    public function findById(int $id): ?CompanyAccountReceivable
+    {
+        return CompanyAccountReceivable::find($id);
+    }
+
+    public function createManual(array $data): CompanyAccountReceivable
+    {
+        return CompanyAccountReceivable::create([
+            'origin'                    => CompanyAccountReceivable::ORIGIN_MANUAL,
+            'financial_plan_account_id' => $data['financial_plan_account_id'] ?? null,
+            'history'                   => $data['history'],
+            'value'                     => $data['value'],
+            'due_date'                  => $data['due_date'],
+            'obs'                       => $data['obs'] ?? null,
+            'paid_value'                => 0,
+            'discount'                  => 0,
+            'fine'                      => 0,
+            'status'                    => 'pending',
+        ])->load('planAccount:id,code,description');
+    }
+
+    public function settle(CompanyAccountReceivable $receivable, array $data): CompanyAccountReceivable
+    {
+        if ($receivable->status === 'paid') {
+            throw ValidationException::withMessages(['status' => 'Este recebível já está pago.']);
+        }
+
+        if ($receivable->status === 'cancelled') {
+            throw ValidationException::withMessages([
+                'status' => 'Este recebível está cancelado e não pode receber baixa.',
+            ]);
+        }
+
+        $discount = isset($data['discount']) ? (float) $data['discount'] : (float) $receivable->discount;
+        $fine     = isset($data['fine']) ? (float) $data['fine'] : (float) $receivable->fine;
+
+        $paidValue = isset($data['paid_value'])
+            ? (float) $data['paid_value']
+            : round((float) $receivable->value - $discount + $fine, 2);
+
+        $receivable->update([
+            'status'       => 'paid',
+            'discount'     => $discount,
+            'fine'         => $fine,
+            'paid_value'   => $paidValue,
+            'payment_date' => $data['payment_date'] ?? Carbon::today()->format('Y-m-d'),
+        ]);
+
+        return $receivable->fresh('planAccount:id,code,description');
+    }
+
+    public function reopen(CompanyAccountReceivable $receivable): CompanyAccountReceivable
+    {
+        if ($receivable->status !== 'paid') {
+            throw ValidationException::withMessages([
+                'status' => 'Só é possível reabrir recebíveis que estão pagos.',
+            ]);
+        }
+
+        $receivable->update([
+            'status'       => 'pending',
+            'paid_value'   => 0,
+            'payment_date' => null,
+        ]);
+
+        return $receivable->fresh('planAccount:id,code,description');
+    }
+}

+ 45 - 0
database/migrations/2026_07_02_000004_create_company_account_payables_table.php

@@ -0,0 +1,45 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Contas a Pagar da matriz (Franqueadora).
+     *
+     * Lançamentos manuais de despesas da própria franqueadora (sem unidade e sem
+     * fornecedor obrigatório). Plano de contas opcional. Baixa manual.
+     */
+    public function up(): void
+    {
+        Schema::create('company_account_payables', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('financial_plan_account_id')
+                ->nullable()
+                ->constrained('financial_plan_accounts')
+                ->nullOnDelete();
+            $table->string('origin')->default('manual'); // manual
+            $table->string('history');
+            $table->decimal('value', 10, 2);
+            $table->decimal('paid_value', 10, 2)->default(0);
+            $table->decimal('discount', 10, 2)->default(0);
+            $table->decimal('fine', 10, 2)->default(0);
+            $table->date('due_date');
+            $table->date('payment_date')->nullable();
+            $table->string('status')->default('pending'); // pending | paid | overdue | cancelled
+            $table->text('obs')->nullable();
+            $table->timestamps();
+            $table->softDeletes();
+
+            $table->index('status');
+            $table->index('due_date');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('company_account_payables');
+    }
+};

+ 45 - 0
database/migrations/2026_07_02_000005_create_company_account_receivables_table.php

@@ -0,0 +1,45 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Contas a Receber avulsas da matriz (Franqueadora).
+     *
+     * Lançamentos manuais de receitas da própria franqueadora, além dos
+     * recebíveis do TBR (franchisee_account_receives). Plano de contas opcional.
+     */
+    public function up(): void
+    {
+        Schema::create('company_account_receivables', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('financial_plan_account_id')
+                ->nullable()
+                ->constrained('financial_plan_accounts')
+                ->nullOnDelete();
+            $table->string('origin')->default('manual'); // manual
+            $table->string('history');
+            $table->decimal('value', 10, 2);
+            $table->decimal('paid_value', 10, 2)->default(0);
+            $table->decimal('discount', 10, 2)->default(0);
+            $table->decimal('fine', 10, 2)->default(0);
+            $table->date('due_date');
+            $table->date('payment_date')->nullable();
+            $table->string('status')->default('pending'); // pending | paid | overdue | cancelled
+            $table->text('obs')->nullable();
+            $table->timestamps();
+            $table->softDeletes();
+
+            $table->index('status');
+            $table->index('due_date');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('company_account_receivables');
+    }
+};

+ 11 - 0
routes/authRoutes/company_payable.php

@@ -0,0 +1,11 @@
+<?php
+
+use Illuminate\Support\Facades\Route;
+use App\Http\Controllers\CompanyAccountPayableController;
+
+Route::controller(CompanyAccountPayableController::class)->prefix('company-payable')->group(function () {
+    Route::get('/', 'index')->middleware('permission:financial-account-payable,view');
+    Route::post('/', 'store')->middleware('permission:financial-account-payable,add');
+    Route::post('/{id}/settle', 'settle')->middleware('permission:financial-account-payable,edit');
+    Route::post('/{id}/reopen', 'reopen')->middleware('permission:financial-account-payable,edit');
+});

+ 11 - 0
routes/authRoutes/company_receivable.php

@@ -0,0 +1,11 @@
+<?php
+
+use Illuminate\Support\Facades\Route;
+use App\Http\Controllers\CompanyAccountReceivableController;
+
+Route::controller(CompanyAccountReceivableController::class)->prefix('company-receivable')->group(function () {
+    Route::get('/', 'index')->middleware('permission:financial-account-receive,view');
+    Route::post('/', 'store')->middleware('permission:financial-account-receive,add');
+    Route::post('/{id}/settle', 'settle')->middleware('permission:financial-account-receive,edit');
+    Route::post('/{id}/reopen', 'reopen')->middleware('permission:financial-account-receive,edit');
+});