Initial commit: monorepo Naturcalabacera reservas (apps/api + apps/web + packages/shared)

This commit is contained in:
2026-04-30 10:09:44 +01:00
commit a0ccb8ca64
188 changed files with 16418 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
import type { ReservationOrigin } from '../types/reservation.js';
export interface OriginConfig {
label: string;
shortLabel: string;
color: {
dot: string;
border: string;
checkedBg: string;
checkedText: string;
calendarBg: string;
calendarBgDark: string;
calendarBorder: string;
};
}
export const ORIGIN_CONFIG: Record<ReservationOrigin, OriginConfig> = {
Teneriffa2000: {
label: 'Teneriffa',
shortLabel: 'Teneriffa',
color: {
dot: 'bg-blue-500',
border: 'peer-checked:border-blue-500',
checkedBg: 'peer-checked:bg-blue-50',
checkedText: 'peer-checked:text-blue-700',
calendarBg: 'bg-blue-600/30',
calendarBgDark: 'dark:bg-blue-500/30',
calendarBorder: 'border-l-4 border-blue-500',
},
},
Naturcalabacera: {
label: 'Natur',
shortLabel: 'Natur',
color: {
dot: 'bg-yellow-500',
border: 'peer-checked:border-yellow-500',
checkedBg: 'peer-checked:bg-yellow-50',
checkedText: 'peer-checked:text-yellow-700',
calendarBg: 'bg-yellow-500/30',
calendarBgDark: 'dark:bg-yellow-400/30',
calendarBorder: 'border-l-4 border-yellow-500',
},
},
};

View File

@@ -0,0 +1,78 @@
import type { Property } from '../types/reservation.js';
export interface PropertyConfig {
label: string;
shortLabel: string;
color: {
bg: string;
border: string;
text: string;
gradient: string;
gradientDark: string;
};
// Configuración de precios Natur
pricing: {
baseRatePerNight: number; // Canon mínimo por noche
includedPersons: number; // Personas incluidas en el canon
// Tarifa por persona adicional (por noche), por año.
// El año mostrado es el inferior del tramo: la tarifa rige desde ese año
// hasta el siguiente definido. Para años posteriores al último definido
// se aplica la última tarifa.
extraPersonRateByYear: Record<number, number>;
};
}
export const PROPERTY_CONFIG: Record<Property, PropertyConfig> = {
los_dragos: {
label: 'Los Dragos',
shortLabel: 'Dragos',
color: {
bg: 'bg-emerald-50 dark:bg-emerald-950/20',
border: 'border-emerald-200 dark:border-emerald-800',
text: 'text-emerald-700 dark:text-emerald-300',
gradient: 'from-emerald-500 to-teal-600',
gradientDark: 'from-emerald-400 to-teal-500',
},
pricing: {
baseRatePerNight: 900,
includedPersons: 50,
extraPersonRateByYear: { 2026: 12, 2027: 14 },
},
},
la_esquinita: {
label: 'La Esquinita',
shortLabel: 'Esquinita',
color: {
bg: 'bg-amber-50 dark:bg-amber-950/20',
border: 'border-amber-200 dark:border-amber-800',
text: 'text-amber-700 dark:text-amber-300',
gradient: 'from-amber-500 to-orange-600',
gradientDark: 'from-amber-400 to-orange-500',
},
pricing: {
baseRatePerNight: 1450,
includedPersons: 60,
extraPersonRateByYear: { 2026: 12, 2027: 14 },
},
},
};
export const PROPERTIES: Property[] = ['los_dragos', 'la_esquinita'];
export const DEFAULT_IGIC_RATE = 0.07; // 7% — configurable por reserva
/**
* Devuelve la tarifa por persona extra vigente para un año concreto.
* Si el año es anterior al primer tramo definido, usa el primero.
* Si es posterior al último, usa el último.
*/
export function getExtraPersonRate(property: Property, year: number): number {
const map = PROPERTY_CONFIG[property].pricing.extraPersonRateByYear;
const years = Object.keys(map).map(Number).sort((a, b) => a - b);
if (years.length === 0) return 0;
let rate = map[years[0]];
for (const y of years) {
if (year >= y) rate = map[y];
}
return rate;
}

