Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/adr/0049-rank-periodic-bonus-on-the-house-edge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# ADR-0049: Rank periodic bonus on the house edge

**Date**: 2026-10-29
**Status**: Accepted

## Context

Each rank tier pays a daily, weekly and monthly bonus. Until now that bonus was a flat amount,
granted to every player who held the tier and met the activity rule. A flat amount costs the
operator the same whether the player gave up a lot or a little to the house, so it is hard to
price safely on low-edge games.

ADR-0048 already carries the game's RTP on `WagerContext.rtp` and prices a bet's house edge,
`realAmount × (1 − rtp)`, with the shared `houseEdge` in `@openora/core/server`. The periodic
bonus can reuse it.

## Decision

**One config switch, defaulting to today.** `promo.rankPeriodic.basis` (`flat` | `margin`) lives
in platform config next to `promo.rakeback`, for the same reason: it changes what the platform
owes, so it ships with a deploy. `flat` behaves exactly as before.

**The tier sets a percent per kind.** `dailyBonusPercent`, `weeklyBonusPercent` and
`monthlyBonusPercent` are nullable, above 0 and at most 100, and are set through the rank
ladder admin. Under `margin` the bonus is
`min(period margin × percent / 100, cap)`. The cap is the tier's existing
`dailyBonus`/`weeklyBonus`/`monthlyBonus`, and null means uncapped. A null percent pays nothing
of that kind. A result that truncates to zero pays nothing. One bet can fund rakeback and all
three bonuses, so a tier whose percents total more than 100 is refused under either basis. A
database check enforces the total on the row as written, since a percent left out of a save
keeps its stored value. On the `margin` rakeback basis `RakebackService` trims an active
streak boost to the headroom the tier leaves, so rakeback, the boost and the `margin` bonuses never
pay more than the bet's edge. On the `stake` basis rakeback is a share of turnover and is not
bounded by the edge.

**The margin is counted on the period counter.** `promo_rank_period_wager` gains a `margin`
column that `RankService.recordWager` adds to in the same upsert as `wagered`. The margin is
converted into the ladder currency at the rate the stake converted at. Only own money counts. A
bet whose rtp is missing, zero or malformed adds zero, as `houseEdge` prices it. The margin is counted under both bases, so a
later switch to `margin` does not start the open periods from zero.

**Under `margin` a counter row is required.** No counter means no edge was given up, so nothing
is paid, whatever `periodicRequiresActivity` says. `periodicMinimumWager` still applies to the
stake `wagered`, not to the margin.

**A rollback lowers the margin.** `promo_rank_round_wager` retains each round's remaining
own-money stake and converted margin, so `WagerReversalArgs` needs no rtp. A reversal locks that
row and removes the reversed stake's proportional share of its recorded margin, bounded by what
remains. A partial reversal uses the round's blended margin. Exhausted rows are deleted; an
unknown round reverses nothing. Period counters are clamped at zero; `wagered` and lifetime
wagered stay monotonic.

**Visible to consumers.** The ladder, the player's rank and `promo.rank.changed` carry the
percents and `periodicBasis`. Under `margin` a client can present the amounts as caps.

## Consequences

- A margin bonus depends on the provider reporting `rtp`. A provider that does not earns its
players nothing under that basis.
- A rollback that lands after its period closed reduces the period now open, not the closed one.
The bonus for the closed period is already settled.
- A round row is written for every staked round, under `flat` and for ineligible products too,
so a mixed round reverses at its blended margin. The daily period prune deletes a row created
more than 35 days ago, whether or not any periodic payout has run, so the table holds about five
weeks of rounds. A rollback of a pruned round takes no margin back.
- Rounds recorded before this change have no standing margin to reverse.
29 changes: 29 additions & 0 deletions docs/modules/gamification.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,35 @@ ends when the next period closes and the stuck one is left behind. A retry re-co
of the moment, so a player already paid no longer matches their grant; that conflict, and a grant
the bonus engine refuses outright, count as settled and do not hold the watermark.

## What a periodic bonus is worth

`promo.rankPeriodic.basis` in platform config decides it, and defaults to `flat`.

- **`flat`** pays the tier's `dailyBonus`, `weeklyBonus` or `monthlyBonus` as is.
- **`margin`** pays the tier's `dailyBonusPercent`, `weeklyBonusPercent` or `monthlyBonusPercent`
of the house edge the player gave up in the period: the own-money stake times `1 - rtp`, summed
on the period counter as the bets happen, priced by the shared `houseEdge` rakeback uses. The flat amount
becomes a cap, and a null amount leaves the bonus uncapped. A tier with no percent for a kind
pays nothing of that kind. A bet whose rtp is missing, zero or malformed adds nothing.

A tier's `rakebackPercent` and its three bonus percents may total at most 100, under either basis,
since one bet can fund all four. On the `margin` rakeback basis a streak boost is trimmed to what
that total leaves, so rakeback, the boost and the `margin` bonuses pay at most the bet's whole
edge, and a tier already at 100 gets nothing from a boost. On the `stake` basis rakeback is a share
of turnover and can exceed the edge.

