The hourly reward evaluator
Running, reading and diagnosing Process MLM Referral Conditions — the hourly job that pays every commission rule the live trigger does not, its idempotency key, its warnings and its silences.
Half of this addon's commission rules are not paid by the code that processed the transaction. They are paid by one hourly cron job, and when that job is not running, or is running and quietly refusing to pay, nothing on any affiliate screen says so. The referrals still list, the conditions still show their rates, the dashboard still totals what it paid last month. Only the number stops growing.
This page is how to find that job, read what it said, and work out which of the six things it needs is missing.
Where it lives
Admin → System → Scheduled Tasks (/admin/system/cron), category filter
MLM.
| Job name | processMlmReferralConditions |
| Title on screen | Process MLM Referral Conditions |
| Category | mlm |
| Period | 3,600,000 ms — one hour, shown in the job row as 1h |
| Page permission | access.cron |
| API permission | view.cron to read, manage.cron to press Run now |
Addon jobs are gated on their extension. With the mlm extension switched off
the job is not in the registry at all — it does not appear on the cron screen as
idle or failed, it simply is not there. The scheduler re-evaluates that gating
about every 60 seconds, so enabling or disabling the extension starts or stops
this job without a restart.
An empty MLM category on the cron screen is therefore a meaningful answer: either the extension is off, or the process that registers jobs is not running. The scheduler console tells the two apart.
What one run does
-
Loads every active condition —
mlm_referral_conditionrows withstatus = true. -
Loads every
ACTIVEreferral once, for all conditions.PENDINGandREJECTEDreferrals are not read at all. -
Builds one run context — the referred users' wallets, their currencies, and the conversion rates into each reward currency. This is shared by every condition, which is why the run logs a line like
Referred users hold 412 wallet(s) in 6 currenc(ies): USDT, BTC, ETH, …before it evaluates anything. -
Skips every event-driven condition by name (below).
-
For each remaining condition, evaluates two windows — the current calendar period and the one just before it — summing each referred user's qualifying
COMPLETEDtransactions, converted into the condition's reward currency. -
Creates at most one reward per condition, per referrer, per referred user, per period, and creates the referrer's payout wallet alongside it.
The job is read-only on transactions and creates only reward rows. It never
credits a wallet and never records a platform loss — that happens when the
member presses Claim. Its one write outside mlm_referral_reward is creating
the referrer's empty payout wallet.
The skip list — 19 rules this job never touches
Conditions whose commission is paid at the moment of the trade are skipped here
by name, so no activity is ever paid twice. Each one logs
Skipping <NAME>: it is paid when the transaction happens, not on a schedule.
WELCOME_BONUS · ECOMMERCE_PURCHASE · ICO_CONTRIBUTION · STAKING ·
STAKING_LOYALTY · AI_INVESTMENT · INVESTMENT · GENERAL_INVESTMENT ·
FOREX_INVESTMENT · NFT_PURCHASE · NFT_SALE · P2P_TRADE ·
P2P_TRADE_COMPLETION · COPY_TRADING · FUTURES_TRADE · BINARY_WIN ·
BINARY_TRADE_VOLUME · FX_TRADE_COMMISSION · FX_TRADE_VOLUME
Seeing your rule in that list is the answer to "the cron ran and my condition
was not evaluated". It was not meant to be. If that rule is not paying, the
problem is in the addon that owns the trade, not here. Nothing on the conditions
screen labels a rule EVENT or CRON — the sidebar rows carry the
condition's title, its rate and its wallet type, and that is all. The way to
tell is to open the condition and read the System name in its banner
(WELCOME_BONUS, SPOT_TRADE, …) against the list above. The one place the
split is written out is the overlap warning card, which annotates each other
condition competing for the same activity as "paid at the moment of the event"
or "paid by the periodic evaluator" — never the condition you have open.
Commission conditions covers the split.
The evaluation window, and the period key
A condition's period — DAILY, WEEKLY or MONTHLY — is a fixed calendar
window in UTC, not a rolling lookback. The window and the idempotency key are
derived from the same boundaries, so a transaction can never fall into two
different keys across runs.
period |
Window starts | Key suffix |
|---|---|---|
DAILY (the model default) |
00:00 UTC today | 2026-08-06 |
WEEKLY |
Monday 00:00 UTC of the current ISO week | 2026-W32 |
MONTHLY |
The 1st at 00:00 UTC | 2026-08 |
Every condition is evaluated twice per run: once over the in-progress period, then again over the period that just closed. Without the second pass, a qualifying transaction landing after a period's final hourly tick but before the calendar boundary would fall into no window at all — the next run starts fresh at the new boundary and never looks back. Re-evaluating the closed period is harmless because the key already exists.
Neither the create nor the update endpoint accepts period, so on a normal
install every condition sits on DAILY. Changing it is a database edit, and it
changes the payout cadence: DAILY is one reward per referred user per day,
MONTHLY is one per month over a much larger summed volume.
Idempotency — and why a deleted reward never comes back
Every reward this job creates carries a deterministic sourceId:
${conditionId}_${referrerId}_${referredId}_${periodKey}mlm_referral_reward.sourceId has a unique index
(mlmReferralRewardSourceIdUnique), and the existence check runs with
paranoid: false — it reads soft-deleted rows too.
A reward you delete from Admin → Affiliate → Rewards is soft-deleted: the
row keeps its sourceId, the next hourly run sees that key already taken, and
it does not recreate the reward. Deleting is a decision, not a refresh.
If you deleted one by mistake, the only way back is to create a replacement by hand or to credit the member's wallet directly. There is no "re-run for that period" that will restore it.
The same key is what makes a manual Run now safe: a second run inside the
same period re-reads the same volume, computes the same keys, finds them all
present and creates nothing. A concurrent run that loses the race is caught by
the unique constraint and logged as
Reward already exists for <sourceId> (unique constraint).
Currency — the conversion happens before the sum
A Bicrypto transaction row carries no currency of its own; the amount is
denominated by the wallet it sits on. The reward, however, is credited verbatim
in the condition's rewardCurrency at claim time. So every amount is converted
into that currency before it is summed and before it is compared to
minAmount:
- Rates are built with
getConversionRates, which prices both legs through USD.exchangeCurrency.priceis USD per unit and multiplies;currency.price(fiat) is units per USD and inverts. minAmountis converted into each wallet's currency so the threshold stays in SQL, per transaction. Applying it afterSUM()would let ten sub-threshold trades qualify as one.- A currency neither price table can quote is excluded, never counted 1:1 — counting an unpriced token at parity is how 0.4 BTC becomes 0.40.
Two consequences an operator has to know:
If the reward currency itself cannot be priced, that condition pays nobody — including activity already denominated in that currency. With no denominator there is nothing to value against, so the whole condition is skipped.
If a referred user's wallet currency cannot be priced, that volume silently does not count. The member traded, the condition is fine, and the reward is smaller than it should be. The job says which currencies it dropped.
Reading the run — and the two different logs
This is the single most misunderstood part of the job, and it is worth being blunt about.
| Where | What it contains | Persisted? |
|---|---|---|
| The live log on the cron screen | Every line the job emits — each reward it creates, each condition it skips, each qualifying count | No. Nothing is stored. |
pm2 logs cron (module prefixes MLM_CRON, MLM_CONDITION_EVAL, MLM_REWARD_CREATE) |
Errors, and the configuration warnings — rate-limited | Yes, to the PM2 log file |
The live log is produced only while somebody has the cron screen open. The
job checks whether the dashboard has an audience before it builds a log message
at all; with nobody watching, those lines are never created. So "read the cron
log" means: open Admin → System → Scheduled Tasks, press Run now on
Process MLM Referral Conditions, and watch. Reading it after the fact is not
possible for anything except the error and warning lines, which also go to
pm2 logs cron. See Logs for where those
files live.
The lines that mean something is wrong
| Line | What it means | Fix |
|---|---|---|
Unknown condition type: X |
The condition's type is not one the evaluator maps to any ledger entry. It will never pay. |
Almost always a hand-edited or imported row. Set type to a supported value on Admin → Affiliate → Conditions. |
Condition type X is settled by its own addon at the moment of the trade, not on a schedule — skipping |
Deliberate, and logged as info, not a warning. FOREX_TRADING is mapped to no ledger type on purpose. |
Nothing. If that rule is not paying, look at the Forex Trading addon. |
Condition X maps to unknown transaction type(s): … |
The type maps to a transaction type the ledger enum does not contain — a schema drift. The condition quietly pays nobody. | Report it; this is a code/schema mismatch, not a setting. |
Condition <id> has no rewardCurrency; skipping (its reward could not be denominated) |
The condition's rewardCurrency is blank. |
Edit the condition and set a currency your platform prices. |
Condition <id>: no rate for the reward currency USD — cannot value any activity |
Nothing can be valued against that currency. Every condition paying in it is stopped. | Add a rate for it, or retarget the conditions. New conditions seed as SPOT/USDT; older installs may still be on FIAT/USD. |
Condition <id>: no rate for NGN, XYZ — that volume is excluded |
Referred users hold wallets in currencies the platform cannot price. Their volume earns nothing. | Price those currencies, or accept the under-count knowingly. |
Condition <id>: none of the N referred user(s) hold any wallet yet — nothing to evaluate |
Real, and usually early-life: referrals exist, none of them has a wallet, so there are no transactions to sum. | Nothing to fix. It is not a fault. |
Skipping reward for condition <id>: calculated amount is 0 |
The computed reward was zero or negative — a reward of 0, or a percentage of a rounding-error volume. The row is deliberately not created, so the period's key stays free and a corrected condition can still pay it. |
Check the condition's reward value. |
Why a broken condition produces one line, not thirty-five
The two rate warnings go through a deduplicator. Each is keyed on the problem, not the condition — thirty-five conditions paying in one unpriceable currency are one misconfiguration — and is emitted:
- at most once per run (in-memory), and
- at most once per four hours across runs, using the Redis key
mlm:cron:warn:<key>withNXand a 4-hour expiry. The key is shared across processes, so a split deployment does not multiply it.
The live console still gets one line every run, because that view is ephemeral and an operator watching it should see the current state. Only the persistent log is throttled.
The rate limiter fails open. Losing a real misconfiguration because Redis blinked is the failure this deduplication exists to avoid, so an unreachable Redis means more log lines, never fewer.
The payout wallet the job creates
When a reward is created, the job checks whether the referrer holds a wallet
of the condition's rewardWalletType and rewardCurrency, and creates one
through the wallet creation service if not. On success it logs
Created SPOT USDT wallet for referrer <id> so the reward can be claimed.
This is a head start, not a dependency. If creation fails — an ECO wallet
generates an on-chain address and can fail for reasons that have nothing to do
with the reward, such as a missing master wallet or a dead RPC — the job logs
Could not pre-create the … wallet for referrer <id>: <reason>. The claim endpoint will retry it. and carries on. The reward is still created. The
claim endpoint does the same get-or-create when the member presses Claim, so the
consequence of a failure here is that the failure resurfaces at claim time
instead of being visible to you now.
Wallets are created for the referrer only, never for referred users. An empty wallet has no transactions to sum, so creating them would silence the "nobody holds a wallet" diagnostic without making one person qualify.
Run states, and what a failure costs
The job broadcasts its state twice per run — running when it starts, then
completed or failed when it ends. The cron screen renders that in three
places: the job row's badge, the Attention tab count, and the detail
modal's recent-runs strip with its success rate.
| State | Where it shows | What it means for that hour |
|---|---|---|
running |
The row shows a running badge; the live log fills | In progress |
completed |
The row goes green, the run is added to recent runs with its duration | Rewards for that hour exist |
failed |
The row goes red, lastRunError is set, the job is counted in Attention until a later run succeeds |
Nothing was created by that run |
A failed run is not a lost period. The failure is raised before or during
evaluation, so the run's rewards were never created — and because the window is
a calendar period, not "the last hour", the next successful tick re-evaluates
the same period and creates them. A single failure costs an hour of delay, not
a day of commission.
Each run also re-evaluates the period that has just closed, so a job that fails
for a whole day and recovers the next still picks up yesterday's DAILY
rewards. What it does not reach is two periods back. A job that has been
failing across two consecutive periods — two days on a DAILY condition, two
months on a MONTHLY one — has permanently lost the older of them, because the
window only ever looks back one boundary. That is the point at which a stuck
cron becomes unrecoverable money rather than a delay, and it is the reason the
Attention tab's count is worth alerting on.
Individual conditions fail independently. An error inside one condition is
caught, logged as Error evaluating condition <id>: …, and the run continues
with the next condition — so a single broken rule does not stop the other
thirty-four, and the run still reports completed.
Running it by hand
-
Open Admin → System → Scheduled Tasks and set the category filter to MLM.
-
Click the job row to open its detail, or find Process MLM Referral Conditions in the list.
-
Switch the right-hand pane to Live log before you trigger it — the log is only produced while somebody is watching, and lines emitted before you look are gone.
-
Press Run now. It needs
manage.cron. A run already in progress returns a 409 rather than starting a second one. -
Read the run top to bottom. The line you are usually looking for is
Condition <id>: N referred user(s) qualify this period—N = 0on every condition, with no warnings above it, means the conditions are healthy and nobody transacted enough.
Manual runs are safe to repeat: the sourceId makes a second run inside the
same period a no-op.
"Nobody is being paid" — the check order
Work down this list. Each step is cheap and rules out everything below it.
-
Is the
mlmextension enabled? Admin → System → Extension Manager (/admin/system/extension). Disabled, the cron job is not registered and the event engine returns immediately. The affiliate screens still load and still show old data. -
Is the referral
ACTIVE? APENDINGreferral — the approval queue — is never read by this job, and approval is not retroactive. See Running the programme. -
Is the condition active? Inactive conditions are not loaded at all.
-
Which engine owns the rule — EVENT or CRON? No screen labels it. Open the condition on Admin → Affiliate → Conditions and read the System name in its banner: if it is one of the 19 event-driven names above, this job is the wrong place to look entirely.
-
Do the level percentages total 100% or less? Over 100%, the engine logs an error and creates nothing for the whole transaction. This guard lives in the event-driven path — see the note below.
-
Can the currency be priced? Both the condition's
rewardCurrencyand the referred users' wallet currencies. This is the failure that produces a completely silent, completely broken programme, and the run log names it explicitly.
Under BINARY or UNILEVEL, the level percentages on
Programme settings are applied by the event-driven
engine, which walks the sponsor chain and pays each level its share. The hourly
evaluator does not: it creates one reward for the immediate referrer on each
qualifying referral row, at the condition's full rate, and never reads the level
configuration.
So a cron-driven rule pays level 1 only, whatever structure you have configured, and the >100% guard cannot stop it. If you are costing a plan, cost the event-driven and cron-driven rules separately.
The event-driven engine notifies the referrer and raises an admin notification when it pays. This job does not — it creates the row and stops. Members find cron-driven commission by looking at their rewards screen, not by being told.
What this job cannot explain
- A reward that exists but cannot be claimed. That is the payout threshold
or the
withdraw_affiliateKYC gate — see Rewards and payouts. - A referral that never attached. Attribution happens at registration and is nothing to do with this job — see Troubleshooting.
- A tree that looks wrong. Placement and spillover are display-only and do not change who is paid — see Referral structures.