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
| Job | Operations | Permission |
|---|---|---|
| Rep runs the day | myVisits, myVisitsRange, addUnplannedVisit, checkInVisit, checkOutVisit, completeVisit, rescheduleVisit, cancelVisit, reorderVisits | Reads: none beyond a signed-in user. Changes: visits:visit:execute |
| Admin plans routes | journeyPlan, journeyPlans, journeyPlansCount, journeyPlanSummary, upsertJourneyPlan, generateRoutePlan, sendVisitMessage | visits:journey-plan:manage |
| Rep plans their own cycle | generateMyRoutePlan | visits:journey-plan:generate-own |
| Rep reads their commission | myCommissions, myCommissionSummary | None beyond a signed-in user |
| Admin reads the ledger | commissions, commissionsCount, commissionOrderGroups, commissionBeneficiaries, commissionLedgerSummary, commissionPayouts and the role, rep and customer rate reads | commissions:commission:read |
| Admin sets rates and pays out | setCommissionRoleRate, generateCommissionPayout, markCommissionPayoutPaid and the other rate mutations | commissions: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.
myVisits(date)lists the day's stops in route order, planned and unplanned together. Omitdatefor today.myVisitsRange(dateFrom, dateTo)reads a range of up to 92 days.addUnplannedVisit(partnerId, date, onlyIfNotScheduled)adds a stop in the field. The customer must be one the rep may see. SendonlyIfNotScheduled: trueto get the rep's existing stop for that customer and day instead of a second one.checkInVisit(visitId, latitude, longitude)moves aPLANNEDvisit toCHECKED_IN. The location is optional and advisory: nothing authorizes on it.checkOutVisit(visitId, latitude, longitude)stamps the departure. It needs an open check-in.completeVisit(visitId, outcome, noOrderReason)finishes the visit.outcomeisORDER_PLACEDorNO_ORDER.NO_ORDERneeds anoOrderReason, andORDER_PLACEDmust 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:
checkInVisittakesclientCheckInAt(when the rep tapped) andidempotencyKey. 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.checkOutVisittakesclientCheckOutAt. A replay whose check-out already landed succeeds without writing.completeVisittakesclientCompletedAt. 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.
commissionRoleRateslists the rate of each role.setCommissionRoleRate(input)sets one, withrole,rateandisActive. 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 needscommissions:commission:updateand deleting needscommissions:commission:delete.commissionHierarchyshows 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
generateCommissionPayout(input)gathers one person'sLOCKED, unpaid commissions for a period (periodasYYYY-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.- Pay the person outside the system.
markCommissionPayoutPaid(input)takes thepayoutIdand an optionalreference. It marks the payout and its commissionsPAID. 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 see | Why | What to do |
|---|---|---|
| A visit reads as not found | The visit is not the signed-in rep's, or does not exist | Reload the day |
| Check-in is refused | The visit is not PLANNED (online path) | Read the visit's current status first |
| Check-out is refused | There is no open check-in | Check in first |
| Completing is refused | NO_ORDER has no reason, ORDER_PLACED has one, or the visit is already finished | Fix the arguments, or read the visit |
| Rescheduling is refused | The visit already has a check-in, or is completed or cancelled | Leave it where it is |
| Cancelling is refused | The visit is not a planned stop | Cancel only planned stops |
| Reordering is refused | visitIds is not exactly today's planned visits | Re-read today's list and send all of it |
upsertJourneyPlan is refused | A customer is repeated or not in the organization, or a stop with a check-in was left out | Rebuild the list from journeyPlan |
| A range read is refused | The range is longer than 92 days, or ends before it starts | Shorten the range |
generateRoutePlan is refused | The cycle is over 90 days, or the rep is outside the caller's reporting line | Shorten the cycle, or use an account that covers that rep |
| Setting a role rate is refused | An active rate above zero equals another role's rate, or it is outside 0 to 100 | Pick a distinct rate |
generateCommissionPayout is refused | The period has not ended, nothing is locked, or the payout is already paid | Wait, or check the ledger |
markCommissionPayoutPaid is refused | The payout is already paid | Nothing to do |