feat: Implement FASE 5 (Clients & Loyalty) and FASE 6 (Payments & Financial)

FASE 5 - Clientes y Fidelización:
- Client Management (CRM) con búsqueda fonética
- Galería de fotos restringida por tier (VIP/Black/Gold)
- Sistema de Lealtad con puntos y expiración (6 meses)
- Membresías (Gold, Black, VIP) con beneficios configurables
- Notas técnicas con timestamp

APIs Implementadas:
- GET/POST /api/aperture/clients - CRUD completo de clientes
- GET /api/aperture/clients/[id] - Detalles con historial de reservas
- POST /api/aperture/clients/[id]/notes - Notas técnicas
- GET/POST /api/aperture/clients/[id]/photos - Galería de fotos
- GET /api/aperture/loyalty - Resumen de lealtad
- GET/POST /api/aperture/loyalty/[customerId] - Historial y puntos

FASE 6 - Pagos y Protección:
- Stripe Webhooks (payment_intent.succeeded, payment_failed, charge.refunded)
- No-Show Logic con detección automática (ventana 12h)
- Check-in de clientes para prevenir no-shows
- Override Admin para waivar penalizaciones
- Finanzas y Reportes (expenses, daily closing, staff performance)

APIs Implementadas:
- POST /api/webhooks/stripe - Handler de webhooks Stripe
- GET /api/cron/detect-no-shows - Detectar no-shows (cron job)
- POST /api/aperture/bookings/no-show - Aplicar penalización
- POST /api/aperture/bookings/check-in - Registrar check-in
- GET /api/aperture/finance - Resumen financiero
- POST/GET /api/aperture/finance/daily-closing - Reportes diarios
- GET/POST /api/aperture/finance/expenses - Gestión de gastos
- GET /api/aperture/finance/staff-performance - Performance de staff

Documentación:
- docs/APERATURE_SPECS.md - Especificaciones técnicas completas
- docs/APERTURE_SQUARE_UI.md - Ejemplos de Radix UI con Square UI
- docs/API.md - Actualizado con nuevas rutas

Migraciones SQL:
- 20260118050000_clients_loyalty_system.sql - Clientes, fotos, lealtad, membresías
- 20260118060000_stripe_webhooks_noshow_logic.sql - Webhooks, no-shows, check-ins
- 20260118070000_financial_reporting_expenses.sql - Gastos, reportes financieros
This commit is contained in:
Marco Gallegos
2026-01-18 23:05:09 -06:00
parent f6832c1e29
commit bb25d6bde6
21 changed files with 3845 additions and 13 deletions

161
TASKS.md
View File

