Skip to main content

Field sales and commissions

This domain has two halves that share one cast: the sales reps.

  • Visits and journey plans are the field half. An admin plans each rep's day as a route of customer stops. The rep checks in at each stop, checks out, and records the outcome.
  • Commissions are the money half. Orders earn commission for the rep and for the people above them in the reporting chain. Commission is locked when the order is paid, and you pay it out by period.

For the exact field list of every type, see the API reference. The examples use <...> for values you supply, and dates are ISO-8601 strings.

1. Plan the integration​

JobOperationsPermission
Rep runs the daymyVisits, myVisitsRange, addUnplannedVisit, checkInVisit, checkOutVisit, completeVisit, rescheduleVisit, cancelVisit, reorderVisitsReads: none beyond a signed-in user. Changes: visits:visit:execute
Admin plans routesjourneyPlan, journeyPlans, journeyPlansCount, journeyPlanSummary, upsertJourneyPlan, generateRoutePlan, sendVisitMessagevisits:journey-plan:manage
Rep plans their own cyclegenerateMyRoutePlanvisits:journey-plan:generate-own
Rep reads their commissionmyCommissions, myCommissionSummaryNone beyond a signed-in user
Admin reads the ledgercommissions, commissionsCount, commissionOrderGroups, commissionBeneficiaries, commissionLedgerSummary, commissionPayouts and the role, rep and customer rate readscommissions:commission:read
Admin sets rates and pays outsetCommissionRoleRate, generateCommissionPayout, markCommissionPayoutPaid and the other rate mutationscommissions:commission:update, commissions:commission:delete

Everything is scoped to your organization, which comes from the token and never from an argument. A rep's own reads (myVisits, myCommissions and the others that start with my) return only that rep's rows.

2. Plan a rep's day​

A journey plan is one rep's route for one calendar day. Its stops are visits, each a customer in a given order. A visit has a status: PLANNED, CHECKED_IN, COMPLETED, CANCELLED or SKIPPED.

Read plans​

query Plan($salesRepId: ID!, $planDate: DateTime!) {
journeyPlan(salesRepId: $salesRepId, planDate: $planDate) {
id
planDate
visits { id sequence status partnerHasLocation hasUnreadMessage }
}
}

journeyPlan returns one rep's day, or null when there is neither a saved plan nor an unplanned stop that day. When a rep has only unplanned stops, you get a plan that is built on the fly and not saved. Do not send its id back. A plan is identified by the rep and the day.

journeyPlans(dateFrom, dateTo, salesRepId, filter, skip, take) lists plans over a range. Pair it with journeyPlansCount for the pager total and journeyPlanSummary for plan, planned-stop and completed-stop totals. A range may span at most 92 days.

Save a route by hand​

upsertJourneyPlan(input) replaces the whole day for one rep. Send every stop you want to keep, in order:

{
"input": {
"salesRepId": "<rep id>",
"planDate": "<ISO-8601 date>",
"notes": "<notes>",
"visits": [
{ "partnerId": "<customer id>", "sequence": 1 },
{ "partnerId": "<customer id>", "sequence": 2 }
]
}
}

Stops are matched by customer. A kept stop is re-ordered, a new customer becomes a PLANNED visit, and a stop you leave out is removed. A stop the rep added in the field is taken into the plan when you name its customer. Build the list from what journeyPlan returned, so you do not drop stops by accident.

The save is refused as a whole when:

  • The rep is not in the organization, or a customer is not.
  • The same customer appears twice.
  • You leave out a stop that already has a check-in. Field truth beats the route.

Generate a route​

generateRoutePlan(input) builds a cycle of day routes for one rep from their customer book. Give salesRepId, cycleLengthDays (1 to 90), cycleStartDate and, optionally, includeSundays. generateMyRoutePlan(cycleLengthDays, cycleStartDate) does the same for the signed-in rep, under its own permission, so a rep can plan without the admin permission.

