# DDL Specification: billing Schema ## Identificacion | Campo | Valor | |-------|-------| | **Schema** | billing | | **Modulo** | MGN-015 Billing y Suscripciones | | **Version** | 1.0 | | **Fecha** | 2025-12-05 | | **Estado** | Ready | --- ## Diagrama ER ```mermaid erDiagram tenants ||--o| tenant_owners : has_owner tenant_owners ||--o{ payment_methods : has tenants ||--o{ invoices : receives tenants ||--o{ payments : makes tenants ||--o{ usage_records : tracks invoices ||--o{ invoice_lines : contains invoices ||--o{ payments : paid_by coupons ||--o{ coupon_redemptions : used_in tenants ||--o{ coupon_redemptions : redeems tenant_owners { uuid id PK uuid tenant_id FK UK uuid user_id FK string fiscal_name string tax_id jsonb billing_address timestamp created_at } payment_methods { uuid id PK uuid tenant_owner_id FK string provider string method_type string token jsonb card_info boolean is_default boolean is_active } invoices { uuid id PK uuid tenant_id FK string invoice_number UK string period decimal subtotal decimal tax decimal total string status timestamp due_date timestamp paid_at } invoice_lines { uuid id PK uuid invoice_id FK string description int quantity decimal unit_price decimal amount } payments { uuid id PK uuid tenant_id FK uuid invoice_id FK uuid payment_method_id FK decimal amount string status string external_id timestamp created_at } coupons { uuid id PK string code UK string discount_type decimal discount_value int max_uses int current_uses timestamp valid_until boolean is_active } coupon_redemptions { uuid id PK uuid coupon_id FK uuid tenant_id FK timestamp redeemed_at } usage_records { uuid id PK uuid tenant_id FK string metric_type bigint value string period timestamp recorded_at } ``` --- ## Tablas ### 1. tenant_owners Propietarios de cuenta que gestionan billing. ```sql CREATE TABLE billing.tenant_owners ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL UNIQUE REFERENCES core_tenants.tenants(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES core_users.users(id), -- Datos fiscales fiscal_name VARCHAR(200) NOT NULL, -- Nombre o razon social tax_id VARCHAR(50), -- RFC/NIT/VAT tax_system VARCHAR(50), -- Regimen fiscal (para CFDI) -- Direccion de facturacion billing_address JSONB NOT NULL DEFAULT '{}'::jsonb, -- Ejemplo: {"street": "...", "city": "...", "zip": "...", "country": "MX", "state": "JAL"} -- Datos de contacto para facturacion billing_email VARCHAR(200), billing_phone VARCHAR(50), created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_tenant_owners_tax_id CHECK (tax_id IS NULL OR char_length(tax_id) >= 8) ); -- Indices CREATE INDEX idx_tenant_owners_user ON billing.tenant_owners(user_id); CREATE INDEX idx_tenant_owners_tax_id ON billing.tenant_owners(tax_id) WHERE tax_id IS NOT NULL; -- Trigger para updated_at CREATE TRIGGER trg_tenant_owners_updated_at BEFORE UPDATE ON billing.tenant_owners FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- RLS ALTER TABLE billing.tenant_owners ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_owners_tenant_isolation ON billing.tenant_owners FOR ALL USING (tenant_id = current_setting('app.tenant_id')::uuid); -- Comentarios COMMENT ON TABLE billing.tenant_owners IS 'Propietarios de cuenta para billing (MGN-015)'; COMMENT ON COLUMN billing.tenant_owners.fiscal_name IS 'Nombre legal para facturacion'; COMMENT ON COLUMN billing.tenant_owners.tax_system IS 'Regimen fiscal para CFDI Mexico'; ``` --- ### 2. payment_methods Metodos de pago tokenizados. ```sql CREATE TABLE billing.payment_methods ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_owner_id UUID NOT NULL REFERENCES billing.tenant_owners(id) ON DELETE CASCADE, -- Provider info provider VARCHAR(20) NOT NULL, -- stripe, conekta, mercadopago method_type VARCHAR(20) NOT NULL, -- card, bank_account, oxxo token VARCHAR(200) NOT NULL, -- Token del gateway (nunca PAN) -- Informacion visible (no sensible) card_info JSONB DEFAULT '{}'::jsonb, -- Ejemplo: {"brand": "visa", "last4": "4242", "exp_month": 12, "exp_year": 2025} -- Estado is_default BOOLEAN NOT NULL DEFAULT false, is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMPTZ, CONSTRAINT chk_payment_methods_provider CHECK (provider IN ('stripe', 'conekta', 'mercadopago', 'openpay')), CONSTRAINT chk_payment_methods_type CHECK (method_type IN ('card', 'bank_account', 'oxxo', 'spei')) ); -- Indices CREATE INDEX idx_payment_methods_owner ON billing.payment_methods(tenant_owner_id); CREATE INDEX idx_payment_methods_default ON billing.payment_methods(tenant_owner_id, is_default) WHERE is_default = true AND is_active = true; -- Trigger para updated_at CREATE TRIGGER trg_payment_methods_updated_at BEFORE UPDATE ON billing.payment_methods FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- Comentarios COMMENT ON TABLE billing.payment_methods IS 'Metodos de pago tokenizados (PCI compliant)'; COMMENT ON COLUMN billing.payment_methods.token IS 'Token del gateway, nunca almacenar datos de tarjeta'; ``` --- ### 3. invoices Facturas generadas para tenants. ```sql CREATE TABLE billing.invoices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id), subscription_id UUID REFERENCES core_tenants.subscriptions(id), -- Identificacion invoice_number VARCHAR(50) NOT NULL UNIQUE, period VARCHAR(7) NOT NULL, -- YYYY-MM -- Montos subtotal DECIMAL(10,2) NOT NULL, discount DECIMAL(10,2) NOT NULL DEFAULT 0, tax DECIMAL(10,2) NOT NULL DEFAULT 0, total DECIMAL(10,2) NOT NULL, currency VARCHAR(3) NOT NULL DEFAULT 'USD', -- Estado status VARCHAR(20) NOT NULL DEFAULT 'draft', due_date TIMESTAMPTZ NOT NULL, paid_at TIMESTAMPTZ, -- Datos fiscales (snapshot al momento de facturar) billing_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, -- Copia de tenant_owners al momento de facturar -- Integracion CFDI (Mexico) cfdi_uuid VARCHAR(36), -- UUID del timbrado cfdi_xml TEXT, -- XML completo cfdi_pdf_url VARCHAR(500), -- URL del PDF -- Integracion gateway external_invoice_id VARCHAR(100), created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_invoices_status CHECK (status IN ('draft', 'open', 'paid', 'void', 'uncollectible')), CONSTRAINT chk_invoices_total CHECK (total >= 0), CONSTRAINT chk_invoices_period CHECK (period ~ '^\d{4}-\d{2}$') ); -- Indices CREATE INDEX idx_invoices_tenant ON billing.invoices(tenant_id); CREATE INDEX idx_invoices_status ON billing.invoices(status); CREATE INDEX idx_invoices_due_date ON billing.invoices(due_date) WHERE status = 'open'; CREATE INDEX idx_invoices_period ON billing.invoices(period DESC); CREATE INDEX idx_invoices_cfdi ON billing.invoices(cfdi_uuid) WHERE cfdi_uuid IS NOT NULL; -- Trigger para updated_at CREATE TRIGGER trg_invoices_updated_at BEFORE UPDATE ON billing.invoices FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- RLS ALTER TABLE billing.invoices ENABLE ROW LEVEL SECURITY; CREATE POLICY invoices_tenant_isolation ON billing.invoices FOR ALL USING (tenant_id = current_setting('app.tenant_id')::uuid); -- Comentarios COMMENT ON TABLE billing.invoices IS 'Facturas de suscripcion SaaS'; COMMENT ON COLUMN billing.invoices.cfdi_uuid IS 'UUID de timbrado SAT (Mexico)'; COMMENT ON COLUMN billing.invoices.billing_snapshot IS 'Snapshot de datos fiscales al momento de facturar'; ``` --- ### 4. invoice_lines Lineas de detalle de factura. ```sql CREATE TABLE billing.invoice_lines ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), invoice_id UUID NOT NULL REFERENCES billing.invoices(id) ON DELETE CASCADE, line_type VARCHAR(20) NOT NULL DEFAULT 'subscription', description VARCHAR(500) NOT NULL, quantity INT NOT NULL DEFAULT 1, unit_price DECIMAL(10,2) NOT NULL, amount DECIMAL(10,2) NOT NULL, -- Metadata product_code VARCHAR(50), -- Codigo de producto (para CFDI) metadata JSONB DEFAULT '{}'::jsonb, sort_order INT NOT NULL DEFAULT 0, CONSTRAINT chk_invoice_lines_type CHECK (line_type IN ('subscription', 'seat', 'addon', 'usage', 'discount', 'tax')), CONSTRAINT chk_invoice_lines_quantity CHECK (quantity != 0), CONSTRAINT chk_invoice_lines_amount CHECK (amount = quantity * unit_price) ); -- Indices CREATE INDEX idx_invoice_lines_invoice ON billing.invoice_lines(invoice_id); -- Comentarios COMMENT ON TABLE billing.invoice_lines IS 'Lineas de detalle de facturas'; COMMENT ON COLUMN billing.invoice_lines.line_type IS 'Tipo: subscription, seat (extra), addon, usage, discount, tax'; ``` --- ### 5. payments Registro de pagos y transacciones. ```sql CREATE TABLE billing.payments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id), invoice_id UUID REFERENCES billing.invoices(id), payment_method_id UUID REFERENCES billing.payment_methods(id), -- Monto amount DECIMAL(10,2) NOT NULL, currency VARCHAR(3) NOT NULL DEFAULT 'USD', -- Estado status VARCHAR(20) NOT NULL DEFAULT 'pending', failure_reason VARCHAR(500), -- Integracion gateway external_payment_id VARCHAR(100), external_charge_id VARCHAR(100), gateway_response JSONB DEFAULT '{}'::jsonb, -- Intentos attempt_number INT NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, processed_at TIMESTAMPTZ, CONSTRAINT chk_payments_status CHECK (status IN ('pending', 'processing', 'succeeded', 'failed', 'refunded', 'partially_refunded')), CONSTRAINT chk_payments_amount CHECK (amount > 0) ); -- Indices CREATE INDEX idx_payments_tenant ON billing.payments(tenant_id); CREATE INDEX idx_payments_invoice ON billing.payments(invoice_id); CREATE INDEX idx_payments_status ON billing.payments(status); CREATE INDEX idx_payments_external ON billing.payments(external_payment_id); -- Trigger para updated_at CREATE TRIGGER trg_payments_updated_at BEFORE UPDATE ON billing.payments FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- RLS ALTER TABLE billing.payments ENABLE ROW LEVEL SECURITY; CREATE POLICY payments_tenant_isolation ON billing.payments FOR ALL USING (tenant_id = current_setting('app.tenant_id')::uuid); -- Comentarios COMMENT ON TABLE billing.payments IS 'Registro de pagos y transacciones'; COMMENT ON COLUMN billing.payments.gateway_response IS 'Respuesta completa del gateway (debug)'; ``` --- ### 6. coupons Cupones de descuento. ```sql CREATE TABLE billing.coupons ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), code VARCHAR(50) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, description VARCHAR(500), -- Descuento discount_type VARCHAR(20) NOT NULL, discount_value DECIMAL(10,2) NOT NULL, max_discount DECIMAL(10,2), -- Tope maximo para porcentajes -- Restricciones applicable_plans UUID[], -- NULL = todos los planes min_seats INT, -- Minimo de asientos para aplicar -- Limites max_uses INT, -- NULL = ilimitado max_uses_per_tenant INT DEFAULT 1, current_uses INT NOT NULL DEFAULT 0, -- Vigencia valid_from TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, valid_until TIMESTAMPTZ, -- Duracion del descuento duration_months INT, -- NULL = solo primer pago, >0 = X meses is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID, CONSTRAINT chk_coupons_type CHECK (discount_type IN ('percentage', 'fixed_amount')), CONSTRAINT chk_coupons_value CHECK ( (discount_type = 'percentage' AND discount_value > 0 AND discount_value <= 100) OR (discount_type = 'fixed_amount' AND discount_value > 0) ), CONSTRAINT chk_coupons_code CHECK (code ~ '^[A-Z0-9_-]+$') ); -- Indices CREATE INDEX idx_coupons_code ON billing.coupons(code); CREATE INDEX idx_coupons_active ON billing.coupons(is_active, valid_until); -- Comentarios COMMENT ON TABLE billing.coupons IS 'Cupones de descuento para suscripciones'; COMMENT ON COLUMN billing.coupons.duration_months IS 'Meses de duracion del descuento, NULL = solo primer pago'; ``` --- ### 7. coupon_redemptions Historial de cupones usados. ```sql CREATE TABLE billing.coupon_redemptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), coupon_id UUID NOT NULL REFERENCES billing.coupons(id), tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id), subscription_id UUID REFERENCES core_tenants.subscriptions(id), -- Monto del descuento aplicado discount_applied DECIMAL(10,2) NOT NULL, -- Duracion restante months_remaining INT, redeemed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMPTZ, CONSTRAINT uq_coupon_tenant UNIQUE (coupon_id, tenant_id) ); -- Indices CREATE INDEX idx_coupon_redemptions_tenant ON billing.coupon_redemptions(tenant_id); CREATE INDEX idx_coupon_redemptions_coupon ON billing.coupon_redemptions(coupon_id); CREATE INDEX idx_coupon_redemptions_active ON billing.coupon_redemptions(tenant_id, expires_at) WHERE expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP; -- RLS ALTER TABLE billing.coupon_redemptions ENABLE ROW LEVEL SECURITY; CREATE POLICY coupon_redemptions_tenant_isolation ON billing.coupon_redemptions FOR ALL USING (tenant_id = current_setting('app.tenant_id')::uuid); -- Comentarios COMMENT ON TABLE billing.coupon_redemptions IS 'Historial de cupones aplicados por tenant'; ``` --- ### 8. usage_records Registro de uso para facturacion basada en consumo. ```sql CREATE TABLE billing.usage_records ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE, -- Tipo de metrica metric_type VARCHAR(50) NOT NULL, -- Ejemplos: 'users', 'storage_bytes', 'api_calls', 'ai_tokens', 'whatsapp_conversations' -- Valor value BIGINT NOT NULL, delta BIGINT, -- Cambio respecto al registro anterior -- Periodo period VARCHAR(7) NOT NULL, -- YYYY-MM period_start TIMESTAMPTZ NOT NULL, period_end TIMESTAMPTZ NOT NULL, -- Metadata recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, source VARCHAR(50), -- sistema que registro la metrica CONSTRAINT uq_usage_records UNIQUE (tenant_id, metric_type, period), CONSTRAINT chk_usage_records_period CHECK (period ~ '^\d{4}-\d{2}$'), CONSTRAINT chk_usage_records_value CHECK (value >= 0) ); -- Indices CREATE INDEX idx_usage_records_tenant ON billing.usage_records(tenant_id); CREATE INDEX idx_usage_records_period ON billing.usage_records(period DESC); CREATE INDEX idx_usage_records_metric ON billing.usage_records(metric_type); -- RLS ALTER TABLE billing.usage_records ENABLE ROW LEVEL SECURITY; CREATE POLICY usage_records_tenant_isolation ON billing.usage_records FOR ALL USING (tenant_id = current_setting('app.tenant_id')::uuid); -- Comentarios COMMENT ON TABLE billing.usage_records IS 'Metricas de uso para facturacion por consumo'; COMMENT ON COLUMN billing.usage_records.metric_type IS 'Tipo: users, storage_bytes, api_calls, ai_tokens, whatsapp_conversations'; ``` --- ### 9. subscription_history Historial de cambios en suscripciones. ```sql CREATE TABLE billing.subscription_history ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id), subscription_id UUID NOT NULL REFERENCES core_tenants.subscriptions(id), -- Tipo de evento event_type VARCHAR(50) NOT NULL, -- created, upgraded, downgraded, seats_added, seats_removed, renewed, canceled, reactivated -- Cambios from_plan_id UUID REFERENCES core_tenants.plans(id), to_plan_id UUID REFERENCES core_tenants.plans(id), from_quantity INT, to_quantity INT, -- Monto del cambio (prorrateado) amount_change DECIMAL(10,2), currency VARCHAR(3) DEFAULT 'USD', -- Metadata reason VARCHAR(500), performed_by UUID, performed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_subscription_history_event CHECK (event_type IN ( 'created', 'upgraded', 'downgraded', 'seats_added', 'seats_removed', 'renewed', 'canceled', 'reactivated', 'trial_started', 'trial_ended', 'payment_failed' )) ); -- Indices CREATE INDEX idx_subscription_history_tenant ON billing.subscription_history(tenant_id); CREATE INDEX idx_subscription_history_subscription ON billing.subscription_history(subscription_id); CREATE INDEX idx_subscription_history_event ON billing.subscription_history(event_type); CREATE INDEX idx_subscription_history_date ON billing.subscription_history(performed_at DESC); -- RLS ALTER TABLE billing.subscription_history ENABLE ROW LEVEL SECURITY; CREATE POLICY subscription_history_tenant_isolation ON billing.subscription_history FOR ALL USING (tenant_id = current_setting('app.tenant_id')::uuid); -- Comentarios COMMENT ON TABLE billing.subscription_history IS 'Historial completo de cambios en suscripciones'; ``` --- ## Funciones de Utilidad ### Generar Numero de Factura ```sql CREATE OR REPLACE FUNCTION billing.generate_invoice_number() RETURNS VARCHAR AS $$ DECLARE v_year VARCHAR(4); v_sequence INT; v_invoice_number VARCHAR(50); BEGIN v_year := to_char(CURRENT_DATE, 'YYYY'); -- Obtener siguiente secuencia del año SELECT COALESCE(MAX( CAST(SPLIT_PART(invoice_number, '-', 2) AS INT) ), 0) + 1 INTO v_sequence FROM billing.invoices WHERE invoice_number LIKE 'INV-' || v_year || '-%'; v_invoice_number := 'INV-' || v_year || '-' || LPAD(v_sequence::text, 6, '0'); RETURN v_invoice_number; END; $$ LANGUAGE plpgsql; ``` ### Aplicar Cupon ```sql CREATE OR REPLACE FUNCTION billing.apply_coupon( p_tenant_id UUID, p_coupon_code VARCHAR ) RETURNS TABLE ( success BOOLEAN, discount_type VARCHAR, discount_value DECIMAL, message VARCHAR ) AS $$ DECLARE v_coupon RECORD; BEGIN -- Buscar cupon SELECT * INTO v_coupon FROM billing.coupons WHERE code = UPPER(p_coupon_code) AND is_active = true AND (valid_until IS NULL OR valid_until > CURRENT_TIMESTAMP) AND (max_uses IS NULL OR current_uses < max_uses); IF v_coupon IS NULL THEN RETURN QUERY SELECT false, NULL::VARCHAR, NULL::DECIMAL, 'Cupon invalido o expirado'::VARCHAR; RETURN; END IF; -- Verificar si ya fue usado por este tenant IF EXISTS ( SELECT 1 FROM billing.coupon_redemptions WHERE coupon_id = v_coupon.id AND tenant_id = p_tenant_id ) THEN RETURN QUERY SELECT false, NULL::VARCHAR, NULL::DECIMAL, 'Cupon ya utilizado'::VARCHAR; RETURN; END IF; -- Registrar uso INSERT INTO billing.coupon_redemptions (coupon_id, tenant_id, discount_applied, months_remaining, expires_at) VALUES ( v_coupon.id, p_tenant_id, v_coupon.discount_value, v_coupon.duration_months, CASE WHEN v_coupon.duration_months IS NOT NULL THEN CURRENT_TIMESTAMP + (v_coupon.duration_months || ' months')::interval ELSE NULL END ); -- Incrementar contador UPDATE billing.coupons SET current_uses = current_uses + 1 WHERE id = v_coupon.id; RETURN QUERY SELECT true, v_coupon.discount_type, v_coupon.discount_value, 'Cupon aplicado correctamente'::VARCHAR; END; $$ LANGUAGE plpgsql; ``` ### Calcular Monto de Factura ```sql CREATE OR REPLACE FUNCTION billing.calculate_invoice_amount( p_tenant_id UUID, p_period VARCHAR ) RETURNS TABLE ( subtotal DECIMAL, discount DECIMAL, tax DECIMAL, total DECIMAL, lines JSONB ) AS $$ DECLARE v_subscription RECORD; v_plan RECORD; v_base_amount DECIMAL; v_seats_amount DECIMAL; v_discount DECIMAL := 0; v_tax_rate DECIMAL := 0.16; -- 16% IVA Mexico v_lines JSONB := '[]'::jsonb; BEGIN -- Obtener suscripcion activa SELECT s.*, p.name AS plan_name, p.base_price, p.included_seats, p.per_seat_price INTO v_subscription FROM core_tenants.subscriptions s JOIN core_tenants.plans p ON p.id = s.plan_id WHERE s.tenant_id = p_tenant_id AND s.status IN ('active', 'trialing') ORDER BY s.created_at DESC LIMIT 1; IF v_subscription IS NULL THEN RETURN QUERY SELECT 0::DECIMAL, 0::DECIMAL, 0::DECIMAL, 0::DECIMAL, '[]'::JSONB; RETURN; END IF; -- Linea base del plan v_base_amount := v_subscription.base_price; v_lines := v_lines || jsonb_build_object( 'type', 'subscription', 'description', 'Plan ' || v_subscription.plan_name, 'quantity', 1, 'unit_price', v_base_amount, 'amount', v_base_amount ); -- Linea de asientos adicionales IF v_subscription.quantity > v_subscription.included_seats THEN v_seats_amount := (v_subscription.quantity - v_subscription.included_seats) * v_subscription.per_seat_price; v_lines := v_lines || jsonb_build_object( 'type', 'seat', 'description', 'Usuarios adicionales', 'quantity', v_subscription.quantity - v_subscription.included_seats, 'unit_price', v_subscription.per_seat_price, 'amount', v_seats_amount ); ELSE v_seats_amount := 0; END IF; -- Verificar cupon activo SELECT cr.discount_applied INTO v_discount FROM billing.coupon_redemptions cr JOIN billing.coupons c ON c.id = cr.coupon_id WHERE cr.tenant_id = p_tenant_id AND (cr.expires_at IS NULL OR cr.expires_at > CURRENT_TIMESTAMP) AND (cr.months_remaining IS NULL OR cr.months_remaining > 0); -- Calcular totales subtotal := v_base_amount + v_seats_amount; discount := LEAST(v_discount, subtotal); -- No puede ser mayor al subtotal tax := (subtotal - discount) * v_tax_rate; total := subtotal - discount + tax; RETURN QUERY SELECT subtotal, discount, tax, total, v_lines; END; $$ LANGUAGE plpgsql; ``` --- ## Vistas ### Vista: Resumen de Billing por Tenant ```sql CREATE VIEW billing.vw_tenant_billing_summary AS SELECT t.id AS tenant_id, t.name AS tenant_name, bo.fiscal_name, bo.tax_id, p.name AS plan_name, s.quantity AS seats, s.status AS subscription_status, ( SELECT COUNT(*) FROM billing.invoices i WHERE i.tenant_id = t.id AND i.status = 'paid' ) AS paid_invoices_count, ( SELECT COALESCE(SUM(i.total), 0) FROM billing.invoices i WHERE i.tenant_id = t.id AND i.status = 'paid' ) AS total_paid, ( SELECT i.total FROM billing.invoices i WHERE i.tenant_id = t.id AND i.status = 'open' ORDER BY i.due_date LIMIT 1 ) AS pending_amount, ( SELECT i.due_date FROM billing.invoices i WHERE i.tenant_id = t.id AND i.status = 'open' ORDER BY i.due_date LIMIT 1 ) AS next_due_date FROM core_tenants.tenants t LEFT JOIN billing.tenant_owners bo ON bo.tenant_id = t.id LEFT JOIN core_tenants.subscriptions s ON s.tenant_id = t.id AND s.status IN ('active', 'trialing') LEFT JOIN core_tenants.plans p ON p.id = s.plan_id WHERE t.deleted_at IS NULL; ``` ### Vista: Uso Actual vs Limites ```sql CREATE VIEW billing.vw_current_usage AS SELECT t.id AS tenant_id, t.name AS tenant_name, p.name AS plan_name, -- Usuarios (SELECT COUNT(*) FROM core_users.users u WHERE u.tenant_id = t.id AND u.deleted_at IS NULL) AS current_users, COALESCE(p.max_seats, -1) AS max_users, -- Tokens IA COALESCE(( SELECT value FROM billing.usage_records ur WHERE ur.tenant_id = t.id AND ur.metric_type = 'ai_tokens' AND ur.period = to_char(CURRENT_DATE, 'YYYY-MM') ), 0) AS ai_tokens_used, COALESCE((p.features->>'ai_monthly_token_limit')::INT, 0) AS ai_tokens_limit, -- WhatsApp COALESCE(( SELECT value FROM billing.usage_records ur WHERE ur.tenant_id = t.id AND ur.metric_type = 'whatsapp_conversations' AND ur.period = to_char(CURRENT_DATE, 'YYYY-MM') ), 0) AS whatsapp_conversations FROM core_tenants.tenants t JOIN core_tenants.subscriptions s ON s.tenant_id = t.id AND s.status IN ('active', 'trialing') JOIN core_tenants.plans p ON p.id = s.plan_id WHERE t.deleted_at IS NULL; ``` --- ## Data Seed ### Cupones de Ejemplo ```sql INSERT INTO billing.coupons (code, name, description, discount_type, discount_value, max_uses, duration_months) VALUES ('WELCOME20', 'Bienvenida 20%', '20% de descuento primer mes', 'percentage', 20, NULL, 1), ('ANNUAL50', 'Descuento Anual', '50% descuento pago anual', 'percentage', 50, NULL, 12), ('STARTUP', 'Programa Startups', '3 meses gratis', 'percentage', 100, 100, 3); ``` --- ## Resumen de Tablas | Tabla | Columnas | Descripcion | |-------|----------|-------------| | tenant_owners | 11 | Propietarios de cuenta | | payment_methods | 12 | Metodos de pago tokenizados | | invoices | 18 | Facturas de suscripcion | | invoice_lines | 9 | Lineas de factura | | payments | 15 | Pagos y transacciones | | coupons | 16 | Cupones de descuento | | coupon_redemptions | 7 | Cupones aplicados | | usage_records | 10 | Metricas de uso | | subscription_history | 13 | Historial de cambios | **Total: 9 tablas, 111 columnas** --- ## Historial | Version | Fecha | Autor | Cambios | |---------|-------|-------|---------| | 1.0 | 2025-12-05 | System | Creacion inicial (MGN-015) |