Skip to content

API Reference — commissions module

All tRPC procedures and REST endpoints of the module. Verified against the code in apps/api/src/modules/commissions/.


Conventions

  • Auth gate: all procedures in the commissions.* router use adminProcedure + requirePermission('commissions.manage'). Those in the seller.* router use sellerProcedure (or publicProcedure for invitations).
  • Errors: mapped to TRPCError with standard HTTP codes. See Error codes.
  • Input: validated with Valibot. Schemas in apps/api/src/modules/commissions/commissions.schema.ts.
  • Date format: YYYY-MM-DD for dates, YYYY-MM for periods.
  • Amounts: always in USD cents (integers). 1099 = $10.99.

Router commissions.*

commissions.settings

commissions.settings.get

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   none
Output:  CommissionSettings (singleton row id=1)
ts
// Output shape
{
  id: 1,
  defaultBasis: 'gross' | 'net',
  defaultCurrency: string,           // 'USD'
  stripeFeePercent: string,          // numeric string, e.g. '2.9'
  stripeFeeFixedCents: number,       // 30
  defaultRecurringPercent: string | null,
  defaultRecurringFixedCents: number | null,
  defaultOneTimeAmountCents: number | null,
  oneTimeQualificationMonths: number,
  cronDayOfMonth: number,
  cronHour: number,
  autoFinalize: boolean,
  autoEmailOnFinalize: boolean,
  updatedAt: string,
}

commissions.settings.update

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   Partial<CommissionSettings> (without id)
Output:  Updated CommissionSettings
Errors:  BAD_REQUEST if validation fails

commissions.reps

commissions.reps.list

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   { search?: string, status?: 'active' | 'inactive', managerId?: string | null }
Output:  CommissionRep[]

commissions.reps.get

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  CommissionRep + manager: CommissionRep | null + directReports: CommissionRep[]
Errors:  NOT_FOUND

commissions.reps.create

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:
  {
    name: string,
    email: string,
    phone?: string,
    taxId?: string,
    country?: string,
    paymentMethod?: 'ach' | 'wire' | 'check' | 'paypal' | 'wise' | 'other',
    paymentDetails?: object,
    defaultRecurringPercent?: string,
    defaultRecurringFixedCents?: number,
    defaultOneTimeAmountCents?: number,
    defaultBasis?: 'gross' | 'net',
    refCode?: string,     // if null, auto-generated from name
    notes?: string,
  }
Output:  Created CommissionRep
Errors:  EMAIL_EXISTS, REF_CODE_EXISTS

commissions.reps.update

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid, patch: Partial<CreateRepInput> }
Output:  Updated CommissionRep
Errors:  NOT_FOUND, EMAIL_EXISTS, REF_CODE_EXISTS

commissions.reps.delete

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  { success: true }
Errors:  NOT_FOUND, CONFLICT (if has finalized or paid statements)

commissions.reps.setManager

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid, managerId: uuid | null }
Output:  Updated CommissionRep
Errors:  NOT_FOUND, CYCLE (if it forms a cycle in the hierarchy)

commissions.reps.setOverride

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid, overridePercent: string }  // numeric string
Output:  Updated CommissionRep
Errors:  NOT_FOUND

commissions.reps.invite

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  { invitationId: uuid, setupUrl: string, expiresAt: string }
Errors:  NOT_FOUND, ALREADY_LINKED (if already has userId)

Sends invitation email (fire-and-forget). Response includes setupUrl for copy/paste if email fails.

commissions.reps.pendingInvitation

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  SellerInvitation | null  // most recent active invitation, or null

commissions.reps.revokeAccess

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  { success: true }
Errors:  NOT_FOUND

Sets commission_reps.userId = null. Does not delete the Better Auth user.


commissions.assignments

commissions.assignments.list

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   { commissionRepId?: uuid, tenantId?: uuid, status?: 'active' | 'ended' }
Output:  CommissionAssignment[]

commissions.assignments.get

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  CommissionAssignment + participants: CommissionParticipant[]
Errors:  NOT_FOUND

