Overview
Core Projects implements a sophisticated sales workflow that supports two primary transaction types: Separaciones (property reservations) and Ventas (full sales). The system handles complex payment plans, automatic pricing adjustments, and commission calculations while maintaining data integrity through transactional operations.
Sale Types
Separación (Reservation) Temporary property hold with deposit - client commits to purchase within a specified timeframe
Venta (Full Sale) Complete property sale with down payment and installment plan
Venta Model
class Venta extends Model
{
protected $table = 'ventas' ;
protected $primaryKey = 'id_venta' ;
const TIPO_VENTA = 'venta' ;
const TIPO_SEPARACION = 'separacion' ;
protected $fillable = [
'id_empleado' , // Salesperson
'documento_cliente' , // Customer ID
'fecha_venta' , // Transaction date
'fecha_vencimiento' , // Due date
'id_apartamento' , // Apartment (if applicable)
'id_local' , // Commercial space (if applicable)
'id_proyecto' , // Project reference
'id_forma_pago' , // Payment method
'id_estado_inmueble' , // Property state
'cuota_inicial' , // Down payment amount
'valor_restante' , // Remaining balance
'descripcion' , // Notes
'valor_base' , // Base property price
'iva' , // Tax
'valor_total' , // Total transaction value
'tipo_operacion' , // 'venta' or 'separacion'
'valor_separacion' , // Separation deposit
'fecha_limite_separacion' , // Separation deadline
'plazo_separacion_dias' , // Separation period (days)
'plazo_cuota_inicial_meses' , // Down payment period (months)
'frecuencia_cuota_inicial_meses' , // Payment frequency
'id_parqueadero' , // Additional parking (optional)
];
protected $casts = [
'fecha_venta' => 'date' ,
'fecha_vencimiento' => 'date' ,
'fecha_limite_separacion' => 'date' ,
'valor_base' => 'decimal:2' ,
'iva' => 'decimal:2' ,
'valor_total' => 'decimal:2' ,
'cuota_inicial' => 'decimal:2' ,
'valor_restante' => 'decimal:2' ,
'valor_separacion' => 'decimal:2' ,
];
}
Helper Methods
public function esVenta () : bool
{
return $this -> tipo_operacion === self :: TIPO_VENTA ;
}
public function esSeparacion () : bool
{
return $this -> tipo_operacion === self :: TIPO_SEPARACION ;
}
public function estaVencida ()
{
return $this -> fecha_vencimiento && now () -> greaterThan ( $this -> fecha_vencimiento );
}
public function inmueble ()
{
if ( $this -> id_apartamento ) {
return $this -> apartamento ();
}
if ( $this -> id_local ) {
return $this -> local ();
}
return null ;
}
Separation (Separación) Process
What is a Separation?
A separation is a property reservation that:
Requires a minimum deposit amount
Has a maximum duration (typically 30 days)
Blocks the property from other buyers
Can be converted to a full sale
Can be cancelled or can expire
Creating a Separation
app/Services/VentaService.php
public function crearOperacion ( array $data ) : Venta
{
return DB :: transaction ( function () use ( $data ) {
$proyecto = Proyecto :: findOrFail ( $data [ 'id_proyecto' ]);
// 1. Lock property and verify availability
$inmueble = $this -> lockAndVerifyProperty ( $data );
// 2. Validate separation rules
if ( $data [ 'tipo_operacion' ] === 'separacion' ) {
$this -> validarSeparacion ( $data , $proyecto );
}
// 3. Calculate pricing
$data [ 'valor_base' ] = $inmueble -> valor_final ;
$data [ 'valor_total' ] = $this -> calcularValorTotal ( $inmueble , $data );
// 4. Create transaction record
$venta = Venta :: create ( $data );
// 5. Update property state
$this -> updatePropertyState ( $inmueble , 'Separado' );
return $venta ;
});
}
Separation Validation
protected function validarSeparacion ( array $data , Proyecto $proyecto ) : void
{
$valorSep = ( float )( $data [ 'valor_separacion' ] ?? 0 );
$fechaLimite = $data [ 'fecha_limite_separacion' ] ?? null ;
// Check minimum deposit
if ( $valorSep < $proyecto -> valor_min_separacion ) {
throw new ValidationException ([
'valor_separacion' => [ 'El valor de separación es menor al mÃnimo permitido.' ]
]);
}
// Check maximum period
if ( $fechaLimite ) {
$maxDias = ( int ) $proyecto -> plazo_max_separacion_dias ;
$fechaMax = now () -> addDays ( $maxDias ) -> toDateString ();
if ( $fechaLimite > $fechaMax ) {
throw new ValidationException ([
'fecha_limite_separacion' => [ 'La fecha excede el máximo permitido.' ]
]);
}
}
}
Separation constraints are defined at the project level, allowing different rules for different developments.
Converting Separation to Sale
When a client is ready to proceed, the separation is converted:
app/Http/Controllers/Ventas/VentaWebController.php
public function convertirEnVenta ( Request $request , $id )
{
$separacion = Venta :: findOrFail ( $id );
if ( ! $separacion -> esSeparacion ()) {
abort ( 400 , 'Solo se pueden convertir separaciones.' );
}
$validated = $request -> validate ([
'cuota_inicial' => 'required|numeric|min:0' ,
'plazo_cuota_inicial_meses' => 'required|integer|min:1' ,
'frecuencia_cuota_inicial_meses' => 'required|integer|min:1' ,
'id_forma_pago' => 'required|exists:formas_pago,id_forma_pago' ,
]);
DB :: transaction ( function () use ( $separacion , $validated ) {
$proyecto = $separacion -> proyecto ;
// Validate sale terms
$this -> ventaService -> validarVenta ( $validated , $proyecto );
// Update separation to sale
$separacion -> update ([
'tipo_operacion' => 'venta' ,
'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' => $separacion -> valor_total - $validated [ 'cuota_inicial' ],
'id_forma_pago' => $validated [ 'id_forma_pago' ],
]);
// Update property state
$inmueble = $separacion -> inmueble ();
$estadoVendido = EstadoInmueble :: where ( 'nombre' , 'Vendido' ) -> first ();
$inmueble -> update ([ 'id_estado_inmueble' => $estadoVendido -> id_estado_inmueble ]);
// Generate payment plan
$this -> ventaService -> generarPlanCuotaInicial ( $separacion , $proyecto );
});
return redirect () -> route ( 'ventas.show' , $separacion -> id_venta );
}
Cancelling a Separation
public function cancelarSeparacion ( $id )
{
$separacion = Venta :: findOrFail ( $id );
if ( ! $separacion -> esSeparacion ()) {
abort ( 400 , 'Solo se pueden cancelar separaciones.' );
}
DB :: transaction ( function () use ( $separacion ) {
// Free property
$inmueble = $separacion -> inmueble ();
$estadoDisponible = EstadoInmueble :: where ( 'nombre' , 'Disponible' ) -> first ();
$inmueble -> update ([ 'id_estado_inmueble' => $estadoDisponible -> id_estado_inmueble ]);
// Release additional parking if applicable
if ( $separacion -> id_parqueadero ) {
$this -> ventaService -> liberarParqueaderoDeApartamento (
$separacion -> id_parqueadero ,
$separacion -> id_apartamento
);
}
// Delete separation record
$separacion -> delete ();
});
return redirect () -> route ( 'ventas.index' );
}
Full Sale (Venta) Process
Creating a Direct Sale
Properties can be sold directly without a separation phase:
$venta = $this -> ventaService -> crearOperacion ([
'tipo_operacion' => 'venta' ,
'id_proyecto' => 1 ,
'inmueble_tipo' => 'apartamento' ,
'inmueble_id' => 42 ,
'documento_cliente' => '123456789' ,
'id_empleado' => $empleado -> id_empleado ,
'fecha_venta' => now (),
'cuota_inicial' => 50000000 ,
'plazo_cuota_inicial_meses' => 24 ,
'frecuencia_cuota_inicial_meses' => 1 ,
'id_forma_pago' => 1 ,
'id_parqueadero' => 15 , // Optional additional parking
]);
Sale Validation
app/Services/VentaService.php
public function validarVenta ( array $data , Proyecto $proyecto ) : void
{
$valorTotal = ( float )( $data [ 'valor_total' ] ?? 0 );
$cuotaInicial = ( float )( $data [ 'cuota_inicial' ] ?? 0 );
$plazoMeses = ( int )( $data [ 'plazo_cuota_inicial_meses' ] ?? 0 );
// Verify minimum down payment
$minCuota = $valorTotal * ( $proyecto -> porcentaje_cuota_inicial_min / 100 );
if ( $cuotaInicial < $minCuota ) {
throw new RuntimeException (
'La cuota inicial es menor al mÃnimo permitido para el proyecto.'
);
}
// Verify maximum payment term
if (
$proyecto -> plazo_max_cuota_inicial_meses > 0 &&
$plazoMeses > $proyecto -> plazo_max_cuota_inicial_meses
) {
throw new RuntimeException (
'El plazo de cuota inicial excede el máximo permitido.'
);
}
// Validate payment frequency
$frecuencia = ( int )( $data [ 'frecuencia_cuota_inicial_meses' ] ?? 1 );
if ( $frecuencia < 1 ) $frecuencia = 1 ;
if ( $plazoMeses > 0 && $frecuencia > $plazoMeses ) {
throw new RuntimeException (
'La frecuencia no puede ser mayor al plazo de cuota inicial.'
);
}
}
Sale terms must comply with project-level constraints. The system enforces minimum down payment percentages and maximum payment periods.
Payment Plans (Amortization)
Plan Structure
When a full sale is created, the system generates an installment plan:
app/Models/PlanAmortizacionVenta.php
class PlanAmortizacionVenta extends Model
{
protected $table = 'planes_amortizacion_venta' ;
protected $primaryKey = 'id_plan' ;
protected $fillable = [
'id_venta' ,
'tipo_plan' , // 'cuota_inicial' or 'financiacion'
'valor_interes_anual' , // Annual interest rate
'plazo_meses' , // Total payment period
'fecha_inicio' , // Start date
'observacion'
];
public function cuotas ()
{
return $this -> hasMany ( PlanAmortizacionCuota :: class , 'id_plan' , 'id_plan' );
}
}
Installment Details
app/Models/PlanAmortizacionCuota.php
class PlanAmortizacionCuota extends Model
{
protected $table = 'planes_amortizacion_cuotas' ;
protected $primaryKey = 'id_cuota' ;
protected $fillable = [
'id_plan' ,
'numero_cuota' , // Installment number
'fecha_vencimiento' , // Due date
'valor_cuota' , // Installment amount
'valor_interes' , // Interest portion
'valor_capital' , // Principal portion
'saldo' , // Remaining balance
'estado' // 'Pendiente', 'Pagado', 'Vencido'
];
protected $casts = [
'fecha_vencimiento' => 'date' ,
'valor_cuota' => 'decimal:2' ,
'valor_interes' => 'decimal:2' ,
'valor_capital' => 'decimal:2' ,
'saldo' => 'decimal:2' ,
];
}
Generating Payment Plans
app/Services/VentaService.php
protected function generarPlanCuotaInicial ( Venta $venta , Proyecto $proyecto ) : void
{
$plazo = ( int )( $venta -> plazo_cuota_inicial_meses ?? 0 );
$monto = ( float )( $venta -> cuota_inicial ?? 0 );
$frecuencia = ( int )( $venta -> frecuencia_cuota_inicial_meses ?? 1 );
if ( $plazo <= 0 || $monto <= 0 ) return ;
if ( $frecuencia < 1 ) $frecuencia = 1 ;
if ( $frecuencia > $plazo ) $frecuencia = $plazo ;
$fechaInicio = $venta -> fecha_venta ?? now ();
$numPagos = ( int ) ceil ( $plazo / $frecuencia );
// Create amortization plan
$plan = PlanAmortizacionVenta :: create ([
'id_venta' => $venta -> id_venta ,
'tipo_plan' => 'cuota_inicial' ,
'valor_interes_anual' => 0 ,
'plazo_meses' => $plazo ,
'fecha_inicio' => $fechaInicio ,
'observacion' => "Plan cuota inicial (cada { $frecuencia } mes(es))" ,
]);
// Calculate installment amounts
$cuotaBase = ( int ) floor ( $monto / $numPagos );
$residuo = $monto - ( $cuotaBase * $numPagos );
// Generate installments
for ( $i = 1 ; $i <= $numPagos ; $i ++ ) {
$valorCuota = $cuotaBase ;
// Add remainder to last installment
if ( $i === $numPagos ) {
$valorCuota += $residuo ;
}
$pagadoHastaAhora = ( $cuotaBase * ( $i - 1 ));
$saldo = $monto - $pagadoHastaAhora - $valorCuota ;
$saldo = max ( $saldo , 0 );
$mesOffset = ( $i - 1 ) * $frecuencia ;
PlanAmortizacionCuota :: create ([
'id_plan' => $plan -> id_plan ,
'numero_cuota' => $i ,
'fecha_vencimiento' => Carbon :: parse ( $fechaInicio ) -> addMonths ( $mesOffset ),
'valor_cuota' => $valorCuota ,
'valor_interes' => 0 ,
'valor_capital' => $valorCuota ,
'saldo' => $saldo ,
'estado' => 'Pendiente' ,
]);
}
}
Payment Frequency Examples
Monthly Payments
Quarterly Payments
Semi-Annual Payments
[
'cuota_inicial' => 50000000 ,
'plazo_cuota_inicial_meses' => 24 ,
'frecuencia_cuota_inicial_meses' => 1
]
// Result: 24 monthly installments of ~2,083,333
[
'cuota_inicial' => 50000000 ,
'plazo_cuota_inicial_meses' => 24 ,
'frecuencia_cuota_inicial_meses' => 3
]
// Result: 8 quarterly installments of 6,250,000
[
'cuota_inicial' => 50000000 ,
'plazo_cuota_inicial_meses' => 24 ,
'frecuencia_cuota_inicial_meses' => 6
]
// Result: 4 semi-annual installments of 12,500,000
Recording Payments
Payments are tracked against installments:
class Pago extends Model
{
protected $table = 'pagos' ;
protected $primaryKey = 'id_pago' ;
protected $fillable = [
'id_venta' ,
'id_cuota' , // Linked installment
'id_medio_pago' , // Payment method
'id_concepto_pago' , // Payment concept
'fecha_pago' ,
'valor_pago' ,
'referencia' , // Transaction reference
'observaciones'
];
protected $casts = [
'fecha_pago' => 'date' ,
'valor_pago' => 'decimal:2' ,
];
}
Recording a payment:
$pago = Pago :: create ([
'id_venta' => $venta -> id_venta ,
'id_cuota' => $cuota -> id_cuota ,
'id_medio_pago' => 1 , // e.g., Transfer
'id_concepto_pago' => 2 , // e.g., Down Payment Installment
'fecha_pago' => now (),
'valor_pago' => $cuota -> valor_cuota ,
'referencia' => 'TRX-2025-001234' ,
]);
// Update installment status
$cuota -> update ([ 'estado' => 'Pagado' ]);
Dynamic Pricing Integration
Each sale triggers pricing recalculation:
app/Services/VentaService.php
public function crearOperacion ( array $data ) : Venta
{
return DB :: transaction ( function () use ( $data ) {
// ... create sale ...
// Trigger pricing recalculation
app ( \App\Services\ PriceEngine :: class ) -> recalcularProyecto ( $proyecto );
return $venta ;
});
}
This updates prices for remaining available properties based on active pricing policies. See Projects - Pricing Policies .
Sold properties maintain their original price. Only available properties receive price increases.
Commission Calculation
Commissions are configured per project:
app/Models/PoliticaComision.php
class PoliticaComision extends Model
{
protected $table = 'politicas_comision' ;
protected $primaryKey = 'id_politica_comision' ;
protected $fillable = [
'id_proyecto' ,
'aplica_a' , // 'vendedor', 'gerente', etc.
'base_calculo' , // 'valor_venta', 'valor_separacion'
'porcentaje' , // Commission percentage
'valor_fijo' , // Or fixed amount
'minimo_venta_estado' , // Minimum sale state required
'descripcion' ,
'vigente_desde' ,
'vigente_hasta'
];
protected $casts = [
'vigente_desde' => 'date' ,
'vigente_hasta' => 'date' ,
'porcentaje' => 'decimal:3' ,
'valor_fijo' => 'decimal:2'
];
}
Example commission structure:
// Sales representative commission: 2% of sale value
PoliticaComision :: create ([
'id_proyecto' => 1 ,
'aplica_a' => 'vendedor' ,
'base_calculo' => 'valor_venta' ,
'porcentaje' => 2.0 ,
'minimo_venta_estado' => 'Vendido' ,
'vigente_desde' => now (),
]);
// Manager override: 0.5% of sale value
PoliticaComision :: create ([
'id_proyecto' => 1 ,
'aplica_a' => 'gerente' ,
'base_calculo' => 'valor_venta' ,
'porcentaje' => 0.5 ,
'minimo_venta_estado' => 'Vendido' ,
'vigente_desde' => now (),
]);
Additional Parking in Sales
Clients can purchase extra parking with apartments:
// Validation during sale creation
if ( ! empty ( $idParqueadero )) {
if ( $data [ 'inmueble_tipo' ] !== 'apartamento' ) {
throw new RuntimeException ( 'Parqueadero adicional solo aplica para apartamentos.' );
}
$parqueadero = Parqueadero :: where ( 'id_parqueadero' , $idParqueadero )
-> lockForUpdate ()
-> firstOrFail ();
// Must be unassigned
if ( ! empty ( $parqueadero -> id_apartamento )) {
throw new RuntimeException ( 'El parqueadero no es adicional.' );
}
// Must belong to project
if ( $parqueadero -> id_proyecto !== $proyecto -> id_proyecto ) {
throw new RuntimeException ( 'El parqueadero no pertenece al proyecto.' );
}
// Add to total
$data [ 'valor_total' ] += $parqueadero -> precio ;
}
Race Condition Prevention
The system uses pessimistic locking to prevent double-booking:
// Lock property during availability check
$inmueble = Apartamento :: where ( 'id_apartamento' , $id )
-> lockForUpdate () // Locks row until transaction completes
-> firstOrFail ();
// Verify state
$estadoDisponibleId = EstadoInmueble :: where ( 'nombre' , 'Disponible' )
-> value ( 'id_estado_inmueble' );
if ( $inmueble -> id_estado_inmueble !== $estadoDisponibleId ) {
throw new RuntimeException ( 'El inmueble ya no está disponible.' );
}
// Proceed with sale...
Always wrap sales operations in database transactions with pessimistic locking to ensure data integrity under concurrent load.
Sales Workflow Diagram
Best Practices
Always Use Transactions Wrap all sale operations in DB transactions
Lock Before Check Use lockForUpdate() before verifying property availability
Validate at Service Layer Enforce business rules in service classes, not controllers
Log Everything Maintain audit trail of all sales and state changes
Properties Property types and states
Projects Project pricing policies
User Roles Sales team permissions