Skip to content

FAQ and troubleshooting

Frequently asked questions organized by area, plus diagnostic guides for the most common issues.


Attribution

Why first-touch and not last-touch?

First-touch incentivizes reps to bring in leads at the earliest stage of the sales cycle, when acquisition cost is highest. An SDR who makes 10 cold calls deserves credit even if an AE closes the deal weeks later. The participant split (70/30, etc.) lets you recognize both contributions without disputing attribution.

Last-touch would always favor the closing AE, discouraging prospecting. In Sopinf's model, SDRs would prospect less if they knew they'd lose credit at close.

How do I change the attribution on a lead?

If the lead hasn't converted to a tenant yet:

  • Admin portal → /admin/commissions/atribucion to see the current log.
  • Use commissions.leads.setRep(leadId, newRepId) — creates a lead_admin_set log entry.

If the lead already converted and there's an active assignment:

  • /admin/commissions/asignaciones → edit the assignment → replaceParticipants.
  • Each changed participant generates an admin_reassigned log entry.

Statements already in finalized or paid status are not affected. Only future ones.

What happens if the cookie expires?

The sipsop_ref cookie has Max-Age=7776000 (90 days). If a prospect visits the landing after the cookie expired and there's no active ?ref= querystring, the form is submitted without a refCode and the lead is created without attribution.

If the prospect arrives again with a ?ref=maria-gonzalez link, a new cookie is set (first-touch applies if there was no prior cookie). If a cookie from a previous visit already exists, it is not overwritten — the first touch still wins.

Is the ref code case-sensitive?

Yes. The lookup commission_reps WHERE ref_code = input is case-sensitive in Postgres by default. The system always generates ref codes in lowercase (maria-gonzalez, not Maria-Gonzalez). If a rep shared a link with uppercase letters, the match may fail. Fix: the rep should always share the link generated by the system (copied from the admin portal).


Calculation

How is the net basis pro-rated per component?

The engine first calculates totalGross (sum of all revenue events in the period) and totalNet (after deducting Stripe fees). Then, per component:

componentNet(c) = round(eventsByComponent(c) × totalNet / totalGross)

That is, the net share allocated to each component reflects its weight in the total gross. Stripe fees are not deducted per component individually — the deduction is made on the total and then pro-rated.

What happens with events in a NULL component (legacy)?

Events created before F-CMS-3 have component = NULL. The calculator treats them as a fallback to the assignment's global recurringPercent (not the per-component %). If the assignment is in breakdown mode (some per-component % set), NULL events contribute to the total revenue but their commission uses the global %. This preserves backward compatibility without data loss.

Is override applied before or after the split?

After. The flow is:

  1. Calculate the total commission for the assignment for the period.
  2. Multiply by the participant's share (e.g., 70%).
  3. That amount (post-split) is the IC's commission.
  4. The manager's override is applied on that post-split amount.

Example: assignment generates $100 total. María has a 70% share → $70. Carlos has a 10% override → $7 (10% of $70, not of $100).

Is the one-time bonus pro-rated by share?

Yes. The oneTimeAmountCents on the assignment is the total bonus. If there are multiple participants, each one receives their sharePercent of the bonus. With a $200 bonus, María at 70% receives $140 and Pablo at 30% receives $60.

Manager overrides are also calculated on the IC's pro-rated bonus, the same as for recurring.

What happens with refunds?

Refunds are revenue_events with a negative originalAmountCents. The engine sums them into the period's net — if a tenant paid $118 and there was a $30 refund, the net revenue for the period is $88 and the commission is calculated on that.

If the refund arrives in a period after the already-finalized statement, the event is inserted in its original period but that period's statement is not touched. The impact of the refund appears in the following period as negative net revenue.


Statements

Why does a statement have draft status?

The cron (or the admin manually) generated the statement but it hasn't been reviewed or finalized yet. The amount in a draft can change if:

  • More revenue events arrive for that period (e.g., a delayed Stripe webhook).
  • The admin regenerates the period (statements.generate replaces the draft).
  • The admin changes the participants in an assignment.

A draft is a proposal. The source of truth is established at finalize.

Can I delete a finalized statement?

No. Only statements in draft can be deleted. finalized and paid statements are immutable. If there's an error in a finalized statement, the correction is made with a compensatory revenue event in the following period.

What happens if I regenerate an already-finalized month?

materializeStatementsForPeriod always skips statements in finalized or paid status for that period. It only replaces draft. If all statements for the month are already finalized, the operation returns { created: 0, replaced: 0, skipped: N }.

How do I handle a retroactive refund?

