Skip to main content

Overview

Core Projects handles two types of property transactions:
  • Separations (separaciones): Temporary reservations with deadlines
  • Sales (ventas): Full property purchases with payment plans
The system supports flexible payment structures, automatic price calculation, and parqueadero (parking) management.

Sales Workflow

1. Quotation

Sales begin in the quotation module where advisors can:

Browse Available Units

View all available apartments and locals across active projects with real-time pricing

Calculate Payments

Generate payment scenarios based on project configuration and available terms
public function index(Request $request)
{
    $empleado = $request->user()->load('cargo');
    
    // Available apartments
    $apartamentos = Apartamento::with([
        'torre.proyecto',
        'tipoApartamento',
        'pisoTorre',
        'estadoInmueble'
    ])
    ->whereHas('estadoInmueble', fn($q) => 
        $q->whereRaw('LOWER(nombre) = ?', ['disponible'])
    )
    ->whereHas('torre.proyecto', function ($q) {
        $q->activos();
    })
    ->get();
    
    // Available locals
    $locales = Local::with(['torre.proyecto', 'estadoInmueble'])
        ->whereHas('estadoInmueble', fn($q) => 
            $q->whereRaw('LOWER(nombre) = ?', ['disponible'])
        )
        ->whereHas('torre.proyecto', function ($q) {
            $q->activos();
        })
        ->get();
}

2. Creating a Separation

Separations reserve a unit for a limited time:

Separation Parameters

  • Client: Customer making the reservation
  • Employee: Sales advisor
  • Unit: Apartment or local
  • Separation value: Payment to reserve (typically project’s minimum)
  • Deadline: Date by which client must convert to full sale
  • Optional parking: Additional parqueadero can be added
public function store(Request $request)
{
    $validated = $request->validate([
        'tipo_operacion' => 'required|in:venta,separacion',
        'id_empleado' => 'required|exists:empleados,id_empleado',
        'documento_cliente' => 'required|exists:clientes,documento',
        'fecha_venta' => 'required|date',
        'id_proyecto' => 'required|exists:proyectos,id_proyecto',
        'inmueble_tipo' => 'required|in:apartamento,local',
        'inmueble_id' => 'required|integer',
        'id_forma_pago' => 'required|exists:formas_pago,id_forma_pago',
        'id_parqueadero' => 'nullable|exists:parqueaderos,id_parqueadero',
        'valor_separacion' => 'nullable|numeric|min:0',
        'fecha_limite_separacion' => 'nullable|date|after_or_equal:today',
    ]);
    
    // For separations, clear down payment fields
    if ($validated['tipo_operacion'] === 'separacion') {
        $validated['frecuencia_cuota_inicial_meses'] = null;
        $validated['plazo_cuota_inicial_meses'] = null;
        $validated['cuota_inicial'] = null;
    }
    
    $venta = $this->ventaService->crearOperacion($validated);
    
    return redirect()
        ->route('ventas.show', $venta->id_venta)
        ->with('success', 'Operación registrada exitosamente.');
}

3. Converting Separation to Sale

Separations can be converted to full sales:
public function convertirStore(Request $request, $id)
{
    $venta = Venta::lockForUpdate()->findOrFail($id);
    
    if (!$venta->esSeparacion()) {
        return back()->withErrors([
            'operacion' => 'Esta operación no es una separación.'
        ]);
    }
    
    $validated = $request->validate([
        'id_forma_pago' => 'required|exists:formas_pago,id_forma_pago',
        'cuota_inicial' => 'required|numeric|min:0',
        'plazo_cuota_inicial_meses' => 'required|integer|min:1',
        'frecuencia_cuota_inicial_meses' => 'required|integer|min:1',
        'id_parqueadero' => 'nullable|exists:parqueaderos,id_parqueadero',
    ]);
    
    // Update to full sale
    $venta->update([
        'tipo_operacion' => 'venta',
        'fecha_limite_separacion' => null,
        'estado_operacion' => 'convertida',
        // ... additional fields
    ]);
    
    // Generate amortization plan
    app(VentaService::class)->regenerarPlanCuotaInicial($venta);
    
    // Recalculate project prices
    app(PriceEngine::class)->recalcularProyectoPorVenta($venta);
}

