Files
AnchorOS/supabase/migrations/20260118050000_clients_loyalty_system.sql
Marco Gallegos bb25d6bde6 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
2026-01-18 23:05:09 -06:00

256 lines
8.7 KiB
PL/PgSQL

-- ============================================
-- 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;