mirror of
https://github.com/marcogll/AnchorOS.git
synced 2026-03-15 18:24:31 +00:00
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
398 lines
13 KiB
PL/PgSQL
398 lines
13 KiB
PL/PgSQL
-- ============================================
|
|
-- 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();
|