Skip to main content

Overview

This guide covers the complete sales workflow in Core Projects, from creating property separations (reservations) to processing full sales with payment plans. The system supports two transaction types that can be converted between each other.

Transaction Types

Separación (Reservation)

A temporary property hold with a deposit payment:
tipo_operacion: 'separacion'
valor_separacion: numeric              // Deposit amount
fecha_limite_separacion: date          // Deadline to convert
estado_operacion: 'vigente' | 'vencida' | 'convertida' | 'cancelada'
Business Rules:
  • Minimum: proyecto.valor_min_separacion
  • Maximum duration: proyecto.plazo_max_separacion_dias
  • Property state: “Separado”
  • Must be converted to sale or expires

Venta (Sale)

A complete sale with payment plan:
tipo_operacion: 'venta'
valor_total: numeric                   // Total price (property + parking)
cuota_inicial: numeric                 // Down payment
plazo_cuota_inicial_meses: integer     // Payment term
frecuencia_cuota_inicial_meses: integer // Frequency (1=monthly, 2=bimonthly)
valor_restante: numeric                // Balance after down payment
Features:
  • Configurable payment frequency
  • Auto-generated amortization schedule
  • Dynamic pricing applied
  • Property state: “Vendido”

Creating a New Sale

1

Access Sale Creation

Navigate to VentasCrear or go directly to /ventas/create.The form loads with available:
  • Clients
  • Projects (active only)
  • Properties (estado “Disponible”)
  • Payment methods
  • Additional parking spaces
2

Select Operation Type

Choose transaction type:
tipo_operacion: 'venta' | 'separacion'
This determines which fields are required and payment structure.
3

Select or Create Client

Option A: Existing ClientSelect from dropdown filtered by documento.Option B: New ClientClick Crear Cliente to open inline form:
// Required fields
documento: string
tipo_documento: 'CC' | 'CE' | 'NIT' | 'Pasaporte'
nombre: string
apellido: string
telefono: string
email: email
tipo_cliente: 'Natural' | 'Jurídica'
Client is created and auto-selected.
4

Select Project and Property

Use cascading selects:
  1. Proyecto → Filters available properties
  2. Inmueble Tipo → Choose apartamento or local
  3. Inmueble → Select specific unit
The form displays available properties:
// Backend filtering
$apartamentos = Apartamento::with(['torre.proyecto', 'estadoInmueble'])
    ->whereHas('estadoInmueble', fn($q) => $q->where('nombre', 'Disponible'))
    ->get();
Property details auto-populate:
  • Unit number and location
  • Current price (from PriceEngine)
  • Associated parking
5

Select Additional Parking (Optional)

For apartamentos only, you can add standalone parking:
id_parqueadero: nullable|exists:parqueaderos,id_parqueadero
Available parking spaces:
  • Have id_apartamento = NULL (additional/standalone)
  • Not reserved in other active sales
  • Belong to same project
Pricing Impact:
$valorTotal = $valorBaseInmueble + $precioParqueadero
6

Configure Payment Terms

Payment configuration differs by operation type:For Separación:
valor_separacion: number              // Deposit amount
fecha_limite_separacion: date         // Deadline (max: plazo_max_separacion_dias)
Validation:
$valorSeparacion >= $proyecto->valor_min_separacion
$fechaLimite <= today()->addDays($proyecto->plazo_max_separacion_dias)
For Venta:
cuota_inicial: number                 // Down payment amount
plazo_cuota_inicial_meses: integer    // Term (1 to proyecto.plazo_cuota_inicial_meses)
frecuencia_cuota_inicial_meses: integer // Payment frequency
id_forma_pago: integer                // Payment method
Available frequencies depend on plazo:
// Only divisors of plazo are valid
const opcionesFrecuencia = computed(() => {
  const plazo = Number(form.plazo_cuota_inicial_meses)
  return frecuenciasDisponibles.filter(f => plazo % f.valor === 0)
})

// Example: plazo = 12 months
// Valid: 1 (monthly), 2 (bimonthly), 3 (quarterly), 4, 6 (semiannual), 12 (annual)
Down Payment Validation:
$porcentajeMinimo = $proyecto->porcentaje_cuota_inicial_min
$cuotaMinimaRequerida = $valorTotal * ($porcentajeMinimo / 100)

