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

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

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

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

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

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

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

View File

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