The generator picks customers who are due: never visited, or last completed visit older than their visit frequency (or, if none is set, the cycle length). It leaves out a customer who already has a planned or checked-in visit. It groups the rest by location and fills each day up to the organization's visit capacity, laying days onto the rep's working days.

The result has two lists: plans, the saved day plans, and deferred, the customers who were due and did not fit. A due customer is never dropped silently. Show deferred to the admin, or add those customers by hand with upsertJourneyPlan. A customer far from the other stops already planned for a day is deferred rather than forced onto it.

A generated cycle over 90 days is refused. The caller must have authority over the rep: a caller who cannot see the whole organization may only generate for people in their own reporting line.

sendVisitMessage(visitId, message) sends a note about one stop to its rep. The rep sees it in their notifications and on the visit. An empty message is refused, and the text is limited to 1000 characters.

3. Run visits​

These calls are for the rep, and they only touch the rep's own visits. A visit that belongs to a colleague reads as not found.

  1. myVisits(date) lists the day's stops in route order, planned and unplanned together. Omit date for today. myVisitsRange(dateFrom, dateTo) reads a range of up to 92 days.
  2. addUnplannedVisit(partnerId, date, onlyIfNotScheduled) adds a stop in the field. The customer must be one the rep may see. Send onlyIfNotScheduled: true to get the rep's existing stop for that customer and day instead of a second one.
  3. checkInVisit(visitId, latitude, longitude) moves a PLANNED visit to CHECKED_IN. The location is optional and advisory: nothing authorizes on it.
  4. checkOutVisit(visitId, latitude, longitude) stamps the departure. It needs an open check-in.
  5. completeVisit(visitId, outcome, noOrderReason) finishes the visit. outcome is ORDER_PLACED or NO_ORDER. NO_ORDER needs a noOrderReason, and ORDER_PLACED must not have one. Completing closes any check-in that is still open.
mutation Complete($visitId: ID!) {
completeVisit(visitId: $visitId, outcome: NO_ORDER, noOrderReason: "<reason>") {
id
status
outcome
}
}

Changes to the route:

  • rescheduleVisit(visitId, newDate) moves a visit to another day. It is refused once the visit has a check-in, or is completed or cancelled.
  • cancelVisit(visitId) removes a planned stop that has not been checked in. The stop disappears from the lists.
  • reorderVisits(visitIds) re-orders the rep's remaining planned stops for today. Send exactly today's planned visits, in the new order. It returns the whole day.

Offline replay​

A rep's app may queue check-ins while offline and send them later. Three calls take extra arguments for this:

  • checkInVisit takes clientCheckInAt (when the rep tapped) and idempotencyKey. Send the same key again and you get the current visit back, not a second check-in. Reusing a key on a different visit is refused.
  • checkOutVisit takes clientCheckOutAt. A replay whose check-out already landed succeeds without writing.
  • completeVisit takes clientCompletedAt. Completing an already completed visit offline returns it unchanged and keeps the first outcome.

The server never rejects a replay for its time. A tap time within the last 24 hours (and not more than 5 minutes ahead) is kept. Anything else falls back to the server's time.

If an admin cancelled the visit while the rep was offline, the replay still records what happened in the field, and the visit's status stays as the admin left it. Compare the returned status with what your app expected.

4. How commission works​

Commission is earned by orders. You do not create it. It accrues from order events:

  • A committed order creates the commission rows. A draft order, or one awaiting payment, creates none.
  • A cancelled or returned order reverses them.
  • A credit note reduces the commission on the order it credits.
  • The commission is locked when the whole order has been collected, and not before.

Each row has a status: PENDING (earned), LOCKED (the order is paid, so it can be paid out), PAID (settled in a payout) or REVERSED.

The commission is worked out on the order's net revenue: line totals less tax and less the order discount, and for a short-closed line only the open share. Amounts are stored in the organization's base currency, converted at the order's frozen exchange rate. Only orderAmount stays in the order's own currency.

