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
Access Sale Creation
Navigate to Ventas → Crear or go directly to /ventas/create. The form loads with available:
Clients
Projects (active only)
Properties (estado “Disponible”)
Payment methods
Additional parking spaces
Select Operation Type
Choose transaction type: tipo_operacion : 'venta' | 'separacion'
This determines which fields are required and payment structure.
Select or Create Client
Option A: Existing Client Select from dropdown filtered by documento. Option B: New Client Click 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.
Select Project and Property
Use cascading selects:
Proyecto → Filters available properties
Inmueble Tipo → Choose apartamento or local
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
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
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 }%"
]);
}
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
Add Description (Optional)
Enter notes or special conditions: descripcion : 'nullable|max:300'
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
Access Conversion Form
From separation detail (/ventas/{id}), click Convertir a Venta or navigate to /ventas/{id}/convertir.
Review Separation Details
The form pre-loads:
Client information (locked)
Property details (locked)
Separation deposit (becomes part of down payment)
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
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
Navigate to Separation
Go to /ventas/{id} for a separación.
Click Cancel
Click Cancelar Separación button.
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.
Navigate to Sale Detail
Go to /ventas/{id} and click Editar .
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)
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:
Navigate to Sale Detail
Go to /ventas/{id} for a completed venta.
View Plan Tab
The detail page shows:
Resumen tab: General sale information
Plan de Pagos tab: Amortization schedule
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:
Navigate to Payments
Go to /pagos/create or from sale detail click Registrar Pago .
Select Sale and Cuota
Choose:
Client (filters their active sales)
Venta
Cuota to pay
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
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.
Confirm No Payments
Verify the sale has no registered payments.
Delete from Detail Page
From /ventas/{id}, administrators see a Eliminar button.
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:
Check if parking is truly available at /parqueaderos
Verify parqueadero.id_apartamento is NULL or matches this apartment
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:
Check proyecto.plazo_cuota_inicial_meses and fecha_inicio
Available plazos calculated based on elapsed time
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