Initial commit: monorepo Naturcalabacera reservas (apps/api + apps/web + packages/shared)
This commit is contained in:
16
packages/shared/package.json
Normal file
16
packages/shared/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@naturcalabacera/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.9.3",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
44
packages/shared/src/constants/origins.ts
Normal file
44
packages/shared/src/constants/origins.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
78
packages/shared/src/constants/properties.ts
Normal file
78
packages/shared/src/constants/properties.ts
Normal 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;
|
||||
}
|
||||
12
packages/shared/src/index.ts
Normal file
12
packages/shared/src/index.ts
Normal 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';
|
||||
23
packages/shared/src/types/notification.ts
Normal file
23
packages/shared/src/types/notification.ts
Normal 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;
|
||||
}
|
||||
30
packages/shared/src/types/pricing.ts
Normal file
30
packages/shared/src/types/pricing.ts
Normal 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)
|
||||
}
|
||||
64
packages/shared/src/types/reservation.ts
Normal file
64
packages/shared/src/types/reservation.ts
Normal 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;
|
||||
}
|
||||
13
packages/shared/src/types/user.ts
Normal file
13
packages/shared/src/types/user.ts
Normal 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;
|
||||
}
|
||||
114
packages/shared/src/utils/pricing.test.ts
Normal file
114
packages/shared/src/utils/pricing.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
68
packages/shared/src/utils/pricing.ts
Normal file
68
packages/shared/src/utils/pricing.ts
Normal 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);
|
||||
}
|
||||
12
packages/shared/tsconfig.json
Normal file
12
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
9
packages/shared/vitest.config.ts
Normal file
9
packages/shared/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
css: false,
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user