if ($cuotaInicial < $cuotaMinimaRequerida) {
    throw ValidationException::withMessages([
        'cuota_inicial' => "Mínimo {$porcentajeMinimo}%"
    ]);
}
7

Review Calculated Totals

The form displays real-time calculations:
// Base property price
valor_base = inmueble.valor_final || inmueble.valor_total

// Add parking if selected
valor_total = valor_base + (parqueadero?.precio || 0)

// For ventas: calculate balance
valor_restante = valor_total - cuota_inicial

// Down payment percentage
porcentaje = (cuota_inicial / valor_total) * 100
8

Add Description (Optional)

Enter notes or special conditions:
descripcion: 'nullable|max:300'
9

Submit Sale

Click Guardar to process the transaction.Backend workflow:
// VentaWebController::store
DB::transaction(function () use ($validated) {
    // 1. Validate and create venta record
    $venta = $this->ventaService->crearOperacion($validated);
    
    // 2. Update property state
    $inmueble->update([
        'id_estado_inmueble' => $estadoDestino // "Separado" or "Vendido"
    ]);
    
    // 3. Reserve parking (if applicable)
    if ($id_parqueadero && $id_apartamento) {
        Parqueadero::where('id_parqueadero', $id_parqueadero)
            ->update(['id_apartamento' => $id_apartamento]);
    }
    
    // 4. Generate payment plan (for ventas)
    if ($tipo_operacion === 'venta') {
        $this->ventaService->regenerarPlanCuotaInicial($venta);
    }
    
    // 5. Recalculate project pricing
    app(PriceEngine::class)->recalcularProyectoPorVenta($venta);
});
Redirects to /ventas/{id} with success message.

Payment Plan Generation

For ventas, the system auto-generates an amortization schedule:

Payment Structure

// VentaService::regenerarPlanCuotaInicial

$cuotaInicial = $venta->cuota_inicial;
$valorSeparacion = $proyecto->valor_min_separacion;
$saldoAmortizar = $cuotaInicial - $valorSeparacion;

$plazo = $venta->plazo_cuota_inicial_meses;
$frecuencia = $venta->frecuencia_cuota_inicial_meses;

// Number of payments
$numPagos = (int) ceil($plazo / $frecuencia);

// Amount per payment
$cuotaPorPago = (int) floor($saldoAmortizar / $numPagos);
$residuo = $saldoAmortizar - ($cuotaPorPago * $numPagos);

Payment Schedule

// Create plan_amortizacion_venta record
$plan = PlanAmortizacionVenta::create([
    'id_venta' => $venta->id_venta,
    'plazo_meses' => $plazo,
    'cuota_mensual' => $cuotaPorPago, // Base amount
]);

// Generate cuotas (installments)
$fechaBase = Carbon::parse($venta->fecha_venta)->startOfMonth();
$fechaPago = $fechaBase->copy()->addMonths($frecuencia);

for ($k = 1; $k <= $numPagos; $k++) {
    $monto = $cuotaPorPago;
    if ($k === $numPagos) {
        $monto += $residuo; // Add remainder to last payment
    }
    
    PlanAmortizacionCuota::create([
        'id_plan' => $plan->id_plan,
        'numero_cuota' => $k,
        'fecha_vencimiento' => $fechaPago->toDateString(),
        'monto' => $monto,
        'estado' => 'pendiente',
    ]);
    
    $fechaPago->addMonths($frecuencia);
}

Example: 12-Month Bimonthly Plan

valor_total: $100,000,000
cuota_inicial: $30,000,000 (30%)
valor_separacion: $5,000,000
saldo_amortizar: $25,000,000
plazo: 12 months
frecuencia: 2 (bimonthly)
numPagos: 6

Schedule:
  Mes 0:  Separación  - $5,000,000
  Mes 2:  Cuota 1/6  - $4,166,666
  Mes 4:  Cuota 2/6  - $4,166,666
  Mes 6:  Cuota 3/6  - $4,166,666
  Mes 8:  Cuota 4/6  - $4,166,666
  Mes 10: Cuota 5/6  - $4,166,666
  Mes 12: Cuota 6/6  - $4,166,670 (with residue)
  Mes 13: Saldo      - $70,000,000

