template-saas-database-v2/ddl/schemas/sales/03-functions.sql
Adrian Flores Cortes ea4f8b18a0 [SAAS-018] feat: Add sales schema DDL
- Create sales schema (00-schema.sql)
- Add sales enums (01-enums.sql): lead_status, lead_source, opportunity_stage, activity_type, activity_status
- Add tables (02-tables.sql): pipeline_stages, leads, opportunities, activities
- Add functions (03-functions.sql): convert_lead_to_opportunity, update_opportunity_stage, calculate_lead_score, get_pipeline_summary, initialize_default_stages
- Add RLS policies (04-rls.sql) for tenant isolation
- Add indexes (05-indexes.sql) for performance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 20:49:35 -06:00

253 lines
7.2 KiB
PL/PgSQL

-- ============================================
-- TEMPLATE-SAAS: Sales Functions
-- Version: 1.0.0
-- Module: SAAS-018
-- ============================================
-- ============================================
-- Function: Convert Lead to Opportunity
-- ============================================
CREATE OR REPLACE FUNCTION sales.convert_lead_to_opportunity(
p_lead_id UUID,
p_opportunity_name VARCHAR(255) DEFAULT NULL,
p_amount DECIMAL(15, 2) DEFAULT 0,
p_expected_close_date DATE DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_lead RECORD;
v_opportunity_id UUID;
v_tenant_id UUID;
BEGIN
-- Get lead data
SELECT * INTO v_lead
FROM sales.leads
WHERE id = p_lead_id
AND deleted_at IS NULL
AND status != 'converted';
IF NOT FOUND THEN
RAISE EXCEPTION 'Lead not found or already converted';
END IF;
v_tenant_id := v_lead.tenant_id;
-- Create opportunity
INSERT INTO sales.opportunities (
tenant_id,
name,
lead_id,
stage,
amount,
currency,
expected_close_date,
assigned_to,
contact_name,
contact_email,
contact_phone,
company_name,
notes,
created_by
) VALUES (
v_tenant_id,
COALESCE(p_opportunity_name, v_lead.company || ' - ' || v_lead.first_name || ' ' || v_lead.last_name),
p_lead_id,
'prospecting',
COALESCE(p_amount, 0),
'USD',
COALESCE(p_expected_close_date, CURRENT_DATE + INTERVAL '30 days'),
v_lead.assigned_to,
v_lead.first_name || ' ' || v_lead.last_name,
v_lead.email,
v_lead.phone,
v_lead.company,
v_lead.notes,
v_lead.created_by
)
RETURNING id INTO v_opportunity_id;
-- Update lead as converted
UPDATE sales.leads
SET status = 'converted',
converted_at = NOW(),
converted_to_opportunity_id = v_opportunity_id
WHERE id = p_lead_id;
RETURN v_opportunity_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION sales.convert_lead_to_opportunity IS 'Converts a lead to an opportunity, copying relevant data';
-- ============================================
-- Function: Update Opportunity Stage
-- ============================================
CREATE OR REPLACE FUNCTION sales.update_opportunity_stage(
p_opportunity_id UUID,
p_new_stage sales.opportunity_stage,
p_notes TEXT DEFAULT NULL
)
RETURNS BOOLEAN AS $$
DECLARE
v_opportunity RECORD;
BEGIN
-- Get current opportunity
SELECT * INTO v_opportunity
FROM sales.opportunities
WHERE id = p_opportunity_id
AND deleted_at IS NULL;
IF NOT FOUND THEN
RAISE EXCEPTION 'Opportunity not found';
END IF;
-- Update stage
UPDATE sales.opportunities
SET stage = p_new_stage,
notes = CASE
WHEN p_notes IS NOT NULL THEN COALESCE(notes, '') || E'\n\n[Stage Change] ' || p_notes
ELSE notes
END,
won_at = CASE WHEN p_new_stage = 'closed_won' THEN NOW() ELSE won_at END,
lost_at = CASE WHEN p_new_stage = 'closed_lost' THEN NOW() ELSE lost_at END,
actual_close_date = CASE
WHEN p_new_stage IN ('closed_won', 'closed_lost') THEN CURRENT_DATE
ELSE actual_close_date
END
WHERE id = p_opportunity_id;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION sales.update_opportunity_stage IS 'Updates opportunity stage with automatic win/loss tracking';
-- ============================================
-- Function: Calculate Lead Score
-- ============================================
CREATE OR REPLACE FUNCTION sales.calculate_lead_score(
p_lead_id UUID
)
RETURNS INT AS $$
DECLARE
v_lead RECORD;
v_score INT := 0;
v_activity_count INT;
BEGIN
-- Get lead data
SELECT * INTO v_lead
FROM sales.leads
WHERE id = p_lead_id
AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN 0;
END IF;
-- Base score for having email
IF v_lead.email IS NOT NULL THEN
v_score := v_score + 10;
END IF;
-- Score for phone
IF v_lead.phone IS NOT NULL THEN
v_score := v_score + 10;
END IF;
-- Score for company
IF v_lead.company IS NOT NULL THEN
v_score := v_score + 15;
END IF;
-- Score based on source
CASE v_lead.source
WHEN 'referral' THEN v_score := v_score + 20;
WHEN 'website' THEN v_score := v_score + 15;
WHEN 'event' THEN v_score := v_score + 10;
ELSE v_score := v_score + 5;
END CASE;
-- Score based on status
CASE v_lead.status
WHEN 'qualified' THEN v_score := v_score + 25;
WHEN 'contacted' THEN v_score := v_score + 15;
WHEN 'new' THEN v_score := v_score + 5;
ELSE NULL;
END CASE;
-- Score for activities
SELECT COUNT(*) INTO v_activity_count
FROM sales.activities
WHERE lead_id = p_lead_id
AND deleted_at IS NULL;
v_score := v_score + LEAST(v_activity_count * 5, 20);
-- Cap at 100
RETURN LEAST(v_score, 100);
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION sales.calculate_lead_score IS 'Calculates a lead score based on completeness and engagement';
-- ============================================
-- Function: Get Pipeline Summary
-- ============================================
CREATE OR REPLACE FUNCTION sales.get_pipeline_summary(
p_tenant_id UUID
)
RETURNS TABLE (
stage sales.opportunity_stage,
count BIGINT,
total_amount DECIMAL(15, 2),
avg_probability NUMERIC
) AS $$
BEGIN
RETURN QUERY
SELECT
o.stage,
COUNT(*)::BIGINT,
COALESCE(SUM(o.amount), 0)::DECIMAL(15, 2),
COALESCE(AVG(o.probability), 0)::NUMERIC
FROM sales.opportunities o
WHERE o.tenant_id = p_tenant_id
AND o.deleted_at IS NULL
GROUP BY o.stage
ORDER BY
CASE o.stage
WHEN 'prospecting' THEN 1
WHEN 'qualification' THEN 2
WHEN 'proposal' THEN 3
WHEN 'negotiation' THEN 4
WHEN 'closed_won' THEN 5
WHEN 'closed_lost' THEN 6
END;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION sales.get_pipeline_summary IS 'Returns summary of opportunities by stage for a tenant';
-- ============================================
-- Function: Initialize Default Pipeline Stages
-- ============================================
CREATE OR REPLACE FUNCTION sales.initialize_default_stages(
p_tenant_id UUID
)
RETURNS VOID AS $$
BEGIN
-- Insert default stages if none exist
IF NOT EXISTS (SELECT 1 FROM sales.pipeline_stages WHERE tenant_id = p_tenant_id) THEN
INSERT INTO sales.pipeline_stages (tenant_id, name, position, color, is_won, is_lost)
VALUES
(p_tenant_id, 'Prospecting', 1, '#94A3B8', FALSE, FALSE),
(p_tenant_id, 'Qualification', 2, '#3B82F6', FALSE, FALSE),
(p_tenant_id, 'Proposal', 3, '#8B5CF6', FALSE, FALSE),
(p_tenant_id, 'Negotiation', 4, '#F59E0B', FALSE, FALSE),
(p_tenant_id, 'Closed Won', 5, '#10B981', TRUE, FALSE),
(p_tenant_id, 'Closed Lost', 6, '#EF4444', FALSE, TRUE);
END IF;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION sales.initialize_default_stages IS 'Creates default pipeline stages for a new tenant';