View File

@@ -0,0 +1,12 @@
// Types
export type { Reservation, NewReservation, ReservationOrigin, Property, PricingSnapshot, WebhookPayload } from './types/reservation.js';
export type { UserRole, UserProfile } from './types/user.js';
export type { PricingInput, PricingResult } from './types/pricing.js';
export type { NotificationEventType, NotificationStatus, NotificationEvent } from './types/notification.js';
// Constants
export { PROPERTY_CONFIG, PROPERTIES, DEFAULT_IGIC_RATE, getExtraPersonRate } from './constants/properties.js';
export { ORIGIN_CONFIG } from './constants/origins.js';
// Utils
export { calculateNaturPrice, formatPrice } from './utils/pricing.js';

View File

@@ -0,0 +1,23 @@
// Eventos de dominio para el sistema de notificaciones
export type NotificationEventType =
| 'reservation.created'
| 'reservation.updated'
| 'reservation.cancelled'
| 'reservation.reminder_24h'
| 'reservation.invoice_second_notice'
| 'reservation.pool_heating_notice';
export type NotificationStatus = 'pending' | 'sent' | 'failed';
export interface NotificationEvent {
id: string;
reservation_id: string;
event_type: NotificationEventType;
scheduled_for?: string; // ISO datetime (UTC)
sent_at?: string;
status: NotificationStatus;
attempts: number;
last_error?: string;
metadata?: Record<string, unknown>;
created_at: string;
}

View File

@@ -0,0 +1,30 @@
import type { Property } from './reservation.js';
export interface PricingInput {
property: Property;
nights: number;
totalPersons: number;
igicRate?: number; // default: 0.07
/**
* Año de la reserva (típicamente el año de start_date). Se usa para
* resolver la tarifa por persona extra desde extraPersonRateByYear.
* Si se omite, se usa el año actual.
*/
year?: number;
/**
* Sobrescribe la tarifa por persona extra (€/pax/noche). Si se proporciona,
* tiene prioridad sobre la resolución por año.
*/
extraPersonRateOverride?: number;
}
export interface PricingResult {
basePrice: number; // Canon base * noches
extraPersonsFee: number; // Personas extra * tarifa * noches
subtotal: number; // basePrice + extraPersonsFee
igicAmount: number; // subtotal * igicRate
total: number; // subtotal + igicAmount
includedPersons: number; // Personas incluidas en el canon
extraPersons: number; // Personas que pagan extra
extraPersonRate: number; // Tarifa aplicada (€/pax/noche)
}

View File

@@ -0,0 +1,64 @@
// Tipos de origen / empresa
export type ReservationOrigin = 'Teneriffa2000' | 'Naturcalabacera';
// Propiedades disponibles
export type Property = 'los_dragos' | 'la_esquinita';
// Snapshot de precio congelado al guardar (solo para Natur)
export interface PricingSnapshot {
basePrice: number;
extraPersonsFee: number;
subtotal: number;
igicAmount: number;
total: number;
calculatedAt: string; // ISO date
// Tarifa por persona extra aplicada (€/pax/noche). Permite reproducir el cálculo y
// detectar overrides manuales sobre la tarifa por año.
extraPersonRate?: number;
// Override manual de la tarifa por persona extra (si el usuario la editó en el form).
// Si está presente, ha tenido prioridad sobre la tarifa por año.
extraPersonRateOverride?: number;
}
// Reserva completa (espejo de la tabla reservations en BD)
export interface Reservation {
id: string;
created_at?: string;
start_date: string; // YYYY-MM-DD
end_date: string; // YYYY-MM-DD
client_name: string;
origin: ReservationOrigin;
property: Property;
invoice_number?: string;
adults_count: number;
children_count: number;
has_cleaning: boolean;
has_pool_heating: boolean;
has_flies_products: boolean;
government_registration?: string;
observations?: string;
// Pricing (solo para Naturcalabacera)
igic_rate?: number; // p.ej. 0.07
pricing_snapshot?: PricingSnapshot | null;
// Auditoría
created_by?: string;
updated_by?: string;
updated_at?: string;
// Evento (solo para Naturcalabacera)
is_event?: boolean;
event_type?: string; // 'Boda' | 'Comunión' | 'Cumpleaños' | 'Evento privado' | 'Corporativo' | 'Otro'
event_type_other?: string; // descripción libre cuando event_type === 'Otro'
attendees_count?: number; // número de asistentes al evento
}
export type NewReservation = Omit<Reservation, 'id' | 'created_at'>;
export interface WebhookPayload {
event: 'registration_filled';
reservation_id: string;
client_name: string;
government_registration: string;
invoice_number?: string;
start_date: string;
end_date: string;
}