The rate is each person's role rate. The first time an order is worked out, the system finds the customer's assigned rep, then walks up their reporting chain. Each person in the chain earns their own role's percentage. A role with no active rate earns 0, and a row recorded at 0 for that reason is flagged rateMissing, so it is not mistaken for an earned 0%. The rate, the person and the chain are saved on the row when it is created, so a later rate change affects new orders only.

A credit note after payout never rewrites money that was already paid. An unpaid row is reduced by the credit. A paid row is left alone and an audit entry records that a clawback is needed.

5. Set up rates​

Rates are percentages between 0 and 100.

  • commissionRoleRates lists the rate of each role. setCommissionRoleRate(input) sets one, with role, rate and isActive. Every role needs its own rate: an active rate above zero cannot equal another role's active rate. deleteCommissionRoleRate(role) removes one and fails when the role has none. Setting needs commissions:commission:update and deleting needs commissions:commission:delete.
  • commissionHierarchy shows the organization structure the rates apply to: each active staff member, their role, level, rate and who they report to.
mutation SetRate($input: SetCommissionRoleRateInput!) {
setCommissionRoleRate(input: $input) {
role
rate
isActive
}
}
{ "input": { "role": "<role name>", "rate": 2.5, "isActive": true } }

The role is the role's display name, the same name the Users and Teams list shows. Per-rep and per-customer rate profiles are also available (setRepCommissionRate, setCustomerCommissionRate and their reads) and are described in the API reference.

6. Read the ledger​

myCommissions(period, status) and myCommissionSummary let a rep see their own entries and totals. period is YYYY-MM.

An admin reads the whole ledger with commissions(filters, skip, take), filtered by person, period and status, and sorted by sortBy and sortOrder. Pair it with commissionsCount for the pager. Other views over the same filters: commissionOrderGroups (one row per order with its chain), commissionBeneficiaries (who earns, with their pending, locked and paid totals) and commissionLedgerSummary (totals by status).

A caller who cannot see the whole organization is limited to their own reporting line. A userId filter can narrow that view but never widen it.

7. Pay out​

  1. generateCommissionPayout(input) gathers one person's LOCKED, unpaid commissions for a period (period as YYYY-MM) into a pending payout. A period can be paid out only after it has ended, and a period with nothing locked is refused. Running it again while the payout is still pending refreshes its total. A payout that is already paid cannot be regenerated.
  2. Pay the person outside the system.
  3. markCommissionPayoutPaid(input) takes the payoutId and an optional reference. It marks the payout and its commissions PAID. Any reversed row is taken out and the total is recomputed first. A payout that is already paid cannot be marked again.
{ "input": { "userId": "<rep id>", "period": "2026-09" } }

commissionPayouts(userId, period, skip, take) lists payouts, and commissionPayoutsCount gives the pager total. Recording a payout moves no money.

8. Common errors​

What you seeWhyWhat to do
A visit reads as not foundThe visit is not the signed-in rep's, or does not existReload the day
Check-in is refusedThe visit is not PLANNED (online path)Read the visit's current status first
Check-out is refusedThere is no open check-inCheck in first
Completing is refusedNO_ORDER has no reason, ORDER_PLACED has one, or the visit is already finishedFix the arguments, or read the visit
Rescheduling is refusedThe visit already has a check-in, or is completed or cancelledLeave it where it is
Cancelling is refusedThe visit is not a planned stopCancel only planned stops
Reordering is refusedvisitIds is not exactly today's planned visitsRe-read today's list and send all of it
upsertJourneyPlan is refusedA customer is repeated or not in the organization, or a stop with a check-in was left outRebuild the list from journeyPlan
A range read is refusedThe range is longer than 92 days, or ends before it startsShorten the range
generateRoutePlan is refusedThe cycle is over 90 days, or the rep is outside the caller's reporting lineShorten the cycle, or use an account that covers that rep
Setting a role rate is refusedAn active rate above zero equals another role's rate, or it is outside 0 to 100Pick a distinct rate
generateCommissionPayout is refusedThe period has not ended, nothing is locked, or the payout is already paidWait, or check the ledger
markCommissionPayoutPaid is refusedThe payout is already paidNothing to do