Fixing a Multi-Level Incentive System (L1–L4) Without Breaking the Workflow
How I debugged and stabilized a hierarchical incentive system with approval, hold, and reporting logic across multiple roles.
This started with a system that looked correct on paper:
A multi-level incentive flow with 4 roles (L1 → L4), approvals, and reporting.
But once I started testing real scenarios, everything began to break in subtle ways.
The original goal
I wanted a system where:
- Sales (L1) creates incentives
- Team Lead (L2) reviews
- Manager (L3) reviews
- Owner (L4) finalizes
And each level should:
- Only see incentives when the previous level is approved
- Be blocked if a lower level puts it on hold
- Resume correctly when reopened
This sounds straightforward.
It wasn’t.
Where things went wrong
The main issue was this:
Database state ≠ UI state
Examples:
- L2 could see incentives even if L1 hadn’t approved
- Held incentives didn’t propagate correctly upward
- Higher levels still saw items they shouldn’t act on
- Reports showed everything, including irrelevant states
Everything worked individually, but not together.
The core realization
I stopped relying on raw database fields like:
statusholdReasonheldBy
And introduced a derived concept:
Effective State
Effective State
Instead of trusting the current incentive row, I computed:
- effective status
- effective hold reason
- effective blocker
Based on lower-level incentives in the same sale
Example idea:
function computeEffectiveState(inc) {
const lowerHeld = checkLowerLevelHold(inc)
if (lowerHeld) {
return {
effectiveStatus: "ON_HOLD",
effectiveHeldBy: lowerHeld.heldBy
}
}
return {
effectiveStatus: inc.status
}
}
This changed everything.
Now the UI reflects workflow reality, not just DB values.
Visibility logic (the real rules)
Each level follows an upward dependency:
L1 → always visible
L2 → only if L1 is CLAIMABLE or ON_HOLD
L3 → only if L2 is ready
L4 → only if L3 is ready
Additionally:
If any lower level is ON_HOLD, all upper levels:
cannot act
see it as blocked
This creates a controlled flow instead of chaos.
Reports needed different rules
Reports are not workflow.
They are financial output.
So I restricted reports to:
CLAIMABLE
CLAIM_REQUESTED
PAID
And always scoped them to:
beneficiaryUserId = currentUser
That removed a lot of noise instantly.
Frontend bugs that surfaced
Filters didn’t update the table
The API was returning correct data.
But the UI didn’t change.
Cause:
React Query key didn’t include filters
Fix:
queryKey: ["reports", queryParams]
Select input didn’t update
Using:
defaultValue
Instead of:
value
So UI didn’t reflect URL changes.
CSV export broke numbers
₹5,000 became 5
Cause:
CSV comma parsing
Fix:
"₹5,000"
Escaping values solved it.
UI improvements
I also fixed a structural issue:
I was reusing one filter component everywhere.
But:
Incentives page → full lifecycle
Review page → pending + hold
Reports → only financial states
So I made filters configurable instead of duplicating them.
Small but useful addition
Added per-row export in reports:
Export one incentive as CSV
Keep bulk export for full dataset
This made reports more practical.
Tech stack
Next.js (App Router)
React Query
Nodejs (Express.js)
Prisma
PostgreSQL
The challenge was not tooling.
It was correctly modeling workflow behavior.
What this taught me
Database status is not enough
Always derive UI state when hierarchy exists
Query keys must include filters
CSV is fragile unless escaped
Reusable components should be configurable, not rigid
Closing
This wasn’t about adding more code.
It was about making the system behave like the real process it represents.
Once the logic matched reality, everything else became simpler.