View File

@@ -0,0 +1,13 @@
// Roles disponibles — extensible para futuros roles sin reescribir lógica
export type UserRole =
| 'admin' // Acceso total
| 'internal_staff' // Acceso completo, sin gestión de usuarios
| 'external_availability_viewer'; // Solo ve ocupado/libre (Teneriffa)
export interface UserProfile {
id: string;
email: string;
role: UserRole;
display_name?: string;
created_at?: string;
}

View File

@@ -0,0 +1,114 @@
import { describe, it, expect } from 'vitest';
import { calculateNaturPrice, formatPrice } from './pricing.js';
describe('calculateNaturPrice — Los Dragos', () => {
it('calculates base price for 1 night, within included persons', () => {
const result = calculateNaturPrice({ property: 'los_dragos', nights: 1, totalPersons: 10, igicRate: 0 });
expect(result.basePrice).toBe(900);
expect(result.extraPersonsFee).toBe(0);
expect(result.subtotal).toBe(900);
expect(result.total).toBe(900);
expect(result.extraPersons).toBe(0);
expect(result.includedPersons).toBe(50);
});
it('calculates extra persons fee above 50 included', () => {
// 55 persons = 5 extra, 2 nights, extra fee = 5 * 12 * 2 = 120
const result = calculateNaturPrice({ property: 'los_dragos', nights: 2, totalPersons: 55, igicRate: 0 });
expect(result.basePrice).toBe(1800);
expect(result.extraPersons).toBe(5);
expect(result.extraPersonsFee).toBe(120);
expect(result.subtotal).toBe(1920);
expect(result.total).toBe(1920);
});
it('applies IGIC at 7%', () => {
const result = calculateNaturPrice({ property: 'los_dragos', nights: 1, totalPersons: 1, igicRate: 0.07 });
expect(result.subtotal).toBe(900);
expect(result.igicAmount).toBe(63);
expect(result.total).toBe(963);
});
it('returns zeros when nights is 0', () => {
const result = calculateNaturPrice({ property: 'los_dragos', nights: 0, totalPersons: 20, igicRate: 0.07 });
expect(result.total).toBe(0);
expect(result.basePrice).toBe(0);
});
it('returns zeros when totalPersons is 0', () => {
const result = calculateNaturPrice({ property: 'los_dragos', nights: 3, totalPersons: 0, igicRate: 0.07 });
expect(result.total).toBe(0);
});
it('uses default IGIC rate when not provided', () => {
const withDefault = calculateNaturPrice({ property: 'los_dragos', nights: 1, totalPersons: 1 });
const with7Pct = calculateNaturPrice({ property: 'los_dragos', nights: 1, totalPersons: 1, igicRate: 0.07 });
expect(withDefault.total).toBe(with7Pct.total);
});
it('exacty at included person limit produces no extra fee', () => {
const result = calculateNaturPrice({ property: 'los_dragos', nights: 1, totalPersons: 50, igicRate: 0 });
expect(result.extraPersons).toBe(0);
expect(result.extraPersonsFee).toBe(0);
});
});
describe('calculateNaturPrice — La Esquinita', () => {
it('calculates base price at 1450€/night', () => {
const result = calculateNaturPrice({ property: 'la_esquinita', nights: 1, totalPersons: 10, igicRate: 0 });
expect(result.basePrice).toBe(1450);
expect(result.extraPersonsFee).toBe(0);
expect(result.includedPersons).toBe(60);
});
it('calculates extra persons above 60 included', () => {
// 65 persons = 5 extra, 1 night, extra fee = 5 * 12 = 60
const result = calculateNaturPrice({ property: 'la_esquinita', nights: 1, totalPersons: 65, igicRate: 0 });
expect(result.extraPersons).toBe(5);
expect(result.extraPersonsFee).toBe(60);
expect(result.subtotal).toBe(1510);
});
it('applies IGIC to subtotal including extras', () => {
// subtotal = 1510, IGIC 7% = 105.70, total = 1615.70
const result = calculateNaturPrice({ property: 'la_esquinita', nights: 1, totalPersons: 65, igicRate: 0.07 });
expect(result.igicAmount).toBe(105.7);
expect(result.total).toBe(1615.7);
});
it('rounds IGIC to 2 decimal places', () => {
// 1 night, 1 person (within included), subtotal 1450, IGIC 7% = 101.5
const result = calculateNaturPrice({ property: 'la_esquinita', nights: 1, totalPersons: 1, igicRate: 0.07 });
expect(result.igicAmount).toBe(101.5);
// Check it's a clean number (not e.g. 101.50000000001)
expect(Number.isFinite(result.igicAmount)).toBe(true);
});
});
describe('calculateNaturPrice — configurable IGIC rate', () => {
it('supports 0% IGIC', () => {
const result = calculateNaturPrice({ property: 'los_dragos', nights: 1, totalPersons: 10, igicRate: 0 });
expect(result.igicAmount).toBe(0);
expect(result.total).toBe(result.subtotal);
});
it('supports 10% IGIC', () => {
const result = calculateNaturPrice({ property: 'los_dragos', nights: 1, totalPersons: 10, igicRate: 0.1 });
expect(result.igicAmount).toBe(90);
expect(result.total).toBe(990);
});
});
describe('formatPrice', () => {
it('formats as euros with Spanish locale', () => {
// Note: Intl output can vary by environment; test the structure
const result = formatPrice(1234.5);
expect(result).toContain('€');
expect(result).toContain('1');
});
it('handles zero', () => {
const result = formatPrice(0);
expect(result).toContain('0');
});
});