4. Creating Direct Sales

Direct sales skip the separation phase:

Sale Configuration

  • Down payment: Initial payment amount
  • Down payment term: Months to complete down payment
  • Payment frequency: Monthly, bimonthly, quarterly, etc.
  • Remaining value: Financed through mortgage/other means
  • Parking: Optional additional parqueadero

Payment Plans

Down Payment Amortization

Core Projects generates automatic amortization schedules for down payments:
class PlanAmortizacionVenta extends Model
{
    protected $fillable = [
        'id_venta',
        'tipo_plan',                    // 'cuota_inicial' or 'financiacion'
        'valor_interes_anual',          // Annual interest rate
        'plazo_meses',                  // Term in months
        'fecha_inicio',                 // Start date
        'observacion',                  // Notes
    ];
    
    public function cuotas()
    {
        return $this->hasMany(PlanAmortizacionCuota::class, 'id_plan');
    }
}

Payment Frequency

Payments can be scheduled at different frequencies:
  • Monthly (frecuencia = 1): Payment every month
  • Bimonthly (frecuencia = 2): Payment every 2 months
  • Quarterly (frecuencia = 3): Payment every 3 months
  • Custom: Any frequency from 1-12 months
// Number of payments = ceiling(term / frequency)
$numPagos = (int) ceil($plazo / $frecuencia);

// Amount per payment
$cuotaPorPago = $numPagos > 0 ? floor($saldoAmortizar / $numPagos) : 0;
$residuo = $saldoAmortizar - ($cuotaPorPago * $numPagos);

// Add residue to final payment
if ($k === $numPagos) {
    $valorCuota += $residuo;
}

Parking Management

Additional parking spaces (parqueaderos) can be added to apartment sales:

Parking Rules

Only parking spaces NOT already assigned to a unit (id_apartamento IS NULL) can be sold additionally.
Additional parking can only be added to apartment sales, not locals.
Parking price is added to the total sale value automatically.
When a parking space is sold with an apartment, it’s linked in the parqueaderos table.
if (!empty($idParqueadero)) {
    if (!$validated['id_apartamento']) {
        throw new RuntimeException(
            'El parqueadero adicional solo aplica para apartamentos.'
        );
    }
    
    $p = Parqueadero::where('id_parqueadero', $idParqueadero)
        ->lockForUpdate()
        ->firstOrFail();
    
    // Validate it's additional (free or assigned to this apartment)
    if (!empty($p->id_apartamento) && 
        (int)$p->id_apartamento !== (int)$validated['id_apartamento']) {
        throw new RuntimeException(
            'El parqueadero no es adicional o ya está asignado.'
        );
    }
    
    $precioParqueadero = (float)($p->precio ?? 0);
}

$valorTotal = $valorBaseInmueble + $precioParqueadero;

Price Calculation

Sale prices are calculated dynamically:

Price Components

  1. Base Value: From unit type configuration
  2. Height Premium: Based on floor level
  3. Pricing Policy: Based on sales progress
  4. Parking: If additional parking included
// 1. Base from unit type
$valorBase = (float)($tipoApartamento->valor_estimado ?? 0);

// 2. Add height premium
$primaAltura = $this->calcularPrimaAltura($idPiso, $idTorre);
$valorConPrima = $valorBase + $primaAltura;

// 3. Apply pricing policy
$politicaCalc = $this->calcularValorConPolitica(
    $valorConPrima, 
    $idProyecto
);
$valorFinal = $politicaCalc['valor_final'];

// 4. Add parking if applicable
if ($parqueadero) {
    $valorFinal += $parqueadero->precio;
}

Sales States

Sales and separations track state through:

Property State

  • Disponible: Available for sale
  • Separado: Reserved with separation
  • Vendido: Sold
  • Bloqueado: Administratively blocked

