/admin/marketing, though only the referral programme
has a form there; loyalty definitions are published through /v1/panel.
Configuring the referral programme
referral_programs is a singleton row — the primary key is a boolean that must
be true. A second programme would mean two answers to “what does an invite
earn?”, and the customer would already have been told one of them.
Open /admin/marketing and scroll to the bottom card. Editing needs
settings.write, not a marketing permission: changing what an invite pays is
a commercial decision with a bill attached, so it sits beside the other things
that cost the installation money.
An installation that never configured a programme reads back the disabled
defaults —
RUB, first_paid_order, 90 days validity, no cap — rather than a
missing-row error.
Beneath the form sits the programme’s record to date: attributed, qualified,
rejected, and the money rewarded. It is placed there so a change is made against
evidence rather than against a guess.
What the API refuses, all with 422
- A
qualificationoutsidefirst_paid_order. It is a closed set because a free-text field would be a promise nothing enforces. - A currency that is not exactly three upper-case letters.
- A negative reward on either side.
- Enabled with both rewards at zero. That would be a scheme that promises customers something and pays nothing, which is worse than having no scheme: the invites still go out. The panel shows a red “pays nothing” alert and disables Save, so an operator learns this before pressing the button rather than after.
attributionValidityDaysat or below zero, and — when present at all — aninviterRewardCaporrewardExpiryDaysat or below zero.
referral.program.saved under the marketing
category.
What actually qualifies a referral
Attribution comes first, and only once
A customer’s referral code is ten base-32 characters matching^[A-Z0-9]{10}$,
minted lazily the first time they open their referral screen. The same generator runs in
the bot and in the web panel, so a code minted on one surface is attributable on
the other.
referral_attributions is keyed on the invited customer, inserted with
ON CONFLICT DO NOTHING. Attribution is therefore immutable and first-write-wins:
whoever invited a customer is settled the moment they arrive, and no later
/start link rewrites it. A self-referral is impossible by construction — the
table carries a CHECK that the two customers differ, and the insert also refuses
a code owned by the customer using it.
Qualification runs inside the settling transaction
Qualification and granting happen in the same database transaction that settles the qualifying order. That is what makes them exactly-once: a retried webhook, a replayed Stars update, or a manual re-approval cannot credit a wallet twice. All of the following must hold:- The programme is enabled and its
qualificationisfirst_paid_order. - The order buys subscription time:
purchase,extension,renewal,upgrade, ordowngrade. A wallet top-up, a shop purchase, a gift bought for somebody else, and an access code a distributor paid for are not looked at — they neither qualify the referral nor, by settling first, stop a later subscription order from qualifying it. - The order reached
paidorfulfilled. - A payment provider actually settled money —
paid_minornet ofwallet_minoris positive — so a fully wallet-funded order does not mint new value out of a referral. The wallet may hold this very referral’s invitee credit; only money from outside qualifies. - It is the customer’s first settled subscription order. Nothing earlier of
theirs with one of those operations sits in
paid,fulfilled,partially_refunded, orrefunded. - The attribution is still inside
attributionValidityDays. - The inviter is under
inviterRewardCap, counted over the rewards they already hold as an inviter. - That role has not already been granted for this pair.
Reward-once, and what a grant writes
referral_rewards is unique on (referred_user_id, role), where role is
inviter or invitee. Each grant writes, in the same transaction:
- a balanced double-entry ledger transaction — a
customer_walletcredit against aplatform_clearingdebit — with the idempotency keyreferral:<invited customer>:<role>; - the
referral_rewardsrow, inserted withON CONFLICT DO NOTHING; - an optional
expires_atderived fromrewardExpiryDays; - an audit event,
referral.reward_granted; qualified_atandqualifying_order_idstamped once on the attribution.
Referral review and reversal
Deciding that somebody gamed the programme and taking their reward back is an adverse decision about a customer, so the review routes sit behind the risk permissions rather than marketing ones — the same permissions that govern the blocklist.customerID is the invited customer, because that is the attribution’s key.
The body is {state, note} where state is clear, held, or rejected.
There is no panel screen for either route. Review is done through
/v1/panel
requests, and there is currently nothing that raises a signal automatically —
the held state and the signalled=true filter are reachable only by an
operator setting them.Signals ask for a person; they never punish
A signal holds a referral pair for review and moves it fromclear to held. It
can never move a pair to rejected on its own.
That is the whole policy. An automatic system that punishes customers silently is
one nobody can explain to the customer it punished, so the heuristic’s only power
is to ask for a human. A signal records what it saw in terms an operator can
check — counts and identifiers, never a device fingerprint or an IP address,
because a review surface is not a reason to widen what is retained.
Rejecting reverses through the ledger
Rejecting a pair walks every reward on it that has not already been reversed and writes a compensatingcorrection ledger transaction — a wallet debit against
platform clearing — keyed referral-reversal:<rewardID>. It then stamps
reversed_at, reversed_by, reversal_reason, and the reversal’s ledger
transaction id on the reward row. A CHECK constraint guarantees a reward cannot
read as reversed without the money having moved back.
It does not delete the original or edit it to zero. The ledger is append-only, and
“this was granted and then taken back, by this operator, for this reason” is a
different fact from “this was never granted” — the customer saw the balance either
way, and the record has to say so.
A rejection requires a non-empty note, because it takes money back from a
customer and the reason is what an operator quotes to them.
A reversal also returns the inviter’s cap slot, because the count that drives the
cap only sees live rewards. The decision is audited as panel.referral.reviewed
under the risk category, with the state and the note.
Loyalty
Definitions are versioned and immutable
loyalty_programs rows are versioned, and there is no update route at all.
Publishing a change means publishing the next version; the old one keeps
explaining the assignments made under it. At most one programme may be enabled,
enforced by a partial unique index, and publishing with enable: true disables
every other programme in the same statement.
A customer who reached gold under one set of thresholds should not silently fall
out of it because somebody changed the numbers. A standing stores the metric the
decision was made on and the version in force, so “why am I silver?” has an answer
that does not depend on what the thresholds say today.
Publishing is API-only:
POST needs
marketing.write; GET /v1/panel/loyalty/programs needs marketing.read.
What a tier is measured on
All three are facts Omniflow already records. That is deliberate: a loyalty model
that needs new tracking is a loyalty model that needs new consent.
Publication rules the domain refuses
Two of these aCHECK constraint cannot express, and all of them are 422:
- No tiers at all.
- The lowest tier’s threshold is not zero, so some customers would reach no tier at all. Every customer must have one.
- A higher tier worth less than a lower one — representable, and certainly a mistake.
- An unsupported metric, or a non-positive window for a windowed metric.
discountBps between 0 and 10000, tier codes and
thresholds unique within a programme, graceDays at or above zero, windowDays
above zero, and a three-letter upper-case currency.
Evaluation is reproducible, and demotion is slower than promotion
Evaluate takes the clock as an argument and touches no database, so the same
inputs always produce the same tier. A loyalty decision nobody can reproduce is
one nobody can defend when a customer disputes it.
The rules, in order:
- No current tier — take the earned tier.
- The held tier belongs to a superseded version — re-place the customer under the definition in force.
- Earned threshold at or above the held one — take the earned tier and clear the grace clock; the customer is not being carried.
- Below the held tier with a zero grace period — demote at once. That is a legitimate configuration rather than a bug.
- Below, with no clock running — keep the tier and start the clock at
now + graceDays. Nothing is written to history, because nothing changed. - Below, clock running and not elapsed — keep the tier.
- Below, clock elapsed — demote.
loyalty_standing_history is appended only when the
standing actually changed, so an operator pressing the button twice produces one
entry rather than two. History entries are written with the reason evaluation.
What the customer sees
/account/referrals renders the ladder, the customer’s rung, the metric the
decision was made on, the distance to the next rung, and a percentage through the
current band. It reads the tier the standing names rather than recomputing
one, because a customer inside their grace period holds a tier their current
metric no longer earns, and recomputing on page load would demote them the moment
they opened the page.
The payload answers enabled: false when no programme is published or the
programme has no tiers, and enabled: true, evaluated: false when the customer’s
standing belongs to a superseded version — the honest answer being that the
placement is pending re-evaluation, not that they have no tier.
The referral summary on the same screen reports invited, qualified, pending, and
rejected counts taken from the rows rather than derived by subtraction, because
an attribution can be qualified and later rejected on review and a subtraction
would report that pair twice. Money earned and money reversed are reported
separately, so a customer whose total dropped can see why rather than guess.
Nothing in that projection carries the invited customer’s identifier. An inviter
is entitled to know that an invite paid out, not to a list of who their friends
are.
Related
The wallet and the ledger
Where a referral credit lands and how a reversal reads.
Audience segments and campaigns
The
loyaltyTier filter, and everything else /admin/marketing does.Risk controls
The other decisions
risk.read and risk.write govern.Customer web panel
/account/referrals from the customer’s side.