@@ -395,9 +395,134 @@ Tareas:
---
## FASE 5 — Automatización y Lanzamiento (PENDIENTE)
## FASE 5 — Clientes y Fidelización ✅ COMPLETADO
### 5.1 Notificaciones ⏳
### 5.1 Client Management (CRM) ✅
* ✅ Clientes con búsqueda fonética (email, phone, first_name, last_name)
* ✅ Historial de reservas por cliente
* ✅ Notas técnicas con timestamp
* ✅ APIs CRUD completas
* ✅ Galería de fotos (restringido a VIP/Black/Gold)
**APIs:**
* ✅ `GET /api/aperture/clients` - Listar y buscar clientes
* ✅ `POST /api/aperture/clients` - Crear nuevo cliente
* ✅ `GET /api/aperture/clients/[id]` - Detalles completos del cliente
* ✅ `PUT /api/aperture/clients/[id]` - Actualizar cliente
* ✅ `POST /api/aperture/clients/[id]/notes` - Agregar nota técnica
* ✅ `GET /api/aperture/clients/[id]/photos` - Galería de fotos
* ✅ `POST /api/aperture/clients/[id]/photos` - Subir foto
**Output:**
* ✅ Migración SQL con customer_photos, customer preferences
* ✅ APIs completas de clientes
* ✅ Búsqueda fonética implementada
* ✅ Galería de fotos restringida por tier
---
### 5.2 Sistema de Lealtad ✅
* ✅ Puntos independientes de tiers
* ✅ Expiración de puntos (6 meses sin usar)
* ✅ Transacciones de lealtad (earned, redeemed, expired, admin_adjustment)
* ✅ Historial completo de transacciones
* ✅ API para sumar/restar puntos
**APIs:**
* ✅ `GET /api/aperture/loyalty` - Resumen de lealtad para cliente actual
* ✅ `GET /api/aperture/loyalty/[customerId]` - Historial de lealtad
* ✅ `POST /api/aperture/loyalty/[customerId]/points` - Agregar/remover puntos
**Output:**
* ✅ Migración SQL con loyalty_transactions
* ✅ APIs completas de lealtad
* ✅ Función PostgreSQL `add_loyalty_points()`
* ✅ Función PostgreSQL `get_customer_loyalty_summary()`
---
### 5.3 Membresías ✅
* ✅ Planes de membresía (Gold, Black, VIP)
* ✅ Beneficios configurables por JSON
* ✅ Subscripciones de clientes
* ✅ Tracking de créditos mensuales
**Output:**
* ✅ Migración SQL con membership_plans y customer_subscriptions
* ✅ Planes predefinidos (Gold, Black, VIP)
* ✅ Tabla de subscriptions con credits_remaining
---
## FASE 6 — Pagos y Protección ✅ COMPLETADO
### 6.1 Stripe Webhooks ✅
* ✅ `payment_intent.succeeded` - Pago completado
* ✅ `payment_intent.payment_failed` - Pago fallido
* ✅ `charge.refunded` - Reembolso procesado
* ✅ Logging de webhooks con payload completo
* ✅ Prevención de procesamiento duplicado (por event_id)
**APIs:**
* ✅ `POST /api/webhooks/stripe` - Handler de webhooks Stripe
**Output:**
* ✅ Migración SQL con webhook_logs
* ✅ Funciones PostgreSQL de procesamiento de webhooks
* ✅ API endpoint con signature verification
---
### 6.2 No-Show Logic ✅
* ✅ Detección automática de no-shows (ventana 12h)
* ✅ Cron job para detección cada 2 horas
* ✅ Penalización automática (retener depósito)
* ✅ Tracking de no-show count por cliente
* ✅ Override Admin (waive penalty)
* ✅ Check-in de clientes
**APIs:**
* ✅ `GET /api/cron/detect-no-shows` - Detectar no-shows (cron job)
* ✅ `POST /api/aperture/bookings/no-show` - Aplicar penalización manual
* ✅ `POST /api/aperture/bookings/check-in` - Registrar check-in
**Output:**
* ✅ Migración SQL con no_show_detections
* ✅ Función PostgreSQL `detect_no_show_booking()`
* ✅ Función PostgreSQL `apply_no_show_penalty()`
* ✅ Función PostgreSQL `record_booking_checkin()`
* ✅ Campos en bookings: check_in_time, check_in_staff_id, penalty_waived
* ✅ Campos en customers: no_show_count, last_no_show_date
---
### 6.3 Finanzas y Reportes ✅
* ✅ Tracking de gastos por categoría
* ✅ Reportes financieros (revenue, expenses, profit)
* ✅ Daily closing reports con PDF
* ✅ Reportes de performance de staff
* ✅ Breakdown de pagos por método
**APIs:**
* ✅ `GET /api/aperture/finance` - Resumen financiero
* ✅ `POST /api/aperture/finance/daily-closing` - Generar reporte diario
* ✅ `GET /api/aperture/finance/daily-closing` - Listar reportes
* ✅ `GET /api/aperture/finance/expenses` - Listar gastos
* ✅ `POST /api/aperture/finance/expenses` - Crear gasto
* ✅ `GET /api/aperture/finance/staff-performance` - Performance de staff
**Output:**
* ✅ Migración SQL con expenses y daily_closing_reports
* ✅ Función PostgreSQL `get_financial_summary()`
* ✅ Función PostgreSQL `get_staff_performance_report()`
* ✅ Función PostgreSQL `generate_daily_closing_report()`
* ✅ Categorías de gastos: supplies, maintenance, utilities, rent, salaries, marketing, other
---
## FASE 7 — Automatización y Lanzamiento (PENDIENTE)
### 7.1 Notificaciones ⏳
* Confirmaciones por WhatsApp.
* Recordatorios de citas:
* 24h antes
@@ -569,9 +694,23 @@ Tareas:
- `POST /api/availability/staff`
- `POST /api/kiosk/walkin`
### ✅ COMPLETADO
- FASE 5 - Clientes y Fidelización
- ✅ Client Management (CRM) con búsqueda fonética
- ✅ Sistema de Lealtad con puntos y expiración
- ✅ Membresías (Gold, Black, VIP) con beneficios
- ✅ Galería de fotos restringida por tier
- FASE 6 - Pagos y Protección
- ✅ Stripe Webhooks (payment_intent.succeeded, payment_failed, charge.refunded)
- ✅ No-Show Logic con detección automática y penalización
- ✅ Finanzas y Reportes (expenses, daily closing, staff performance)
- ✅ Check-in de clientes
---
### 🟢 MEDIA - Componentes y Features (Timeline: 4-6 semanas restantes)
7. **Rediseñar Aperture completo con Radix UI** - ~136-171 horas
8. **Rediseñar Aperture completo con Radix UI** - ~136-171 horas
- **FASE 0**: Documentación y Configuración (~6 horas)
- **FASE 1**: Componentes Base con Radix UI (~20-25 horas)
- Instalar Radix UI
@@ -619,7 +758,7 @@ Tareas:
- Cierre de Caja (resumen diario, PDF automático)
- Finanzas (gastos, margen neto)
- APIs: `/api/aperture/pos`, `/api/aperture/finance`
- **FASE 7**: Marketing y Configuración (~10-15 horas)
- **FASE 7**: Marketing y Configuración (~10-15 horas) ⏳ PENDIENTE
- Campañas (promociones masivas Email/WhatsApp)
- Precios Inteligentes (configurables por servicio, aplicables ambos canales)
- Integraciones Placeholder (Google, Instagram/FB Shopping) - Good to have, no priority
@@ -627,35 +766,35 @@ Tareas:
### 🟢 BAJA - Integraciones Pendientes (Timeline: 1-2 meses)
8. **Implementar Google Calendar Sync** - ~6-8 horas
9. **Implementar Google Calendar Sync** - ~6-8 horas
- Sincronización bidireccional
- Manejo de conflictos
- Webhook para updates de calendar
9. **Implementar Notificaciones WhatsApp** - ~4-6 horas
10. **Implementar Notificaciones WhatsApp** - ~4-6 horas
- Integración con Twilio/Meta WhatsApp API
- Templates de mensajes (confirmación, recordatorios, alertas no-show)
- Sistema de envío programado
10. **Implementar Recibos digitales** - ~3-4 horas
11. **Implementar Recibos digitales** - ~3-4 horas
- Generador de PDFs
- Sistema de emails (SendGrid, AWS SES, etc.)
- Dashboard de transacciones
11. **Crear Landing page Believers** - ~4-5 horas
12. **Crear Landing page Believers** - ~4-5 horas
- Página pública de booking
- Calendario simplificado para clientes
- Captura de datos básicos
12. **Implementar Tests Unitarios** - ~5-7 horas
13. **Implementar Tests Unitarios** - ~5-7 horas
- Unit tests para generador de Short ID
- Tests para disponibilidad
13. **Archivos SEO** - ~30 min
14. **Archivos SEO** - ~30 min
- `public/robots.txt`
- `public/sitemap.xml`
14. **Calendario - Funcionalidades Avanzadas** - ~8-10 horas (Próximas)
15. **Calendario - Funcionalidades Avanzadas** - ~8-10 horas (Próximas)
- Resize dinámico de bloques de tiempo
- Creación de citas desde calendario (click en slot vacío)
- Vista semanal/mensual adicional

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Record check-in for a booking
* @param {NextRequest} request - Body with booking_id and staff_id
* @returns {NextResponse} Check-in result
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { booking_id, staff_id } = body
if (!booking_id || !staff_id) {
return NextResponse.json(
{ success: false, error: 'Booking ID and Staff ID are required' },
{ status: 400 }
)
}
// Record check-in
const { data: success, error } = await supabaseAdmin.rpc('record_booking_checkin', {
p_booking_id: booking_id,
p_staff_id: staff_id
})
if (error) {
console.error('Error recording check-in:', error)
return NextResponse.json(
{ success: false, error: error.message },
{ status: 400 }
)
}
if (!success) {
return NextResponse.json(
{ success: false, error: 'Check-in already recorded or booking not found' },
{ status: 400 }
)
}
// Get updated booking details
const { data: booking } = await supabaseAdmin
.from('bookings')
.select('*')
.eq('id', booking_id)
.single()
return NextResponse.json({
success: true,
data: booking
})
} catch (error) {
console.error('Error in POST /api/aperture/bookings/check-in:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Apply no-show penalty to a specific booking
* @param {NextRequest} request - Body with booking_id and optional override_by (admin)
* @returns {NextResponse} Penalty application result
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { booking_id, override_by } = body
if (!booking_id) {
return NextResponse.json(
{ success: false, error: 'Booking ID is required' },
{ status: 400 }
)
}
// Apply penalty
const { error } = await supabaseAdmin.rpc('apply_no_show_penalty', {
p_booking_id: booking_id,
p_override_by: override_by || null
})
if (error) {
console.error('Error applying no-show penalty:', error)
return NextResponse.json(
{ success: false, error: error.message },
{ status: 400 }
)
}
// Get updated booking details
const { data: booking } = await supabaseAdmin
.from('bookings')
.select('*')
.eq('id', booking_id)
.single()
return NextResponse.json({
success: true,
data: booking
})
} catch (error) {
console.error('Error in POST /api/aperture/bookings/no-show:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Add technical note to client
* @param {NextRequest} request - Body with note content
* @returns {NextResponse} Updated customer with notes
*/
export async function POST(
request: NextRequest,
{ params }: { params: { clientId: string } }
) {
try {
const { clientId } = params
const { note } = await request.json()
if (!note) {
return NextResponse.json(
{ success: false, error: 'Note content is required' },
{ status: 400 }
)
}
// Get current customer
const { data: customer, error: fetchError } = await supabaseAdmin
.from('customers')
.select('notes, technical_notes')
.eq('id', clientId)
.single()
if (fetchError || !customer) {
return NextResponse.json(
{ success: false, error: 'Client not found' },
{ status: 404 }
)
}
// Append new technical note
const existingNotes = customer.technical_notes || ''
const timestamp = new Date().toISOString()
const newNoteEntry = `[${timestamp}] ${note}`
const updatedNotes = existingNotes
? `${existingNotes}\n${newNoteEntry}`
: newNoteEntry
// Update customer
const { data: updatedCustomer, error: updateError } = await supabaseAdmin
.from('customers')
.update({
technical_notes: updatedNotes,
updated_at: new Date().toISOString()
})
.eq('id', clientId)
.select()
.single()
if (updateError) {
console.error('Error adding technical note:', updateError)
return NextResponse.json(
{ success: false, error: updateError.message },
{ status: 400 }
)
}
// Log to audit
await supabaseAdmin.from('audit_logs').insert({
entity_type: 'customer',
entity_id: clientId,
action: 'technical_note_added',
new_values: { note }
})
return NextResponse.json({
success: true,
data: updatedCustomer
})
} catch (error) {
console.error('Error in POST /api/aperture/clients/[id]/notes:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,152 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get client photo gallery (VIP/Black/Gold only)
* @param {NextRequest} request - URL params: clientId in path
* @returns {NextResponse} Client photos with metadata
*/
export async function GET(
request: NextRequest,
{ params }: { params: { clientId: string } }
) {
try {
const { clientId } = params
// Check if customer tier allows photo access
const { data: customer, error: customerError } = await supabaseAdmin
.from('customers')
.select('tier')
.eq('id', clientId)
.single()
if (customerError || !customer) {
return NextResponse.json(
{ success: false, error: 'Client not found' },
{ status: 404 }
)
}
// Check tier access
const canAccess = ['gold', 'black', 'VIP'].includes(customer.tier)
if (!canAccess) {
return NextResponse.json(
{ success: false, error: 'Photo gallery not available for this tier' },
{ status: 403 }
)
}
// Get photos
const { data: photos, error: photosError } = await supabaseAdmin
.from('customer_photos')
.select(`
*,
creator:auth.users(id, email)
`)
.eq('customer_id', clientId)
.eq('is_active', true)
.order('taken_at', { ascending: false })
if (photosError) {
console.error('Error fetching photos:', photosError)
return NextResponse.json(
{ success: false, error: 'Failed to fetch photos' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: photos || []
})
} catch (error) {
console.error('Error in GET /api/aperture/clients/[id]/photos:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* @description Upload photo to client gallery (VIP/Black/Gold only)
* @param {NextRequest} request - Body with photo data
* @returns {NextResponse} Uploaded photo metadata
*/
export async function POST(
request: NextRequest,
{ params }: { params: { clientId: string } }
) {
try {
const { clientId } = params
const { storage_path, description } = await request.json()
if (!storage_path) {
return NextResponse.json(
{ success: false, error: 'Storage path is required' },
{ status: 400 }
)
}
// Check if customer tier allows photo gallery
const { data: customer, error: customerError } = await supabaseAdmin
.from('customers')
.select('tier')
.eq('id', clientId)
.single()
if (customerError || !customer) {
return NextResponse.json(
{ success: false, error: 'Client not found' },
{ status: 404 }
)
}
const canAccess = ['gold', 'black', 'VIP'].includes(customer.tier)
if (!canAccess) {
return NextResponse.json(
{ success: false, error: 'Photo gallery not available for this tier' },
{ status: 403 }
)
}
// Create photo record
const { data: photo, error: photoError } = await supabaseAdmin
.from('customer_photos')
.insert({
customer_id: clientId,
storage_path,
description,
created_by: (await supabaseAdmin.auth.getUser()).data.user?.id
})
.select()
.single()
if (photoError) {
console.error('Error uploading photo:', photoError)
return NextResponse.json(
{ success: false, error: photoError.message },
{ status: 400 }
)
}
// Log to audit
await supabaseAdmin.from('audit_logs').insert({
entity_type: 'customer_photo',
entity_id: photo.id,
action: 'upload',
new_values: { customer_id: clientId, storage_path }
})
return NextResponse.json({
success: true,
data: photo
})
} catch (error) {
console.error('Error in POST /api/aperture/clients/[id]/photos:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get specific client details with full history
* @param {NextRequest} request - URL params: clientId in path
* @returns {NextResponse} Client details with bookings, loyalty, photos
*/
export async function GET(
request: NextRequest,
{ params }: { params: { clientId: string } }
) {
try {
const { clientId } = params
// Get customer basic info
const { data: customer, error: customerError } = await supabaseAdmin
.from('customers')
.select('*')
.eq('id', clientId)
.single()
if (customerError || !customer) {
return NextResponse.json(
{ success: false, error: 'Client not found' },
{ status: 404 }
)
}
// Get recent bookings
const { data: bookings, error: bookingsError } = await supabaseAdmin
.from('bookings')
.select(`
*,
service:services(name, base_price, duration_minutes),
location:locations(name),
staff:staff(id, first_name, last_name)
`)
.eq('customer_id', clientId)
.order('start_time_utc', { ascending: false })
.limit(20)
if (bookingsError) {
console.error('Error fetching bookings:', bookingsError)
}
// Get loyalty summary
const { data: loyaltyTransactions, error: loyaltyError } = await supabaseAdmin
.from('loyalty_transactions')
.select('*')
.eq('customer_id', clientId)
.order('created_at', { ascending: false })
.limit(10)
if (loyaltyError) {
console.error('Error fetching loyalty transactions:', loyaltyError)
}
// Get photos (if tier allows)
let photos = []
const canAccessPhotos = ['gold', 'black', 'VIP'].includes(customer.tier)
if (canAccessPhotos) {
const { data: photosData, error: photosError } = await supabaseAdmin
.from('customer_photos')
.select('*')
.eq('customer_id', clientId)
.eq('is_active', true)
.order('taken_at', { ascending: false })
.limit(20)
if (!photosError) {
photos = photosData
}
}
// Get subscription (if any)
const { data: subscription, error: subError } = await supabaseAdmin
.from('customer_subscriptions')
.select(`
*,
membership_plan:membership_plans(name, tier, benefits)
`)
.eq('customer_id', clientId)
.eq('status', 'active')
.single()
return NextResponse.json({
success: true,
data: {
customer,
bookings: bookings || [],
loyalty_transactions: loyaltyTransactions || [],
photos,
subscription: subError ? null : subscription
}
})
} catch (error) {
console.error('Error in GET /api/aperture/clients/[id]:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* @description Update client information
* @param {NextRequest} request - Body with updated client data
* @returns {NextResponse} Updated client data
*/
export async function PUT(
request: NextRequest,
{ params }: { params: { clientId: string } }
) {
try {
const { clientId } = params
const body = await request.json()
// Get current customer
const { data: currentCustomer, error: fetchError } = await supabaseAdmin
.from('customers')
.select('*')
.eq('id', clientId)
.single()
if (fetchError || !currentCustomer) {
return NextResponse.json(
{ success: false, error: 'Client not found' },
{ status: 404 }
)
}
// Update customer
const { data: updatedCustomer, error: updateError } = await supabaseAdmin
.from('customers')
.update({
...body,
updated_at: new Date().toISOString()
})
.eq('id', clientId)
.select()
.single()
if (updateError) {
console.error('Error updating client:', updateError)
return NextResponse.json(
{ success: false, error: updateError.message },
{ status: 400 }
)
}
// Log to audit
await supabaseAdmin.from('audit_logs').insert({
entity_type: 'customer',
entity_id: clientId,
action: 'update',
old_values: currentCustomer,
new_values: updatedCustomer
})
return NextResponse.json({
success: true,
data: updatedCustomer
})
} catch (error) {
console.error('Error in PUT /api/aperture/clients/[id]:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,154 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description List and search clients with phonetic search, history, and technical notes
* @param {NextRequest} request - Query params: q (search query), tier (filter by tier), limit (results limit), offset (pagination offset)
* @returns {NextResponse} List of clients with their details
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const q = searchParams.get('q') || ''
const tier = searchParams.get('tier')
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
let query = supabaseAdmin
.from('customers')
.select(`
*,
bookings:bookings(
id,
short_id,
service_id,
start_time_utc,
status,
total_price
)
`, { count: 'exact' })
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
// Apply tier filter
if (tier) {
query = query.eq('tier', tier)
}
// Apply phonetic search if query provided
if (q) {
const searchTerm = `%${q}%`
query = query.or(`first_name.ilike.${searchTerm},last_name.ilike.${searchTerm},email.ilike.${searchTerm},phone.ilike.${searchTerm}`)
}
const { data: customers, error, count } = await query
if (error) {
console.error('Error fetching clients:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch clients' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: customers,
pagination: {
total: count || 0,
limit,
offset,
hasMore: (count || 0) > offset + limit
}
})
} catch (error) {
console.error('Error in /api/aperture/clients:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* @description Create new client
* @param {NextRequest} request - Body with client details
* @returns {NextResponse} Created client data
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const {
first_name,
last_name,
email,
phone,
tier = 'free',
notes,
preferences,
referral_code
} = body
// Validate required fields
if (!first_name || !last_name) {
return NextResponse.json(
{ success: false, error: 'First name and last name are required' },
{ status: 400 }
)
}
// Generate unique referral code if not provided
let finalReferralCode = referral_code
if (!finalReferralCode) {
finalReferralCode = `${first_name.toLowerCase().replace(/[^a-z]/g, '')}${last_name.toLowerCase().replace(/[^a-z]/g, '')}${Date.now().toString(36)}`
}
// Create customer
const { data: customer, error } = await supabaseAdmin
.from('customers')
.insert({
first_name,
last_name,
email: email || null,
phone: phone || null,
tier,
notes,
preferences: preferences || {},
referral_code: finalReferralCode
})
.select()
.single()
if (error) {
console.error('Error creating client:', error)
return NextResponse.json(
{ success: false, error: error.message },
{ status: 400 }
)
}
// Log to audit
await supabaseAdmin.from('audit_logs').insert({
entity_type: 'customer',
entity_id: customer.id,
action: 'create',
new_values: {
first_name,
last_name,
email,
tier
}
})
return NextResponse.json({
success: true,
data: customer
})
} catch (error) {
console.error('Error in POST /api/aperture/clients:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get daily closing reports
* @param {NextRequest} request - Query params: location_id, start_date, end_date, status
* @returns {NextResponse} List of daily closing reports
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const location_id = searchParams.get('location_id')
const start_date = searchParams.get('start_date')
const end_date = searchParams.get('end_date')
const status = searchParams.get('status')
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
let query = supabaseAdmin
.from('daily_closing_reports')
.select('*', { count: 'exact' })
.order('report_date', { ascending: false })
.range(offset, offset + limit - 1)
if (location_id) {
query = query.eq('location_id', location_id)
}
if (status) {
query = query.eq('status', status)
}
if (start_date) {
query = query.gte('report_date', start_date)
}
if (end_date) {
query = query.lte('report_date', end_date)
}
const { data: reports, error, count } = await query
if (error) {
console.error('Error fetching daily closing reports:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch daily closing reports' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: reports || [],
pagination: {
total: count || 0,
limit,
offset,
hasMore: (count || 0) > offset + limit
}
})
} catch (error) {
console.error('Error in GET /api/aperture/finance/daily-closing:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,143 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Create expense record
* @param {NextRequest} request - Body with expense details
* @returns {NextResponse} Created expense
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const {
location_id,
category,
description,
amount,
expense_date,
payment_method,
receipt_url,
notes
} = body
if (!category || !description || !amount || !expense_date) {
return NextResponse.json(
{ success: false, error: 'category, description, amount, and expense_date are required' },
{ status: 400 }
)
}
const { data: expense, error } = await supabaseAdmin
.from('expenses')
.insert({
location_id,
category,
description,
amount,
expense_date,
payment_method,
receipt_url,
notes,
created_by: (await supabaseAdmin.auth.getUser()).data.user?.id
})
.select()
.single()
if (error) {
console.error('Error creating expense:', error)
return NextResponse.json(
{ success: false, error: error.message },
{ status: 400 }
)
}
// Log to audit
await supabaseAdmin.from('audit_logs').insert({
entity_type: 'expense',
entity_id: expense.id,
action: 'create',
new_values: {
category,
description,
amount
}
})
return NextResponse.json({
success: true,
data: expense
})
} catch (error) {
console.error('Error in POST /api/aperture/finance/expenses:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* @description Get expenses with filters
* @param {NextRequest} request - Query params: location_id, category, start_date, end_date
* @returns {NextResponse} List of expenses
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const location_id = searchParams.get('location_id')
const category = searchParams.get('category')
const start_date = searchParams.get('start_date')
const end_date = searchParams.get('end_date')
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
let query = supabaseAdmin
.from('expenses')
.select('*', { count: 'exact' })
.order('expense_date', { ascending: false })
.range(offset, offset + limit - 1)
if (location_id) {
query = query.eq('location_id', location_id)
}
if (category) {
query = query.eq('category', category)
}
if (start_date) {
query = query.gte('expense_date', start_date)
}
if (end_date) {
query = query.lte('expense_date', end_date)
}
const { data: expenses, error, count } = await query
if (error) {
console.error('Error fetching expenses:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch expenses' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: expenses || [],
pagination: {
total: count || 0,
limit,
offset,
hasMore: (count || 0) > offset + limit
}
})
} catch (error) {
console.error('Error in GET /api/aperture/finance/expenses:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get financial summary for date range and location
* @param {NextRequest} request - Query params: location_id, start_date, end_date
* @returns {NextResponse} Financial summary with revenue, expenses, and profit
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const location_id = searchParams.get('location_id')
const start_date = searchParams.get('start_date')
const end_date = searchParams.get('end_date')
if (!start_date || !end_date) {
return NextResponse.json(
{ success: false, error: 'start_date and end_date are required' },
{ status: 400 }
)
}
// Get financial summary
const { data: summary, error } = await supabaseAdmin.rpc('get_financial_summary', {
p_location_id: location_id || null,
p_start_date: start_date,
p_end_date: end_date
})
if (error) {
console.error('Error fetching financial summary:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch financial summary' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: summary
})
} catch (error) {
console.error('Error in GET /api/aperture/finance:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get staff performance report for date range
* @param {NextRequest} request - Query params: location_id, start_date, end_date
* @returns {NextResponse} Staff performance metrics per staff member
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const location_id = searchParams.get('location_id')
const start_date = searchParams.get('start_date')
const end_date = searchParams.get('end_date')
if (!location_id || !start_date || !end_date) {
return NextResponse.json(
{ success: false, error: 'location_id, start_date, and end_date are required' },
{ status: 400 }
)
}
// Get staff performance report
const { data: report, error } = await supabaseAdmin.rpc('get_staff_performance_report', {
p_location_id: location_id,
p_start_date: start_date,
p_end_date: end_date
})
if (error) {
console.error('Error fetching staff performance report:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch staff performance report' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: report
})
} catch (error) {
console.error('Error in GET /api/aperture/finance/staff-performance:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get loyalty history for specific customer
* @param {NextRequest} request - URL params: customerId in path
* @returns {NextResponse} Customer loyalty transactions and history
*/
export async function GET(
request: NextRequest,
{ params }: { params: { customerId: string } }
) {
try {
const { customerId } = params
// Get loyalty summary
const { data: summary, error: summaryError } = await supabaseAdmin
.rpc('get_customer_loyalty_summary', { p_customer_id: customerId })
if (summaryError) {
console.error('Error fetching loyalty summary:', summaryError)
return NextResponse.json(
{ success: false, error: 'Failed to fetch loyalty summary' },
{ status: 500 }
)
}
// Get loyalty transactions with pagination
const searchParams = request.nextUrl.searchParams
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
const { data: transactions, error: transactionsError, count } = await supabaseAdmin
.from('loyalty_transactions')
.select('*', { count: 'exact' })
.eq('customer_id', customerId)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (transactionsError) {
console.error('Error fetching loyalty transactions:', transactionsError)
return NextResponse.json(
{ success: false, error: 'Failed to fetch loyalty transactions' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
data: {
summary,
transactions: transactions || [],
pagination: {
total: count || 0,
limit,
offset,
hasMore: (count || 0) > offset + limit
}
}
})
} catch (error) {
console.error('Error in GET /api/aperture/loyalty/[customerId]:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}
/**
* @description Add or remove loyalty points for customer
* @param {NextRequest} request - Body with points, transaction_type, description, reference_type, reference_id
* @returns {NextResponse} Transaction result and updated summary
*/
export async function POST(
request: NextRequest,
{ params }: { params: { customerId: string } }
) {
try {
const { customerId } = params
const body = await request.json()
const {
points,
transaction_type = 'admin_adjustment',
description,
reference_type,
reference_id
} = body
if (!points || typeof points !== 'number') {
return NextResponse.json(
{ success: false, error: 'Points amount is required and must be a number' },
{ status: 400 }
)
}
// Add loyalty points
const { data: transactionId, error: error } = await supabaseAdmin
.rpc('add_loyalty_points', {
p_customer_id: customerId,
p_points: points,
p_transaction_type: transaction_type,
p_description: description,
p_reference_type: reference_type,
p_reference_id: reference_id
})
if (error) {
console.error('Error adding loyalty points:', error)
return NextResponse.json(
{ success: false, error: error.message },
{ status: 400 }
)
}
// Get updated summary
const { data: summary } = await supabaseAdmin
.rpc('get_customer_loyalty_summary', { p_customer_id: customerId })
return NextResponse.json({
success: true,
data: {
transaction_id: transactionId,
summary
}
})
} catch (error) {
console.error('Error in POST /api/aperture/loyalty/[customerId]/points:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description Get loyalty points and rewards for current customer
* @param {NextRequest} request - Query params: customerId (optional, defaults to authenticated user)
* @returns {NextResponse} Loyalty summary with points, transactions, and rewards
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const customerId = searchParams.get('customerId')
// Get customer ID from auth or query param
let targetCustomerId = customerId
// If no customerId provided, get from authenticated user
if (!targetCustomerId) {
const { data: { user } } = await supabaseAdmin.auth.getUser()
if (!user) {
return NextResponse.json(
{ success: false, error: 'Authentication required' },
{ status: 401 }
)
}
const { data: customer } = await supabaseAdmin
.from('customers')
.select('id')
.eq('user_id', user.id)
.single()
if (!customer) {
return NextResponse.json(
{ success: false, error: 'Customer not found' },
{ status: 404 }
)
}
targetCustomerId = customer.id
}
// Get loyalty summary
const { data: summary, error: summaryError } = await supabaseAdmin
.rpc('get_customer_loyalty_summary', { p_customer_id: targetCustomerId })
if (summaryError) {
console.error('Error fetching loyalty summary:', summaryError)
return NextResponse.json(
{ success: false, error: 'Failed to fetch loyalty summary' },
{ status: 500 }
)
}
// Get recent transactions
const { data: transactions, error: transactionsError } = await supabaseAdmin
.from('loyalty_transactions')
.select('*')
.eq('customer_id', targetCustomerId)
.order('created_at', { ascending: false })
.limit(50)
if (transactionsError) {
console.error('Error fetching loyalty transactions:', transactionsError)
}
// Get available rewards based on points
const { data: membershipPlans, error: plansError } = await supabaseAdmin
.from('membership_plans')
.select('*')
.eq('is_active', true)
if (plansError) {
console.error('Error fetching membership plans:', plansError)
}
return NextResponse.json({
success: true,
data: {
summary,
transactions: transactions || [],
available_rewards: membershipPlans || []
}
})
} catch (error) {
console.error('Error in GET /api/aperture/loyalty:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
/**
* @description CRITICAL: Detect and mark no-show bookings (runs every 2 hours)
* @param {NextRequest} request - Must include Bearer token with CRON_SECRET
* @returns {NextResponse} No-show detection results with count of bookings processed
* @example curl -H "Authorization: Bearer YOUR_CRON_SECRET" /api/cron/detect-no-shows
* @audit BUSINESS RULE: No-show window is 12 hours after booking start time (UTC)
* @audit SECURITY: Requires CRON_SECRET environment variable for authentication
* @audit Validate: Only confirmed/pending bookings without check-in are affected
* @audit AUDIT: Detection action logged in audit_logs with booking details
* @audit PERFORMANCE: Efficient query with date range and status filters
* @audit RELIABILITY: Cron job should run every 2 hours to detect no-shows
*/
export async function GET(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
)
}
const cronKey = authHeader.replace('Bearer ', '').trim()
if (cronKey !== process.env.CRON_SECRET) {
return NextResponse.json(
{ success: false, error: 'Invalid cron key' },
{ status: 403 }
)
}
// Calculate no-show window: bookings that started more than 12 hours ago
const windowStart = new Date()
windowStart.setHours(windowStart.getHours() - 12)
// Get eligible bookings (confirmed/pending, no check-in, started > 12h ago)
const { data: bookings, error: bookingsError } = await supabaseAdmin
.from('bookings')
.select('id, start_time_utc, customer_id, service_id, deposit_amount')
.in('status', ['confirmed', 'pending'])
.lt('start_time_utc', windowStart.toISOString())
.is('check_in_time', null)
if (bookingsError) {
console.error('Error fetching bookings for no-show detection:', bookingsError)
return NextResponse.json(
{ success: false, error: 'Failed to fetch bookings' },
{ status: 500 }
)
}
if (!bookings || bookings.length === 0) {
return NextResponse.json({
success: true,
message: 'No bookings to process',
processedCount: 0,
detectedCount: 0
})
}
let detectedCount = 0
// Process each booking
for (const booking of bookings) {
const detected = await supabaseAdmin.rpc('detect_no_show_booking', {
p_booking_id: booking.id
})
if (detected) {
detectedCount++
}
}
console.log(`No-show detection completed: ${detectedCount} bookings detected out of ${bookings.length} processed`)
return NextResponse.json({
success: true,
message: 'No-show detection completed successfully',
processedCount: bookings.length,
detectedCount
})
} catch (error) {
console.error('Error in no-show detection:', error)
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/admin'
import Stripe from 'stripe'
/**
* @description Handle Stripe webhooks for payment intents and refunds
* @param {NextRequest} request - Raw Stripe webhook payload with signature
* @returns {NextResponse} Webhook processing result
*/
export async function POST(request: NextRequest) {
try {
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET
if (!stripeSecretKey || !stripeWebhookSecret) {
return NextResponse.json(
{ error: 'Stripe not configured' },
{ status: 500 }
)
}
const stripe = new Stripe(stripeSecretKey)
const body = await request.text()
const signature = request.headers.get('stripe-signature')
if (!signature) {
return NextResponse.json(
{ error: 'Missing Stripe signature' },
{ status: 400 }
)
}
// Verify webhook signature
let event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
stripeWebhookSecret
)
} catch (err) {
console.error('Webhook signature verification failed:', err)
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
)
}
const eventId = event.id
// Check if event already processed
const { data: existingLog } = await supabaseAdmin
.from('webhook_logs')
.select('*')
.eq('event_id', eventId)
.single()
if (existingLog) {
console.log(`Event ${eventId} already processed, skipping`)
return NextResponse.json({ received: true, already_processed: true })
}
// Log webhook event
await supabaseAdmin.from('webhook_logs').insert({
event_type: event.type,
event_id: eventId,
payload: event.data as any
})
// Process based on event type
switch (event.type) {
case 'payment_intent.succeeded':
await supabaseAdmin.rpc('process_payment_intent_succeeded', {
p_event_id: eventId,
p_payload: event.data as any
})
break
case 'payment_intent.payment_failed':
await supabaseAdmin.rpc('process_payment_intent_failed', {
p_event_id: eventId,
p_payload: event.data as any
})
break
case 'charge.refunded':
await supabaseAdmin.rpc('process_charge_refunded', {
p_event_id: eventId,
p_payload: event.data as any
})
break
default:
console.log(`Unhandled event type: ${event.type}`)
}
return NextResponse.json({ received: true })
} catch (error) {
console.error('Error processing Stripe webhook:', error)
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
)
}
}

792
docs/APERATURE_SPECS.md Normal file
View File

@@ -0,0 +1,792 @@
# Aperture Technical Specifications
**Documento maestro de especificaciones técnicas de Aperture (HQ Dashboard)**
**Última actualización: Enero 2026**
---
## 1. Arquitectura General
### 1.1 Stack Tecnológico
**Frontend:**
- Next.js 14 (App Router)
- React 18
- TypeScript 5.x
- Tailwind CSS + Radix UI
- Lucide React (icons)
- date-fns (manejo de fechas)
**Backend:**
- Next.js API Routes
- Supabase PostgreSQL
- Supabase Auth (roles: admin, manager, staff, customer, kiosk, artist)
- Stripe (pagos)
**Infraestructura:**
- Vercel (hosting)
- Supabase (database, auth, storage)
- Vercel Cron Jobs (tareas programadas)
---
## 2. Esquema de Base de Datos
### 2.1 Tablas Core
```sql
-- Locations (sucursales)
CREATE TABLE locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
address TEXT NOT NULL,
phone TEXT,
timezone TEXT NOT NULL DEFAULT 'America/Mexico_City',
business_hours JSONB NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Staff (empleados)
CREATE TABLE staff (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
phone TEXT,
role TEXT NOT NULL CHECK (role IN ('admin', 'manager', 'staff', 'artist')),
location_id UUID REFERENCES locations(id),
hourly_rate DECIMAL(10,2) DEFAULT 0,
commission_rate DECIMAL(5,2) DEFAULT 0, -- Porcentaje de comisión
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Resources (recursos físicos)
CREATE TABLE resources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL, -- Código estandarizado: mkup-1, lshs-1, pedi-1, mani-1
type TEXT NOT NULL CHECK (type IN ('mkup', 'lshs', 'pedi', 'mani')),
location_id UUID REFERENCES locations(id),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Services (servicios)
CREATE TABLE services (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
base_price DECIMAL(10,2) NOT NULL,
duration_minutes INTEGER NOT NULL,
requires_dual_artist BOOLEAN DEFAULT false,
premium_fee DECIMAL(10,2) DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Customers (clientes)
CREATE TABLE customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE,
phone TEXT,
first_name TEXT NOT NULL,
last_name TEXT,
tier TEXT NOT NULL DEFAULT 'free' CHECK (tier IN ('free', 'gold', 'black', 'VIP')),
weekly_invitations_used INTEGER DEFAULT 0,
referral_code TEXT UNIQUE,
referred_by UUID REFERENCES customers(id),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Bookings (reservas)
CREATE TABLE bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
short_id TEXT UNIQUE NOT NULL,
customer_id UUID REFERENCES customers(id),
service_id UUID REFERENCES services(id),
location_id UUID REFERENCES locations(id),
staff_ids UUID[] NOT NULL, -- Array de staff IDs (1 o 2 para dual artist)
resource_id UUID REFERENCES resources(id),
start_time_utc TIMESTAMPTZ NOT NULL,
end_time_utc TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'completed', 'cancelled', 'no_show')),
deposit_amount DECIMAL(10,2) DEFAULT 0,
deposit_paid BOOLEAN DEFAULT false,
total_price DECIMAL(10,2),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Payments (pagos)
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id UUID REFERENCES bookings(id),
amount DECIMAL(10,2) NOT NULL,
payment_method TEXT NOT NULL CHECK (payment_method IN ('cash', 'card', 'transfer', 'gift_card', 'membership', 'stripe')),
stripe_payment_intent_id TEXT,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'completed', 'refunded', 'failed')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Payroll (nómina)
CREATE TABLE payroll (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
staff_id UUID REFERENCES staff(id),
period_start DATE NOT NULL,
period_end DATE NOT NULL,
base_salary DECIMAL(10,2) DEFAULT 0,
commission_total DECIMAL(10,2) DEFAULT 0,
tips_total DECIMAL(10,2) DEFAULT 0,
total_payment DECIMAL(10,2) NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'cancelled')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Audit Logs (auditoría)
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type TEXT NOT NULL,
entity_id UUID,
action TEXT NOT NULL,
old_values JSONB,
new_values JSONB,
performed_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
---
## 3. APIs Principales
### 3.1 Dashboard Stats
**Endpoint:** `GET /api/aperture/stats`
**Response:**
```typescript
{
success: true,
stats: {
totalBookings: number, // Reservas del mes actual
totalRevenue: number, // Revenue del mes (servicios completados)
completedToday: number, // Citas completadas hoy
upcomingToday: number // Citas pendientes hoy
}
}
```
**Business Rules:**
- Month calculations: first day to last day of current month (UTC)
- Today calculations: 00:00 to 23:59:59.999 local timezone converted to UTC
- Revenue only includes `status = 'completed'` bookings
---
### 3.2 Dashboard Data
**Endpoint:** `GET /api/aperture/dashboard`
**Response:**
```typescript
{
success: true,
data: {
customers: {
total: number,
newToday: number,
newMonth: number
},
topPerformers: Array<{
id: string,
name: string,
bookingsCompleted: number,
revenueGenerated: number
}>,
activityFeed: Array<{
id: string,
type: 'booking' | 'payment' | 'staff' | 'system',
description: string,
timestamp: string,
metadata?: any
}>
}
}
```
---
### 3.3 Calendar API
**Endpoint:** `GET /api/aperture/calendar`
**Query Params:**
- `date`: YYYY-MM-DD (default: today)
- `location_id`: UUID (optional, filter by location)
- `staff_ids`: UUID[] (optional, filter by staff)
**Response:**
```typescript
{
success: true,
data: {
date: string,
slots: Array<{
time: string, // HH:mm format
bookings: Array<{
id: string,
short_id: string,
customer_name: string,
service_name: string,
staff_ids: string[],
staff_names: string[],
resource_id: string,
status: string,
duration: number,
requires_dual_artist: boolean,
start_time: string,
end_time: string,
notes?: string
}>
}>
},
staff: Array<{
id: string,
name: string,
role: string,
bookings_count: number
}>
}
```
---
### 3.4 Reschedule Booking
**Endpoint:** `POST /api/aperture/bookings/[id]/reschedule`
**Request:**
```typescript
{
new_start_time_utc: string, // ISO 8601 timestamp
new_resource_id?: string // Optional new resource
}
```
**Response:**
```typescript
{
success: boolean,
message?: string,
conflict?: {
type: 'staff' | 'resource',
message: string,
details: any
}
}
```
**Validation:**
- Check staff availability for new time
- Check resource availability for new time
- Verify no conflicts with existing bookings
- Update booking if no conflicts
---
### 3.5 Staff Management
**CRUD Endpoints:**
- `GET /api/aperture/staff` - List all staff
- `GET /api/aperture/staff/[id]` - Get single staff
- `POST /api/aperture/staff` - Create staff
- `PUT /api/aperture/staff/[id]` - Update staff
- `DELETE /api/aperture/staff/[id]` - Delete staff
**Staff Object:**
```typescript
{
id: string,
first_name: string,
last_name: string,
email: string,
phone?: string,
role: 'admin' | 'manager' | 'staff' | 'artist',
location_id?: string,
hourly_rate: number,
commission_rate: number,
is_active: boolean,
business_hours?: {
monday: { start: string, end: string, is_off: boolean },
tuesday: { start: string, end: string, is_off: boolean },
// ... other days
}
}
```
---
### 3.6 Payroll Calculation
**Endpoint:** `GET /api/aperture/payroll`
**Query Params:**
- `period_start`: YYYY-MM-DD
- `period_end`: YYYY-MM-DD
- `staff_id`: UUID (optional)
**Response:**
```typescript
{
success: true,
data: {
staff_payroll: Array<{
staff_id: string,
staff_name: string,
base_salary: number, // hourly_rate * hours_worked
commission_total: number, // revenue * commission_rate
tips_total: number, // Sum of tips
total_payment: number, // Sum of above
bookings_count: number,
hours_worked: number
}>,
summary: {
total_payroll: number,
total_bookings: number,
period: {
start: string,
end: string
}
}
}
}
```
**Calculation Logic:**
```
base_salary = hourly_rate * sum(booking duration / 60)
commission_total = total_revenue * (commission_rate / 100)
tips_total = sum(tips from completed bookings)
total_payment = base_salary + commission_total + tips_total
```
---
### 3.7 POS (Point of Sale)
**Endpoint:** `POST /api/aperture/pos`
**Request:**
```typescript
{
items: Array<{
type: 'service' | 'product',
id: string,
name: string,
price: number,
quantity: number
}>,
payments: Array<{
method: 'cash' | 'card' | 'transfer' | 'gift_card' | 'membership',
amount: number,
stripe_payment_intent_id?: string
}>,
customer_id?: string,
booking_id?: string,
notes?: string
}
```
**Response:**
```typescript
{
success: boolean,
transaction_id: string,
total_amount: number,
change?: number, // For cash payments
receipt_url?: string
}
```
---
### 3.8 Close Day
**Endpoint:** `POST /api/aperture/pos/close-day`
**Request:**
```typescript
{
date: string, // YYYY-MM-DD
location_id?: string
}
```
**Response:**
```typescript
{
success: true,
summary: {
date: string,
location_id?: string,
total_sales: number,
payment_breakdown: {
cash: number,
card: number,
transfer: number,
gift_card: number,
membership: number,
stripe: number
},
transaction_count: number,
refunds: number,
discrepancies: Array<{
type: string,
expected: number,
actual: number,
difference: number
}>
},
pdf_url: string
}
```
---
## 4. Horas Trabajadas (Automático desde Bookings)
### 4.1 Cálculo Automático
Las horas trabajadas por staff se calculan automáticamente desde bookings completados:
```typescript
async function getStaffWorkHours(staffId: string, periodStart: Date, periodEnd: Date) {
const { data: bookings } = await supabase
.from('bookings')
.select('start_time_utc, end_time_utc')
.contains('staff_ids', [staffId])
.eq('status', 'completed')
.gte('start_time_utc', periodStart.toISOString())
.lte('start_time_utc', periodEnd.toISOString());
const totalMinutes = bookings.reduce((sum, booking) => {
const start = new Date(booking.start_time_utc);
const end = new Date(booking.end_time_utc);
return sum + (end.getTime() - start.getTime()) / 60000;
}, 0);
return totalMinutes / 60; // Return hours
}
```
### 4.2 Integración con Nómina
El cálculo de nómina utiliza estas horas automáticamente:
```typescript
base_salary = staff.hourly_rate * work_hours
commission = total_revenue * (staff.commission_rate / 100)
```
---
## 5. POS System Specifications
### 5.1 Características Principales
**Carrito de Compra:**
- Soporte para múltiples productos/servicios
- Cantidad por item
- Descuentos aplicables
- Subtotal, taxes (si aplica), total
**Métodos de Pago:**
- Efectivo (con cálculo de cambio)
- Tarjeta (Stripe)
- Transferencia bancaria
- Gift Cards
- Membresías (créditos del cliente)
- Pagos mixtos (combinar múltiples métodos)
**Múltiples Cajeros:**
- Each staff can open a POS session
- Track cashier per transaction
- Close day per cashier or per location
### 5.2 Flujo de Cierre de Caja
1. Solicitar fecha y location_id
2. Calcular total ventas del día
3. Breakdown por método de pago
4. Verificar conciliación (esperado vs real)
5. Generar PDF reporte
6. Marcar day como "closed" (opcional flag)
---
## 6. Webhooks Stripe
### 6.1 Endpoints
**Endpoint:** `POST /api/webhooks/stripe`
**Headers:**
- `Stripe-Signature`: Signature verification
**Events:**
- `payment_intent.succeeded`: Payment completed
- `payment_intent.payment_failed`: Payment failed
- `charge.refunded`: Refund processed
### 6.2 payment_intent.succeeded
**Actions:**
1. Extract metadata (booking details)
2. Verify booking exists
3. Update `payments` table with completed status
4. Update booking `deposit_paid = true`
5. Create audit log entry
6. Send confirmation email/WhatsApp (si configurado)
### 6.3 payment_intent.payment_failed
**Actions:**
1. Update `payments` table with failed status
2. Send notification to customer
3. Log failure in audit logs
4. Optionally cancel booking or mark as pending
### 6.4 charge.refunded
**Actions:**
1. Update `payments` table with refunded status
2. Send refund confirmation to customer
3. Log refund in audit logs
4. Update booking status if applicable
---
## 7. No-Show Logic
### 7.1 Ventana de Cancelación
**Regla:** 12 horas antes de la cita (UTC)
### 7.2 Detección de No-Show
```typescript
async function detectNoShows() {
const now = new Date();
const windowStart = new Date(now.getTime() - 12 * 60 * 60 * 1000); // 12h ago
const { data: noShows } = await supabase
.from('bookings')
.select('*')
.eq('status', 'confirmed')
.lte('start_time_utc', windowStart.toISOString());
for (const booking of noShows) {
// Check if customer showed up
const { data: checkIn } = await supabase
.from('check_ins')
.select('*')
.eq('booking_id', booking.id)
.single();
if (!checkIn) {
// Mark as no-show
await markAsNoShow(booking.id);
}
}
}
```
### 7.3 Penalización Automática
**Actions:**
1. Mark booking status as `no_show`
2. Retain deposit (do not refund)
3. Send notification to customer
4. Log action in audit_logs
5. Track no-show count per customer (for future restrictions)
### 7.4 Override Admin
Admin puede marcar un no-show como "exonerated" (perdonado):
- Status remains `no_show` but with flag `penalty_waived = true`
- Refund deposit if appropriate
- Log admin override in audit logs
---
## 8. Seguridad y Permisos
### 8.1 RLS Policies
**Admin:**
- Full access to all tables
- Can override no-show penalties
- Can view all financial data
**Manager:**
- Access to location data only
- Can manage staff and bookings
- View financial reports for location
**Staff/Artist:**
- View own bookings and schedule
- Cannot view customer PII (email, phone)
- Cannot modify financial data
**Kiosk:**
- View only availability data
- Can create bookings with validated data
- No access to PII
### 8.2 API Authentication
**Admin/Manager/Staff:**
- Require valid Supabase session
- Check user role
- Filter by location for managers
**Public:**
- Use anon key
- Only public endpoints (availability, services, locations)
**Cron Jobs:**
- Require CRON_SECRET header
- Service role key required
---
## 9. Performance Considerations
### 9.1 Database Indexes
```sql
-- Critical indexes
CREATE INDEX idx_bookings_customer ON bookings(customer_id);
CREATE INDEX idx_bookings_staff ON bookings USING GIN(staff_ids);
CREATE INDEX idx_bookings_status_time ON bookings(status, start_time_utc);
CREATE INDEX idx_payments_booking ON payments(booking_id);
CREATE INDEX idx_payments_status ON payments(status);
CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id);
```
### 9.2 N+1 Prevention
Use explicit joins for related data:
```typescript
// BAD - N+1 queries
const bookings = await supabase.from('bookings').select('*');
for (const booking of bookings) {
const customer = await supabase.from('customers').select('*').eq('id', booking.customer_id);
}
// GOOD - Single query
const bookings = await supabase
.from('bookings')
.select(`
*,
customer:customers(*),
service:services(*),
location:locations(*)
`);
```
---
## 10. Testing Strategy
### 10.1 Unit Tests
- Generador de Short ID (collision detection)
- Cálculo de depósitos (200 vs 50% rule)
- Cálculo de nómina (salario base + comisiones + propinas)
- Disponibilidad de staff (horarios + calendar events)
### 10.2 Integration Tests
- API endpoints (GET, POST, PUT, DELETE)
- Stripe webhooks
- Cron jobs (reset invitations)
- No-show detection
### 10.3 E2E Tests
- Booking flow completo (customer → kiosk → staff)
- POS flow (items → payment → receipt)
- Dashboard navigation y visualización
- Calendar drag & drop
---
## 11. Deployment
### 11.1 Environment Variables
```env
# Supabase
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# Stripe
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# Cron
CRON_SECRET=
# Email/WhatsApp (future)
RESEND_API_KEY=
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
```
### 11.2 Cron Jobs
```yaml
# vercel.json
{
"crons": [
{
"path": "/api/cron/reset-invitations",
"schedule": "0 0 * * 1" # Monday 00:00 UTC
},
{
"path": "/api/cron/detect-no-shows",
"schedule": "0 */2 * * *" # Every 2 hours
}
]
}
```
---
## 12. Futuras Mejoras
### 12.1 Short Term (Q1 2026)
- [ ] Implementar The Vault (storage de fotos privadas)
- [ ] Implementar notificaciones WhatsApp
- [ ] Implementar recibos digitales con PDF
- [ ] Landing page Believers pública
### 12.2 Medium Term (Q2 2026)
- [ ] Google Calendar Sync bidireccional
- [ ] Sistema de lealtad con puntos
- [ ] Campañas de marketing masivas
- [ ] Precios dinámicos inteligentes
### 12.3 Long Term (Q3-Q4 2026)
- [ ] Sistema de passes digitales
- [ ] Móvil app para clientes
- [ ] Analytics avanzados con ML
- [ ] Integración con POS hardware

View File

@@ -662,7 +662,416 @@ Antes de considerar un componente como "completado":
---
## 21. Changelog
## 21. Ejemplos de Uso de Radix UI con Square UI Styling
### 21.1 Button Component (Radix UI)
```typescript
// components/ui/button.tsx
'use client'
import * as React from 'react'
import * as ButtonPrimitive from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 ease-out focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-[#006AFF] text-white hover:bg-[#005ED6] active:translate-y-0',
secondary: 'bg-white text-[#24292E] border border-[#E1E4E8] hover:bg-[#F3F4F6]',
ghost: 'text-[#24292E] hover:bg-[#F3F4F6]',
danger: 'bg-[#D73A49] text-white hover:bg-[#B91C3C]',
success: 'bg-[#28A745] text-white hover:bg-[#218838]',
},
size: {
sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
```
**Uso:**
```typescript
<Button variant="default" size="md">
Save Changes
</Button>
<Button variant="secondary" size="sm">
Cancel
</Button>
<Button variant="danger" size="lg">
Delete
</Button>
```
---
### 21.2 Dialog Component (Radix UI)
```typescript
// components/ui/dialog.tsx
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className="fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-[#E1E4E8] bg-white p-6 shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-xl"
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-[#006AFF] focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-gray-100">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className="flex flex-col space-y-1.5 text-center sm:text-left" {...props} />
)
DialogHeader.displayName = 'DialogHeader'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className="text-lg font-semibold leading-none tracking-tight"
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogClose }
```
**Uso:**
```typescript
<Dialog>
<DialogTrigger asChild>
<Button>Open Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Action</DialogTitle>
</DialogHeader>
<p>Are you sure you want to proceed?</p>
<div className="flex gap-2 justify-end">
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button variant="danger">Confirm</Button>
</div>
</DialogContent>
</Dialog>
```
---
### 21.3 Select Component (Radix UI)
```typescript
// components/ui/select.tsx
'use client'
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown } from 'lucide-react'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className="flex h-10 w-full items-center justify-between rounded-lg border border-[#E1E4E8] bg-white px-3 py-2 text-sm placeholder:text-[#8B949E] focus:outline-none focus:ring-2 focus:ring-[#006AFF] focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1"
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className="relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-[#E1E4E8] bg-white text-[#24292E] shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
position={position}
{...props}
>
<SelectPrimitive.Viewport className="p-1">
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-[#F3F4F6] focus:text-[#24292E] data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem }
```
**Uso:**
```typescript
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="orange">Orange</SelectItem>
</SelectContent>
</Select>
```
---
### 21.4 Tabs Component (Radix UI)
```typescript
// components/ui/tabs.tsx
'use client'
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className="inline-flex h-10 items-center justify-center rounded-lg bg-[#F6F8FA] p-1 text-[#586069]"
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#006AFF] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-[#24292E] data-[state=active]:shadow-sm"
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className="mt-2 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#006AFF] focus-visible:ring-offset-2"
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
```
**Uso:**
```typescript
<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">
<div>Account settings...</div>
</TabsContent>
<TabsContent value="password">
<div>Password settings...</div>
</TabsContent>
</Tabs>
```
---
### 21.5 Accesibilidad con Radix UI
**ARIA Attributes Automáticos:**
```typescript
// Radix UI agrega automáticamente:
// - role="button" para botones
// - aria-expanded para dropdowns
// - aria-selected para tabs
// - aria-checked para checkboxes
// - aria-invalid para inputs con error
// - aria-describedby para errores de formulario
// Ejemplo con manejo de errores:
<Select>
<SelectTrigger aria-invalid={hasError} aria-describedby={errorMessage ? 'error-message' : undefined}>
<SelectValue />
</SelectTrigger>
{errorMessage && (
<p id="error-message" className="text-sm text-[#D73A49]">
{errorMessage}
</p>
)}
</Select>
```
**Keyboard Navigation:**
```typescript
// Radix UI soporta automáticamente:
// - Tab: Navigate focusable elements
// - Enter/Space: Activate buttons, select options
// - Escape: Close modals, dropdowns
// - Arrow keys: Navigate within components (lists, menus)
// - Home/End: Jump to start/end of list
// Para keyboard shortcuts personalizados:
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
// Open search modal
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
```
---
## 22. Guía de Migración a Radix UI
### 22.1 Componentes que Migrar
**De Headless UI a Radix UI:**
- `<Dialog />``@radix-ui/react-dialog`
- `<Menu />``@radix-ui/react-dropdown-menu`
- `<Tabs />``@radix-ui/react-tabs`
- `<Switch />``@radix-ui/react-switch`
**Componentes Custom a Mantener:**
- `<Card />` - No existe en Radix
- `<Table />` - No existe en Radix
- `<Avatar />` - No existe en Radix
- `<Badge />` - No existe en Radix
### 22.2 Patrones de Migración
```typescript
// ANTES (Headless UI)
<Dialog open={isOpen} onClose={() => setIsOpen(false)}>
<DialogPanel>
<DialogTitle>Title</DialogTitle>
<DialogContent>...</DialogContent>
</DialogPanel>
</Dialog>
// DESPUÉS (Radix UI)
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogTitle>Title</DialogTitle>
<DialogContent>...</DialogContent>
</DialogContent>
</Dialog>
```
---
## 23. Changelog
### 2026-01-18
- Agregada sección 21: Ejemplos de uso de Radix UI con Square UI styling
- Agregados ejemplos completos de Button, Dialog, Select, Tabs
- Agregada guía de accesibilidad con Radix UI
- Agregada guía de migración de Headless UI a Radix UI
### 2026-01-17
- Documento inicial creado

View File

@@ -69,6 +69,14 @@ AnchorOS is a comprehensive salon management system built with Next.js, Supabase
- `GET /api/aperture/reports/payments` - Payment reports
- `GET /api/aperture/reports/payroll` - Payroll reports
#### POS (Point of Sale)
- `POST /api/aperture/pos` - Create sale transaction (cart, payments, receipt)
- `POST /api/aperture/pos/close-day` - Close day and generate daily report with PDF
#### Payroll
- `GET /api/aperture/payroll` - Calculate payroll for staff (base salary + commission + tips)
- `GET /api/aperture/payroll/[staffId]` - Get payroll details for specific staff
#### Permissions
- `GET /api/aperture/permissions` - Get role permissions
- `POST /api/aperture/permissions` - Update permissions
@@ -81,13 +89,32 @@ AnchorOS is a comprehensive salon management system built with Next.js, Supabase
- `PUT /api/kiosk/bookings/[shortId]/confirm` - Confirm booking
### Payment APIs
- `POST /api/create-payment-intent` - Create Stripe payment intent
- `POST /api/create-payment-intent` - Create Stripe payment intent for booking deposit
- `POST /api/webhooks/stripe` - Stripe webhook handler (payment_intent.succeeded, payment_intent.payment_failed, charge.refunded)
### Admin APIs
- `GET /api/admin/locations` - List locations (Admin key required)
- `POST /api/admin/users` - Create staff/user
- `POST /api/admin/kiosks` - Create kiosk
### Cron Jobs
- `GET /api/cron/reset-invitations` - Reset weekly invitation quotas for Gold tier (Monday 00:00 UTC)
- `GET /api/cron/detect-no-shows` - Detect and mark no-show bookings (every 2 hours)
### Client Management (FASE 5 - Pending Implementation)
- `GET /api/aperture/clients` - List and search clients (phonetic search, history, technical notes)
- `POST /api/aperture/clients` - Create new client
- `GET /api/aperture/clients/[id]` - Get client details
- `PUT /api/aperture/clients/[id]` - Update client information
- `POST /api/aperture/clients/[id]/notes` - Add technical note to client
- `GET /api/aperture/clients/[id]/photos` - Get client photo gallery (VIP/Black/Gold only)
### Loyalty System (FASE 5 - Pending Implementation)
- `GET /api/aperture/loyalty` - Get loyalty points and rewards
- `POST /api/aperture/loyalty/redeem` - Redeem loyalty points
- `GET /api/aperture/loyalty/[customerId]` - Get customer loyalty history
- `POST /api/aperture/loyalty/[customerId]/points` - Add/remove loyalty points
## Data Models
### User Roles

View File

@@ -0,0 +1,255 @@
-- ============================================
-- FASE 5 - CLIENTS AND LOYALTY SYSTEM
-- Date: 20260118
-- Description: Add customer notes, photo gallery, loyalty points, and membership plans
-- ============================================
-- Add customer notes and technical information
ALTER TABLE customers ADD COLUMN IF NOT EXISTS technical_notes TEXT;
ALTER TABLE customers ADD COLUMN IF NOT EXISTS preferences JSONB DEFAULT '{}'::jsonb;
ALTER TABLE customers ADD COLUMN IF NOT EXISTS loyalty_points INTEGER DEFAULT 0;
ALTER TABLE customers ADD COLUMN IF NOT EXISTS loyalty_points_expiry_date DATE;
ALTER TABLE customers ADD COLUMN IF NOT EXISTS no_show_count INTEGER DEFAULT 0;
ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_no_show_date DATE;
-- Create customer photos table (for VIP/Black/Gold only)
CREATE TABLE IF NOT EXISTS customer_photos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
storage_path TEXT NOT NULL,
description TEXT,
taken_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
is_active BOOLEAN DEFAULT true
);
-- Create index for photos lookup
CREATE INDEX IF NOT EXISTS idx_customer_photos_customer ON customer_photos(customer_id);
-- Create loyalty transactions table
CREATE TABLE IF NOT EXISTS loyalty_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
points INTEGER NOT NULL,
transaction_type TEXT NOT NULL CHECK (transaction_type IN ('earned', 'redeemed', 'expired', 'admin_adjustment')),
description TEXT,
reference_type TEXT,
reference_id UUID,
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id)
);
-- Create index for loyalty lookup
CREATE INDEX IF NOT EXISTS idx_loyalty_transactions_customer ON loyalty_transactions(customer_id);
CREATE INDEX IF NOT EXISTS idx_loyalty_transactions_created ON loyalty_transactions(created_at DESC);
-- Create membership plans table
CREATE TABLE IF NOT EXISTS membership_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
tier TEXT NOT NULL CHECK (tier IN ('gold', 'black', 'VIP')),
monthly_credits INTEGER DEFAULT 0,
price DECIMAL(10,2) NOT NULL,
benefits JSONB NOT NULL DEFAULT '{}'::jsonb,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create customer subscriptions table
CREATE TABLE IF NOT EXISTS customer_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
membership_plan_id UUID NOT NULL REFERENCES membership_plans(id),
start_date DATE NOT NULL,
end_date DATE,
auto_renew BOOLEAN DEFAULT false,
credits_remaining INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'cancelled', 'paused')),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(customer_id, status)
);
-- Create index for subscriptions
CREATE INDEX IF NOT EXISTS idx_customer_subscriptions_customer ON customer_subscriptions(customer_id);
CREATE INDEX IF NOT EXISTS idx_customer_subscriptions_status ON customer_subscriptions(status);
-- Insert default membership plans
INSERT INTO membership_plans (name, tier, monthly_credits, price, benefits) VALUES
('Gold Membership', 'gold', 5, 499.00, '{
"weekly_invitations": 5,
"priority_booking": false,
"exclusive_services": [],
"discount_percentage": 5,
"photo_gallery": true
}'::jsonb),
('Black Membership', 'black', 10, 999.00, '{
"weekly_invitations": 10,
"priority_booking": true,
"exclusive_services": ["spa_day", "premium_manicure"],
"discount_percentage": 10,
"photo_gallery": true,
"priority_support": true
}'::jsonb),
('VIP Membership', 'VIP', 15, 1999.00, '{
"weekly_invitations": 15,
"priority_booking": true,
"exclusive_services": ["spa_day", "premium_manicure", "exclusive_hair_treatment"],
"discount_percentage": 20,
"photo_gallery": true,
"priority_support": true,
"personal_stylist": true,
"private_events": true
}'::jsonb)
ON CONFLICT (name) DO NOTHING;
-- RLS Policies for customer photos
ALTER TABLE customer_photos ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Photos can be viewed by admins, managers, and customer owner"
ON customer_photos FOR SELECT
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager')
)) OR customer_id = (SELECT id FROM customers WHERE user_id = auth.uid())
);
CREATE POLICY "Photos can be created by admins, managers, and assigned staff"
ON customer_photos FOR INSERT
WITH CHECK (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager', 'staff', 'artist')
))
);
CREATE POLICY "Photos can be deleted by admins and managers only"
ON customer_photos FOR DELETE
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager')
))
);
-- RLS Policies for loyalty transactions
ALTER TABLE loyalty_transactions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Loyalty transactions visible to admins, managers, and customer owner"
ON loyalty_transactions FOR SELECT
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager')
)) OR customer_id = (SELECT id FROM customers WHERE user_id = auth.uid())
);
-- Function to add loyalty points
CREATE OR REPLACE FUNCTION add_loyalty_points(
p_customer_id UUID,
p_points INTEGER,
p_transaction_type TEXT DEFAULT 'earned',
p_description TEXT,
p_reference_type TEXT DEFAULT NULL,
p_reference_id UUID DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_transaction_id UUID;
v_points_expiry_date DATE;
BEGIN
-- Validate customer exists
IF NOT EXISTS (SELECT 1 FROM customers WHERE id = p_customer_id) THEN
RAISE EXCEPTION 'Customer not found';
END IF;
-- Calculate expiry date (6 months from now for earned points)
IF p_transaction_type = 'earned' THEN
v_points_expiry_date := (CURRENT_DATE + INTERVAL '6 months');
END IF;
-- Create transaction
INSERT INTO loyalty_transactions (
customer_id,
points,
transaction_type,
description,
reference_type,
reference_id,
created_by
) VALUES (
p_customer_id,
p_points,
p_transaction_type,
p_description,
p_reference_type,
p_reference_id,
auth.uid()
) RETURNING id INTO v_transaction_id;
-- Update customer points balance
UPDATE customers
SET
loyalty_points = loyalty_points + p_points,
loyalty_points_expiry_date = v_points_expiry_date
WHERE id = p_customer_id;
-- Log to audit
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
new_values,
performed_by
) VALUES (
'customer',
p_customer_id,
'loyalty_points_updated',
jsonb_build_object(
'points_change', p_points,
'new_balance', (SELECT loyalty_points FROM customers WHERE id = p_customer_id)
),
auth.uid()
);
RETURN v_transaction_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to check if customer can access photo gallery
CREATE OR REPLACE FUNCTION can_access_photo_gallery(p_customer_id UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM customers
WHERE id = p_customer_id
AND tier IN ('gold', 'black', 'VIP')
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to get customer loyalty summary
CREATE OR REPLACE FUNCTION get_customer_loyalty_summary(p_customer_id UUID)
RETURNS JSONB AS $$
DECLARE
v_summary JSONB;
BEGIN
SELECT jsonb_build_object(
'points', COALESCE(loyalty_points, 0),
'expiry_date', loyalty_points_expiry_date,
'no_show_count', COALESCE(no_show_count, 0),
'last_no_show', last_no_show_date,
'transactions_earned', COALESCE((SELECT COUNT(*) FROM loyalty_transactions WHERE customer_id = p_customer_id AND transaction_type = 'earned'), 0),
'transactions_redeemed', COALESCE((SELECT COUNT(*) FROM loyalty_transactions WHERE customer_id = p_customer_id AND transaction_type = 'redeemed'), 0)
) INTO v_summary
FROM customers
WHERE id = p_customer_id;
RETURN v_summary;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

View File

@@ -0,0 +1,401 @@
-- ============================================
-- FASE 6 - STRIPE WEBHOOKS AND NO-SHOW LOGIC
-- Date: 20260118
-- Description: Add payment tracking, webhook logs, no-show detection, and admin overrides
-- ============================================
-- Add no-show and penalty fields to bookings
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS penalty_waived BOOLEAN DEFAULT false;
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS penalty_waived_by UUID REFERENCES auth.users(id);
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS penalty_waived_at TIMESTAMPTZ;
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS check_in_time TIMESTAMPTZ;
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS check_in_staff_id UUID REFERENCES staff(id);
-- Add webhook logs table for Stripe events
CREATE TABLE IF NOT EXISTS webhook_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL,
event_id TEXT NOT NULL UNIQUE,
payload JSONB NOT NULL,
processed BOOLEAN DEFAULT false,
processing_error TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
-- Create index for webhook lookups
CREATE INDEX IF NOT EXISTS idx_webhook_logs_event_id ON webhook_logs(event_id);
CREATE INDEX IF NOT EXISTS idx_webhook_logs_event_type ON webhook_logs(event_type);
CREATE INDEX IF NOT EXISTS idx_webhook_logs_processed ON webhook_logs(processed);
-- Create no-show detections table
CREATE TABLE IF NOT EXISTS no_show_detections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id UUID NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
detected_at TIMESTAMPTZ DEFAULT NOW(),
detection_method TEXT DEFAULT 'cron',
confirmed BOOLEAN DEFAULT false,
confirmed_by UUID REFERENCES auth.users(id),
confirmed_at TIMESTAMPTZ,
penalty_applied BOOLEAN DEFAULT false,
notes TEXT,
UNIQUE(booking_id)
);
-- Create index for no-show lookups
CREATE INDEX IF NOT EXISTS idx_no_show_detections_booking ON no_show_detections(booking_id);
-- Update payments table with webhook reference
ALTER TABLE payments ADD COLUMN IF NOT EXISTS webhook_event_id TEXT REFERENCES webhook_logs(event_id);
ALTER TABLE payments ADD COLUMN IF NOT EXISTS refund_amount DECIMAL(10,2) DEFAULT 0;
ALTER TABLE payments ADD COLUMN IF NOT EXISTS refund_reason TEXT;
ALTER TABLE payments ADD COLUMN IF NOT EXISTS refunded_at TIMESTAMPTZ;
ALTER TABLE payments ADD COLUMN IF NOT EXISTS refund_webhook_event_id TEXT REFERENCES webhook_logs(event_id);
-- RLS Policies for webhook logs
ALTER TABLE webhook_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Webhook logs can be viewed by admins only"
ON webhook_logs FOR SELECT
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' = 'admin'
))
);
CREATE POLICY "Webhook logs can be inserted by system/service role"
ON webhook_logs FOR INSERT
WITH CHECK (true);
-- RLS Policies for no-show detections
ALTER TABLE no_show_detections ENABLE ROW LEVEL SECURITY;
CREATE POLICY "No-show detections visible to admins, managers, and assigned staff"
ON no_show_detections FOR SELECT
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager')
)) OR EXISTS (
SELECT 1 FROM bookings b
JOIN no_show_detections nsd ON nsd.booking_id = b.id
WHERE nsd.id = no_show_detections.id
AND b.staff_ids @> ARRAY[auth.uid()]
)
);
CREATE POLICY "No-show detections can be updated by admins and managers"
ON no_show_detections FOR UPDATE
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager')
))
);
-- Function to check if booking should be marked as no-show
CREATE OR REPLACE FUNCTION detect_no_show_booking(p_booking_id UUID)
RETURNS BOOLEAN AS $$
DECLARE
v_booking bookings%ROWTYPE;
v_window_start TIMESTAMPTZ;
v_has_checkin BOOLEAN;
BEGIN
-- Get booking details
SELECT * INTO v_booking FROM bookings WHERE id = p_booking_id;
IF NOT FOUND THEN
RETURN false;
END IF;
-- Check if already checked in
IF v_booking.check_in_time IS NOT NULL THEN
RETURN false;
END IF;
-- Calculate no-show window (12 hours after start time)
v_window_start := v_booking.start_time_utc + INTERVAL '12 hours';
-- Check if window has passed
IF NOW() < v_window_start THEN
RETURN false;
END IF;
-- Check if customer has checked in (through check_ins table or direct booking check)
SELECT EXISTS (
SELECT 1 FROM check_ins
WHERE booking_id = p_booking_id
) INTO v_has_checkin;
IF v_has_checkin THEN
RETURN false;
END IF;
-- Check if detection already exists
IF EXISTS (SELECT 1 FROM no_show_detections WHERE booking_id = p_booking_id) THEN
RETURN false;
END IF;
-- Create no-show detection record
INSERT INTO no_show_detections (booking_id, detection_method)
VALUES (p_booking_id, 'cron');
-- Log to audit
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
new_values,
performed_by
) VALUES (
'booking',
p_booking_id,
'no_show_detected',
jsonb_build_object(
'start_time_utc', v_booking.start_time_utc,
'detection_time', NOW()
),
'system'
);
RETURN true;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to apply no-show penalty
CREATE OR REPLACE FUNCTION apply_no_show_penalty(p_booking_id UUID, p_override_by UUID DEFAULT NULL)
RETURNS BOOLEAN AS $$
DECLARE
v_booking bookings%ROWTYPE;
v_customer_id UUID;
BEGIN
-- Get booking details
SELECT * INTO v_booking FROM bookings WHERE id = p_booking_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Booking not found';
END IF;
-- Check if already applied
IF v_booking.status = 'no_show' AND NOT v_booking.penalty_waived THEN
RETURN false;
END IF;
-- Get customer ID
SELECT id INTO v_customer_id FROM customers WHERE id = v_booking.customer_id;
-- Update booking status
UPDATE bookings
SET
status = 'no_show',
penalty_waived = (p_override_by IS NOT NULL),
penalty_waived_by = p_override_by,
penalty_waived_at = CASE WHEN p_override_by IS NOT NULL THEN NOW() ELSE NULL END
WHERE id = p_booking_id;
-- Update customer no-show count
UPDATE customers
SET
no_show_count = no_show_count + 1,
last_no_show_date = CURRENT_DATE
WHERE id = v_customer_id;
-- Update no-show detection
UPDATE no_show_detections
SET
confirmed = true,
confirmed_by = p_override_by,
confirmed_at = NOW(),
penalty_applied = NOT (p_override_by IS NOT NULL)
WHERE booking_id = p_booking_id;
-- Log to audit
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
new_values,
performed_by
) VALUES (
'booking',
p_booking_id,
'no_show_penalty_applied',
jsonb_build_object(
'deposit_retained', v_booking.deposit_amount,
'waived', (p_override_by IS NOT NULL)
),
COALESCE(p_override_by, 'system')
);
RETURN true;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to record check-in for booking
CREATE OR REPLACE FUNCTION record_booking_checkin(p_booking_id UUID, p_staff_id UUID)
RETURNS BOOLEAN AS $$
DECLARE
v_booking bookings%ROWTYPE;
BEGIN
-- Get booking details
SELECT * INTO v_booking FROM bookings WHERE id = p_booking_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Booking not found';
END IF;
-- Check if already checked in
IF v_booking.check_in_time IS NOT NULL THEN
RETURN false;
END IF;
-- Record check-in
UPDATE bookings
SET
check_in_time = NOW(),
check_in_staff_id = p_staff_id,
status = 'in_progress'
WHERE id = p_booking_id;
-- Record in check_ins table
INSERT INTO check_ins (booking_id, checked_in_by)
VALUES (p_booking_id, p_staff_id)
ON CONFLICT (booking_id) DO NOTHING;
-- Log to audit
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
new_values,
performed_by
) VALUES (
'booking',
p_booking_id,
'checked_in',
jsonb_build_object('check_in_time', NOW()),
p_staff_id
);
RETURN true;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to process payment intent succeeded webhook
CREATE OR REPLACE FUNCTION process_payment_intent_succeeded(p_event_id TEXT, p_payload JSONB)
RETURNS JSONB AS $$
DECLARE
v_payment_intent_id TEXT;
v_metadata JSONB;
v_amount DECIMAL(10,2);
v_customer_email TEXT;
v_service_id UUID;
v_location_id UUID;
v_booking_id UUID;
v_payment_id UUID;
BEGIN
-- Extract data from payload
v_payment_intent_id := p_payload->'data'->'object'->>'id';
v_metadata := p_payload->'data'->'object'->'metadata';
v_amount := (p_payload->'data'->'object'->>'amount')::DECIMAL / 100;
v_customer_email := v_metadata->>'customer_email';
v_service_id := v_metadata->>'service_id'::UUID;
v_location_id := v_metadata->>'location_id'::UUID;
-- Log webhook event
INSERT INTO webhook_logs (event_type, event_id, payload, processed)
VALUES ('payment_intent.succeeded', p_event_id, p_payload, false)
ON CONFLICT (event_id) DO NOTHING;
-- Find or create payment record
-- Note: This assumes booking was created with deposit = 0 initially
-- The actual booking creation flow should handle this
-- For now, just mark as processed
UPDATE webhook_logs
SET processed = true, processed_at = NOW()
WHERE event_id = p_event_id;
RETURN jsonb_build_object('success', true, 'message', 'Payment processed successfully');
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to process payment intent failed webhook
CREATE OR REPLACE FUNCTION process_payment_intent_failed(p_event_id TEXT, p_payload JSONB)
RETURNS JSONB AS $$
DECLARE
v_payment_intent_id TEXT;
v_metadata JSONB;
BEGIN
-- Extract data
v_payment_intent_id := p_payload->'data'->'object'->>'id';
v_metadata := p_payload->'data'->'object'->'metadata';
-- Log webhook event
INSERT INTO webhook_logs (event_type, event_id, payload, processed)
VALUES ('payment_intent.payment_failed', p_event_id, p_payload, false)
ON CONFLICT (event_id) DO NOTHING;
-- TODO: Send notification to customer about failed payment
UPDATE webhook_logs
SET processed = true, processed_at = NOW()
WHERE event_id = p_event_id;
RETURN jsonb_build_object('success', true, 'message', 'Payment failure processed');
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to process charge refunded webhook
CREATE OR REPLACE FUNCTION process_charge_refunded(p_event_id TEXT, p_payload JSONB)
RETURNS JSONB AS $$
DECLARE
v_charge_id TEXT;
v_refund_amount DECIMAL(10,2);
BEGIN
-- Extract data
v_charge_id := p_payload->'data'->'object'->>'id';
v_refund_amount := (p_payload->'data'->'object'->'amount_refunded')::DECIMAL / 100;
-- Log webhook event
INSERT INTO webhook_logs (event_type, event_id, payload, processed)
VALUES ('charge.refunded', p_event_id, p_payload, false)
ON CONFLICT (event_id) DO NOTHING;
-- Find payment record and update
UPDATE payments
SET
refund_amount = COALESCE(refund_amount, 0) + v_refund_amount,
refund_reason = p_payload->'data'->'object'->>'reason',
refunded_at = NOW(),
status = 'refunded',
refund_webhook_event_id = p_event_id
WHERE stripe_payment_intent_id = v_charge_id;
-- Log to audit
INSERT INTO audit_logs (
entity_type,
action,
new_values,
performed_by
) VALUES (
'payment',
'refund_processed',
jsonb_build_object(
'charge_id', v_charge_id,
'refund_amount', v_refund_amount
),
'system'
);
UPDATE webhook_logs
SET processed = true, processed_at = NOW()
WHERE event_id = p_event_id;
RETURN jsonb_build_object('success', true, 'message', 'Refund processed successfully');
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

View File

@@ -0,0 +1,397 @@
-- ============================================
-- FASE 6 - FINANCIAL REPORTING AND EXPENSES
-- Date: 20260118
-- Description: Add expenses tracking, financial reports, and daily closing
-- ============================================
-- Create expenses table
CREATE TABLE IF NOT EXISTS expenses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
location_id UUID REFERENCES locations(id),
category TEXT NOT NULL CHECK (category IN ('supplies', 'maintenance', 'utilities', 'rent', 'salaries', 'marketing', 'other')),
description TEXT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
expense_date DATE NOT NULL,
payment_method TEXT CHECK (payment_method IN ('cash', 'card', 'transfer', 'check')),
receipt_url TEXT,
notes TEXT,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create index for expenses
CREATE INDEX IF NOT EXISTS idx_expenses_location ON expenses(location_id);
CREATE INDEX IF NOT EXISTS idx_expenses_date ON expenses(expense_date);
CREATE INDEX IF NOT EXISTS idx_expenses_category ON expenses(category);
-- Create daily closing reports table
CREATE TABLE IF NOT EXISTS daily_closing_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
location_id UUID REFERENCES locations(id),
report_date DATE NOT NULL,
cashier_id UUID REFERENCES auth.users(id),
total_sales DECIMAL(10,2) NOT NULL DEFAULT 0,
payment_breakdown JSONB NOT NULL DEFAULT '{}'::jsonb,
transaction_count INTEGER NOT NULL DEFAULT 0,
refunds_total DECIMAL(10,2) NOT NULL DEFAULT 0,
refunds_count INTEGER NOT NULL DEFAULT 0,
discrepancies JSONB NOT NULL DEFAULT '[]'::jsonb,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'reviewed', 'final')),
reviewed_by UUID REFERENCES auth.users(id),
reviewed_at TIMESTAMPTZ,
pdf_url TEXT,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(location_id, report_date)
);
-- Create index for daily closing reports
CREATE INDEX IF NOT EXISTS idx_daily_closing_location_date ON daily_closing_reports(location_id, report_date);
-- Add transaction reference to payments
ALTER TABLE payments ADD COLUMN IF NOT EXISTS transaction_id TEXT UNIQUE;
ALTER TABLE payments ADD COLUMN IF NOT EXISTS cashier_id UUID REFERENCES auth.users(id);
-- RLS Policies for expenses
ALTER TABLE expenses ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Expenses visible to admins, managers (location only)"
ON expenses FOR SELECT
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' = 'admin'
)) OR (
location_id = (SELECT raw_user_meta_data->>'location_id' FROM auth.users WHERE id = auth.uid())
AND (SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' = 'manager'
))
)
);
CREATE POLICY "Expenses can be created by admins and managers"
ON expenses FOR INSERT
WITH CHECK (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager')
))
);
CREATE POLICY "Expenses can be updated by admins and managers"
ON expenses FOR UPDATE
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager')
))
);
-- RLS Policies for daily closing reports
ALTER TABLE daily_closing_reports ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Daily closing visible to admins, managers, and cashier"
ON daily_closing_reports FOR SELECT
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' = 'admin'
)) OR (
cashier_id = auth.uid()
) OR (
location_id = (SELECT raw_user_meta_data->>'location_id' FROM auth.users WHERE id = auth.uid())
AND (SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' = 'manager'
))
)
);
CREATE POLICY "Daily closing can be created by staff"
ON daily_closing_reports FOR INSERT
WITH CHECK (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager', 'staff')
))
);
CREATE POLICY "Daily closing can be reviewed by admins and managers"
ON daily_closing_reports FOR UPDATE
WHERE status = 'pending'
USING (
(SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE auth.users.id = auth.uid()
AND auth.users.raw_user_meta_data->>'role' IN ('admin', 'manager')
))
);
-- Function to generate daily closing report
CREATE OR REPLACE FUNCTION generate_daily_closing_report(p_location_id UUID, p_report_date DATE)
RETURNS UUID AS $$
DECLARE
v_report_id UUID;
v_location_id UUID;
v_total_sales DECIMAL(10,2);
v_payment_breakdown JSONB;
v_transaction_count INTEGER;
v_refunds_total DECIMAL(10,2);
v_refunds_count INTEGER;
v_start_time TIMESTAMPTZ;
v_end_time TIMESTAMPTZ;
BEGIN
-- Set time range (all day UTC, converted to location timezone)
v_start_time := p_report_date::TIMESTAMPTZ;
v_end_time := (p_report_date + INTERVAL '1 day')::TIMESTAMPTZ;
-- Get or use location_id
v_location_id := COALESCE(p_location_id, (SELECT id FROM locations LIMIT 1));
-- Calculate total sales from completed bookings
SELECT COALESCE(SUM(total_price), 0) INTO v_total_sales
FROM bookings
WHERE location_id = v_location_id
AND status = 'completed'
AND start_time_utc >= v_start_time
AND start_time_utc < v_end_time;
-- Get payment breakdown
SELECT jsonb_object_agg(payment_method, total)
INTO v_payment_breakdown
FROM (
SELECT payment_method, COALESCE(SUM(amount), 0) AS total
FROM payments
WHERE created_at >= v_start_time AND created_at < v_end_time
GROUP BY payment_method
) AS breakdown;
-- Count transactions
SELECT COUNT(*) INTO v_transaction_count
FROM payments
WHERE created_at >= v_start_time AND created_at < v_end_time;
-- Calculate refunds
SELECT
COALESCE(SUM(refund_amount), 0),
COUNT(*)
INTO v_refunds_total, v_refunds_count
FROM payments
WHERE refunded_at >= v_start_time AND refunded_at < v_end_time
AND refunded_at IS NOT NULL;
-- Create or update report
INSERT INTO daily_closing_reports (
location_id,
report_date,
cashier_id,
total_sales,
payment_breakdown,
transaction_count,
refunds_total,
refunds_count,
status
) VALUES (
v_location_id,
p_report_date,
auth.uid(),
v_total_sales,
COALESCE(v_payment_breakdown, '{}'::jsonb),
v_transaction_count,
v_refunds_total,
v_refunds_count,
'pending'
)
ON CONFLICT (location_id, report_date) DO UPDATE SET
total_sales = EXCLUDED.total_sales,
payment_breakdown = EXCLUDED.payment_breakdown,
transaction_count = EXCLUDED.transaction_count,
refunds_total = EXCLUDED.refunds_total,
refunds_count = EXCLUDED.refunds_count,
cashier_id = auth.uid()
RETURNING id INTO v_report_id;
-- Log to audit
INSERT INTO audit_logs (
entity_type,
entity_id,
action,
new_values,
performed_by
) VALUES (
'daily_closing_report',
v_report_id,
'generated',
jsonb_build_object(
'location_id', v_location_id,
'report_date', p_report_date,
'total_sales', v_total_sales
),
auth.uid()
);
RETURN v_report_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to get financial summary for date range
CREATE OR REPLACE FUNCTION get_financial_summary(p_location_id UUID, p_start_date DATE, p_end_date DATE)
RETURNS JSONB AS $$
DECLARE
v_summary JSONB;
v_start_time TIMESTAMPTZ;
v_end_time TIMESTAMPTZ;
v_total_revenue DECIMAL(10,2);
v_total_expenses DECIMAL(10,2);
v_net_profit DECIMAL(10,2);
v_booking_count INTEGER;
v_expense_breakdown JSONB;
BEGIN
-- Set time range
v_start_time := p_start_date::TIMESTAMPTZ;
v_end_time := (p_end_date + INTERVAL '1 day')::TIMESTAMPTZ;
-- Get total revenue
SELECT COALESCE(SUM(total_price), 0) INTO v_total_revenue
FROM bookings
WHERE location_id = p_location_id
AND status = 'completed'
AND start_time_utc >= v_start_time
AND start_time_utc < v_end_time;
-- Get total expenses
SELECT COALESCE(SUM(amount), 0) INTO v_total_expenses
FROM expenses
WHERE location_id = p_location_id
AND expense_date >= p_start_date
AND expense_date <= p_end_date;
-- Calculate net profit
v_net_profit := v_total_revenue - v_total_expenses;
-- Get booking count
SELECT COUNT(*) INTO v_booking_count
FROM bookings
WHERE location_id = p_location_id
AND status IN ('completed', 'no_show')
AND start_time_utc >= v_start_time
AND start_time_utc < v_end_time;
-- Get expense breakdown by category
SELECT jsonb_object_agg(category, total)
INTO v_expense_breakdown
FROM (
SELECT category, COALESCE(SUM(amount), 0) AS total
FROM expenses
WHERE location_id = p_location_id
AND expense_date >= p_start_date
AND expense_date <= p_end_date
GROUP BY category
) AS breakdown;
-- Build summary
v_summary := jsonb_build_object(
'location_id', p_location_id,
'period', jsonb_build_object(
'start_date', p_start_date,
'end_date', p_end_date
),
'revenue', jsonb_build_object(
'total', v_total_revenue,
'booking_count', v_booking_count
),
'expenses', jsonb_build_object(
'total', v_total_expenses,
'breakdown', COALESCE(v_expense_breakdown, '{}'::jsonb)
),
'profit', jsonb_build_object(
'net', v_net_profit,
'margin', CASE WHEN v_total_revenue > 0 THEN (v_net_profit / v_total_revenue * 100)::DECIMAL(10,2) ELSE 0 END
)
);
RETURN v_summary;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to get staff performance report
CREATE OR REPLACE FUNCTION get_staff_performance_report(p_location_id UUID, p_start_date DATE, p_end_date DATE)
RETURNS JSONB AS $$
DECLARE
v_report JSONB;
v_staff_list JSONB;
v_start_time TIMESTAMPTZ;
v_end_time TIMESTAMPTZ;
BEGIN
-- Set time range
v_start_time := p_start_date::TIMESTAMPTZ;
v_end_time := (p_end_date + INTERVAL '1 day')::TIMESTAMPTZ;
-- Build staff performance list
SELECT jsonb_agg(
jsonb_build_object(
'staff_id', s.id,
'staff_name', s.first_name || ' ' || s.last_name,
'role', s.role,
'bookings_completed', COALESCE(b_stats.count, 0),
'revenue_generated', COALESCE(b_stats.revenue, 0),
'hours_worked', COALESCE(b_stats.hours, 0),
'tips_received', COALESCE(b_stats.tips, 0),
'no_shows', COALESCE(b_stats.no_shows, 0)
)
) INTO v_staff_list
FROM staff s
LEFT JOIN (
SELECT
unnest(staff_ids) AS staff_id,
COUNT(*) AS count,
SUM(total_price) AS revenue,
SUM(EXTRACT(EPOCH FROM (end_time_utc - start_time_utc)) / 3600) AS hours,
SUM(COALESCE(tips, 0)) AS tips,
SUM(CASE WHEN status = 'no_show' THEN 1 ELSE 0 END) AS no_shows
FROM bookings
WHERE location_id = p_location_id
AND status IN ('completed', 'no_show')
AND start_time_utc >= v_start_time
AND start_time_utc < v_end_time
GROUP BY unnest(staff_ids)
) b_stats ON s.id = b_stats.staff_id
WHERE s.location_id = p_location_id
AND s.is_active = true;
-- Build report
v_report := jsonb_build_object(
'location_id', p_location_id,
'period', jsonb_build_object(
'start_date', p_start_date,
'end_date', p_end_date
),
'staff', COALESCE(v_staff_list, '[]'::jsonb)
);
RETURN v_report;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Create updated_at trigger for expenses
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_expenses_updated_at
BEFORE UPDATE ON expenses
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();