Managing Separations

Separations are temporary holds that must be converted or will expire.

Viewing Separations

Navigate to /ventas and filter by tipo_operacion = 'separacion'. Each separation shows:
  • Client and property details
  • Deposit amount (valor_separacion)
  • Deadline (fecha_limite_separacion)
  • Current state (estado_operacion)

Converting to Sale

1

Access Conversion Form

From separation detail (/ventas/{id}), click Convertir a Venta or navigate to /ventas/{id}/convertir.
2

Review Separation Details

The form pre-loads:
  • Client information (locked)
  • Property details (locked)
  • Separation deposit (becomes part of down payment)
3

Configure Sale Terms

Enter venta parameters:
id_forma_pago: integer                // Payment method
cuota_inicial: numeric                // Total down payment (>= separacion)
plazo_cuota_inicial_meses: integer
frecuencia_cuota_inicial_meses: integer
descripcion: string (optional)
id_parqueadero: integer (optional)    // Can add/change parking
Validation:
$cuotaInicial >= $venta->valor_separacion
4

Submit Conversion

Click Convertir to process.Backend operation:
// VentaWebController::convertirStore
DB::transaction(function () use ($validated, $venta) {
    // Update venta record
    $venta->update([
        'tipo_operacion' => 'venta',
        'fecha_limite_separacion' => null,
        'estado_operacion' => 'convertida',
        'cuota_inicial' => $validated['cuota_inicial'],
        'plazo_cuota_inicial_meses' => $validated['plazo_cuota_inicial_meses'],
        'frecuencia_cuota_inicial_meses' => $validated['frecuencia_cuota_inicial_meses'],
        'valor_restante' => $valorTotal - $validated['cuota_inicial'],
        'valor_separacion' => null,
    ]);
    
    // Update property state
    $inmueble->update([
        'id_estado_inmueble' => EstadoInmueble::where('nombre', 'Vendido')->first()->id
    ]);
    
    // Handle parking reassignment if changed
    if ($newParqueaderoId !== $oldParqueaderoId) {
        // Release old, assign new
    }
    
    // Generate payment plan
    app(VentaService::class)->regenerarPlanCuotaInicial($venta);
    
    // Recalculate pricing
    app(PriceEngine::class)->recalcularProyectoPorVenta($venta);
});

Canceling Separations

1

Navigate to Separation

Go to /ventas/{id} for a separación.
2

Click Cancel

Click Cancelar Separación button.
3

Confirm Cancellation

System performs:
// VentaWebController::cancelarSeparacion

// 1. Release property
$inmueble->update([
    'id_estado_inmueble' => EstadoInmueble::where('nombre', 'Disponible')->first()->id
]);

// 2. Release parking
if ($venta->id_parqueadero) {
    Parqueadero::where('id_parqueadero', $venta->id_parqueadero)
        ->update(['id_apartamento' => null]);
}

// 3. Delete venta record
$venta->delete();

// 4. Recalculate pricing
app(PriceEngine::class)->recalcularProyecto($proyecto);
Property returns to “Disponible” state.

Automatic Expiration

Separations past their fecha_limite_separacion are automatically marked as expired:
// Cron endpoint: /ventas/cron/vencer-separaciones
Route::get('/ventas/cron/vencer-separaciones', [VentaWebController::class, 'vencerSeparaciones']);

// VentaWebController::vencerSeparaciones
public function vencerSeparaciones()
{
    $hoy = Carbon::today();
    
    $separacionesVencidas = Venta::where('tipo_operacion', 'separacion')
        ->where('estado_operacion', 'vigente')
        ->where('fecha_limite_separacion', '<', $hoy)
        ->get();
    
    foreach ($separacionesVencidas as $sep) {
        $sep->update(['estado_operacion' => 'vencida']);
        
        // Optionally release property
        $inmueble = $sep->apartamento ?? $sep->local;
        if ($inmueble) {
            $inmueble->update([
                'id_estado_inmueble' => EstadoInmueble::where('nombre', 'Disponible')->first()->id
            ]);
        }
    }
}

