Skip to content

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.

sql
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. null para 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.

sql
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, usa recurring_percent sobre 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) o net (descontando Stripe fees).
  • No tiene commission_rep_id — los reps están en commission_participants.

commission_participants

Splits N-arios: los reps que participan en una asignación con su porcentaje. Fuente: commission-participants.ts.

sql
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.

sql
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 un ON CONFLICT DO NOTHING.
  • component: etiqueta del componente de revenue. null para 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 (error STRIPE_IMMUTABLE).

commission_settings

Singleton de configuración global. Fuente: commission-settings.ts.

sql
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.

sql
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.

sql
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_id apunta 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.

sql
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.

sql
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 draft para (repId, periodStart): borra sus line items y reemplaza.
  • Si existe en finalized o paid: 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:

  1. Inserta base line items primero.
  2. Guarda un mapping { tempId → realId } de los insertados.
  3. Inserta override line items con parentLineItemId = realId resuelto del mapping.

Lifecycle de statements

mermaid
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: IMMUTABLE

Transiciones y reglas

TransiciónPrecondiciónSide effects
draftdraft (re-materialize)Statement en draftBorra y reinserta line items
draftfinalizedStatus == draftSetea finalizedAt = now(). Setea oneTimePaidAt = periodStart en assignments con bono en este statement
finalizedpaidStatus == finalizedSetea paidAt = now(), guarda paymentMethod, paymentReference, paymentNotes
DeleteStatus == draftCASCADE 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:

CampoDescripción
originalCurrencyISO 4217 de la moneda del evento (ej: USD, MXN, CAD)
originalAmountCentsMonto en la moneda original
fxRateTasa de cambio al momento del evento (snapshot inmutable)
amountUsdCentsround(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:

  1. Trigger DB trg_no_manager_cycle — BEFORE INSERT OR UPDATE OF manager_id en commission_reps. Walk Postgres hacia arriba hasta encontrar el ciclo o llegar al root (manager_id IS NULL). Si detecta ciclo, RAISE EXCEPTION.

  2. Walk TS en commission-reps.service.ts — antes del UPDATE, sube por la cadena de managers con un Set de visitados. Si el ID del rep aparece en la cadena, lanza CommissionsError('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:

  1. Resolver tenantId vía stripe_customer_id en la tabla tenants.
  2. Por cada línea del invoice (invoice.lines.data):
    • Clasificar component comparando line.price.id con los price IDs en la tabla plans.
    • Crear un revenue_event con sourceRef = ${invoice.id}:${line.id}.
  3. Si el invoice no tiene líneas: crear un evento único con component='other' y sourceRef = 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.

Documentación de SipSop. Producto operado por Sopinf Tech LLC.