fix: pay percentage reward rules as percentages, not flat amounts - #59
Open
wasay-09 wants to merge 1 commit into
Open
fix: pay percentage reward rules as percentages, not flat amounts#59wasay-09 wants to merge 1 commit into
wasay-09 wants to merge 1 commit into
Conversation
Reward rules created through the program setup flow stored their unit as
"percentage", the spelling used by `rewardConfigSchema.valueType`, while
`rewardRuleConfigV1Schema` and both reward engines expect "percent".
The two vocabularies were bridged in the program router by a cast:
unit: rewardConfig.referrer.valueType as "fixed" | "percent"
A cast changes no value at runtime, so "percentage" was written to the
database unchanged. `calculateRewardAmount` then matched neither the
"fixed" nor the "percent" branch and fell through to `return baseAmount`,
paying a 10% rule out as a flat 10. The dashboard read the same value back
with `unit === "percent" ? "percentage" : "fixed"`, so an affected rule
also displayed as "fixed" — the UI agreed with the wrong behaviour.
Changes:
- Add `normalizeRewardUnit` to @refref/types, which maps the legacy
"percentage" spelling onto the canonical "percent". Both engines read
rule config via `rule.config as RewardRuleConfigV1Type` rather than
parsing it, so normalizing on read is what repairs rules already stored.
- Normalize at the write path in the program router, so newly created
rules are stored canonically, and at the dashboard read path, so
existing rules display correctly.
- Extract the duplicated `calculateRewardAmount` from the api and webapp
engines into @refref/types so both apply the same money rules. The two
copies had diverged: on a percentage rule with no order amount the api
returned 0 while the webapp returned the raw percentage as a flat
payout. The shared version keeps the api behaviour, which is what the
existing test asserts.
The unit test declared its own local copy of `calculateRewardAmount`
instead of importing the shipped one, and only ever exercised "percent",
so it could not observe this bug. It now imports the real function and
covers the legacy spelling; those cases fail against the previous code.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
A reward rule configured as a percentage pays out as a flat amount. A "10%" referrer commission pays 10, whatever the order is worth.
The program setup flow writes the reward unit as
"percentage"— the spelling used byrewardConfigSchema.valueTypeinpackages/types/src/program-config.ts— whilerewardRuleConfigV1Schemaand both reward engines expect"percent". The two vocabularies are bridged in the program router with a cast:A cast changes nothing at runtime, so
"percentage"reaches the database unchanged.calculateRewardAmountthen matches neither branch:and falls through to
return baseAmount— the percentage value paid as a flat amount.The dashboard reads the same value back with
unit === "percent" ? "percentage" : "fixed"(reward-step-config.tsx:65), so an affected rule also renders as fixed. The UI agrees with the wrong behaviour, which is likely why this has gone unnoticed.Both the referrer rule (line 282) and the referee rule (line 307) are affected.
Why the tests didn't catch it
apps/api/test/unit/reward-calculation.test.tsdeclared its own local copy ofcalculateRewardAmountat the bottom of the file rather than importing the shipped one, and only ever passed it"percent". It could not observe this bug by construction — the code under test was not the code that runs.The fix
normalizeRewardUnitin@refref/typesmaps the legacy"percentage"onto the canonical"percent". Both engines read rule config viarule.config as RewardRuleConfigV1Typerather than parsing it, so normalizing on read is what repairs rules that are already stored — a write-path-only fix would leave existing rules broken.calculateRewardAmountwas copied inapps/api/src/services/reward-engine.tsandapps/webapp/src/server/services/reward-engine.ts, and the copies had diverged: for a percentage rule with no order amount, the api returned0while the webapp returned the raw percentage as a flat payout. It now lives in@refref/typesso both apply the same money rules.rewardRuleConfigV1Schema.unitalso accepts the legacy spelling and normalizes it, so anything that does parse stored config gets the canonical value.One judgement call worth flagging
Unifying the engines means choosing one behaviour where they disagreed. I kept the api behaviour — a percentage rule with no order amount resolves to
0— because that is what the existing test asserts ("should return 0 when orderAmount is missing (CRITICAL FIX)") and because returning the raw percentage is the same class of bug this PR fixes. This is a behaviour change for the webapp engine. Happy to split it out if you would rather keep that change separate.Verification
The three new legacy-spelling tests fail against the current code with exactly the reported symptom:
and pass with the fix. Full suite on this branch:
No migration is included — normalizing on read means existing rows are interpreted correctly without one. A backfill could be added later to make stored data uniform, but it is not required for correct payouts.