Operation State

  • vigente: Active/current
  • convertida: Separation converted to sale
  • cancelada: Cancelled
  • vencida: Expired (past deadline)

Cancelling Operations

Cancelling Separations

public function cancelarSeparacion($id)
{
    $venta = Venta::findOrFail($id);
    
    if (!$venta->esSeparacion()) {
        return back()->withErrors([
            'operacion' => 'Esta operación no es una separación.'
        ]);
    }
    
    // Release property
    $inmueble = $venta->id_apartamento 
        ? $venta->apartamento 
        : $venta->local;
    
    $estadoDisponible = EstadoInmueble::where('nombre', 'Disponible')->first();
    $inmueble->update(['id_estado_inmueble' => $estadoDisponible->id_estado_inmueble]);
    
    // Release parking if assigned
    if ($venta->id_parqueadero) {
        app(VentaService::class)->liberarParqueaderoDeApartamento(
            (int)$venta->id_parqueadero,
            $venta->id_apartamento ? (int)$venta->id_apartamento : null
        );
    }
    
    // Delete separation
    $venta->delete();
    
    // Recalculate project prices
    if ($proyecto = Proyecto::find($venta->id_proyecto)) {
        app(PriceEngine::class)->recalcularProyecto($proyecto);
    }
}

Payment Recording

Payments against sales are tracked separately:
class Pago extends Model
{
    protected $fillable = [
        'fecha',                  // Payment date
        'id_venta',              // Related sale
        'referencia_pago',       // Payment reference/transaction ID
        'id_concepto_pago',      // Payment concept (separation, down payment, etc.)
        'id_medio_pago',         // Payment method (cash, transfer, check)
        'descripcion',           // Description/notes
        'valor',                 // Amount
        'id_cuota',             // Related installment (if applicable)
    ];
    
    public function venta()
    {
        return $this->belongsTo(Venta::class, 'id_venta');
    }
    
    public function cuota()
    {
        return $this->belongsTo(PlanAmortizacionCuota::class, 'id_cuota');
    }
}

Available Terms Calculation

The system calculates available payment terms based on project timeline:
private function calcularPlazosDisponibles(Proyecto $proyecto)
{
    if (!$proyecto->fecha_inicio || !$proyecto->plazo_cuota_inicial_meses) {
        return [];
    }
    
    $inicio = Carbon::parse($proyecto->fecha_inicio);
    $mesesTranscurridos = $inicio->diffInMonths(now());
    
    $max = $proyecto->plazo_cuota_inicial_meses;
    $restantes = max($max - $mesesTranscurridos, 0);
    
    return range(1, $restantes);
}
This ensures clients can’t select payment terms extending beyond the project’s configured timeline.

Validation Rules

[
    'tipo_operacion' => 'required|in:venta,separacion',
    'id_empleado' => 'required|exists:empleados,id_empleado',
    'documento_cliente' => 'required|exists:clientes,documento',
    'fecha_venta' => 'required|date',
    'id_proyecto' => 'required|exists:proyectos,id_proyecto',
    'inmueble_tipo' => 'required|in:apartamento,local',
    'inmueble_id' => 'required|integer',
    'id_forma_pago' => 'required|exists:formas_pago,id_forma_pago',
    'id_parqueadero' => 'nullable|exists:parqueaderos,id_parqueadero',
    'cuota_inicial' => 'nullable|numeric|min:0',
    'valor_separacion' => 'nullable|numeric|min:0',
    'fecha_limite_separacion' => 'nullable|date|after_or_equal:today',
    'plazo_cuota_inicial_meses' => 'nullable|integer|min:0',
    'frecuencia_cuota_inicial_meses' => 'nullable|integer|min:1',
]

Best Practices

Separations help secure client commitment while allowing time for financing approval.
Track upcoming separation deadlines to follow up with clients before expiration.
Offer flexible payment frequencies to accommodate different client cash flows.
Always check parking availability before offering to clients to avoid conflicts.

Build docs developers (and LLMs) love