commissions.assignments.create

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:
  {
    tenantId: uuid,
    participants: Array<{ commissionRepId: uuid, sharePercent: string, role?: string }>,
    recurringPercent?: string,
    recurringFixedCents?: number,
    oneTimeAmountCents?: number,
    basis?: 'gross' | 'net',
    startsAt: string,         // YYYY-MM-DD
    endsAt?: string,
    notes?: string,
    recurringPercentBase?: string,
    recurringPercentPhones?: string,
    recurringPercentLines?: string,
    recurringPercentOverage?: string,
  }
Output:  Created CommissionAssignment + participants
Errors:  NO_TERMS, INVALID_PARTICIPANTS (shares ≠ 100, empty array, duplicate rep)

Terms are resolved by cascade: input → rep default → global default.

commissions.assignments.update

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid, patch: Partial<CreateAssignmentInput excl. tenantId> }
Output:  Updated CommissionAssignment
Errors:  NOT_FOUND

Does not allow changing tenantId.

commissions.assignments.end

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  CommissionAssignment with status='ended', endsAt=today
Errors:  NOT_FOUND

commissions.assignments.replaceParticipants

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:
  {
    id: uuid,
    participants: Array<{ commissionRepId: uuid, sharePercent: string, role?: string }>
  }
Output:  CommissionParticipant[] (the new ones)
Errors:  NOT_FOUND, INVALID_PARTICIPANTS

Atomic transaction: DELETE all + INSERT new. DEFERRABLE trigger validates sum=100 at COMMIT.


commissions.tenantsPicker

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   none
Output:  Array<{ id: uuid, name: string, slug: string, status: string }>

Lists all tenants for use in comboboxes.


commissions.revenueEvents

commissions.revenueEvents.list

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:
  {
    tenantId?: uuid,
    commissionRepId?: uuid,
    period?: string,           // exact YYYY-MM
    rangeStart?: string,       // YYYY-MM
    rangeEnd?: string,
    source?: 'stripe_invoice' | 'wire' | 'check' | 'manual' | 'other'
  }
Output:  RevenueEvent[]

commissions.revenueEvents.create

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:
  {
    tenantId: uuid,
    period: string,               // YYYY-MM
    originalCurrency: string,     // ISO 4217
    originalAmountCents: number,  // can be negative
    fxRate?: string,              // auto-calculated if not provided
    source: 'wire' | 'check' | 'manual' | 'other',  // does NOT allow stripe_invoice
    sourceRef?: string,
    component?: 'base' | 'phones' | 'lines' | 'overage' | 'other',
    notes?: string,
  }
Output:  Created RevenueEvent
Errors:  FORBIDDEN (if source='stripe_invoice')

commissions.revenueEvents.update

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid, patch: Partial<CreateRevenueEventInput> }
Output:  Updated RevenueEvent
Errors:  NOT_FOUND, STRIPE_IMMUTABLE

commissions.revenueEvents.delete

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  { success: true }
Errors:  NOT_FOUND, STRIPE_IMMUTABLE

commissions.preview

commissions.preview.calculate

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:
  {
    periodStart: string,       // YYYY-MM-DD (first day of month)
    periodEnd: string,         // YYYY-MM-DD (last day of month)
    commissionRepId?: uuid,
    tenantId?: uuid,
  }
Output:  CalculatedStatement[]

Runs the calculator in preview mode (without persisting). Same result as what generate would produce.

ts
// CalculatedStatement shape
{
  commissionRepId: string,
  currency: string,
  subtotalRecurringCents: number,
  subtotalOneTimeCents: number,
  subtotalOverrideCents: number,
  totalCents: number,
  lineItems: CalculatedLineItem[],
}

commissions.statements

commissions.statements.list

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   { commissionRepId?: uuid, status?: string, rangeStart?: string, rangeEnd?: string }
Output:  CommissionStatement[]

commissions.statements.get

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  { statement: CommissionStatement, lineItems: CommissionLineItem[] }
Errors:  NOT_FOUND

commissions.statements.generate

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { period: string }   // YYYY-MM
Output:  { created: number, replaced: number, skipped: number, statementIds: uuid[] }

commissions.statements.finalize

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  CommissionStatement with status='finalized'
Errors:  NOT_FOUND, CONFLICT (status ≠ 'draft')

commissions.statements.markPaid

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:
  {
    id: uuid,
    paymentMethod: 'ach' | 'wire' | 'check' | 'paypal' | 'wise' | 'other',
    paymentReference?: string,
    paymentNotes?: string,
  }
