-- ============================================================================ -- SAAS LAYER - BILLING SCHEMA -- ============================================================================ -- Version: 1.0.0 -- Description: Billing, subscriptions, and payments management for SaaS -- Target: All ERP Suite products (POS Micro, ERP Básico, Verticales) -- ============================================================================ -- Enable required extensions CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- ============================================================================ -- SCHEMA CREATION -- ============================================================================ CREATE SCHEMA IF NOT EXISTS saas; SET search_path TO saas, public; -- ============================================================================ -- ENUMS -- ============================================================================ CREATE TYPE saas.plan_type AS ENUM ( 'pos_micro', -- 100 MXN/mes 'erp_basic', -- 300 MXN/mes 'erp_pro', -- 500 MXN/mes 'vertical' -- 1000+ MXN/mes ); CREATE TYPE saas.billing_cycle AS ENUM ( 'monthly', 'yearly' ); CREATE TYPE saas.subscription_status AS ENUM ( 'trial', 'active', 'past_due', 'suspended', 'cancelled', 'expired' ); CREATE TYPE saas.payment_status AS ENUM ( 'pending', 'processing', 'completed', 'failed', 'refunded', 'cancelled' ); CREATE TYPE saas.invoice_status AS ENUM ( 'draft', 'pending', 'paid', 'overdue', 'cancelled', 'refunded' ); CREATE TYPE saas.payment_provider AS ENUM ( 'stripe', 'conekta', 'oxxo', 'transfer', 'manual' ); -- ============================================================================ -- TABLE 1: plans (Subscription plans) -- ============================================================================ CREATE TABLE saas.plans ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identification code VARCHAR(50) UNIQUE NOT NULL, name VARCHAR(100) NOT NULL, description TEXT, -- Type plan_type saas.plan_type NOT NULL, -- Pricing (MXN) price_monthly DECIMAL(10,2) NOT NULL, price_yearly DECIMAL(10,2), -- Usually with discount -- Limits max_users INTEGER NOT NULL DEFAULT 1, max_products INTEGER DEFAULT 500, max_sales_per_month INTEGER DEFAULT 1000, max_storage_mb INTEGER DEFAULT 100, -- Features (JSON for flexibility) features JSONB NOT NULL DEFAULT '{}', -- Status is_active BOOLEAN NOT NULL DEFAULT TRUE, is_public BOOLEAN NOT NULL DEFAULT TRUE, -- Show in pricing page sort_order INTEGER DEFAULT 0, -- Stripe/Conekta IDs stripe_price_id_monthly VARCHAR(100), stripe_price_id_yearly VARCHAR(100), conekta_plan_id VARCHAR(100), -- Audit created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP ); -- ============================================================================ -- TABLE 2: plan_features (Feature definitions) -- ============================================================================ CREATE TABLE saas.plan_features ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), plan_id UUID NOT NULL REFERENCES saas.plans(id) ON DELETE CASCADE, -- Feature feature_code VARCHAR(50) NOT NULL, -- e.g., 'accounting', 'hr', 'cfdi' feature_name VARCHAR(100) NOT NULL, -- Pricing (for add-ons) is_included BOOLEAN NOT NULL DEFAULT FALSE, addon_price_monthly DECIMAL(10,2), -- Price if add-on -- Status is_active BOOLEAN NOT NULL DEFAULT TRUE, CONSTRAINT uq_plan_features UNIQUE (plan_id, feature_code) ); -- ============================================================================ -- TABLE 3: tenants (All SaaS customers) -- ============================================================================ CREATE TABLE saas.tenants ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Business info business_name VARCHAR(200) NOT NULL, legal_name VARCHAR(200), tax_id VARCHAR(20), -- RFC México -- Contact owner_name VARCHAR(200) NOT NULL, email VARCHAR(255) NOT NULL, phone VARCHAR(20) NOT NULL, whatsapp VARCHAR(20), -- Address address TEXT, city VARCHAR(100), state VARCHAR(50), zip_code VARCHAR(10), country VARCHAR(2) DEFAULT 'MX', -- Product reference (which product schema to use) product_type saas.plan_type NOT NULL, product_schema VARCHAR(50), -- e.g., 'pos_micro', 'erp_basic' -- Settings timezone VARCHAR(50) DEFAULT 'America/Mexico_City', currency VARCHAR(3) DEFAULT 'MXN', language VARCHAR(10) DEFAULT 'es', settings JSONB DEFAULT '{}', -- Audit created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP, CONSTRAINT uq_tenants_email UNIQUE (email), CONSTRAINT uq_tenants_phone UNIQUE (phone) ); CREATE INDEX idx_tenants_email ON saas.tenants(email); CREATE INDEX idx_tenants_phone ON saas.tenants(phone); CREATE INDEX idx_tenants_product_type ON saas.tenants(product_type); -- ============================================================================ -- TABLE 4: subscriptions (Active subscriptions) -- ============================================================================ CREATE TABLE saas.subscriptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES saas.tenants(id) ON DELETE CASCADE, plan_id UUID NOT NULL REFERENCES saas.plans(id), -- Billing billing_cycle saas.billing_cycle NOT NULL DEFAULT 'monthly', current_price DECIMAL(10,2) NOT NULL, -- Dates trial_starts_at TIMESTAMP, trial_ends_at TIMESTAMP, current_period_start TIMESTAMP NOT NULL, current_period_end TIMESTAMP NOT NULL, cancelled_at TIMESTAMP, -- Status status saas.subscription_status NOT NULL DEFAULT 'trial', cancel_reason TEXT, -- External IDs stripe_subscription_id VARCHAR(100), conekta_subscription_id VARCHAR(100), -- Features (active add-ons) active_features JSONB DEFAULT '[]', -- Audit created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP, CONSTRAINT uq_subscriptions_tenant UNIQUE (tenant_id) -- One active subscription per tenant ); CREATE INDEX idx_subscriptions_tenant_id ON saas.subscriptions(tenant_id); CREATE INDEX idx_subscriptions_status ON saas.subscriptions(status); CREATE INDEX idx_subscriptions_period_end ON saas.subscriptions(current_period_end); -- ============================================================================ -- TABLE 5: invoices (Billing invoices) -- ============================================================================ CREATE TABLE saas.invoices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES saas.tenants(id) ON DELETE CASCADE, subscription_id UUID REFERENCES saas.subscriptions(id), -- Invoice number invoice_number VARCHAR(50) NOT NULL, -- Dates invoice_date DATE NOT NULL DEFAULT CURRENT_DATE, due_date DATE NOT NULL, paid_at TIMESTAMP, -- Amounts subtotal DECIMAL(10,2) NOT NULL, tax_amount DECIMAL(10,2) NOT NULL DEFAULT 0, discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0, total DECIMAL(10,2) NOT NULL, -- Currency currency VARCHAR(3) NOT NULL DEFAULT 'MXN', -- Status status saas.invoice_status NOT NULL DEFAULT 'draft', -- CFDI (Mexico) cfdi_uuid VARCHAR(50), cfdi_xml TEXT, cfdi_pdf_url VARCHAR(500), -- External IDs stripe_invoice_id VARCHAR(100), conekta_order_id VARCHAR(100), -- Notes notes TEXT, -- Audit created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP, CONSTRAINT uq_invoices_number UNIQUE (invoice_number) ); CREATE INDEX idx_invoices_tenant_id ON saas.invoices(tenant_id); CREATE INDEX idx_invoices_status ON saas.invoices(status); CREATE INDEX idx_invoices_due_date ON saas.invoices(due_date); -- ============================================================================ -- TABLE 6: invoice_items (Invoice line items) -- ============================================================================ CREATE TABLE saas.invoice_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), invoice_id UUID NOT NULL REFERENCES saas.invoices(id) ON DELETE CASCADE, -- Description description VARCHAR(255) NOT NULL, -- Amounts quantity INTEGER NOT NULL DEFAULT 1, unit_price DECIMAL(10,2) NOT NULL, subtotal DECIMAL(10,2) NOT NULL, -- Reference plan_id UUID REFERENCES saas.plans(id), feature_code VARCHAR(50), -- Audit created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_invoice_items_invoice_id ON saas.invoice_items(invoice_id); -- ============================================================================ -- TABLE 7: payments (Payment transactions) -- ============================================================================ CREATE TABLE saas.payments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES saas.tenants(id) ON DELETE CASCADE, invoice_id UUID REFERENCES saas.invoices(id), -- Amount amount DECIMAL(10,2) NOT NULL, currency VARCHAR(3) NOT NULL DEFAULT 'MXN', -- Provider provider saas.payment_provider NOT NULL, provider_payment_id VARCHAR(100), provider_customer_id VARCHAR(100), -- Status status saas.payment_status NOT NULL DEFAULT 'pending', -- Details payment_method_type VARCHAR(50), -- card, oxxo, spei, etc. last_four VARCHAR(4), brand VARCHAR(20), -- visa, mastercard, etc. -- Error handling failure_code VARCHAR(50), failure_message TEXT, -- Dates paid_at TIMESTAMP, refunded_at TIMESTAMP, -- Metadata metadata JSONB DEFAULT '{}', -- Audit created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP ); CREATE INDEX idx_payments_tenant_id ON saas.payments(tenant_id); CREATE INDEX idx_payments_invoice_id ON saas.payments(invoice_id); CREATE INDEX idx_payments_status ON saas.payments(status); CREATE INDEX idx_payments_provider ON saas.payments(provider); -- ============================================================================ -- TABLE 8: payment_methods (Saved payment methods) -- ============================================================================ CREATE TABLE saas.payment_methods ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES saas.tenants(id) ON DELETE CASCADE, -- Provider provider saas.payment_provider NOT NULL, provider_payment_method_id VARCHAR(100) NOT NULL, -- Card details (if applicable) card_type VARCHAR(20), card_brand VARCHAR(20), card_last_four VARCHAR(4), card_exp_month INTEGER, card_exp_year INTEGER, -- Status is_default BOOLEAN NOT NULL DEFAULT FALSE, is_active BOOLEAN NOT NULL DEFAULT TRUE, -- Audit created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP ); CREATE INDEX idx_payment_methods_tenant_id ON saas.payment_methods(tenant_id); -- ============================================================================ -- TABLE 9: usage_tracking (Usage metrics for billing) -- ============================================================================ CREATE TABLE saas.usage_tracking ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES saas.tenants(id) ON DELETE CASCADE, -- Period period_start DATE NOT NULL, period_end DATE NOT NULL, -- Metrics users_count INTEGER DEFAULT 0, products_count INTEGER DEFAULT 0, sales_count INTEGER DEFAULT 0, storage_used_mb INTEGER DEFAULT 0, api_calls INTEGER DEFAULT 0, -- WhatsApp/AI usage (billable) whatsapp_messages INTEGER DEFAULT 0, ai_tokens_used INTEGER DEFAULT 0, -- Audit created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP, CONSTRAINT uq_usage_tracking_tenant_period UNIQUE (tenant_id, period_start, period_end) ); CREATE INDEX idx_usage_tracking_tenant_id ON saas.usage_tracking(tenant_id); CREATE INDEX idx_usage_tracking_period ON saas.usage_tracking(period_start, period_end); -- ============================================================================ -- TABLE 10: support_tickets (Basic support) -- ============================================================================ CREATE TABLE saas.support_tickets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES saas.tenants(id) ON DELETE CASCADE, -- Ticket ticket_number VARCHAR(20) NOT NULL, subject VARCHAR(255) NOT NULL, description TEXT NOT NULL, -- Category category VARCHAR(50) NOT NULL DEFAULT 'general', -- billing, technical, feature, general priority VARCHAR(20) NOT NULL DEFAULT 'normal', -- low, normal, high, urgent -- Status status VARCHAR(20) NOT NULL DEFAULT 'open', -- open, in_progress, waiting, resolved, closed -- Assignment assigned_to VARCHAR(100), -- Resolution resolution TEXT, resolved_at TIMESTAMP, -- Audit created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP, CONSTRAINT uq_support_tickets_number UNIQUE (ticket_number) ); CREATE INDEX idx_support_tickets_tenant_id ON saas.support_tickets(tenant_id); CREATE INDEX idx_support_tickets_status ON saas.support_tickets(status); -- ============================================================================ -- UTILITY FUNCTIONS -- ============================================================================ -- Function: Update updated_at timestamp CREATE OR REPLACE FUNCTION saas.update_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$ LANGUAGE plpgsql; -- Function: Generate invoice number CREATE OR REPLACE FUNCTION saas.generate_invoice_number() RETURNS VARCHAR AS $$ DECLARE v_year VARCHAR; v_seq INTEGER; BEGIN v_year := TO_CHAR(CURRENT_DATE, 'YYYY'); SELECT COALESCE(MAX( CAST(SUBSTRING(invoice_number FROM 5) AS INTEGER) ), 0) + 1 INTO v_seq FROM saas.invoices WHERE invoice_number LIKE v_year || '%'; RETURN v_year || LPAD(v_seq::TEXT, 6, '0'); END; $$ LANGUAGE plpgsql; -- Function: Generate ticket number CREATE OR REPLACE FUNCTION saas.generate_ticket_number() RETURNS VARCHAR AS $$ DECLARE v_date VARCHAR; v_seq INTEGER; BEGIN v_date := TO_CHAR(CURRENT_DATE, 'YYMMDD'); SELECT COALESCE(MAX( CAST(SUBSTRING(ticket_number FROM 8) AS INTEGER) ), 0) + 1 INTO v_seq FROM saas.support_tickets WHERE DATE(created_at) = CURRENT_DATE; RETURN 'TK' || v_date || LPAD(v_seq::TEXT, 4, '0'); END; $$ LANGUAGE plpgsql; -- ============================================================================ -- TRIGGERS -- ============================================================================ CREATE TRIGGER trg_plans_updated_at BEFORE UPDATE ON saas.plans FOR EACH ROW EXECUTE FUNCTION saas.update_updated_at(); CREATE TRIGGER trg_tenants_updated_at BEFORE UPDATE ON saas.tenants FOR EACH ROW EXECUTE FUNCTION saas.update_updated_at(); CREATE TRIGGER trg_subscriptions_updated_at BEFORE UPDATE ON saas.subscriptions FOR EACH ROW EXECUTE FUNCTION saas.update_updated_at(); CREATE TRIGGER trg_invoices_updated_at BEFORE UPDATE ON saas.invoices FOR EACH ROW EXECUTE FUNCTION saas.update_updated_at(); CREATE TRIGGER trg_payments_updated_at BEFORE UPDATE ON saas.payments FOR EACH ROW EXECUTE FUNCTION saas.update_updated_at(); CREATE TRIGGER trg_payment_methods_updated_at BEFORE UPDATE ON saas.payment_methods FOR EACH ROW EXECUTE FUNCTION saas.update_updated_at(); CREATE TRIGGER trg_usage_tracking_updated_at BEFORE UPDATE ON saas.usage_tracking FOR EACH ROW EXECUTE FUNCTION saas.update_updated_at(); CREATE TRIGGER trg_support_tickets_updated_at BEFORE UPDATE ON saas.support_tickets FOR EACH ROW EXECUTE FUNCTION saas.update_updated_at(); -- Auto-generate invoice number CREATE OR REPLACE FUNCTION saas.auto_invoice_number() RETURNS TRIGGER AS $$ BEGIN IF NEW.invoice_number IS NULL THEN NEW.invoice_number := saas.generate_invoice_number(); END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_invoices_auto_number BEFORE INSERT ON saas.invoices FOR EACH ROW EXECUTE FUNCTION saas.auto_invoice_number(); -- Auto-generate ticket number CREATE OR REPLACE FUNCTION saas.auto_ticket_number() RETURNS TRIGGER AS $$ BEGIN IF NEW.ticket_number IS NULL THEN NEW.ticket_number := saas.generate_ticket_number(); END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_tickets_auto_number BEFORE INSERT ON saas.support_tickets FOR EACH ROW EXECUTE FUNCTION saas.auto_ticket_number(); -- ============================================================================ -- SEED DATA: Default plans -- ============================================================================ INSERT INTO saas.plans (code, name, description, plan_type, price_monthly, price_yearly, max_users, max_products, max_sales_per_month, features) VALUES -- POS Micro ('pos_micro', 'POS Micro', 'Punto de venta ultra-básico para pequeños negocios', 'pos_micro', 100.00, 1000.00, 1, 500, 1000, '{"offline_mode": true, "whatsapp_basic": true, "reports_basic": true}'), -- ERP Básico ('erp_basic', 'ERP Básico', 'ERP completo pero austero para PyMEs', 'erp_basic', 300.00, 3000.00, 5, 10000, 5000, '{"inventory": true, "sales": true, "purchases": true, "reports": true, "multi_warehouse": false}'), -- ERP Pro ('erp_pro', 'ERP Pro', 'ERP completo con módulos avanzados', 'erp_pro', 500.00, 5000.00, 10, 50000, 20000, '{"inventory": true, "sales": true, "purchases": true, "reports": true, "multi_warehouse": true, "accounting_basic": true}'), -- Vertical (template) ('vertical_base', 'Vertical Industry', 'ERP especializado por industria', 'vertical', 1000.00, 10000.00, 20, 100000, 50000, '{"inventory": true, "sales": true, "purchases": true, "reports": true, "multi_warehouse": true, "accounting": true, "industry_specific": true}'); -- Feature definitions INSERT INTO saas.plan_features (plan_id, feature_code, feature_name, is_included, addon_price_monthly) VALUES -- POS Micro add-ons ((SELECT id FROM saas.plans WHERE code = 'pos_micro'), 'cfdi', 'Facturación CFDI', FALSE, 50.00), ((SELECT id FROM saas.plans WHERE code = 'pos_micro'), 'whatsapp_pro', 'WhatsApp Avanzado', FALSE, 100.00), -- ERP Básico add-ons ((SELECT id FROM saas.plans WHERE code = 'erp_basic'), 'accounting', 'Contabilidad', FALSE, 150.00), ((SELECT id FROM saas.plans WHERE code = 'erp_basic'), 'hr', 'Recursos Humanos', FALSE, 100.00), ((SELECT id FROM saas.plans WHERE code = 'erp_basic'), 'cfdi', 'Facturación CFDI', FALSE, 100.00), ((SELECT id FROM saas.plans WHERE code = 'erp_basic'), 'extra_user', 'Usuario Extra', FALSE, 50.00), ((SELECT id FROM saas.plans WHERE code = 'erp_basic'), 'multi_warehouse', 'Multi-Almacén', FALSE, 100.00), -- ERP Pro included features ((SELECT id FROM saas.plans WHERE code = 'erp_pro'), 'accounting', 'Contabilidad Básica', TRUE, NULL), ((SELECT id FROM saas.plans WHERE code = 'erp_pro'), 'multi_warehouse', 'Multi-Almacén', TRUE, NULL), ((SELECT id FROM saas.plans WHERE code = 'erp_pro'), 'cfdi', 'Facturación CFDI', FALSE, 100.00), ((SELECT id FROM saas.plans WHERE code = 'erp_pro'), 'hr', 'Recursos Humanos', FALSE, 100.00), ((SELECT id FROM saas.plans WHERE code = 'erp_pro'), 'extra_user', 'Usuario Extra', FALSE, 50.00); -- ============================================================================ -- VIEWS -- ============================================================================ -- View: Active subscriptions with plan details CREATE OR REPLACE VIEW saas.active_subscriptions_view AS SELECT s.id, s.tenant_id, t.business_name, t.email, p.code as plan_code, p.name as plan_name, s.billing_cycle, s.current_price, s.status, s.current_period_start, s.current_period_end, s.trial_ends_at, CASE WHEN s.status = 'trial' THEN s.trial_ends_at - CURRENT_TIMESTAMP ELSE s.current_period_end - CURRENT_TIMESTAMP END as time_remaining FROM saas.subscriptions s JOIN saas.tenants t ON s.tenant_id = t.id JOIN saas.plans p ON s.plan_id = p.id WHERE s.status IN ('trial', 'active', 'past_due'); -- View: Revenue by plan CREATE OR REPLACE VIEW saas.revenue_by_plan AS SELECT p.code as plan_code, p.name as plan_name, COUNT(s.id) as active_subscriptions, SUM(s.current_price) as monthly_revenue FROM saas.plans p LEFT JOIN saas.subscriptions s ON p.id = s.plan_id AND s.status IN ('active', 'trial') GROUP BY p.id, p.code, p.name ORDER BY monthly_revenue DESC NULLS LAST; -- ============================================================================ -- COMMENTS -- ============================================================================ COMMENT ON SCHEMA saas IS 'SaaS Layer - Billing, subscriptions, and multi-tenancy management'; COMMENT ON TABLE saas.plans IS 'Available subscription plans with pricing'; COMMENT ON TABLE saas.plan_features IS 'Features included or available as add-ons per plan'; COMMENT ON TABLE saas.tenants IS 'All SaaS customers/businesses'; COMMENT ON TABLE saas.subscriptions IS 'Active subscriptions per tenant'; COMMENT ON TABLE saas.invoices IS 'Generated invoices for billing'; COMMENT ON TABLE saas.payments IS 'Payment transactions'; COMMENT ON TABLE saas.payment_methods IS 'Saved payment methods per tenant'; COMMENT ON TABLE saas.usage_tracking IS 'Usage metrics for billing and limits'; COMMENT ON TABLE saas.support_tickets IS 'Customer support tickets'; -- ============================================================================ -- SCHEMA COMPLETE -- ============================================================================ DO $$ BEGIN RAISE NOTICE 'SaaS Billing schema created successfully!'; RAISE NOTICE 'Tables: 10 (plans, plan_features, tenants, subscriptions, invoices, invoice_items, payments, payment_methods, usage_tracking, support_tickets)'; RAISE NOTICE 'Default plans: POS Micro (100 MXN), ERP Básico (300 MXN), ERP Pro (500 MXN), Vertical (1000+ MXN)'; END $$;