Under `margin` a player with no counter for the period is never paid, whatever the activity
setting, and the minimum is still measured on the stake wagered, not on the margin. A rollback
removes a proportional share of the round's recorded margin under a row lock, bounded by its
remaining own-money stake. The round retains its converted contribution, so differing RTPs,
changed rates, and repeated callbacks cannot remove another round's margin. Partial reversals
use the round's blended margin. Open period counters are clamped at zero; lifetime wagered stays
monotonic. A rollback after its period closes still reduces the new period. The round's row is
written under either basis and for ineligible products too. The same daily job deletes it once it
was created more than 35 days ago, measured from the clock rather than a watermark, because a
rollback only reduces the period open when it lands. A rollback of a pruned round takes no margin
back.

## What a reward is paid in

A ladder can be priced in a unit the wallet cannot hold - a fiat ticker on a crypto-only operator
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/contracts/adapters/wager-tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ export type WagerTrackingArgs = {
weightedAmount: string;
/**
* The part of `amount` staked out of the player's own funds - `amount` minus whatever a bonus
* grant covered. `RankService`'s lifetime-wagering counter intentionally ignores this and
* counts the full stake (see its own doc comment); every other consumer here - rakeback,
* grant covered. `RankService`'s wagered counters intentionally ignore this and count the full
* stake (see its own doc comment); every other counter here - the rank margin, rakeback,
* streak, races, the rank challenge - counts only this, since none of them may reward money
* the player never risked.
*/
Expand Down Expand Up @@ -88,7 +88,7 @@ export type WagerTrackingCommands = {
recordWager(tx: unknown, args: WagerTrackingArgs): Promise<WagerTrackingWalletCredit[]>;
/**
* Takes a rolled-back stake back out of whatever counter it advanced. Optional: a consumer whose
* counter is deliberately monotonic (the rank ladder) has nothing to undo.
* counter is deliberately monotonic has nothing to undo.
*/
reverseWager?(tx: unknown, args: WagerReversalArgs): Promise<void>;
/** Optional: only a consumer that counts a product at settlement rather than at the wager. */
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/contracts/schemas/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
BonusForfeitReasonSchema,
BonusGrantSourceSchema,
ContributionPercentSchema,
RANK_PERIODIC_BASES,
} from './promo.js';

// Optional request-origin metadata shared by HTTP-triggered events; both fields may be absent.
Expand Down Expand Up @@ -884,6 +885,11 @@ export const domainEventSchemas = {
dailyBonus: MoneyAmountSchema.nullable().optional(),
weeklyBonus: MoneyAmountSchema.nullable().optional(),
monthlyBonus: MoneyAmountSchema.nullable().optional(),
dailyBonusPercent: ContributionPercentSchema.nullable().optional(),
weeklyBonusPercent: ContributionPercentSchema.nullable().optional(),
monthlyBonusPercent: ContributionPercentSchema.nullable().optional(),
// Under `margin` the bonuses above are caps on a percent of the house edge, not flat amounts.
periodicBasis: z.enum(RANK_PERIODIC_BASES).optional(),
}),
// Standings frozen and prizes granted. Emitted once, after the settlement transaction.
'promo.race.settled': z.object({
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/contracts/schemas/platform-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CurrencyTickerInputSchema, MoneyAmountSchema } from './common.js';
import { AgentsConfigSchema } from './agents.js';
import { HostAllowlistEntrySchema, UrlPrefixAllowlistEntrySchema } from './host-allowlist.js';
import { createToken } from '../adapters/token.js';
import { RANK_PERIODIC_BASES } from './promo.js';

/**
* Platform-wide T0 configuration consumed by the slot evaluation context and
Expand Down Expand Up @@ -332,6 +333,16 @@ export const PromoConfigSchema = z
})
.strict()
.prefault({}),
rankPeriodic: z
.object({
/**
* `flat` pays each tier's daily, weekly and monthly bonus amount; `margin` pays the tier's
* percent of the period's own-money stake × (1 − rtp), capped by that amount.
*/
basis: z.enum(RANK_PERIODIC_BASES).default('flat'),
})
.strict()
.prefault({}),
})
.strict();

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/contracts/schemas/promo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ export type BonusForfeitReason = z.infer<typeof BonusForfeitReasonSchema>;
export type BonusGrantEntryType = z.infer<typeof BonusGrantEntryTypeSchema>;
export type PromoOfferStatus = z.infer<typeof PromoOfferStatusSchema>;

/** How the rank daily, weekly and monthly bonuses are sized; see `promo.rankPeriodic.basis`. */
export const RANK_PERIODIC_BASES = ['flat', 'margin'] as const;
export type RankPeriodicBasis = (typeof RANK_PERIODIC_BASES)[number];

