Skip to main content

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

app/Models/Venta.php
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

[
    'cuota_inicial' => 50000000,
    'plazo_cuota_inicial_meses' => 24,
    'frecuencia_cuota_inicial_meses' => 1
]
// Result: 24 monthly installments of ~2,083,333

Recording Payments

Payments are tracked against installments:
app/Models/Pago.php
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

Build docs developers (and LLMs) love