diff --git a/TASKS.md b/TASKS.md index 3913a3c..1e90892 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 diff --git a/app/api/aperture/bookings/check-in/route.ts b/app/api/aperture/bookings/check-in/route.ts new file mode 100644 index 0000000..faaf22b --- /dev/null +++ b/app/api/aperture/bookings/check-in/route.ts @@ -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 } + ) + } +} diff --git a/app/api/aperture/bookings/no-show/route.ts b/app/api/aperture/bookings/no-show/route.ts new file mode 100644 index 0000000..b364c02 --- /dev/null +++ b/app/api/aperture/bookings/no-show/route.ts @@ -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 } + ) + } +} diff --git a/app/api/aperture/clients/[id]/notes/route.ts b/app/api/aperture/clients/[id]/notes/route.ts new file mode 100644 index 0000000..03c097b --- /dev/null +++ b/app/api/aperture/clients/[id]/notes/route.ts @@ -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 } + ) + } +} diff --git a/app/api/aperture/clients/[id]/photos/route.ts b/app/api/aperture/clients/[id]/photos/route.ts new file mode 100644 index 0000000..cc1849c --- /dev/null +++ b/app/api/aperture/clients/[id]/photos/route.ts @@ -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 } + ) + } +} diff --git a/app/api/aperture/clients/[id]/route.ts b/app/api/aperture/clients/[id]/route.ts new file mode 100644 index 0000000..7c2a056 --- /dev/null +++ b/app/api/aperture/clients/[id]/route.ts @@ -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 } + ) + } +} diff --git a/app/api/aperture/clients/route.ts b/app/api/aperture/clients/route.ts new file mode 100644 index 0000000..c045b50 --- /dev/null +++ b/app/api/aperture/clients/route.ts @@ -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 } + ) + } +} diff --git a/app/api/aperture/finance/daily-closing/route.ts b/app/api/aperture/finance/daily-closing/route.ts new file mode 100644 index 0000000..19590be --- /dev/null +++ b/app/api/aperture/finance/daily-closing/route.ts @@ -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 } + ) + } +} diff --git a/app/api/aperture/finance/expenses/route.ts b/app/api/aperture/finance/expenses/route.ts new file mode 100644 index 0000000..2cbbd6a --- /dev/null +++ b/app/api/aperture/finance/expenses/route.ts @@ -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 } + ) + } +} diff --git a/app/api/aperture/finance/route.ts b/app/api/aperture/finance/route.ts new file mode 100644 index 0000000..d2d8f6b --- /dev/null +++ b/app/api/aperture/finance/route.ts @@ -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 } + ) + } +} diff --git a/app/api/aperture/finance/staff-performance/route.ts b/app/api/aperture/finance/staff-performance/route.ts new file mode 100644 index 0000000..12c20ca --- /dev/null +++ b/app/api/aperture/finance/staff-performance/route.ts @@ -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 } + ) + } +} diff --git a/app/api/aperture/loyalty/[customerId]/route.ts b/app/api/aperture/loyalty/[customerId]/route.ts new file mode 100644 index 0000000..bb178de --- /dev/null +++ b/app/api/aperture/loyalty/[customerId]/route.ts @@ -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 } + ) + } +} diff --git a/app/api/aperture/loyalty/route.ts b/app/api/aperture/loyalty/route.ts new file mode 100644 index 0000000..f52b8d0 --- /dev/null +++ b/app/api/aperture/loyalty/route.ts @@ -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 } + ) + } +} diff --git a/app/api/cron/detect-no-shows/route.ts b/app/api/cron/detect-no-shows/route.ts new file mode 100644 index 0000000..bfc90d1 --- /dev/null +++ b/app/api/cron/detect-no-shows/route.ts @@ -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 } + ) + } +} diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts new file mode 100644 index 0000000..93a6030 --- /dev/null +++ b/app/api/webhooks/stripe/route.ts @@ -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 } + ) + } +} diff --git a/docs/APERATURE_SPECS.md b/docs/APERATURE_SPECS.md new file mode 100644 index 0000000..4128b65 --- /dev/null +++ b/docs/APERATURE_SPECS.md @@ -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 diff --git a/docs/APERTURE_SQUARE_UI.md b/docs/APERTURE_SQUARE_UI.md index cf6ff4d..7d6c965 100644 --- a/docs/APERTURE_SQUARE_UI.md +++ b/docs/APERTURE_SQUARE_UI.md @@ -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, + VariantProps {} + +const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => { + return ( + + + + + +``` + +--- + +### 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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = 'DialogHeader' + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogClose } +``` + +**Uso:** +```typescript + + + + + + + Confirm Action + +

Are you sure you want to proceed?

+
+ + + + +
+
+
+``` + +--- + +### 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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + {children} + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem } +``` + +**Uso:** +```typescript + +``` + +--- + +### 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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } +``` + +**Uso:** +```typescript + + + Account + Password + + +
Account settings...
+
+ +
Password settings...
+
+
+``` + +--- + +### 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: + +``` + +**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:** +- `` → `@radix-ui/react-dialog` +- `` → `@radix-ui/react-dropdown-menu` +- `` → `@radix-ui/react-tabs` +- `` → `@radix-ui/react-switch` + +**Componentes Custom a Mantener:** +- `` - No existe en Radix +- `` - No existe en Radix +- `` - No existe en Radix +- `` - No existe en Radix + +### 22.2 Patrones de Migración + +```typescript +// ANTES (Headless UI) + setIsOpen(false)}> + + Title + ... + + + +// DESPUÉS (Radix UI) + + + Title + ... + + +``` + +--- + +## 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 diff --git a/docs/API.md b/docs/API.md index 0c3990f..1508f41 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 diff --git a/supabase/migrations/20260118050000_clients_loyalty_system.sql b/supabase/migrations/20260118050000_clients_loyalty_system.sql new file mode 100644 index 0000000..cf912f8 --- /dev/null +++ b/supabase/migrations/20260118050000_clients_loyalty_system.sql @@ -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; diff --git a/supabase/migrations/20260118060000_stripe_webhooks_noshow_logic.sql b/supabase/migrations/20260118060000_stripe_webhooks_noshow_logic.sql new file mode 100644 index 0000000..52a150f --- /dev/null +++ b/supabase/migrations/20260118060000_stripe_webhooks_noshow_logic.sql @@ -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; diff --git a/supabase/migrations/20260118070000_financial_reporting_expenses.sql b/supabase/migrations/20260118070000_financial_reporting_expenses.sql new file mode 100644 index 0000000..7416b3e --- /dev/null +++ b/supabase/migrations/20260118070000_financial_reporting_expenses.sql @@ -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();