// A weight as a percentage of the stake, `numeric(5,2)` in the database. Zero excludes a product
// or game from wagering; above 100 would count a bet for more than it was worth.
export const CONTRIBUTION_PERCENT_PRECISION = 5;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,10 @@ describe('notificationEventMap', () => {
dailyBonus: '1',
weeklyBonus: '5',
monthlyBonus: '20',
dailyBonusPercent: '10.00',
weeklyBonusPercent: '15.00',
monthlyBonusPercent: '20.00',
periodicBasis: 'margin',
},
'promo.streak.milestone.reached': { userId: id(), day: 7, grantId: null },
'promo.rakeback.claimable': { userId: id(), amount: '12.5', currency: 'USDT' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ export const NOTIFICATION_PARAM_KEYS = {
'dailyBonus',
'weeklyBonus',
'monthlyBonus',
'dailyBonusPercent',
'weeklyBonusPercent',
'monthlyBonusPercent',
'periodicBasis',
],
},
'promo.streak.milestone.reached': { required: ['day'], optional: [] },
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/engagement/notifications/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,10 @@ export const notificationEventMap: NotificationMapEntry[] = [
dailyBonus: p.dailyBonus,
weeklyBonus: p.weeklyBonus,
monthlyBonus: p.monthlyBonus,
dailyBonusPercent: p.dailyBonusPercent,
weeklyBonusPercent: p.weeklyBonusPercent,
monthlyBonusPercent: p.monthlyBonusPercent,
periodicBasis: p.periodicBasis,
},
data: { tierId: p.tierId },
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm';
import { createTestDb, type TestDb } from '@openora/core/testing';
import { mock } from '../../../testing/mock.js';
import type {
RankPeriodicBasis,
WagerContext,
WagerProduct,
WalletCommands,
Expand Down Expand Up @@ -65,8 +66,8 @@ const wager = (
const wagerOn = (userId: string, product: WagerProduct) =>
wager(userId, '100', '100', { provider: 'aggregator', product });

const serviceWith = (settings: RakebackSettings) =>
new RakebackService(() => mock<WalletCommands>({ credit }), logger, settings);
const serviceWith = (settings: RakebackSettings, periodicBasis?: RankPeriodicBasis) =>
new RakebackService(() => mock<WalletCommands>({ credit }), logger, settings, periodicBasis);

const betOn = (
service: RakebackService,
Expand Down Expand Up @@ -310,6 +311,71 @@ describe('rakeback on the house edge', () => {
expect(credit.mock.calls[0]?.[1]).toMatchObject({ amount: '0.240000000000000000' });
});

describe('a streak boost on a tier whose bonus percents leave 2 points of the edge', () => {
const boostBy = async (userId: string, boostPercent: string) => {
await givePlayerTier(userId, 'gold');
await db.drizzle.db
.update(promoRankTier)
.set({ dailyBonusPercent: '50', weeklyBonusPercent: '30', monthlyBonusPercent: '13' })
.where(eq(promoRankTier.key, 'gold'));
await db.drizzle.db
.update(promoPlayerRank)
.set({
rakebackBoostPercent: boostPercent,
rakebackBoostExpiresAt: new Date(Date.now() + 86_400_000),
})
.where(eq(promoPlayerRank.userId, userId));
};

it('is trimmed so rakeback and the margin bonuses pay at most the whole edge', async () => {
const userId = randomUUID();
await boostBy(userId, '5');

await betOn(serviceWith({ basis: 'margin', payout: 'instant' }, 'margin'), userId, '100', {
rtp: '0.96',
});

expect(credit.mock.calls[0]?.[1]).toMatchObject({ amount: '0.280000000000000000' });
});

it('records the trimmed boost on a claimable accrual', async () => {
const userId = randomUUID();
await boostBy(userId, '5');

await betOn(serviceWith({ basis: 'margin', payout: 'claim' }, 'margin'), userId, '100', {
rtp: '0.96',
});

expect(await accrualsOf(userId)).toEqual([
expect.objectContaining({
amount: '0.280000000000000000',
ratePercent: '5.00',
boostPercent: '2.00',
}),
]);
});

it('is paid in full under the flat periodic basis, where the bonus percents pay nothing', async () => {
const userId = randomUUID();
await boostBy(userId, '5');

await betOn(serviceWith({ basis: 'margin', payout: 'instant' }, 'flat'), userId, '100', {
rtp: '0.96',
});

expect(credit.mock.calls[0]?.[1]).toMatchObject({ amount: '0.400000000000000000' });
});

it('is paid in full on the stake basis, which pays on turnover', async () => {
const userId = randomUUID();
await boostBy(userId, '5');

await betOn(serviceWith({ basis: 'stake', payout: 'instant' }, 'margin'), userId, '100');

expect(credit.mock.calls[0]?.[1]).toMatchObject({ amount: '10.000000000000000000' });
});
});

it.each([
['missing', undefined],
['out of range', '1.2'],
Expand Down
Loading
Loading