Editing Sales

You can modify active sales and separations.
1

Navigate to Sale Detail

Go to /ventas/{id} and click Editar.
2

Modify Fields

You can change:
  • Payment terms (for ventas)
  • Deadline (for separaciones)
  • Payment method
  • Associated parking
  • Description
Locked Fields:
  • tipo_operacion (cannot change venta ↔ separacion)
  • Client
  • Property (cannot reassign to different unit)
3

Save Changes

Submit form. Backend performs:
// VentaWebController::update
DB::transaction(function () use ($validated, $venta) {
    // Update venta
    $venta->update($validated);
    
    // Recalculate totals
    $venta->valor_total = $valorBase + $precioParqueadero;
    $venta->valor_restante = $venta->valor_total - $venta->cuota_inicial;
    $venta->save();
    
    // Regenerate payment plan (if plazo/frecuencia changed)
    if ($venta->tipo_operacion === 'venta') {
        $plan = $venta->planAmortizacion;
        if ($plan) {
            $plan->cuotas()->delete();
            $plan->delete();
        }
        app(VentaService::class)->regenerarPlanCuotaInicial($venta);
    }
    
    // Recalculate pricing
    app(PriceEngine::class)->recalcularProyectoPorVenta($venta);
});

Parking Management in Sales

Parking can be included or sold separately:

Included Parking

Parking already assigned to apartment (parqueadero.id_apartamento set):
  • Shown in property details
  • Price included in valor_total
  • Cannot be removed from sale

Additional Parking

Standalone parking (parqueadero.id_apartamento = NULL):
  • Selectable in sale form
  • Added to valor_total
  • Becomes assigned when sale completes

Parking Assignment Logic

// When creating/updating venta with parking
if ($id_parqueadero && $id_apartamento) {
    // Validate parking is available
    $p = Parqueadero::findOrFail($id_parqueadero);
    
    // Check it's either free or already assigned to THIS apartment
    if (!empty($p->id_apartamento) && $p->id_apartamento !== $id_apartamento) {
        throw new RuntimeException('Parking already assigned to another apartment');
    }
    
    // Check it's not reserved in another active sale
    $ocupado = Venta::whereNotNull('id_parqueadero')
        ->where('id_parqueadero', $id_parqueadero)
        ->where('id_venta', '!=', $venta->id_venta)
        ->whereIn('tipo_operacion', ['venta', 'separacion'])
        ->exists();
    
    if ($ocupado) {
        throw new RuntimeException('Parking already reserved');
    }
    
    // Assign parking to apartment
    Parqueadero::where('id_parqueadero', $id_parqueadero)
        ->update(['id_apartamento' => $id_apartamento]);
}

Sale Validation Rules

The VentaService enforces business rules:
// VentaService::validarVenta
public function validarVenta(array $data, Proyecto $proyecto)
{
    $valorTotal = $data['valor_total'];
    
    if ($data['tipo_operacion'] === 'separacion') {
        // Separation amount validation
        if ($data['valor_separacion'] < $proyecto->valor_min_separacion) {
            throw new ValidationException('Valor mínimo de separación no cumplido');
        }
        
        // Deadline validation
        $maxDias = $proyecto->plazo_max_separacion_dias;
        $fechaLimite = Carbon::parse($data['fecha_limite_separacion']);
        if ($fechaLimite->gt(Carbon::today()->addDays($maxDias))) {
            throw new ValidationException("Plazo máximo: {$maxDias} días");
        }
    }
    
    if ($data['tipo_operacion'] === 'venta') {
        // Down payment percentage
        $porcentajeMin = $proyecto->porcentaje_cuota_inicial_min;
        $cuotaMinima = $valorTotal * ($porcentajeMin / 100);
        
        if ($data['cuota_inicial'] < $cuotaMinima) {
            throw new ValidationException("Cuota inicial mínima: {$porcentajeMin}%");
        }
        
        // Term validation
        $plazoMax = $proyecto->plazo_cuota_inicial_meses;
        if ($data['plazo_cuota_inicial_meses'] > $plazoMax) {
            throw new ValidationException("Plazo máximo: {$plazoMax} meses");
        }
        
        // Frequency must divide evenly into plazo
        $plazo = $data['plazo_cuota_inicial_meses'];
        $freq = $data['frecuencia_cuota_inicial_meses'];
        if ($plazo % $freq !== 0) {
            throw new ValidationException("Frecuencia inválida para plazo de {$plazo} meses");
        }
    }
}

