Kaynağa Gözat

feat(unit-receivable): recebíveis avulsos manuais da unidade

- Tabela unit_account_receivables (aluno e plano de contas opcionais,
  origin='manual'), espelho do Contas a Pagar manual.
- Endpoints separados /unit-receivable/me/manual[/settle|/reopen] sem
  tocar no fluxo Asaas das parcelas de aluno.
- indexMe mescla parcelas + avulsos na mesma lista, normalizados com o
  campo 'source' e ordenados por vencimento.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ebagabee 3 hafta önce
ebeveyn
işleme
2dc07264f8

+ 77 - 3
app/Http/Controllers/UnitReceivableController.php

@@ -4,6 +4,8 @@
 
 use App\Http\Controllers\Concerns\ResolvesActiveUnit;
 use App\Http\Requests\SettleReceivableRequest;
+use App\Http\Requests\StoreUnitReceivableRequest;
+use App\Http\Resources\UnitAccountReceivableResource;
 use App\Http\Resources\UnitReceivableResource;
 use App\Services\UnitReceivableService;
 use Illuminate\Http\JsonResponse;
@@ -25,10 +27,82 @@ public function indexMe(Request $request): JsonResponse
             return $this->errorResponse(message: 'Unidade não encontrada', code: 404);
         }
 
-        $filters = $request->only(['status', 'student_id', 'type']);
-        $items   = $this->service->list($unitId, array_filter($filters, fn ($v) => $v !== null));
+        $filters = array_filter($request->only(['status', 'student_id', 'type']), fn ($v) => $v !== null);
 
-        return $this->successResponse(payload: UnitReceivableResource::collection($items));
+        // Parcelas de aluno + recebíveis avulsos convivem na mesma listagem,
+        // normalizados e ordenados por vencimento. 'source' distingue a origem.
+        $installments = UnitReceivableResource::collection($this->service->list($unitId, $filters))
+            ->toArray($request);
+        $manual = UnitAccountReceivableResource::collection($this->service->listManual($unitId, $filters))
+            ->toArray($request);
+
+        $merged = collect(array_merge($installments, $manual))
+            ->sortBy('due_date')
+            ->values()
+            ->all();
+
+        return $this->successResponse(payload: $merged);
+    }
+
+    public function storeManualMe(StoreUnitReceivableRequest $request): JsonResponse
+    {
+        $unitId = $this->activeUnitId();
+
+        if (!$unitId) {
+            return $this->errorResponse(message: 'Unidade não encontrada', code: 404);
+        }
+
+        $item = $this->service->createManual($unitId, $request->validated());
+
+        return $this->successResponse(
+            payload: new UnitAccountReceivableResource($item),
+            message: __('messages.created'),
+            code: 201,
+        );
+    }
+
+    public function settleManualMe(SettleReceivableRequest $request, int $id): JsonResponse
+    {
+        $unitId = $this->activeUnitId();
+
+        if (!$unitId) {
+            return $this->errorResponse(message: 'Unidade não encontrada', code: 404);
+        }
+
+        $receivable = $this->service->findManualForUnit($id, $unitId);
+
+        if (!$receivable) {
+            return $this->errorResponse(message: 'Recebível não encontrado', code: 404);
+        }
+
+        $item = $this->service->settleManual($receivable, $request->validated());
+
+        return $this->successResponse(
+            payload: new UnitAccountReceivableResource($item),
+            message: 'Baixa realizada com sucesso',
+        );
+    }
+
+    public function reopenManualMe(int $id): JsonResponse
+    {
+        $unitId = $this->activeUnitId();
+
+        if (!$unitId) {
+            return $this->errorResponse(message: 'Unidade não encontrada', code: 404);
+        }
+
+        $receivable = $this->service->findManualForUnit($id, $unitId);
+
+        if (!$receivable) {
+            return $this->errorResponse(message: 'Recebível não encontrado', code: 404);
+        }
+
+        $item = $this->service->reopenManual($receivable);
+
+        return $this->successResponse(
+            payload: new UnitAccountReceivableResource($item),
+            message: 'Recebível reaberto',
+        );
     }
 
     public function settleMe(SettleReceivableRequest $request, int $id): JsonResponse

+ 20 - 0
app/Http/Requests/StoreUnitReceivableRequest.php

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

+ 42 - 0
app/Http/Resources/UnitAccountReceivableResource.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Http\Resources;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
+
+/**
+ * Recebível avulso (manual) da unidade, normalizado com os mesmos campos das
+ * parcelas de aluno para conviver na mesma listagem de Contas a Receber.
+ */
+class UnitAccountReceivableResource extends JsonResource
+{
+    public function toArray(Request $request): array
+    {
+        return [
+            'id'                        => $this->id,
+            'source'                    => 'manual',
+            'student_contract_id'       => null,
+            'student_id'                => $this->student_id,
+            'student_name'              => $this->student?->name,
+            'financial_plan_account_id' => $this->financial_plan_account_id,
+            'type'                      => 'manual',
+            'history'                   => $this->history,
+            'installment_number'        => null,
+            'total_installments'        => null,
+            'order'                     => null,
+            '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,
+            'asaas_id'                  => null,
+            'invoice_url'               => null,
+            'billing_type'              => null,
+            'asaas_status'              => null,
+        ];
+    }
+}

