Arquitectura del módulo de comisiones
Schema de base de datos, motor de cálculo, y lifecycle de los statements.
Schema: las 9 tablas
Todas en packages/db/src/schema/commissions/. Sin RLS — son datos del MSP, no del tenant.
commission_reps
Vendedores externos o staff MSP linkeado. Fuente: commission-reps.ts.
commission_reps (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id text REFERENCES "user"(id) ON DELETE SET NULL,
name varchar(255) NOT NULL,
email varchar(255) NOT NULL,
phone varchar(50),
tax_id varchar(100),
country varchar(2),
payment_method varchar(20),
payment_details jsonb,
default_recurring_percent numeric(5,2),
default_recurring_fixed_cents integer,
default_one_time_amount_cents integer,
default_basis varchar(10),
manager_id uuid REFERENCES commission_reps(id) ON DELETE SET NULL,
override_percent numeric(5,2) NOT NULL DEFAULT 0,
ref_code varchar(50) UNIQUE,
status varchar(20) NOT NULL DEFAULT 'active',
notes text,
created_at timestamp NOT NULL DEFAULT now(),
updated_at timestamp NOT NULL DEFAULT now(),
UNIQUE INDEX on email,
UNIQUE INDEX on user_id WHERE user_id IS NOT NULL,
INDEX on status,
INDEX on manager_id
)Columnas clave:
user_id: link opcional al usuario del sistema. Permite crear el rep antes de invitarlo al portal.nullpara reps externos sin cuenta aún.manager_id: self-FK para la jerarquía MLM.null= rep top-level.override_percent: % que este rep gana sobre las comisiones de sus reportes directos e indirectos.ref_code: slug único auto-generado al crear el rep (ej:maria-gonzalez). Se usa en URLs del landing.default_*: términos default que se aplican al crear asignaciones (cascade resolver).
commission_assignments
Vínculo entre un tenant y los reps que ganan comisión por él. Fuente: commission-assignments.ts.
commission_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
recurring_percent numeric(5,2) NOT NULL DEFAULT 0,
recurring_fixed_cents integer NOT NULL DEFAULT 0,
one_time_amount_cents integer NOT NULL DEFAULT 0,
basis varchar(10) NOT NULL DEFAULT 'gross',
starts_at date NOT NULL,
ends_at date,
one_time_paid_at date,
recurring_percent_base numeric(5,2),
recurring_percent_phones numeric(5,2),
recurring_percent_lines numeric(5,2),
recurring_percent_overage numeric(5,2),
status varchar(20) NOT NULL DEFAULT 'active',
notes text,
created_at timestamp NOT NULL DEFAULT now(),
updated_at timestamp NOT NULL DEFAULT now(),
INDEX on (tenant_id, status),
CHECK recurring_percent > 0 OR recurring_fixed_cents > 0 OR one_time_amount_cents > 0
)Columnas clave:
recurring_percent: % aplicado al revenue total del tenant (modo legacy / fallback cuando no hay % por componente).recurring_percent_base/phones/lines/overage: % por componente (F-CMS-3). Si alguno está set, el calculator entra en modo breakdown. Si todos son null, usarecurring_percentsobre el total.one_time_paid_at: se setea cuando el statement con el bono se finaliza. Previene duplicación.basis:gross(sobre el revenue bruto) onet(descontando Stripe fees).- No tiene
commission_rep_id— los reps están encommission_participants.
commission_participants
Splits N-arios: los reps que participan en una asignación con su porcentaje. Fuente: commission-participants.ts.
commission_participants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
assignment_id uuid NOT NULL REFERENCES commission_assignments(id) ON DELETE CASCADE,
commission_rep_id uuid NOT NULL REFERENCES commission_reps(id) ON DELETE RESTRICT,
share_percent numeric(5,2) NOT NULL,
role varchar(30),
created_at timestamp NOT NULL DEFAULT now(),
updated_at timestamp NOT NULL DEFAULT now(),
UNIQUE on (assignment_id, commission_rep_id),
INDEX on assignment_id,
INDEX on commission_rep_id,
CHECK share_percent > 0 AND share_percent <= 100
)Columnas clave:
share_percent: porcentaje de la comisión total de la asignación que le corresponde a este rep. La suma de todos los participants de una asignación debe ser exactamente 100 (validado por constraint trigger DEFERRABLE en DB).role: texto libre informativo. No afecta cálculo. Valores típicos:'AE','SDR','partner','primary'.
revenue_events
Ledger de ingresos por tenant. Una fila por evento de pago. Fuente: revenue-events.ts.
revenue_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
period varchar(7) NOT NULL, -- 'YYYY-MM'
original_currency varchar(3) NOT NULL, -- ISO 4217
original_amount_cents integer NOT NULL, -- puede ser negativo (refunds)
fx_rate numeric(18,8) NOT NULL DEFAULT 1,
amount_usd_cents integer NOT NULL, -- snapshot: round(original * fx_rate)
source varchar(20) NOT NULL, -- 'stripe_invoice' | 'wire' | 'check' | 'manual' | 'other'
source_ref text NOT NULL,
component varchar(20), -- 'base' | 'phones' | 'lines' | 'overage' | 'other' | null
recorded_by text REFERENCES "user"(id) ON DELETE SET NULL,
notes text,
created_at timestamp NOT NULL DEFAULT now(),
UNIQUE on (source, source_ref),
INDEX on (tenant_id, period),
INDEX on period,
INDEX on component
)Columnas clave:
source_ref: identificador único por evento. Para Stripe:${invoice.id}:${line.id}(un evento por línea del invoice desde F-CMS-3). Para refunds:${charge.id}.refund. Para manuales:manual:${uuid}.UNIQUE(source, source_ref): garantiza idempotencia. El replay de un webhook es unON CONFLICT DO NOTHING.component: etiqueta del componente de revenue.nullpara eventos pre-F-CMS-3 (legacy). El calculator trata los null como fallback al % global.- Eventos de
source='stripe_invoice'son inmutables via la API (errorSTRIPE_IMMUTABLE).
commission_settings
Singleton de configuración global. Fuente: commission-settings.ts.
commission_settings (
id integer PRIMARY KEY DEFAULT 1,
default_basis varchar(10) NOT NULL DEFAULT 'gross',
default_currency varchar(3) NOT NULL DEFAULT 'USD',
stripe_fee_percent numeric(5,2) NOT NULL DEFAULT 2.9,
stripe_fee_fixed_cents integer NOT NULL DEFAULT 30,
default_recurring_percent numeric(5,2),
default_recurring_fixed_cents integer,
default_one_time_amount_cents integer,
one_time_qualification_months integer NOT NULL DEFAULT 3,
cron_day_of_month integer NOT NULL DEFAULT 1,
cron_hour integer NOT NULL DEFAULT 3,
auto_finalize boolean NOT NULL DEFAULT false,
auto_email_on_finalize boolean NOT NULL DEFAULT false,
updated_at timestamp NOT NULL DEFAULT now(),
CHECK id = 1,
CHECK cron_day_of_month BETWEEN 1 AND 28,
CHECK cron_hour BETWEEN 0 AND 23
)id=1 es la única fila posible (CHECK constraint). El servicio garantiza que siempre existe vía seed migration y que solo se updatea, nunca se inserta. Cada tick del cron re-lee esta tabla — cambiar el cronDayOfMonth o cronHour toma efecto sin reiniciar el servidor.
commission_statements
Statement de comisiones de un rep para un período mensual. Fuente: commission-statements.ts.
commission_statements (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
commission_rep_id uuid NOT NULL REFERENCES commission_reps(id) ON DELETE RESTRICT,
period_start date NOT NULL,
period_end date NOT NULL,
status varchar(20) NOT NULL DEFAULT 'draft',
subtotal_recurring_cents integer NOT NULL DEFAULT 0,
subtotal_one_time_cents integer NOT NULL DEFAULT 0,
subtotal_override_cents integer NOT NULL DEFAULT 0,
total_cents integer NOT NULL DEFAULT 0,
currency varchar(3) NOT NULL DEFAULT 'USD',
finalized_at timestamp,
paid_at timestamp,
payment_method varchar(50),
payment_reference text,
payment_notes text,
created_at timestamp NOT NULL DEFAULT now(),
updated_at timestamp NOT NULL DEFAULT now(),
UNIQUE on (commission_rep_id, period_start),
INDEX on (status, period_start)
)UNIQUE(commission_rep_id, period_start): un solo statement por rep por período. El materialize puede reemplazar drafts pero no toca finalized/paid.
commission_line_items
Una línea calculada dentro de un statement. Snapshot inmutable. Fuente: commission-line-items.ts.
commission_line_items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
statement_id uuid NOT NULL REFERENCES commission_statements(id) ON DELETE CASCADE,
assignment_id uuid NOT NULL REFERENCES commission_assignments(id) ON DELETE RESTRICT,
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
kind varchar(30) NOT NULL, -- 'recurring_percent' | 'recurring_fixed' | 'one_time' | 'override'
basis varchar(10) NOT NULL, -- 'gross' | 'net'
revenue_usd_cents integer NOT NULL,
rate_applied numeric(5,2), -- null para fixed y one_time
component varchar(20), -- 'base' | 'phones' | 'lines' | 'overage' | 'other' | null
amount_cents integer NOT NULL,
parent_line_item_id uuid REFERENCES commission_line_items(id) ON DELETE CASCADE,
override_of_rep_id uuid REFERENCES commission_reps(id),
created_at timestamp NOT NULL DEFAULT now(),
INDEX on statement_id,
INDEX on assignment_id,
INDEX on parent_line_item_id
)Columnas clave:
kind='override': line item de comisión del manager.parent_line_item_idapunta al base line item. Si el base se borra (re-materialize de un draft), los overrides se borran en cascada.override_of_rep_id: rep cuya comisión fue la base (informativo para UI/PDF).component: hereda el componente del base line item en override line items.
commission_attribution_log
Audit log inmutable de cambios de atribución. Fuente: commission-attribution-log.ts.
commission_attribution_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id uuid REFERENCES leads(id) ON DELETE SET NULL,
tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL,
action varchar(40) NOT NULL,
old_rep_id uuid REFERENCES commission_reps(id),
new_rep_id uuid REFERENCES commission_reps(id),
changed_by text REFERENCES "user"(id) ON DELETE SET NULL,
notes text,
created_at timestamp NOT NULL DEFAULT now()
)action values: lead_ref_code_set, lead_admin_set, lead_converted, admin_reassigned.
seller_invitations
Magic-link invitations para el portal del vendedor. Fuente: seller-invitations.ts.
seller_invitations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
commission_rep_id uuid NOT NULL REFERENCES commission_reps(id) ON DELETE CASCADE,
token varchar(64) NOT NULL UNIQUE, -- 32 random bytes hex
email varchar(255) NOT NULL, -- snapshot de rep.email al invitar
expires_at timestamp NOT NULL, -- now() + 7 days
used_at timestamp, -- set al consume del token
invited_by text REFERENCES "user"(id) ON DELETE SET NULL,
created_at timestamp NOT NULL DEFAULT now(),
UNIQUE on token,
INDEX on commission_rep_id,
INDEX on expires_at WHERE used_at IS NULL
)Estados de una invitación:
- active:
used_at IS NULL AND expires_at > now() - expired:
used_at IS NULL AND expires_at <= now() - used:
used_at IS NOT NULL
Re-invitar invalida pending previos (UPDATE used_at = now()) antes de crear la nueva — no puede haber dos tokens válidos simultáneamente.
Algoritmo del calculator
apps/api/src/modules/commissions/commission-calculator.ts — función pura, sin side effects.
Modo preview
Retorna el array de CalculatedStatement[] sin tocar la DB. Usado por commissions.preview.calculate y el endpoint PDF de preview.
Modo materialize
Abre una transacción y persiste los statements calculados:
- Si existe un statement
draftpara(repId, periodStart): borra sus line items y reemplaza. - Si existe en
finalizedopaid: skip (inmutable). - Si no existe: insert nuevo.
- Devuelve
{ created, replaced, skipped, statementIds }.
Pasos detallados
1. Traer asignaciones activas cuyo rango [startsAt, endsAt ?? ∞]
intersecta [periodStart, periodEnd].
Filtros opcionales: commissionRepId, tenantId.
2. Por cada asignación A:
a. Traer sus commission_participants. Verificar SUM(sharePercent) == 100.
b. Sumar revenue_events.amountUsdCents del tenant en period=YYYY-MM.
Los refunds (negativos) se suman al neto.
c. Contar revenue_events con source='stripe_invoice' para la fórmula net.
d. Calcular netRevenue:
- basis='gross': netRevenue = totalGross
- basis='net':
netRevenue = round(totalGross × (1 - stripeFeePercent/100))
- (stripeInvoiceCount × stripeFeeFixedCents)
e. Bucketear revenue_events por component.
Si ALGUNO de los 4 recurringPercent por componente está set en A:
Modo breakdown — generar hasta 4 line items recurring_percent,
uno por componente con revenue > 0.
componentNet(c) = round(eventsByComponent(c) × netRevenue / totalGross)
Eventos con component=NULL o 'other' → fallback al recurringPercent global.
Si TODOS son null:
Modo legacy — 1 line item recurring_percent con % global sobre netRevenue total.
f. Si recurringFixedCents > 0 → 1 line item recurring_fixed.
g. Si one-time calificó este período (vesting check) → 1 line item one_time.
h. Por cada participant P (aplicando sharePercent/100 a cada line item de A):
i. Generar line items base prorateados.
ii. Override walk: subir por la cadena managerId.
Para cada manager M con overridePercent > 0:
Generar 1 line item override por cada base line item de P.
amount = round(baseAmount × overridePercent / 100).
Si amount == 0: skip.
Detener si se detecta ciclo (TS walk con Set de visitados).
3. Agrupar todos los line items por commissionRepId → CalculatedStatement[]:
subtotalRecurringCents = sum(recurring_percent, recurring_fixed)
subtotalOneTimeCents = sum(one_time)
subtotalOverrideCents = sum(override)
totalCents = subtotalRecurringCents + subtotalOneTimeCents + subtotalOverrideCents
4. Skip statements con totalCents == 0.Orden de inserción en materialize
Los override line items tienen FK a parent_line_item_id. El servicio:
- Inserta base line items primero.
- Guarda un mapping
{ tempId → realId }de los insertados. - Inserta override line items con
parentLineItemId = realIdresuelto del mapping.
Lifecycle de statements
stateDiagram-v2
[*] --> draft: materialize\n(manual o cron)
draft --> draft: re-materialize\n(reemplaza line items)
draft --> finalized: finalizeStatement\n(lock oneTimePaidAt)
draft --> [*]: deleteStatement\n(cascade line items)
finalized --> paid: markStatementPaid\n(paymentMethod requerido)
finalized --> finalized: IMMUTABLE
paid --> paid: IMMUTABLETransiciones y reglas
| Transición | Precondición | Side effects |
|---|---|---|
draft → draft (re-materialize) | Statement en draft | Borra y reinserta line items |
draft → finalized | Status == draft | Setea finalizedAt = now(). Setea oneTimePaidAt = periodStart en assignments con bono en este statement |
finalized → paid | Status == finalized | Setea paidAt = now(), guarda paymentMethod, paymentReference, paymentNotes |
| Delete | Status == draft | CASCADE borra line items |
Intentar finalize/markPaid/delete con status incorrecto devuelve error CONFLICT (409).
Multi-currency
Cada revenue_event guarda tres campos relacionados con la moneda:
| Campo | Descripción |
|---|---|
originalCurrency | ISO 4217 de la moneda del evento (ej: USD, MXN, CAD) |
originalAmountCents | Monto en la moneda original |
fxRate | Tasa de cambio al momento del evento (snapshot inmutable) |
amountUsdCents | round(originalAmountCents × fxRate) — la verdad que usa el calculator |
Para eventos de Stripe en USD: fxRate = 1. Para otras monedas: se consulta https://api.exchangerate.host/${date} y se cachea en Redis 24h (fx:${date}:${from}:${to}). Si la API de FX falla, el evento se encola en commissions-stripe-retry para reintento posterior (hasta 5 intentos con backoff exponencial).
El calculator siempre opera sobre amountUsdCents. Statements y line items están en USD.
Detección de ciclos en la jerarquía
Defense in depth:
Trigger DB
trg_no_manager_cycle— BEFORE INSERT OR UPDATE OF manager_id encommission_reps. Walk Postgres hacia arriba hasta encontrar el ciclo o llegar al root (manager_id IS NULL). Si detecta ciclo, RAISE EXCEPTION.Walk TS en
commission-reps.service.ts— antes del UPDATE, sube por la cadena de managers con unSetde visitados. Si el ID del rep aparece en la cadena, lanzaCommissionsError('CYCLE')con mensaje claro antes de llegar al DB.
El router convierte CYCLE en HTTP 400 con mensaje: "Cycle detected in commission_reps manager hierarchy".
Webhook de Stripe: revenue events
El handler invoice.paid del módulo stripe se extiende con un side-effect:
- Resolver
tenantIdvíastripe_customer_iden la tablatenants. - Por cada línea del invoice (
invoice.lines.data):- Clasificar
componentcomparandoline.price.idcon los price IDs en la tablaplans. - Crear un
revenue_eventconsourceRef = ${invoice.id}:${line.id}.
- Clasificar
- Si el invoice no tiene líneas: crear un evento único con
component='other'ysourceRef = invoice.id.
charge.refunded: crea un evento negativo con component='other' (refunds no se desglosan por componente). sourceRef = ${charge.id}.refund.
El side-effect falla silenciosamente (no bloquea el 200 a Stripe). Los fallos se encolan en BullMQ commissions-stripe-retry.