Viewing Payment Plans

Access the amortization schedule for any venta:
1

Navigate to Sale Detail

Go to /ventas/{id} for a completed venta.
2

View Plan Tab

The detail page shows:
  • Resumen tab: General sale information
  • Plan de Pagos tab: Amortization schedule
3

Review Installments

The plan displays:
// Loaded with sale
$venta->load(['planAmortizacion.cuotas', 'pagos']);
Each cuota shows:
  • Number (1/12, 2/12, etc.)
  • Due date (fecha_vencimiento)
  • Amount (monto)
  • Status (pendiente, pagada, vencida)
  • Associated payment (if paid)

Recording Payments

To register a payment against a sale:
1

Navigate to Payments

Go to /pagos/create or from sale detail click Registrar Pago.
2

Select Sale and Cuota

Choose:
  • Client (filters their active sales)
  • Venta
  • Cuota to pay
3

Enter Payment Details

fecha_pago: date
monto: numeric
medio_pago: integer      // Transfer, Cash, Check, Card
concepto_pago: integer   // Down payment, Monthly, etc.
numero_recibo: string
observaciones: text
4

Submit Payment

System updates:
// Create payment record
Pago::create($data);

// Update cuota status
$cuota->update(['estado' => 'pagada']);

// Recalculate sale balance if needed

Deleting Sales

Deleting sales is restricted and should only be done by administrators in exceptional cases.
1

Confirm No Payments

Verify the sale has no registered payments.
2

Delete from Detail Page

From /ventas/{id}, administrators see a Eliminar button.
3

Confirm Deletion

System performs cleanup:
// VentaWebController::destroy
DB::transaction(function () use ($venta) {
    // Release property
    $inmueble = $venta->apartamento ?? $venta->local;
    if ($inmueble) {
        $inmueble->update([
            'id_estado_inmueble' => 1 // Disponible
        ]);
    }
    
    // Release parking
    if ($venta->id_parqueadero) {
        app(VentaService::class)->liberarParqueaderoDeApartamento(
            $venta->id_parqueadero,
            $venta->id_apartamento
        );
    }
    
    // Delete venta (cascades to plan/cuotas)
    $venta->delete();
    
    // Recalculate pricing
    app(PriceEngine::class)->recalcularProyecto($proyecto);
});

Common Workflows

Quick Sale (Direct to Venta)

Reservation Flow

Troubleshooting

”El parqueadero ya fue reservado”

Cause: Parking is assigned to another active sale. Fix:
  1. Check if parking is truly available at /parqueaderos
  2. Verify parqueadero.id_apartamento is NULL or matches this apartment
  3. Search for other active sales with same id_parqueadero

”Cuota inicial mínima no cumplida”

Cause: Down payment below project minimum percentage. Fix:
$proyecto->porcentaje_cuota_inicial_min  // e.g., 30%
$cuotaMinima = $valorTotal * 0.30
// Ensure cuota_inicial >= $cuotaMinima

“Plazo no disponible para este proyecto”

Cause: Selected term exceeds project configuration. Fix:
  1. Check proyecto.plazo_cuota_inicial_meses and fecha_inicio
  2. Available plazos calculated based on elapsed time
  3. Administrator may need to extend plazo_cuota_inicial_meses

Next Steps

Management Analytics

Learn how managers track sales performance and analytics.

Technical Reference

  • Controllers:
    • app/Http/Controllers/Ventas/VentaWebController.php
    • app/Http/Controllers/Ventas/PlanAmortizacionVentaWebController.php
    • app/Http/Controllers/Ventas/PagoWebController.php
  • Services:
    • app/Services/VentaService.php
    • app/Services/PriceEngine.php
  • Views: resources/js/Pages/Ventas/Venta/
  • Routes: routes/web.php:321-412
  • Models: Venta, PlanAmortizacionVenta, PlanAmortizacionCuota, Pago

Build docs developers (and LLMs) love