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 useadminProcedure+requirePermission('commissions.manage'). Those in theseller.*router usesellerProcedure(orpublicProcedurefor invitations). - Errors: mapped to
TRPCErrorwith standard HTTP codes. See Error codes. - Input: validated with Valibot. Schemas in
apps/api/src/modules/commissions/commissions.schema.ts. - Date format:
YYYY-MM-DDfor dates,YYYY-MMfor 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)// 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 failscommissions.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_FOUNDcommissions.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_EXISTScommissions.reps.update
Type: Mutation
Auth: adminProcedure + commissions.manage
Input: { id: uuid, patch: Partial<CreateRepInput> }
Output: Updated CommissionRep
Errors: NOT_FOUND, EMAIL_EXISTS, REF_CODE_EXISTScommissions.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_FOUNDcommissions.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 nullcommissions.reps.revokeAccess
Type: Mutation
Auth: adminProcedure + commissions.manage
Input: { id: uuid }
Output: { success: true }
Errors: NOT_FOUNDSets 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_FOUNDcommissions.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_FOUNDDoes 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_FOUNDcommissions.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_PARTICIPANTSAtomic 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_IMMUTABLEcommissions.revenueEvents.delete
Type: Mutation
Auth: adminProcedure + commissions.manage
Input: { id: uuid }
Output: { success: true }
Errors: NOT_FOUND, STRIPE_IMMUTABLEcommissions.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.
// 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_FOUNDcommissions.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_FOUNDManual 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 | nullcommissions.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:
- SELECT FOR UPDATE of the active invitation.
- Creates user in Better Auth with role='seller'.
- Links
commission_reps.userId = newUser.id. - 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 repseller.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 repseller.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_FOUNDThe "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 entryStripe 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 comparingline.price.idwith the price IDs inplans. - Creates a
revenue_eventwithsourceRef = ${invoice.id}:${line.id}. - If no lines: creates 1 event with
component='other'andsourceRef = invoice.id. - FX rate: if currency is not USD, fetches from
exchangerate.host(Redis cache 24h).
charge.refunded — creates a negative event:
originalAmountCents = -refund.amountcomponent = '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
| Code | HTTP | When |
|---|---|---|
NOT_FOUND | 404 | Resource doesn't exist |
CONFLICT | 409 | Delete with existing statements, finalize/markPaid with wrong status |
FORBIDDEN | 403 | No permission, or attempt to mutate Stripe event |
BAD_REQUEST | 400 | Valibot validation failed, cascade without terms, invalid participants, cycle |
EMAIL_EXISTS | 409 | Duplicate email in commission_reps |
REF_CODE_EXISTS | 409 | Duplicate ref code |
USER_ALREADY_LINKED | 409 | userId already assigned to the rep |
ALREADY_LINKED | 409 | Rep already has userId (when inviting) |
NO_TERMS | 400 | Cascade resolver didn't resolve any monetary term > 0 |
STRIPE_IMMUTABLE | 403 | Attempt to edit/delete event with source='stripe_invoice' |
CYCLE | 400 | managerId would form a cycle in the hierarchy |
INVALID_PARTICIPANTS | 400 | Shares don't sum to 100, empty array, or duplicate rep |
INVALID_OR_EXPIRED | 400 | Invitation token doesn't exist, expired, or already used |
INTERNAL | 500 | Unexpected server error |