erp-core/docs/04-modelado/database-design/DDL-SPEC-billing.md

26 KiB

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

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.

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.

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.

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.

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.

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.

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.

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.

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.

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

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

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

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

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

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

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)