Output:  CommissionStatement with status='paid'
Errors:  NOT_FOUND, CONFLICT (status ≠ 'finalized')

commissions.statements.delete

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid }
Output:  { success: true }
Errors:  NOT_FOUND, CONFLICT (status ≠ 'draft')

commissions.overview

commissions.overview.kpis

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:   none
Output:
  {
    period: string,               // current YYYY-MM
    revenueThisMonth: number,     // sum of revenue_events.amountUsdCents for the current period
    pendingStatements: number,    // count of statements with status='draft'
    repCount: number,             // active reps
    assignmentCount: number,      // active assignments
  }

commissions.leads

commissions.leads.setRep

Type:    Mutation
Auth:    adminProcedure + commissions.manage
Input:   { id: uuid, commissionRepId: uuid | null }
Output:  Updated Lead
Errors:  NOT_FOUND

Manual override of a lead's attribution. Creates an entry in commission_attribution_log with action='lead_admin_set'.


commissions.attribution

commissions.attribution.list

Type:    Query
Auth:    adminProcedure + commissions.manage
Input:
  {
    leadId?: uuid,
    tenantId?: uuid,
    action?: string,
    rangeStart?: string,   // ISO date
    rangeEnd?: string,
  }
Output:  CommissionAttributionLog[]

commissions.me

Procedures accessible to any admin_msp, without requiring commissions.manage. Only return data for the rep linked to the authenticated user.

commissions.me.rep

Type:    Query
Auth:    adminProcedure (without commissions.manage)
Input:   none
Output:  CommissionRep | null

commissions.me.statements

Type:    Query
Auth:    adminProcedure (without commissions.manage)
Input:   none
Output:  CommissionStatement[]

commissions.me.assignments

Type:    Query
Auth:    adminProcedure (without commissions.manage)
Input:   none
Output:  CommissionAssignment[]   // only status='active'

Router seller.*

seller.invitations — publicProcedure (no auth)

seller.invitations.preview

Type:    Query
Auth:    publicProcedure (no session required)
Input:   { token: string }
Output:  { valid: boolean, email?: string, repName?: string }

If the token doesn't exist, is expired, or has already been used: { valid: false }.

seller.invitations.consume

Type:    Mutation
Auth:    publicProcedure (no session required)
Input:   { token: string, password: string }
Output:  { success: true, userId: string, redirectTo: '/seller/dashboard' }
Errors:  INVALID_OR_EXPIRED, INTERNAL (if Better Auth fails to create user)

Atomic transaction:

  1. SELECT FOR UPDATE of the active invitation.
  2. Creates user in Better Auth with role='seller'.
  3. Links commission_reps.userId = newUser.id.
  4. Marks invitation as used.

seller.me

Type:    Query
Auth:    sellerProcedure
Input:   none
Output:  { rep: CommissionRep }

seller.statements

seller.statements.list

Type:    Query
Auth:    sellerProcedure
Input:
  {
    status?: 'draft' | 'finalized' | 'paid',
    rangeStart?: string,   // YYYY-MM-DD
    rangeEnd?: string,
  }
Output:  CommissionStatement[]  // only those of the authenticated rep

seller.statements.get