If Stripe issues a refund for a period whose statement is already finalized:

  1. The webhook correctly creates the negative revenue_event in the original period.
  2. The statement for that period is NOT modified (it's finalized).
  3. The impact is reflected in the following period: the net revenue of the original period (the refund's period) is lower, and the engine accounts for it in the next calculation. Effectively, the deduction appears in the month's statement when the cron runs.

If you want to reflect the impact manually and immediately, you can create a negative manual revenue event in the current period.


Seller portal

Can the seller see data from other sellers?

No. The sellerProcedure injects the authenticated user's commissionRep into the context, and all queries filter strictly by that repId. There is no way to view another rep's statements from the seller portal. The ownership check on the PDF endpoint also prevents cross-access.

What happens if I disable a rep mid-period?

If the admin sets commission_reps.status = 'inactive' or revokes access (userId = null):

  • The sellerProcedure rejects the next request with FORBIDDEN.
  • Existing statements (draft, finalized, paid) remain in the DB — the admin can see them, the seller cannot.
  • If the rep is re-invited, they can access their full history again.

Commissions accrued up to the point of deactivation do not disappear — the rep is entitled to them if the statements were already generated.

How do I reset their password?

Current option: the admin uses revokeAccess (sets userId = null) and then re-invites. The seller creates a new password when consuming the new token. If Better Auth has a password reset flow enabled in the project, that path can also be used.

Can the seller change their email?

Not from the portal. The email is the Better Auth user's email and is not exposed for editing in the seller portal. To change it: the admin updates commission_reps.email, revokes access, and re-invites (the new token will use the updated email).


Troubleshooting

"My statement isn't showing up"

Checklist:

  1. Was the period generated? → /admin/commissions/statements and search by period and rep.
  2. Did the rep have active assignments in that period? → verify startsAt / endsAt on the assignment.
  3. Are there revenue events for the tenant in that period? → /admin/commissions/ingresos filtered by tenant + period.
  4. Is the calculated totalCents > 0? → Use commissions.preview.calculate for that period and rep. If it returns [] or total=0, the calculator found no commission.
  5. Does the sum of participant shares equal 100? → If not, the DB trigger would have rejected the assignment at creation, but check anyway.

"The override isn't being calculated"

Debug steps:

  1. Verify the manager has overridePercent > 0 in their profile.
  2. Verify the IC has managerId pointing to the correct manager.
  3. Run commissions.preview.calculate for the period — check whether there are kind='override' line items in the output.
  4. If the IC had totalCents = 0 that period, there's no base to calculate override on.
  5. If there's a cycle in the hierarchy (there shouldn't be — the trigger prevents it), the TS walk breaks and logs CYCLE_DETECTED. Check the API logs.
  6. Verify that the manager also has an active assignment to have a statement — actually, no: overrides generate line items in the manager's statement even if the manager has no assignment of their own. The statement is created regardless.

"The invitation email didn't arrive"

  1. The invitation is sent fire-and-forget. If the send failed, there's an error in the logs but the mutation succeeded.
  2. The commissions.reps.invite response includes setupUrl for copy/paste. The admin can send it manually through another channel.
  3. To verify whether the invitation is active: commissions.reps.pendingInvitation({ id: repId }).
  4. Check the email service environment variable (Resend). If it's not configured, the service only logs the link.

"Stripe webhook didn't create revenue events"

  1. Check the API logs for errors in the invoice.paid handler.
  2. Review the BullMQ queue commissions-stripe-retry — if there are jobs there, the side-effect failed and is in retry.
  3. The tenantId is resolved via stripe_customer_id in the tenants table. If the Stripe customer isn't linked to any tenant, the handler logs a warning and skips. Verify the tenant has the correct stripeCustomerId.
  4. If the issue is FX (non-USD currency), the exchangerate.host API may be down or the Redis cache expired. Retry jobs retry up to 5 times with exponential backoff.
  5. As a last resort: create the revenue event manually via commissions.revenueEvents.create with the correct amount and period.

"Cycle detected when assigning a manager"

The DB trigger trg_no_manager_cycle rejected the UPDATE because it would form a cycle. Possible causes:

  • Rep A wants to have rep B as their manager, but B already has A as their manager (direct or indirect).
  • The rep is trying to assign themselves as their own manager.

To resolve: review the current chain with commissions.reps.get (includes manager and directReports). Choose a manager that's on a separate branch of the tree.

"INVALID_PARTICIPANTS when creating/editing an assignment"

The error can be caused by:

  • Shares not summing to exactly 100. Check the sum in the drawer (the live validator shows it).
  • Empty participants array (at least 1 is required).
  • Duplicate rep in the array (the same rep appears twice).
  • sharePercent = 0 on any participant (the CHECK constraint rejects it).

The DEFERRABLE trigger in the DB validates at the end of the transaction, so the error may arrive as a Postgres error instead of the INVALID_PARTICIPANTS tRPC error. Both are equivalent in meaning.

SipSop documentation. Product operated by Sopinf Tech LLC.