26 KiB
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) |