Type:    Query
Auth:    sellerProcedure
Input:   { id: uuid }
Output:  { statement: CommissionStatement, lineItems: CommissionLineItem[] }
Errors:  NOT_FOUND (also if the statement exists but doesn't belong to the authenticated rep)

seller.assignments

seller.assignments.list

Type:    Query
Auth:    sellerProcedure
Input:   none
Output:  Array<CommissionAssignment & { role: string, sharePercent: string }>
         // only assignments where the rep is a participant, status='active'

seller.leads

seller.leads.list

Type:    Query
Auth:    sellerProcedure
Input:   none
Output:  Lead[]  // all leads with commissionRepId of the authenticated rep

seller.kpis

Type:    Query
Auth:    sellerProcedure
Input:   none
Output:
  {
    earnedThisMonth: number,     // totalCents from the current period statement
    pendingStatements: number,   // count of draft or finalized statements (unpaid)
    leadsTotal: number,
    leadsConverted: number,      // leads with status='converted'
  }

REST endpoints

GET /api/v1/admin/commissions/preview/pdf

Generates a preview PDF without persisting. Runs the calculator in real time.

Auth:    active session + role='admin_msp' + commissions.manage
Query params:
  periodStart: string (YYYY-MM-DD, required)
  periodEnd: string (YYYY-MM-DD, required)
  commissionRepId: uuid (optional)
  tenantId: uuid (optional)
Response:
  Content-Type: application/pdf
  Content-Disposition: attachment; filename="preview-{email}-{periodStart}.pdf"
Errors:
  401 UNAUTHORIZED
  403 FORBIDDEN
  400 MISSING_PARAMS
  404 NO_DATA (no commissions for the given filters)

The PDF includes a prominent "DRAFT" badge.


GET /api/v1/admin/commissions/statements/:id/pdf

Generates PDF for a persisted statement.

Auth:    active session + role='admin_msp' + commissions.manage
Params:
  id: uuid (statement id)
Response:
  Content-Type: application/pdf
  Content-Disposition: attachment; filename="statement-{email}-{periodStart}.pdf"
Errors:
  401 UNAUTHORIZED
  403 FORBIDDEN
  404 NOT_FOUND

The "DRAFT" badge only appears if the statement has status='draft'.


GET /api/v1/seller/statements/:id/pdf

Generates PDF for a statement of the authenticated seller. Verifies ownership.

Auth:    active session + role='seller' + active rep
Params:
  id: uuid (statement id)
Response:
  Content-Type: application/pdf
  Content-Disposition: attachment; filename="statement-{email}-{periodStart}.pdf"
Errors:
  401 UNAUTHORIZED
  403 FORBIDDEN (not a seller, or inactive rep)
  404 NOT_FOUND (statement doesn't exist or doesn't belong to the authenticated rep)

The ownership check always returns 404 (never 403) to avoid exposing whether the statement exists.


POST /api/v1/leads — commissions side effect

Existing public endpoint extended with refCode:

Auth:    public (no auth)
Body:    { name, email, phone?, company?, message?, refCode?: string }
Commissions side effect:
  If refCode present and resolves to an active rep:
    - leads.commissionRepId = rep.id
    - INSERT commission_attribution_log (action='lead_ref_code_set')
  If refCode present but doesn't resolve:
    - leads.refCode = refCode, commissionRepId = null
    - INSERT log (action='lead_ref_code_set', notes='Unknown ref code: ...')
  If refCode absent:
    - No commissions changes, no log entry

Stripe webhook side effects

The existing POST /api/v1/webhooks/stripe webhook handles:

invoice.paid — creates revenue events:

  • Breaks down the invoice into N lines from invoice.lines.data.
  • For each line: classifies the component (base, phones, lines, overage, other) by comparing line.price.id with the price IDs in plans.
  • Creates a revenue_event with sourceRef = ${invoice.id}:${line.id}.
  • If no lines: creates 1 event with component='other' and sourceRef = invoice.id.
  • FX rate: if currency is not USD, fetches from exchangerate.host (Redis cache 24h).

charge.refunded — creates a negative event:

  • originalAmountCents = -refund.amount
  • component = 'other'
  • sourceRef = ${charge.id}.refund

Side effect failures don't block the 200 to Stripe. They're queued in BullMQ commissions-stripe-retry.


Error codes

CodeHTTPWhen
NOT_FOUND404Resource doesn't exist
CONFLICT409Delete with existing statements, finalize/markPaid with wrong status
FORBIDDEN403No permission, or attempt to mutate Stripe event
BAD_REQUEST400Valibot validation failed, cascade without terms, invalid participants, cycle
EMAIL_EXISTS409Duplicate email in commission_reps
REF_CODE_EXISTS409Duplicate ref code
USER_ALREADY_LINKED409userId already assigned to the rep
ALREADY_LINKED409Rep already has userId (when inviting)
NO_TERMS400Cascade resolver didn't resolve any monetary term > 0
STRIPE_IMMUTABLE403Attempt to edit/delete event with source='stripe_invoice'
CYCLE400managerId would form a cycle in the hierarchy
INVALID_PARTICIPANTS400Shares don't sum to 100, empty array, or duplicate rep
INVALID_OR_EXPIRED400Invitation token doesn't exist, expired, or already used
INTERNAL500Unexpected server error

SipSop documentation. Product operated by Sopinf Tech LLC.