View File

@@ -0,0 +1,68 @@
import { PROPERTY_CONFIG, DEFAULT_IGIC_RATE, getExtraPersonRate } from '../constants/properties.js';
import type { PricingInput, PricingResult } from '../types/pricing.js';
/**
* Calcula el precio de una reserva Naturcalabacera.
*
* Reglas:
* - Los Dragos: 900€/noche base, hasta 50 personas incluidas
* - La Esquinita: 1450€/noche base, hasta 60 personas incluidas
* - Tarifa por persona extra: 12€ en 2026, 14€ desde 2027 (configurable por año)
* - IGIC se aplica sobre el subtotal (base + extras)
* - Si nights <= 0 o totalPersons <= 0, retorna todo a cero
*/
export function calculateNaturPrice(input: PricingInput): PricingResult {
const {
property,
nights,
totalPersons,
igicRate = DEFAULT_IGIC_RATE,
year = new Date().getFullYear(),
extraPersonRateOverride,
} = input;
const config = PROPERTY_CONFIG[property].pricing;
const extraPersonRate = extraPersonRateOverride ?? getExtraPersonRate(property, year);
if (nights <= 0 || totalPersons <= 0) {
return {
basePrice: 0,
extraPersonsFee: 0,
subtotal: 0,
igicAmount: 0,
total: 0,
includedPersons: config.includedPersons,
extraPersons: 0,
extraPersonRate,
};
}
const extraPersons = Math.max(0, totalPersons - config.includedPersons);
const basePrice = config.baseRatePerNight * nights;
const extraPersonsFee = extraPersons * extraPersonRate * nights;
const subtotal = basePrice + extraPersonsFee;
const igicAmount = Math.round(subtotal * igicRate * 100) / 100;
const total = Math.round((subtotal + igicAmount) * 100) / 100;
return {
basePrice,
extraPersonsFee,
subtotal,
igicAmount,
total,
includedPersons: config.includedPersons,
extraPersons,
extraPersonRate,
};
}
/**
* Formatea un número como precio en euros.
* Ej: 1234.5 → "1.234,50 €"
*/
export function formatPrice(amount: number): string {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
}).format(amount);
}