+ 1 - 0
app/Http/Resources/UnitReceivableResource.php

@@ -11,6 +11,7 @@ public function toArray(Request $request): array
     {
         return [
             'id'                  => $this->id,
+            'source'              => 'installment',
             'student_contract_id' => $this->student_contract_id,
             'student_id'          => $this->student_id,
             'student_name'        => $this->student?->name,

+ 64 - 0
app/Models/UnitAccountReceivable.php

@@ -0,0 +1,64 @@
+<?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 unidade (lançamentos manuais, aluno opcional).
+ *
+ * @property int $id
+ * @property int $unit_id
+ * @property int|null $student_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 UnitAccountReceivable extends Model
+{
+    use HasFactory, SoftDeletes;
+
+    public const ORIGIN_MANUAL = 'manual';
+
+    protected $table = 'unit_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 unit(): BelongsTo
+    {
+        return $this->belongsTo(Unit::class, 'unit_id');
+    }
+
+    public function student(): BelongsTo
+    {
+        return $this->belongsTo(Student::class, 'student_id');
+    }
+
+    public function planAccount(): BelongsTo
+    {
+        return $this->belongsTo(FinancialPlanAccount::class, 'financial_plan_account_id');
+    }
+}

+ 94 - 0
app/Services/UnitReceivableService.php

@@ -5,6 +5,7 @@
 use App\Exceptions\AsaasException;
 use App\Jobs\SyncStudentChargeJob;
 use App\Models\StudentContractInstallment;
+use App\Models\UnitAccountReceivable;
 use App\Models\UnitPaymentAccount;
 use Carbon\Carbon;
 use Illuminate\Database\Eloquent\Collection;
@@ -138,4 +139,97 @@ public function reopen(StudentContractInstallment $installment): StudentContract
 
         return $installment->fresh(['student']);
     }
+
+    // ------------------------------------------------------------------
+    // Recebíveis avulsos (manuais) — tabela unit_account_receivables
+    // ------------------------------------------------------------------
+    public function listManual(int $unitId, array $filters = []): Collection
+    {
+        return UnitAccountReceivable::with('student')
+            ->where('unit_id', $unitId)
+            ->when(
+                isset($filters['status']),
+                fn ($q) => $q->where('status', $filters['status']),
+            )
+            ->when(
+                isset($filters['student_id']),
+                fn ($q) => $q->where('student_id', $filters['student_id']),
+            )
+            ->orderBy('due_date')
+            ->get();
+    }
+
+    public function findManualForUnit(int $id, int $unitId): ?UnitAccountReceivable
+    {
+        return UnitAccountReceivable::where('id', $id)
+            ->where('unit_id', $unitId)
+            ->first();
+    }
+
+    public function createManual(int $unitId, array $data): UnitAccountReceivable
+    {
+        return UnitAccountReceivable::create([
+            'unit_id'                   => $unitId,
+            'origin'                    => UnitAccountReceivable::ORIGIN_MANUAL,
+            'student_id'                => $data['student_id'] ?? null,
+            '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('student');
+    }
+
+    public function settleManual(UnitAccountReceivable $receivable, array $data): UnitAccountReceivable
+    {
+        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('student');
+    }
+
+    public function reopenManual(UnitAccountReceivable $receivable): UnitAccountReceivable
+    {
+        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('student');
+    }
 }

+ 50 - 0
database/migrations/2026_07_02_000003_create_unit_account_receivables_table.php

@@ -0,0 +1,50 @@
+<?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 unidade (Franchisee).
+     *
+     * Lançamentos manuais que NÃO são parcelas de contrato de aluno
+     * (student_contract_installments). Aluno e plano de contas são opcionais.
+     * Baixa manual (sem Asaas). Convivem na mesma listagem de Contas a Receber,
+     * marcados com origem 'manual'.
+     */
+    public function up(): void
+    {
+        Schema::create('unit_account_receivables', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('unit_id')->constrained('units')->cascadeOnDelete();
+            $table->foreignId('student_id')->nullable()->constrained('students')->nullOnDelete();
+            $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('unit_id');
+            $table->index('status');
+            $table->index('due_date');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('unit_account_receivables');
+    }
+};

+ 6 - 0
routes/authRoutes/unit_receivable.php

@@ -5,6 +5,12 @@
 
 Route::controller(UnitReceivableController::class)->prefix('unit-receivable')->group(function () {
     Route::get('/me', 'indexMe');
+
+    // Recebíveis avulsos (manuais) — registrados antes das rotas /me/{id}.
+    Route::post('/me/manual', 'storeManualMe');
+    Route::post('/me/manual/{id}/settle', 'settleManualMe');
+    Route::post('/me/manual/{id}/reopen', 'reopenManualMe');
+
     Route::post('/me/{id}/charge', 'chargeMe');
     Route::post('/me/{id}/settle', 'settleMe');
     Route::post('/me/{id}/reopen', 'reopenMe');