Skip to content

fix: pay percentage reward rules as percentages, not flat amounts - #59

Open
wasay-09 wants to merge 1 commit into
amicalhq:mainfrom
wasay-09:fix/reward-unit-percentage-mismatch
Open

fix: pay percentage reward rules as percentages, not flat amounts#59
wasay-09 wants to merge 1 commit into
amicalhq:mainfrom
wasay-09:fix/reward-unit-percentage-mismatch

Conversation

@wasay-09

Copy link
Copy Markdown

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 by rewardConfigSchema.valueType in packages/types/src/program-config.ts — while rewardRuleConfigV1Schema and both reward engines expect "percent". The two vocabularies are bridged in the program router with a cast:

// apps/webapp/src/server/api/routers/program.ts:282
unit: rewardConfig.referrer.valueType as "fixed" | "percent",

A cast changes nothing at runtime, so "percentage" reaches the database unchanged. calculateRewardAmount then matches neither branch:

if (rewardConfig.unit === "fixed")   return baseAmount;
if (rewardConfig.unit === "percent") { /* ... */ }
return baseAmount; // "percentage" lands here

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.ts declared its own local copy of calculateRewardAmount at 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

  • Normalize on read. normalizeRewardUnit in @refref/types maps the legacy "percentage" 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 that are already stored — a write-path-only fix would leave existing rules broken.
  • Normalize at the write path in the program router, so new rules are stored canonically, and at the dashboard read path, so existing rules display correctly.
  • De-duplicate the engine. calculateRewardAmount was copied in apps/api/src/services/reward-engine.ts and apps/webapp/src/server/services/reward-engine.ts, and the copies had diverged: for a percentage rule with no order amount, the api returned 0 while the webapp returned the raw percentage as a flat payout. It now lives in @refref/types so both apply the same money rules.
  • Fix the test to import the real function and cover the legacy spelling.

rewardRuleConfigV1Schema.unit also 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:

AssertionError: expected 10 to be +0
- Expected   0
+ Received  10

and pass with the fix. Full suite on this branch:

turbo run type:check test:run
Tasks: 23 successful, 23 total
  @refref/api      56 passed
  @refref/refer    19 passed
  @refref/webapp   11 passed

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.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant