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