diff --git a/docs/adr/0049-rank-periodic-bonus-on-the-house-edge.md b/docs/adr/0049-rank-periodic-bonus-on-the-house-edge.md new file mode 100644 index 000000000..327ea1c0e --- /dev/null +++ b/docs/adr/0049-rank-periodic-bonus-on-the-house-edge.md @@ -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. diff --git a/docs/modules/gamification.md b/docs/modules/gamification.md index 0711b8b10..d26a50adf 100644 --- a/docs/modules/gamification.md +++ b/docs/modules/gamification.md @@ -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 diff --git a/packages/core/src/contracts/adapters/wager-tracking.ts b/packages/core/src/contracts/adapters/wager-tracking.ts index b8d20ba25..8c81df410 100644 --- a/packages/core/src/contracts/adapters/wager-tracking.ts +++ b/packages/core/src/contracts/adapters/wager-tracking.ts @@ -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. */ @@ -88,7 +88,7 @@ export type WagerTrackingCommands = { recordWager(tx: unknown, args: WagerTrackingArgs): Promise; /** * 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; /** Optional: only a consumer that counts a product at settlement rather than at the wager. */ diff --git a/packages/core/src/contracts/schemas/events.ts b/packages/core/src/contracts/schemas/events.ts index 6c93ba51f..87831c841 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -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. @@ -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({ diff --git a/packages/core/src/contracts/schemas/platform-config.ts b/packages/core/src/contracts/schemas/platform-config.ts index 78b0f4573..63204f665 100644 --- a/packages/core/src/contracts/schemas/platform-config.ts +++ b/packages/core/src/contracts/schemas/platform-config.ts @@ -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 @@ -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(); diff --git a/packages/core/src/contracts/schemas/promo.ts b/packages/core/src/contracts/schemas/promo.ts index 4d7a31791..fe1a8d041 100644 --- a/packages/core/src/contracts/schemas/promo.ts +++ b/packages/core/src/contracts/schemas/promo.ts @@ -72,6 +72,10 @@ export type BonusForfeitReason = z.infer; export type BonusGrantEntryType = z.infer; export type PromoOfferStatus = z.infer; +/** 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; diff --git a/packages/core/src/engagement/notifications/__tests__/notification-event-map.test.ts b/packages/core/src/engagement/notifications/__tests__/notification-event-map.test.ts index 5515d1632..723804433 100644 --- a/packages/core/src/engagement/notifications/__tests__/notification-event-map.test.ts +++ b/packages/core/src/engagement/notifications/__tests__/notification-event-map.test.ts @@ -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' }, diff --git a/packages/core/src/engagement/notifications/contract/index.ts b/packages/core/src/engagement/notifications/contract/index.ts index 0f36e3d45..a2f2cefaf 100644 --- a/packages/core/src/engagement/notifications/contract/index.ts +++ b/packages/core/src/engagement/notifications/contract/index.ts @@ -87,6 +87,10 @@ export const NOTIFICATION_PARAM_KEYS = { 'dailyBonus', 'weeklyBonus', 'monthlyBonus', + 'dailyBonusPercent', + 'weeklyBonusPercent', + 'monthlyBonusPercent', + 'periodicBasis', ], }, 'promo.streak.milestone.reached': { required: ['day'], optional: [] }, diff --git a/packages/core/src/engagement/notifications/plugin.ts b/packages/core/src/engagement/notifications/plugin.ts index 8dfbd6d3f..d435d11ce 100644 --- a/packages/core/src/engagement/notifications/plugin.ts +++ b/packages/core/src/engagement/notifications/plugin.ts @@ -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 }, }), diff --git a/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts b/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts index 513cd6c32..239314927 100644 --- a/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts @@ -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, @@ -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({ credit }), logger, settings); +const serviceWith = (settings: RakebackSettings, periodicBasis?: RankPeriodicBasis) => + new RakebackService(() => mock({ credit }), logger, settings, periodicBasis); const betOn = ( service: RakebackService, @@ -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'], diff --git a/packages/core/src/promo/gamification/__tests__/rank-payout.int.test.ts b/packages/core/src/promo/gamification/__tests__/rank-payout.int.test.ts index 47e01212e..bf24881b2 100644 --- a/packages/core/src/promo/gamification/__tests__/rank-payout.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/rank-payout.int.test.ts @@ -7,6 +7,7 @@ import type { BonusGrantCommands, ExchangeRateReader, PlayEligibilityPort, + RankPeriodicBasis, WalletReader, } from '@openora/core/contracts'; import { migrate } from '../migrate.js'; @@ -14,6 +15,7 @@ import { promoPlayerRank, promoRankConfig, promoRankPeriodWager, + promoRankRoundWager, promoRankLevelUp, promoRankTier, } from '../schema/index.js'; @@ -34,7 +36,7 @@ const NOW = new Date('2026-09-22T00:00:00Z'); const YESTERDAY_NOON = new Date('2026-09-21T12:00:00Z'); const TWO_DAYS_AGO = new Date('2026-09-20T12:00:00Z'); -const service = () => +const service = (periodicBasis: RankPeriodicBasis = 'flat') => new RankPayoutService( db.drizzle, mock({ grant }), @@ -42,6 +44,7 @@ const service = () => mock({ convert }), mock({ getBalances }), logger, + periodicBasis, ); const tierId = async (key: string) => { @@ -78,10 +81,15 @@ const levelUp = async (id: string) => { const DAILY_PERIOD_KEY = 'rank-daily:2026-09-21T00'; /** What the player wagered inside the period the daily payout settles. */ -const wageredInPeriod = (userId: string, wagered: string, periodKey = DAILY_PERIOD_KEY) => +const wageredInPeriod = ( + userId: string, + wagered: string, + periodKey = DAILY_PERIOD_KEY, + margin = '0', +) => db.drizzle.db .insert(promoRankPeriodWager) - .values({ userId, kind: 'daily', periodKey, currency: 'USD', wagered }); + .values({ userId, kind: 'daily', periodKey, currency: 'USD', wagered, margin }); const holding = async (key: string, lastWageredAt: Date | null) => { const userId = randomUUID(); @@ -116,6 +124,7 @@ beforeEach(async () => { getBalances.mockResolvedValue({ activeCurrency: 'USD', balances: [] }); await db.drizzle.db.delete(promoRankLevelUp); await db.drizzle.db.delete(promoRankPeriodWager); + await db.drizzle.db.delete(promoRankRoundWager); await db.drizzle.db.delete(promoPlayerRank); await db.drizzle.db.delete(promoRankTier); await db.drizzle.db.delete(promoRankConfig); @@ -588,6 +597,116 @@ describe('paying a periodic bonus', () => { }); }); +describe('paying a periodic bonus on the house edge', () => { + /** Holds silver and wagered `wagered` yesterday, of which the house edge was `margin`. */ + const playedWithMargin = async (margin: string, wagered = '100') => { + const userId = await holding('silver', YESTERDAY_NOON); + await wageredInPeriod(userId, wagered, DAILY_PERIOD_KEY, margin); + return userId; + }; + + const silverPays = (dailyBonusPercent: string | null, dailyBonus: string | null) => + db.drizzle.db + .update(promoRankTier) + .set({ dailyBonusPercent, dailyBonus }) + .where(eq(promoRankTier.key, 'silver')); + + it('pays the tier percent of the house edge the player gave up in the period', async () => { + await silverPays('10', null); + const userId = await playedWithMargin('4'); + + const granted = await service('margin').payPeriodic('daily', NOW); + + expect(granted).toHaveLength(1); + expect(grant).toHaveBeenCalledWith(expect.anything(), { + userId, + currency: 'USD', + amount: '0.400000000000000000', + source: 'rank', + sourceRef: DAILY_PERIOD_KEY, + actor: { type: 'system' }, + terms: DAILY_TERMS, + }); + }); + + it('caps the bonus at the tier amount', async () => { + await silverPays('50', '0.5'); + await playedWithMargin('4'); + + await service('margin').payPeriodic('daily', NOW); + + expect(grant).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ amount: '0.500000000000000000' }), + ); + }); + + it('pays below the cap uncapped', async () => { + await silverPays('5', '0.5'); + await playedWithMargin('4'); + + await service('margin').payPeriodic('daily', NOW); + + expect(grant).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ amount: '0.200000000000000000' }), + ); + }); + + it('pays nothing for a tier without a percent, whatever its flat amount', async () => { + await silverPays(null, '0.5'); + await playedWithMargin('4'); + + expect(await service('margin').payPeriodic('daily', NOW)).toEqual([]); + expect(grant).not.toHaveBeenCalled(); + }); + + it('pays nothing on a zero margin, or one too small to survive the percent', async () => { + await silverPays('10', null); + await playedWithMargin('0'); + await playedWithMargin('0.000000000000000001'); + + expect(await service('margin').payPeriodic('daily', NOW)).toEqual([]); + expect(grant).not.toHaveBeenCalled(); + }); + + it('needs a counter for the period even when the operator does not require activity', async () => { + await silverPays('10', null); + await db.drizzle.db.update(promoRankConfig).set({ periodicRequiresActivity: false }); + await holding('silver', TWO_DAYS_AGO); + const userId = await playedWithMargin('4'); + + const granted = await service('margin').payPeriodic('daily', NOW); + + expect(granted.map((g) => g.userId)).toEqual([userId]); + }); + + it('still holds the minimum to the stake wagered, not the margin', async () => { + await silverPays('10', null); + await db.drizzle.db + .update(promoRankConfig) + .set({ periodicMinimumWager: '25', periodicRequiresActivity: false }); + const enough = await playedWithMargin('1', '25'); + await playedWithMargin('4', '24'); + + const granted = await service('margin').payPeriodic('daily', NOW); + + expect(granted.map((g) => g.userId)).toEqual([enough]); + }); + + it('leaves the flat amounts untouched when the basis is flat', async () => { + await silverPays('10', '0.5'); + await playedWithMargin('4'); + + await service().payPeriodic('daily', NOW); + + expect(grant).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ amount: '0.500000000000000000' }), + ); + }); +}); + describe('announcing a promotion', () => { /** A player the bet has promoted to `to`, last told about `from` (null: never told anything). */ const promoted = async (from: string | null, to: string) => { @@ -628,6 +747,10 @@ describe('announcing a promotion', () => { dailyBonus: '0.500000000000000000', weeklyBonus: null, monthlyBonus: null, + dailyBonusPercent: null, + weeklyBonusPercent: null, + monthlyBonusPercent: null, + periodicBasis: 'flat', }, ]); expect(await announcedTier(userId)).toBe(await tierId('silver')); @@ -797,3 +920,47 @@ describe('pruning settled period counters', () => { expect(await remaining()).toEqual([weekly]); }); }); + +describe('pruning round wagers', () => { + const DAY_MS = 24 * 60 * 60 * 1000; + const NOW = new Date('2026-09-22T00:00:00Z'); + + /** A round row created `daysAgo` days before `NOW`. */ + const round = async (daysAgo: number) => { + const [row] = await db.drizzle.db + .insert(promoRankRoundWager) + .values({ + userId: randomUUID(), + providerName: 'aggregator', + externalRoundId: randomUUID(), + currency: 'USD', + ladderCurrency: 'USD', + stake: '100', + margin: '4', + createdAt: new Date(NOW.getTime() - daysAgo * DAY_MS), + }) + .returning({ id: promoRankRoundWager.id }); + return row?.id ?? ''; + }; + + const remaining = async () => + (await db.drizzle.db.select({ id: promoRankRoundWager.id }).from(promoRankRoundWager)).map( + (row) => row.id, + ); + + it('deletes a round written more than 35 days ago, and keeps a recent one', async () => { + await round(40); + const recent = await round(30); + + expect(await service().pruneRoundWagers(NOW)).toBe(1); + expect(await remaining()).toEqual([recent]); + }); + + it('prunes when no periodic reward is configured and no kind was ever paid', async () => { + await db.drizzle.db.update(promoRankConfig).set({ rewards: {}, paidThrough: {} }); + await round(40); + + expect(await service().pruneRoundWagers(NOW)).toBe(1); + expect(await remaining()).toEqual([]); + }); +}); diff --git a/packages/core/src/promo/gamification/__tests__/rank.int.test.ts b/packages/core/src/promo/gamification/__tests__/rank.int.test.ts index cf20d398a..a6758474e 100644 --- a/packages/core/src/promo/gamification/__tests__/rank.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/rank.int.test.ts @@ -10,6 +10,7 @@ import { promoRankConfig, promoRankLevelUp, promoRankPeriodWager, + promoRankRoundWager, promoRankTier, } from '../schema/index.js'; import { DEFAULT_PAYOUT_ANCHORS } from '../contract/index.js'; @@ -90,6 +91,7 @@ afterAll(() => db.drop()); beforeEach(async () => { vi.clearAllMocks(); + await db.drizzle.db.delete(promoRankRoundWager); await db.drizzle.db.delete(promoRankLevelUp); await db.drizzle.db.delete(promoRankPeriodWager); await db.drizzle.db.delete(promoPlayerRank); @@ -98,6 +100,28 @@ beforeEach(async () => { await seedRankLadder(db.drizzle.db, LADDER); }); +describe('seeding rank percentages', () => { + it('refuses a seeded tier whose combined percentages exceed 100', async () => { + await expect( + seedRankLadder(db.drizzle.db, { + ...LADDER, + tiers: [ + { + key: 'excess', + name: 'Excess', + wagerThreshold: '1', + rakebackPercent: '60', + dailyBonusPercent: '50', + }, + ], + }), + ).rejects.toMatchObject({ cause: { code: '23514' } }); + expect( + await db.drizzle.db.select().from(promoRankTier).where(eq(promoRankTier.key, 'excess')), + ).toEqual([]); + }); +}); + describe('recording a wager toward the rank ladder', () => { it('accrues the exact stake of a ladder-currency wager, not the bonus-weighted amount', async () => { const userId = randomUUID(); @@ -312,6 +336,219 @@ describe('recording a wager toward the rank ladder', () => { }); }); +describe('counting the house edge toward the periodic bonus', () => { + const SLOT = { ...CASINO, rtp: '0.96' }; + + const counters = (userId: string) => + db.drizzle.db + .select({ + kind: promoRankPeriodWager.kind, + wagered: promoRankPeriodWager.wagered, + margin: promoRankPeriodWager.margin, + }) + .from(promoRankPeriodWager) + .where(eq(promoRankPeriodWager.userId, userId)) + .orderBy(promoRankPeriodWager.kind); + + const marginOf = async (userId: string) => { + const rows = await counters(userId); + expect(new Set(rows.map((row) => row.margin)).size).toBe(1); + return rows[0]?.margin; + }; + + const bet = (userId: string, args: { amount: string; realAmount?: string; currency?: string }) => + db.drizzle.db.transaction((tx) => + ranks.recordWager(tx, { + userId, + currency: args.currency ?? 'USDT', + amount: args.amount, + weightedAmount: args.amount, + realAmount: args.realAmount ?? args.amount, + context: SLOT, + round: { providerName: 'aggregator', externalRoundId: userId }, + }), + ); + + const rollback = (userId: string, realAmount: string) => + db.drizzle.db.transaction((tx) => + ranks.reverseWager(tx, { + userId, + currency: 'USDT', + round: { providerName: 'aggregator', externalRoundId: userId }, + realAmount, + }), + ); + + it('sums own-money stake × (1 − rtp) into every open period', async () => { + const userId = randomUUID(); + + await bet(userId, { amount: '100' }); + await bet(userId, { amount: '50' }); + + expect(await counters(userId)).toEqual( + ['daily', 'monthly', 'weekly'].map((kind) => ({ + kind, + wagered: '150.000000000000000000', + margin: '6.000000000000000000', + })), + ); + }); + + it('adds nothing for a bet with no rtp, or one outside zero to one', async () => { + const userId = randomUUID(); + + await wager(userId, '100'); + await wager(userId, '100', 'USDT', { ...CASINO, rtp: '96' }); + + expect(await marginOf(userId)).toBe('0.000000000000000000'); + expect((await rankOf(userId))?.lifetimeWagered).toBe('200.000000000000000000'); + }); + + it('adds nothing for the bonus-funded part of a stake', async () => { + const userId = randomUUID(); + + await bet(userId, { amount: '100', realAmount: '25' }); + await bet(userId, { amount: '100', realAmount: '0' }); + + expect(await marginOf(userId)).toBe('1.000000000000000000'); + }); + + it('converts the margin at the rate the stake converted at', async () => { + const userId = randomUUID(); + convert.mockResolvedValue('200'); + + await bet(userId, { amount: '0.002', currency: 'BTC' }); + + expect(await marginOf(userId)).toBe('8.000000000000000000'); + }); + + it('skips conversion for excluded products while preserving mixed-round reversal proportions', async () => { + const userId = randomUUID(); + await db.drizzle.db.update(promoRankConfig).set({ eligibleProducts: ['casino'] }); + await bet(userId, { amount: '100' }); + await db.drizzle.db.transaction((tx) => + ranks.recordWager(tx, { + userId, + currency: 'USDT', + amount: '100', + weightedAmount: '100', + realAmount: '100', + context: { ...SLOT, product: 'pvp' }, + round: { providerName: 'aggregator', externalRoundId: userId }, + }), + ); + await db.drizzle.db.transaction((tx) => + ranks.recordWager(tx, { + userId: randomUUID(), + currency: 'BTC', + amount: '1', + weightedAmount: '1', + realAmount: '1', + context: { ...SLOT, product: 'pvp' }, + round: { providerName: 'aggregator', externalRoundId: randomUUID() }, + }), + ); + expect(convert).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + await rollback(userId, '100'); + expect(await marginOf(userId)).toBe('2.000000000000000000'); + }); + + it('takes a rolled-back stake back out of the margin, and leaves the wagered counters', async () => { + const userId = randomUUID(); + await bet(userId, { amount: '100' }); + + await rollback(userId, '25'); + + expect(await marginOf(userId)).toBe('3.000000000000000000'); + expect((await counters(userId)).map((row) => row.wagered)).toEqual( + Array(3).fill('100.000000000000000000'), + ); + expect((await rankOf(userId))?.lifetimeWagered).toBe('100.000000000000000000'); + }); + + it('never takes the margin below zero', async () => { + const userId = randomUUID(); + await bet(userId, { amount: '10' }); + + await rollback(userId, '1000'); + + expect(await marginOf(userId)).toBe('0.000000000000000000'); + }); + + it('preserves small converted margins without intermediate truncation', async () => { + const userId = randomUUID(); + convert.mockResolvedValue('0.0000000003'); + await bet(userId, { amount: '0.00000001', currency: 'ETH' }); + expect(await marginOf(userId)).toBe('0.000000000012000000'); + }); + + it('reverses every contribution in a round with different RTP values', async () => { + const userId = randomUUID(); + for (const rtp of ['0.90', '0.99']) { + await db.drizzle.db.transaction((tx) => + ranks.recordWager(tx, { + userId, + currency: 'USDT', + amount: '100', + realAmount: '100', + weightedAmount: '100', + context: { ...CASINO, rtp }, + round: { providerName: 'aggregator', externalRoundId: userId }, + }), + ); + } + expect(await marginOf(userId)).toBe('11.000000000000000000'); + await rollback(userId, '200'); + expect(await marginOf(userId)).toBe('0.000000000000000000'); + }); + + it('bounds repeated and concurrent reversals by the round contribution', async () => { + const userId = randomUUID(); + await bet(userId, { amount: '100' }); + await wager(userId, '100', 'USDT', SLOT); + await Promise.all([rollback(userId, '100'), rollback(userId, '100')]); + expect(await marginOf(userId)).toBe('4.000000000000000000'); + expect( + await db.drizzle.db + .select() + .from(promoRankRoundWager) + .where(eq(promoRankRoundWager.userId, userId)), + ).toEqual([]); + }); + + it('retains tiny partial reversals and caps their total at the recorded margin', async () => { + const userId = randomUUID(); + await bet(userId, { amount: '0.00000001' }); + await rollback(userId, '0.000000005'); + expect(await marginOf(userId)).toBe('0.000000000200000000'); + await rollback(userId, '0.000000005'); + expect(await marginOf(userId)).toBe('0.000000000000000000'); + }); + + it('ignores an unknown round and another provider with the same round id', async () => { + const userId = randomUUID(); + await bet(userId, { amount: '100' }); + await db.drizzle.db.transaction((tx) => + ranks.reverseWager(tx, { + userId, + currency: 'USDT', + realAmount: '100', + round: { providerName: 'other', externalRoundId: userId }, + }), + ); + expect(await marginOf(userId)).toBe('4.000000000000000000'); + }); + + it('reverses the recorded margin after the product stops being eligible', async () => { + const userId = randomUUID(); + await bet(userId, { amount: '100' }); + await db.drizzle.db.update(promoRankConfig).set({ eligibleProducts: ['pvp'] }); + await rollback(userId, '50'); + expect(await marginOf(userId)).toBe('2.000000000000000000'); + }); +}); + describe('public rank lookup', () => { it('returns the tier key and name for each ranked user, null for one with no rank', async () => { const ranked = randomUUID(); diff --git a/packages/core/src/promo/gamification/contract/index.ts b/packages/core/src/promo/gamification/contract/index.ts index f72af18ed..a7b2f9a28 100644 --- a/packages/core/src/promo/gamification/contract/index.ts +++ b/packages/core/src/promo/gamification/contract/index.ts @@ -6,6 +6,7 @@ import { CurrencyTickerSchema, MoneyAmountSchema, RAKEBACK_BASES, + RANK_PERIODIC_BASES, TimestampSchema, UuidSchema, WAGER_PRODUCTS, @@ -24,12 +25,23 @@ export const RankTierSchema = z.object({ weeklyBonus: MoneyAmountSchema.nullable(), monthlyBonus: MoneyAmountSchema.nullable(), levelUpBonus: MoneyAmountSchema.nullable(), + /** + * Under the `margin` periodic basis, the percent of the period's margin each bonus pays, capped + * by the matching amount above (no cap when that is null). Null pays nothing of that kind. + */ + dailyBonusPercent: ContributionPercentSchema.nullable(), + weeklyBonusPercent: ContributionPercentSchema.nullable(), + monthlyBonusPercent: ContributionPercentSchema.nullable(), }); export type RankTier = z.infer; +/** `flat` pays the daily, weekly and monthly amounts; `margin` pays their percents of the margin. */ +const RankPeriodicBasisSchema = z.enum(RANK_PERIODIC_BASES); + export const PlayerRankSchema = z.object({ currency: CurrencyTickerSchema, + periodicBasis: RankPeriodicBasisSchema, lifetimeWagered: MoneyAmountSchema, tierId: UuidSchema.nullable(), tiers: z.array(RankTierSchema), @@ -39,6 +51,7 @@ export type PlayerRank = z.infer; export const RankLadderSchema = z.object({ currency: CurrencyTickerSchema, + periodicBasis: RankPeriodicBasisSchema, tiers: z.array(RankTierSchema), }); @@ -48,10 +61,14 @@ const MAX_TIERS = 50; /** * A tier as an operator submits it. No `id` means a tier to create; position is the entry's place - * in the array, so a ladder is reordered by reordering it. + * in the array, so a ladder is reordered by reordering it. A percent left out keeps what the tier + * holds, and is null on a new tier. */ const SubmittedTierSchema = RankTierSchema.omit({ id: true, position: true }).extend({ id: UuidSchema.optional(), + dailyBonusPercent: ContributionPercentSchema.nullable().optional(), + weeklyBonusPercent: ContributionPercentSchema.nullable().optional(), + monthlyBonusPercent: ContributionPercentSchema.nullable().optional(), }); export type SubmittedRankTier = z.infer; @@ -78,17 +95,50 @@ export const SetRankLadderInputSchema = z.object({ (tiers) => tiers.every((tier) => BONUS_FIELDS.every((field) => isAbsentOrPositive(tier[field]))), 'a bonus amount is either absent or above zero', + ) + .refine( + (tiers) => + tiers.every((tier) => + PERCENT_FIELDS.every((field) => isAbsentOrPositive(tier[field] ?? null)), + ), + 'a bonus percent is either absent or above zero', + ) + .refine( + (tiers) => !tiers.some(paysOverHouseEdge), + 'rakeback and the bonus percents of a tier add up to at most 100', ), }); export type SetRankLadderInput = z.infer; const BONUS_FIELDS = ['dailyBonus', 'weeklyBonus', 'monthlyBonus', 'levelUpBonus'] as const; +const PERCENT_FIELDS = ['dailyBonusPercent', 'weeklyBonusPercent', 'monthlyBonusPercent'] as const; // MoneyAmountSchema has already rejected anything negative or malformed, so a single non-zero // digit is the exact test for "above zero" - and it never rounds the way a float would. const isAbsentOrPositive = (amount: string | null) => amount === null || /[1-9]/.test(amount); +// ContributionPercentSchema allows at most two decimals, so hundredths add exactly as integers. +const hundredths = (percent: string | null | undefined) => { + const [whole = '0', fraction = ''] = (percent ?? '0').split('.'); + return Number(whole) * 100 + Number(fraction.padEnd(2, '0')); +}; + +/** + * One bet can pay rakeback and all three periodic bonuses, so their percents together stay at or + * under 100. On the `stake` rakeback basis rakeback is a share of turnover, not of the edge. A + * percent not set counts as zero. + */ +export const paysOverHouseEdge = (tier: { + rakebackPercent: string; + dailyBonusPercent?: string | null | undefined; + weeklyBonusPercent?: string | null | undefined; + monthlyBonusPercent?: string | null | undefined; +}) => + hundredths(tier.rakebackPercent) + + PERCENT_FIELDS.reduce((sum, field) => sum + hundredths(tier[field]), 0) > + 100 * 100; + /** What a rank pays out on a schedule: the level-up bonuses owed, and each periodic bonus. */ export const RANK_PAYOUT_KINDS = ['levelUp', 'daily', 'weekly', 'monthly'] as const; export const RankPayoutKindSchema = z.enum(RANK_PAYOUT_KINDS); diff --git a/packages/core/src/promo/gamification/drizzle/migrations/0022_rank_periodic_margin.sql b/packages/core/src/promo/gamification/drizzle/migrations/0022_rank_periodic_margin.sql new file mode 100644 index 000000000..73f401b62 --- /dev/null +++ b/packages/core/src/promo/gamification/drizzle/migrations/0022_rank_periodic_margin.sql @@ -0,0 +1,25 @@ +CREATE TABLE "promo_rank_round_wager" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "provider_name" text NOT NULL, + "external_round_id" text NOT NULL, + "currency" text NOT NULL, + "ladder_currency" text NOT NULL, + "stake" numeric(38, 18) NOT NULL, + "margin" numeric(38, 18) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "promo_rank_round_wager_non_negative" CHECK ("promo_rank_round_wager"."stake" >= 0 AND "promo_rank_round_wager"."margin" >= 0) +); +--> statement-breakpoint +ALTER TABLE "promo_rank_period_wager" ADD COLUMN "margin" numeric(38, 18) DEFAULT '0' NOT NULL;--> statement-breakpoint +ALTER TABLE "promo_rank_tier" ADD COLUMN "daily_bonus_percent" numeric(5, 2);--> statement-breakpoint +ALTER TABLE "promo_rank_tier" ADD COLUMN "weekly_bonus_percent" numeric(5, 2);--> statement-breakpoint +ALTER TABLE "promo_rank_tier" ADD COLUMN "monthly_bonus_percent" numeric(5, 2);--> statement-breakpoint +CREATE UNIQUE INDEX "promo_rank_round_wager_round_idx" ON "promo_rank_round_wager" USING btree ("user_id","provider_name","currency","external_round_id","ladder_currency");--> statement-breakpoint +CREATE INDEX "promo_rank_round_wager_created_idx" ON "promo_rank_round_wager" USING btree ("created_at");--> statement-breakpoint +ALTER TABLE "promo_rank_period_wager" ADD CONSTRAINT "promo_rank_period_wager_margin_non_negative" CHECK ("promo_rank_period_wager"."margin" >= 0);--> statement-breakpoint +ALTER TABLE "promo_rank_tier" ADD CONSTRAINT "promo_rank_tier_bonus_percent_bounds" CHECK (("promo_rank_tier"."daily_bonus_percent" is null OR ("promo_rank_tier"."daily_bonus_percent" > 0 AND "promo_rank_tier"."daily_bonus_percent" <= 100)) + AND ("promo_rank_tier"."weekly_bonus_percent" is null OR ("promo_rank_tier"."weekly_bonus_percent" > 0 AND "promo_rank_tier"."weekly_bonus_percent" <= 100)) + AND ("promo_rank_tier"."monthly_bonus_percent" is null OR ("promo_rank_tier"."monthly_bonus_percent" > 0 AND "promo_rank_tier"."monthly_bonus_percent" <= 100)));--> statement-breakpoint +ALTER TABLE "promo_rank_tier" ADD CONSTRAINT "promo_rank_tier_total_percent" CHECK ("promo_rank_tier"."rakeback_percent" + coalesce("promo_rank_tier"."daily_bonus_percent", 0) + + coalesce("promo_rank_tier"."weekly_bonus_percent", 0) + coalesce("promo_rank_tier"."monthly_bonus_percent", 0) <= 100); \ No newline at end of file diff --git a/packages/core/src/promo/gamification/drizzle/migrations/meta/0022_snapshot.json b/packages/core/src/promo/gamification/drizzle/migrations/meta/0022_snapshot.json new file mode 100644 index 000000000..6f5ccd0c7 --- /dev/null +++ b/packages/core/src/promo/gamification/drizzle/migrations/meta/0022_snapshot.json @@ -0,0 +1,2503 @@ +{ + "id": "f80a541b-a6ec-4b17-85dc-e81af7cb5147", + "prevId": "8b2b9593-414d-4801-810d-5501646ba100", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.promo_player_rank": { + "name": "promo_player_rank", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lifetime_wagered": { + "name": "lifetime_wagered", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "tier_id": { + "name": "tier_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "announced_tier_id": { + "name": "announced_tier_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_wagered_at": { + "name": "last_wagered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rakeback_boost_percent": { + "name": "rakeback_boost_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "rakeback_boost_expires_at": { + "name": "rakeback_boost_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_claimable_notified_at": { + "name": "last_claimable_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rakeback_reminder_checked_at": { + "name": "rakeback_reminder_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_player_rank_unannounced_idx": { + "name": "promo_player_rank_unannounced_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_player_rank\".\"announced_tier_id\" IS DISTINCT FROM \"promo_player_rank\".\"tier_id\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_player_rank_tier_id_promo_rank_tier_id_fk": { + "name": "promo_player_rank_tier_id_promo_rank_tier_id_fk", + "tableFrom": "promo_player_rank", + "tableTo": "promo_rank_tier", + "columnsFrom": ["tier_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_player_rank_userId_unique": { + "name": "promo_player_rank_userId_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": { + "promo_player_rank_lifetime_wagered_non_negative": { + "name": "promo_player_rank_lifetime_wagered_non_negative", + "value": "\"promo_player_rank\".\"lifetime_wagered\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_player_streak": { + "name": "promo_player_streak", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "current": { + "name": "current", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "best": { + "name": "best", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_qualifying_day": { + "name": "last_qualifying_day", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_player_streak_userId_unique": { + "name": "promo_player_streak_userId_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": { + "promo_player_streak_counts_non_negative": { + "name": "promo_player_streak_counts_non_negative", + "value": "\"promo_player_streak\".\"current\" >= 0 AND \"promo_player_streak\".\"best\" >= 0 AND \"promo_player_streak\".\"current\" <= \"promo_player_streak\".\"best\"" + } + }, + "isRLSEnabled": false + }, + "public.promo_race": { + "name": "promo_race", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_at": { + "name": "start_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_at": { + "name": "end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "prize_pool": { + "name": "prize_pool", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "positions": { + "name": "positions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "eligible_products": { + "name": "eligible_products", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_race_open_idx": { + "name": "promo_race_open_idx", + "columns": [ + { + "expression": "start_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "end_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_race\".\"closed_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_race_prize_pool_positive": { + "name": "promo_race_prize_pool_positive", + "value": "\"promo_race\".\"prize_pool\" > 0" + }, + "promo_race_dates_ordered": { + "name": "promo_race_dates_ordered", + "value": "\"promo_race\".\"end_at\" > \"promo_race\".\"start_at\"" + }, + "promo_race_eligible_products_set": { + "name": "promo_race_eligible_products_set", + "value": "cardinality(\"promo_race\".\"eligible_products\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_race_payout": { + "name": "promo_race_payout", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "race_id": { + "name": "race_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "promo_race_payout_race_id_user_id_idx": { + "name": "promo_race_payout_race_id_user_id_idx", + "columns": [ + { + "expression": "race_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_race_payout_race_id_idx": { + "name": "promo_race_payout_race_id_idx", + "columns": [ + { + "expression": "race_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_race_payout_race_id_promo_race_id_fk": { + "name": "promo_race_payout_race_id_promo_race_id_fk", + "tableFrom": "promo_race_payout", + "tableTo": "promo_race", + "columnsFrom": ["race_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_race_payout_position_positive": { + "name": "promo_race_payout_position_positive", + "value": "\"promo_race_payout\".\"position\" > 0" + }, + "promo_race_payout_amount_non_negative": { + "name": "promo_race_payout_amount_non_negative", + "value": "\"promo_race_payout\".\"amount\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_race_round_wager": { + "name": "promo_race_round_wager", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "race_id": { + "name": "race_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_round_id": { + "name": "external_round_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stake": { + "name": "stake", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "wagered": { + "name": "wagered", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "promo_race_round_wager_round_idx": { + "name": "promo_race_round_wager_round_idx", + "columns": [ + { + "expression": "race_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_race_round_wager_user_id_external_round_id_idx": { + "name": "promo_race_round_wager_user_id_external_round_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_race_round_wager_race_id_promo_race_id_fk": { + "name": "promo_race_round_wager_race_id_promo_race_id_fk", + "tableFrom": "promo_race_round_wager", + "tableTo": "promo_race", + "columnsFrom": ["race_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_race_round_wager_non_negative": { + "name": "promo_race_round_wager_non_negative", + "value": "\"promo_race_round_wager\".\"stake\" >= 0 AND \"promo_race_round_wager\".\"wagered\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_race_wager": { + "name": "promo_race_wager", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "race_id": { + "name": "race_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wagered": { + "name": "wagered", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_race_wager_race_id_user_id_idx": { + "name": "promo_race_wager_race_id_user_id_idx", + "columns": [ + { + "expression": "race_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_race_wager_race_id_wagered_idx": { + "name": "promo_race_wager_race_id_wagered_idx", + "columns": [ + { + "expression": "race_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "wagered", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_race_wager_race_id_promo_race_id_fk": { + "name": "promo_race_wager_race_id_promo_race_id_fk", + "tableFrom": "promo_race_wager", + "tableTo": "promo_race", + "columnsFrom": ["race_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_race_wager_non_negative": { + "name": "promo_race_wager_non_negative", + "value": "\"promo_race_wager\".\"wagered\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_rakeback_accrual": { + "name": "promo_rakeback_accrual", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "stake": { + "name": "stake", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "rolled_back_stake": { + "name": "rolled_back_stake", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "basis": { + "name": "basis", + "type": "promo_rakeback_basis", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "rtp": { + "name": "rtp", + "type": "numeric(19, 18)", + "primaryKey": false, + "notNull": false + }, + "rate_percent": { + "name": "rate_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "boost_percent": { + "name": "boost_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_game_key": { + "name": "provider_game_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_round_id": { + "name": "external_round_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "promo_rakeback_accrual_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'accrued'" + }, + "claim_id": { + "name": "claim_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "voided_by": { + "name": "voided_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "void_reason": { + "name": "void_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "promo_rakeback_accrual_user_id_status_idx": { + "name": "promo_rakeback_accrual_user_id_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rakeback_accrual_round_idx": { + "name": "promo_rakeback_accrual_round_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rakeback_accrual_claim_id_idx": { + "name": "promo_rakeback_accrual_claim_id_idx", + "columns": [ + { + "expression": "claim_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rakeback_accrual_unclaimed_idx": { + "name": "promo_rakeback_accrual_unclaimed_idx", + "columns": [ + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_rakeback_accrual\".\"status\" = 'accrued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rakeback_accrual_created_at_idx": { + "name": "promo_rakeback_accrual_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_rakeback_accrual_claim_id_promo_rakeback_claim_id_fk": { + "name": "promo_rakeback_accrual_claim_id_promo_rakeback_claim_id_fk", + "tableFrom": "promo_rakeback_accrual", + "tableTo": "promo_rakeback_claim", + "columnsFrom": ["claim_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_rakeback_accrual_bounds": { + "name": "promo_rakeback_accrual_bounds", + "value": "\"promo_rakeback_accrual\".\"amount\" > 0 AND \"promo_rakeback_accrual\".\"stake\" >= 0 AND \"promo_rakeback_accrual\".\"rolled_back_stake\" >= 0 AND \"promo_rakeback_accrual\".\"rolled_back_stake\" <= \"promo_rakeback_accrual\".\"stake\" AND (\"promo_rakeback_accrual\".\"rtp\" is null OR (\"promo_rakeback_accrual\".\"rtp\" > 0 AND \"promo_rakeback_accrual\".\"rtp\" <= 1))" + }, + "promo_rakeback_accrual_settlement": { + "name": "promo_rakeback_accrual_settlement", + "value": "(\"promo_rakeback_accrual\".\"status\" = 'claimed') = (\"promo_rakeback_accrual\".\"claim_id\" is not null)\n AND (\"promo_rakeback_accrual\".\"status\" = 'voided') = (\"promo_rakeback_accrual\".\"void_reason\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.promo_rakeback_claim": { + "name": "promo_rakeback_claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "promo_rakeback_claim_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_rakeback_claim_user_id_idempotency_key_idx": { + "name": "promo_rakeback_claim_user_id_idempotency_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promo_rakeback_claim_payout": { + "name": "promo_rakeback_claim_payout", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "claim_id": { + "name": "claim_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "transaction_id": { + "name": "transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "promo_rakeback_claim_payout_claim_id_currency_idx": { + "name": "promo_rakeback_claim_payout_claim_id_currency_idx", + "columns": [ + { + "expression": "claim_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rakeback_claim_payout_user_id_currency_idx": { + "name": "promo_rakeback_claim_payout_user_id_currency_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_rakeback_claim_payout_claim_id_promo_rakeback_claim_id_fk": { + "name": "promo_rakeback_claim_payout_claim_id_promo_rakeback_claim_id_fk", + "tableFrom": "promo_rakeback_claim_payout", + "tableTo": "promo_rakeback_claim", + "columnsFrom": ["claim_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_rakeback_claim_payout_amount_positive": { + "name": "promo_rakeback_claim_payout_amount_positive", + "value": "\"promo_rakeback_claim_payout\".\"amount\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_rank_challenge_claim": { + "name": "promo_rank_challenge_claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tier_id": { + "name": "tier_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cash_amount": { + "name": "cash_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "physical_item": { + "name": "physical_item", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cash_grant_id": { + "name": "cash_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "physical_fulfilled_at": { + "name": "physical_fulfilled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "physical_fulfilled_by": { + "name": "physical_fulfilled_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "physical_fulfillment_note": { + "name": "physical_fulfillment_note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "promo_rank_challenge_claim_tier_id_idx": { + "name": "promo_rank_challenge_claim_tier_id_idx", + "columns": [ + { + "expression": "tier_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rank_challenge_claim_unsettled_idx": { + "name": "promo_rank_challenge_claim_unsettled_idx", + "columns": [ + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_rank_challenge_claim\".\"settled_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rank_challenge_claim_fulfilment_queue_idx": { + "name": "promo_rank_challenge_claim_fulfilment_queue_idx", + "columns": [ + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_rank_challenge_claim\".\"physical_item\" is not null AND \"promo_rank_challenge_claim\".\"physical_fulfilled_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promo_rank_challenge_tier": { + "name": "promo_rank_challenge_tier", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wager_threshold": { + "name": "wager_threshold", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "cash_amount": { + "name": "cash_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "physical_item": { + "name": "physical_item", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_rank_challenge_tier_key_unique": { + "name": "promo_rank_challenge_tier_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + }, + "promo_rank_challenge_tier_position_unique": { + "name": "promo_rank_challenge_tier_position_unique", + "nullsNotDistinct": false, + "columns": ["position"] + } + }, + "policies": {}, + "checkConstraints": { + "promo_rank_challenge_tier_bounds": { + "name": "promo_rank_challenge_tier_bounds", + "value": "\"promo_rank_challenge_tier\".\"position\" >= 0 AND \"promo_rank_challenge_tier\".\"wager_threshold\" >= 0\n AND (\"promo_rank_challenge_tier\".\"cash_amount\" is null OR \"promo_rank_challenge_tier\".\"cash_amount\" > 0)\n AND (\"promo_rank_challenge_tier\".\"physical_item\" is not null OR \"promo_rank_challenge_tier\".\"cash_amount\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.promo_rank_challenge_wager": { + "name": "promo_rank_challenge_wager", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lifetime_wagered": { + "name": "lifetime_wagered", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_rank_challenge_wager_userId_unique": { + "name": "promo_rank_challenge_wager_userId_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": { + "promo_rank_challenge_wager_non_negative": { + "name": "promo_rank_challenge_wager_non_negative", + "value": "\"promo_rank_challenge_wager\".\"lifetime_wagered\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_rank_config": { + "name": "promo_rank_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "singleton_key": { + "name": "singleton_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "eligible_products": { + "name": "eligible_products", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "rakeback_eligible_products": { + "name": "rakeback_eligible_products", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"casino\",\"live-casino\",\"pvp\"}'" + }, + "rank_challenge_eligible_products": { + "name": "rank_challenge_eligible_products", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"casino\",\"live-casino\",\"pvp\"}'" + }, + "rewards": { + "name": "rewards", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "payout_currency": { + "name": "payout_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pay_in_player_currency": { + "name": "pay_in_player_currency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "periodic_requires_activity": { + "name": "periodic_requires_activity", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "periodic_minimum_wager": { + "name": "periodic_minimum_wager", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "payout_anchors": { + "name": "payout_anchors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"dailyHour\":0,\"weeklyDay\":1,\"monthlyDay\":1}'::jsonb" + }, + "paid_through": { + "name": "paid_through", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_rank_config_singletonKey_unique": { + "name": "promo_rank_config_singletonKey_unique", + "nullsNotDistinct": false, + "columns": ["singleton_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promo_rank_level_up": { + "name": "promo_rank_level_up", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tier_id": { + "name": "tier_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "reached_at": { + "name": "reached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "promo_rank_level_up_user_id_tier_id_idx": { + "name": "promo_rank_level_up_user_id_tier_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rank_level_up_unsettled_idx": { + "name": "promo_rank_level_up_unsettled_idx", + "columns": [ + { + "expression": "reached_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_rank_level_up\".\"settled_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_rank_level_up_amount_positive": { + "name": "promo_rank_level_up_amount_positive", + "value": "\"promo_rank_level_up\".\"amount\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_rank_period_wager": { + "name": "promo_rank_period_wager", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_key": { + "name": "period_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wagered": { + "name": "wagered", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "margin": { + "name": "margin", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_rank_period_wager_user_id_kind_period_key_idx": { + "name": "promo_rank_period_wager_user_id_kind_period_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rank_period_wager_kind_period_key_idx": { + "name": "promo_rank_period_wager_kind_period_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rank_period_wager_kind_updated_at_idx": { + "name": "promo_rank_period_wager_kind_updated_at_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_rank_period_wager_non_negative": { + "name": "promo_rank_period_wager_non_negative", + "value": "\"promo_rank_period_wager\".\"wagered\" >= 0" + }, + "promo_rank_period_wager_margin_non_negative": { + "name": "promo_rank_period_wager_margin_non_negative", + "value": "\"promo_rank_period_wager\".\"margin\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_rank_round_wager": { + "name": "promo_rank_round_wager", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_round_id": { + "name": "external_round_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ladder_currency": { + "name": "ladder_currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stake": { + "name": "stake", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "margin": { + "name": "margin", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_rank_round_wager_round_idx": { + "name": "promo_rank_round_wager_round_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ladder_currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_rank_round_wager_created_idx": { + "name": "promo_rank_round_wager_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_rank_round_wager_non_negative": { + "name": "promo_rank_round_wager_non_negative", + "value": "\"promo_rank_round_wager\".\"stake\" >= 0 AND \"promo_rank_round_wager\".\"margin\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_rank_tier": { + "name": "promo_rank_tier", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wager_threshold": { + "name": "wager_threshold", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "rakeback_percent": { + "name": "rakeback_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "daily_bonus": { + "name": "daily_bonus", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "weekly_bonus": { + "name": "weekly_bonus", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "monthly_bonus": { + "name": "monthly_bonus", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "level_up_bonus": { + "name": "level_up_bonus", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "daily_bonus_percent": { + "name": "daily_bonus_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "weekly_bonus_percent": { + "name": "weekly_bonus_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "monthly_bonus_percent": { + "name": "monthly_bonus_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_rank_tier_key_unique": { + "name": "promo_rank_tier_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "promo_rank_tier_bounds": { + "name": "promo_rank_tier_bounds", + "value": "\"promo_rank_tier\".\"position\" >= 0 AND \"promo_rank_tier\".\"wager_threshold\" >= 0\n AND \"promo_rank_tier\".\"rakeback_percent\" >= 0 AND \"promo_rank_tier\".\"rakeback_percent\" <= 100\n AND (\"promo_rank_tier\".\"daily_bonus\" is null OR \"promo_rank_tier\".\"daily_bonus\" > 0)\n AND (\"promo_rank_tier\".\"weekly_bonus\" is null OR \"promo_rank_tier\".\"weekly_bonus\" > 0)\n AND (\"promo_rank_tier\".\"monthly_bonus\" is null OR \"promo_rank_tier\".\"monthly_bonus\" > 0)\n AND (\"promo_rank_tier\".\"level_up_bonus\" is null OR \"promo_rank_tier\".\"level_up_bonus\" > 0)" + }, + "promo_rank_tier_bonus_percent_bounds": { + "name": "promo_rank_tier_bonus_percent_bounds", + "value": "(\"promo_rank_tier\".\"daily_bonus_percent\" is null OR (\"promo_rank_tier\".\"daily_bonus_percent\" > 0 AND \"promo_rank_tier\".\"daily_bonus_percent\" <= 100))\n AND (\"promo_rank_tier\".\"weekly_bonus_percent\" is null OR (\"promo_rank_tier\".\"weekly_bonus_percent\" > 0 AND \"promo_rank_tier\".\"weekly_bonus_percent\" <= 100))\n AND (\"promo_rank_tier\".\"monthly_bonus_percent\" is null OR (\"promo_rank_tier\".\"monthly_bonus_percent\" > 0 AND \"promo_rank_tier\".\"monthly_bonus_percent\" <= 100))" + }, + "promo_rank_tier_total_percent": { + "name": "promo_rank_tier_total_percent", + "value": "\"promo_rank_tier\".\"rakeback_percent\" + coalesce(\"promo_rank_tier\".\"daily_bonus_percent\", 0)\n + coalesce(\"promo_rank_tier\".\"weekly_bonus_percent\", 0) + coalesce(\"promo_rank_tier\".\"monthly_bonus_percent\", 0) <= 100" + } + }, + "isRLSEnabled": false + }, + "public.promo_streak_config": { + "name": "promo_streak_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "singleton_key": { + "name": "singleton_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "daily_min_wager": { + "name": "daily_min_wager", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "eligible_products": { + "name": "eligible_products", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "milestones": { + "name": "milestones", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reset_after_day": { + "name": "reset_after_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_streak_config_singletonKey_unique": { + "name": "promo_streak_config_singletonKey_unique", + "nullsNotDistinct": false, + "columns": ["singleton_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promo_streak_daily_wager": { + "name": "promo_streak_daily_wager", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wagered": { + "name": "wagered", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_streak_daily_wager_user_id_day_idx": { + "name": "promo_streak_daily_wager_user_id_day_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_streak_daily_wager_non_negative": { + "name": "promo_streak_daily_wager_non_negative", + "value": "\"promo_streak_daily_wager\".\"wagered\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_streak_milestone_grant": { + "name": "promo_streak_milestone_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reached_at": { + "name": "reached_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "announced_at": { + "name": "announced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "promo_streak_milestone_grant_user_id_day_idx": { + "name": "promo_streak_milestone_grant_user_id_day_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_streak_milestone_grant_unsettled_idx": { + "name": "promo_streak_milestone_grant_unsettled_idx", + "columns": [ + { + "expression": "reached_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_streak_milestone_grant\".\"settled_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_streak_milestone_grant_day_positive": { + "name": "promo_streak_milestone_grant_day_positive", + "value": "\"promo_streak_milestone_grant\".\"day\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_streak_round_wager": { + "name": "promo_streak_round_wager", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_round_id": { + "name": "external_round_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "stake": { + "name": "stake", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "wagered": { + "name": "wagered", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "promo_streak_round_wager_round_idx": { + "name": "promo_streak_round_wager_round_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_streak_round_wager_day_idx": { + "name": "promo_streak_round_wager_day_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_streak_round_wager_non_negative": { + "name": "promo_streak_round_wager_non_negative", + "value": "\"promo_streak_round_wager\".\"stake\" >= 0 AND \"promo_streak_round_wager\".\"wagered\" >= 0" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.promo_rakeback_accrual_status": { + "name": "promo_rakeback_accrual_status", + "schema": "public", + "values": ["accrued", "claimed", "reversed", "voided"] + }, + "public.promo_rakeback_basis": { + "name": "promo_rakeback_basis", + "schema": "public", + "values": ["stake", "margin"] + }, + "public.promo_rakeback_claim_outcome": { + "name": "promo_rakeback_claim_outcome", + "schema": "public", + "values": ["claimed", "auto_paid"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/promo/gamification/drizzle/migrations/meta/_journal.json b/packages/core/src/promo/gamification/drizzle/migrations/meta/_journal.json index 03660a20e..8b87b75f2 100644 --- a/packages/core/src/promo/gamification/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/promo/gamification/drizzle/migrations/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1793630935027, "tag": "0021_rakeback_claim", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1793799306817, + "tag": "0022_rank_periodic_margin", + "breakpoints": true } ] } diff --git a/packages/core/src/promo/gamification/plugin.ts b/packages/core/src/promo/gamification/plugin.ts index c3853b499..6d4c61e15 100644 --- a/packages/core/src/promo/gamification/plugin.ts +++ b/packages/core/src/promo/gamification/plugin.ts @@ -139,7 +139,13 @@ class CompositeWagerTracking implements WagerTrackingCommands { } const rankService = (c: TypedContainer) => - new RankService(c.get(DRIZZLE), c.get(EXCHANGE_RATE_READER), c.get(AUDIT_WRITER), logger); + new RankService( + c.get(DRIZZLE), + c.get(EXCHANGE_RATE_READER), + c.get(AUDIT_WRITER), + logger, + promoConfig(c).rankPeriodic.basis, + ); const streakService = (c: TypedContainer) => new StreakService(c.get(DRIZZLE), c.get(EXCHANGE_RATE_READER), logger); @@ -155,6 +161,7 @@ const rakebackService = (c: TypedContainer) => () => (c.has(WALLET_COMMANDS) ? c.get(WALLET_COMMANDS) : undefined), logger, promoConfig(c).rakeback, + promoConfig(c).rankPeriodic.basis, ); const rankChallengeService = (c: TypedContainer) => @@ -320,7 +327,8 @@ export default { return; } const count = await rankPayouts.pruneSettledPeriodWagers(); - logger.info({ count }, 'rank period counters pruned'); + const rounds = await rankPayouts.pruneRoundWagers(new Date()); + logger.info({ count, rounds }, 'rank period counters and round wagers pruned'); }, }); @@ -482,6 +490,7 @@ export default { c.get(EXCHANGE_RATE_READER), c.get(WALLET_READER), logger, + promoConfig(c).rankPeriodic.basis, ); streakPayouts = new StreakPayoutService({ drizzle: c.get(DRIZZLE), @@ -606,7 +615,11 @@ export default { return createGamificationRouter({ ranks: rankService(c), - admin: new RankAdminService(c.get(DRIZZLE), c.get(AUDIT_WRITER)), + admin: new RankAdminService( + c.get(DRIZZLE), + c.get(AUDIT_WRITER), + promoConfig(c).rankPeriodic.basis, + ), streaks, streakPayouts, onStreakClaimed: async ({ granted, cashPaid }) => { diff --git a/packages/core/src/promo/gamification/schema/index.ts b/packages/core/src/promo/gamification/schema/index.ts index 53a504b03..f23f6f322 100644 --- a/packages/core/src/promo/gamification/schema/index.ts +++ b/packages/core/src/promo/gamification/schema/index.ts @@ -37,6 +37,8 @@ import type { } from '../contract/index.js'; const money = () => decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }); +const percent = () => + decimal({ precision: CONTRIBUTION_PERCENT_PRECISION, scale: CONTRIBUTION_PERCENT_SCALE }); export const promoRankTier = pgTable( 'promo_rank_tier', @@ -55,6 +57,11 @@ export const promoRankTier = pgTable( weeklyBonus: money(), monthlyBonus: money(), levelUpBonus: money(), + // Under the `margin` periodic basis: the share of the period's margin paid, capped by the + // matching `*Bonus` amount. Null pays nothing of that kind. + dailyBonusPercent: percent(), + weeklyBonusPercent: percent(), + monthlyBonusPercent: percent(), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp({ withTimezone: true }) .notNull() @@ -71,6 +78,17 @@ export const promoRankTier = pgTable( AND (${t.monthlyBonus} is null OR ${t.monthlyBonus} > 0) AND (${t.levelUpBonus} is null OR ${t.levelUpBonus} > 0)`, ), + check( + 'promo_rank_tier_bonus_percent_bounds', + sql`(${t.dailyBonusPercent} is null OR (${t.dailyBonusPercent} > 0 AND ${t.dailyBonusPercent} <= 100)) + AND (${t.weeklyBonusPercent} is null OR (${t.weeklyBonusPercent} > 0 AND ${t.weeklyBonusPercent} <= 100)) + AND (${t.monthlyBonusPercent} is null OR (${t.monthlyBonusPercent} > 0 AND ${t.monthlyBonusPercent} <= 100))`, + ), + check( + 'promo_rank_tier_total_percent', + sql`${t.rakebackPercent} + coalesce(${t.dailyBonusPercent}, 0) + + coalesce(${t.weeklyBonusPercent}, 0) + coalesce(${t.monthlyBonusPercent}, 0) <= 100`, + ), ], ); @@ -201,6 +219,8 @@ export const promoRankPeriodWager = pgTable( periodKey: text().notNull(), currency: text().notNull(), wagered: money().notNull().default('0'), + /** Own-money stake × (1 − rtp) inside the period, the base of a `margin` periodic bonus. */ + margin: money().notNull().default('0'), updatedAt: timestamp({ withTimezone: true }) .notNull() .defaultNow() @@ -217,11 +237,43 @@ export const promoRankPeriodWager = pgTable( index('promo_rank_period_wager_kind_period_key_idx').on(t.kind, t.periodKey), index('promo_rank_period_wager_kind_updated_at_idx').on(t.kind, t.updatedAt), check('promo_rank_period_wager_non_negative', sql`${t.wagered} >= 0`), + check('promo_rank_period_wager_margin_non_negative', sql`${t.margin} >= 0`), ], ); export type PromoRankPeriodWager = typeof promoRankPeriodWager.$inferSelect; +/** + * Remaining native stake and ladder-currency margin, retained to bound round reversals. One row + * per staked round under either basis and for ineligible products too, kept until fully reversed + * or 35 days old. + */ +export const promoRankRoundWager = pgTable( + 'promo_rank_round_wager', + { + id: uuid().primaryKey().defaultRandom(), + userId: uuid().notNull(), + providerName: text().notNull(), + externalRoundId: text().notNull(), + currency: text().notNull(), + ladderCurrency: text().notNull(), + stake: money().notNull(), + margin: money().notNull(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('promo_rank_round_wager_round_idx').on( + t.userId, + t.providerName, + t.currency, + t.externalRoundId, + t.ladderCurrency, + ), + index('promo_rank_round_wager_created_idx').on(t.createdAt), + check('promo_rank_round_wager_non_negative', sql`${t.stake} >= 0 AND ${t.margin} >= 0`), + ], +); + /** * A level-up bonus a player has earned and the payout job has yet to settle. Written in the bet's * transaction with the amount the tier paid at that moment, so an amount an admin fills in later @@ -640,9 +692,6 @@ export const promoRankChallengeClaim = pgTable( export type PromoRankChallengeClaim = typeof promoRankChallengeClaim.$inferSelect; -const percent = () => - decimal({ precision: CONTRIBUTION_PERCENT_PRECISION, scale: CONTRIBUTION_PERCENT_SCALE }); - export const promoRakebackBasisEnum = pgEnum('promo_rakeback_basis', RAKEBACK_BASES); export const promoRakebackAccrualStatusEnum = pgEnum( 'promo_rakeback_accrual_status', diff --git a/packages/core/src/promo/gamification/seed/index.ts b/packages/core/src/promo/gamification/seed/index.ts index 36f7bc3f2..35302def5 100644 --- a/packages/core/src/promo/gamification/seed/index.ts +++ b/packages/core/src/promo/gamification/seed/index.ts @@ -15,6 +15,9 @@ export type RankTierSeed = { dailyBonus?: string | null; weeklyBonus?: string | null; monthlyBonus?: string | null; + dailyBonusPercent?: string | null; + weeklyBonusPercent?: string | null; + monthlyBonusPercent?: string | null; levelUpBonus?: string | null; }; diff --git a/packages/core/src/promo/gamification/service/rakeback.service.ts b/packages/core/src/promo/gamification/service/rakeback.service.ts index eaaa572a0..f8c58e9e9 100644 --- a/packages/core/src/promo/gamification/service/rakeback.service.ts +++ b/packages/core/src/promo/gamification/service/rakeback.service.ts @@ -3,6 +3,7 @@ import { isUsableRtp, type RakebackBasis, type RakebackPayout, + type RankPeriodicBasis, type WagerReversalArgs, type WagerTrackingArgs, type WagerTrackingCommands, @@ -67,6 +68,7 @@ export class RakebackService implements WagerTrackingCommands { private readonly getWallet: () => WalletCommands | undefined, private readonly logger: { warn: (context: object, message: string) => void }, private readonly settings: RakebackSettings = DEFAULT_SETTINGS, + private readonly periodicBasis: RankPeriodicBasis = 'flat', ) {} async recordWager(tx: DrizzleTx, args: WagerTrackingArgs): Promise { @@ -225,7 +227,12 @@ export class RakebackService implements WagerTrackingCommands { return null; } const [tier] = await tx - .select({ rakebackPercent: promoRankTier.rakebackPercent }) + .select({ + rakebackPercent: promoRankTier.rakebackPercent, + dailyBonusPercent: promoRankTier.dailyBonusPercent, + weeklyBonusPercent: promoRankTier.weeklyBonusPercent, + monthlyBonusPercent: promoRankTier.monthlyBonusPercent, + }) .from(promoRankTier) .where(eq(promoRankTier.id, rank.tierId)); if (!tier) { @@ -233,9 +240,34 @@ export class RakebackService implements WagerTrackingCommands { } const boostActive = rank.rakebackBoostExpiresAt !== null && rank.rakebackBoostExpiresAt > new Date(); + const boost = boostActive ? (rank.rakebackBoostPercent ?? ZERO) : ZERO; return { ratePercent: tier.rakebackPercent, - boostPercent: boostActive ? (rank.rakebackBoostPercent ?? ZERO) : ZERO, + boostPercent: this.settings.basis === 'margin' ? this.boostWithinEdge(tier, boost) : boost, }; } + + /** Trims the boost so rakeback and the margin bonuses one bet funds pay at most its whole edge. */ + private boostWithinEdge( + tier: { + rakebackPercent: string; + dailyBonusPercent: string | null; + weeklyBonusPercent: string | null; + monthlyBonusPercent: string | null; + }, + boost: string, + ): string { + const bonuses = + this.periodicBasis === 'margin' + ? [tier.dailyBonusPercent, tier.weeklyBonusPercent, tier.monthlyBonusPercent] + : []; + const headroom = bonuses.reduce( + (left, percent) => moneySubtract(left, percent ?? ZERO), + moneySubtract('100', tier.rakebackPercent), + ); + if (moneyCompare(headroom, ZERO) <= 0) { + return ZERO; + } + return moneyCompare(boost, headroom) > 0 ? headroom : boost; + } } diff --git a/packages/core/src/promo/gamification/service/rank-admin.service.ts b/packages/core/src/promo/gamification/service/rank-admin.service.ts index 7ae8c89a2..63938790e 100644 --- a/packages/core/src/promo/gamification/service/rank-admin.service.ts +++ b/packages/core/src/promo/gamification/service/rank-admin.service.ts @@ -1,5 +1,5 @@ import { asc, eq, inArray } from 'drizzle-orm'; -import type { AuditWritePort, Uuid } from '@openora/core/contracts'; +import type { AuditWritePort, RankPeriodicBasis, Uuid } from '@openora/core/contracts'; import { createDomainError, findOneOrThrow, @@ -113,6 +113,7 @@ export class RankAdminService { constructor( private readonly drizzle: DrizzleService, private readonly audit: AuditWritePort, + private readonly periodicBasis: RankPeriodicBasis = 'flat', ) {} async get() { @@ -124,7 +125,7 @@ export class RankAdminService { if (!lowest) { throw new RankLadderNotConfiguredError('default'); } - return toLadder(lowest.currency, tiers); + return toLadder(lowest.currency, tiers, this.periodicBasis); } async set(adminId: Uuid, input: SetRankLadderInput) { @@ -135,7 +136,7 @@ export class RankAdminService { .orderBy(asc(promoRankTier.position)) .for('update'); const [lowest] = locked; - const before = toLadder(lowest?.currency ?? input.currency, locked); + const before = toLadder(lowest?.currency ?? input.currency, locked, this.periodicBasis); assertKnownTiers(new Set(locked.map((tier) => tier.id)), input.tiers); assertLadderHolds(input.tiers); @@ -156,7 +157,7 @@ export class RankAdminService { .select({ ...TIER_COLUMNS, currency: promoRankTier.currency }) .from(promoRankTier) .orderBy(asc(promoRankTier.position)); - const ladder = toLadder(input.currency, saved); + const ladder = toLadder(input.currency, saved, this.periodicBasis); await this.audit.recordInTransaction(tx, { actorId: adminId, actorType: 'admin', @@ -259,6 +260,12 @@ async function writeTiers(tx: DrizzleTx, input: SetRankLadderInput) { await tx.update(promoRankTier).set(values).where(eq(promoRankTier.id, tier.id)); } } catch (err) { + // An update keeps any percent the input left out, so only the row as written shows the total. + if (uniqueConstraintName(err) === 'promo_rank_tier_total_percent') { + throw new RankLadderInvalidError( + 'rakeback and the bonus percents of a tier add up to at most 100', + ); + } if (uniqueConstraintName(err) === null) { throw err; } diff --git a/packages/core/src/promo/gamification/service/rank-payout.service.ts b/packages/core/src/promo/gamification/service/rank-payout.service.ts index 5d3ee0b9a..707882aea 100644 --- a/packages/core/src/promo/gamification/service/rank-payout.service.ts +++ b/packages/core/src/promo/gamification/service/rank-payout.service.ts @@ -5,11 +5,13 @@ import type { ExchangeRateReader, DomainEventPayload, PlayEligibilityPort, + RankPeriodicBasis, Uuid, WalletReader, } from '@openora/core/contracts'; import { moneyCompare, + moneyDivide, moneyScaleBy, type DrizzleService, type DrizzleTx, @@ -19,6 +21,7 @@ import { promoRankConfig, promoRankLevelUp, promoRankPeriodWager, + promoRankRoundWager, promoRankTier, type RankRewardTerms, type RankRewards, @@ -47,11 +50,20 @@ const BATCH = 500; const SETTLED_COUNTER_AGE_MS = 35 * 24 * 60 * 60 * 1000; const PERIOD_BONUS = { - daily: promoRankTier.dailyBonus, - weekly: promoRankTier.weeklyBonus, - monthly: promoRankTier.monthlyBonus, + daily: { amount: promoRankTier.dailyBonus, percent: promoRankTier.dailyBonusPercent }, + weekly: { amount: promoRankTier.weeklyBonus, percent: promoRankTier.weeklyBonusPercent }, + monthly: { amount: promoRankTier.monthlyBonus, percent: promoRankTier.monthlyBonusPercent }, } as const; +/** `margin × percent / 100`, capped at `cap` when there is one; null when it rounds to nothing. */ +function marginBonus(margin: string, percent: string, cap: string | null): string | null { + const earned = moneyDivide(moneyScaleBy(margin, percent), '100'); + if (moneyCompare(earned, '0') <= 0) { + return null; + } + return cap !== null && moneyCompare(earned, cap) > 0 ? cap : earned; +} + /** * Pays what a rank earns: the level-up bonuses `RankService.recordWager` recorded, and the * daily, weekly and monthly bonuses. Every payout is a grant through BONUS_GRANTS, whose @@ -74,6 +86,7 @@ export class RankPayoutService { private readonly rates: ExchangeRateReader, private readonly wallet: WalletReader, private readonly logger: Logger, + private readonly periodicBasis: RankPeriodicBasis = 'flat', ) {} /** @@ -143,6 +156,9 @@ export class RankPayoutService { dailyBonus: promoRankTier.dailyBonus, weeklyBonus: promoRankTier.weeklyBonus, monthlyBonus: promoRankTier.monthlyBonus, + dailyBonusPercent: promoRankTier.dailyBonusPercent, + weeklyBonusPercent: promoRankTier.weeklyBonusPercent, + monthlyBonusPercent: promoRankTier.monthlyBonusPercent, }) .from(promoPlayerRank) .innerJoin(promoRankTier, eq(promoRankTier.id, promoPlayerRank.tierId)) @@ -183,7 +199,7 @@ export class RankPayoutService { ) .returning({ userId: promoPlayerRank.userId }); if (caughtUp && !silent) { - announced.push(change); + announced.push({ ...change, periodicBasis: this.periodicBasis }); } } catch (err) { this.logger.error({ err, userId: change.userId }, 'rank change announcement failed'); @@ -226,10 +242,16 @@ export class RankPayoutService { }); for (const player of due) { - if (player.amount === null || (await this.isBlocked(player.userId))) { + const amount = + this.periodicBasis === 'flat' + ? player.amount + : player.percent === null || player.margin === null + ? null + : marginBonus(player.margin, player.percent, player.amount); + if (amount === null || (await this.isBlocked(player.userId))) { continue; } - const owed = { ...player, amount: player.amount }; + const owed = { userId: player.userId, currency: player.currency, amount }; try { const paid = await this.drizzle.db.transaction((tx) => this.grant(tx, owed, period.sourceRef, terms, payout), @@ -266,8 +288,10 @@ export class RankPayoutService { } /** - * Everyone a period owes: the players holding a rank that carries an amount and, when the - * operator asks for it, who wagered enough inside the period the payout is settling. + * Everyone a period owes: the players holding a rank that carries an amount (a percent, under + * the `margin` basis) and, when the operator asks for it, who wagered enough inside the period + * the payout is settling. A margin bonus is a share of what the period counted, so it always + * needs the period's counter. */ private playersOwed( kind: RankPeriodKind, @@ -276,18 +300,22 @@ export class RankPayoutService { after: string, activity: { requiresActivity: boolean; minimumWager: string | null }, ) { - const ranked = this.drizzle.db - .select({ - userId: promoPlayerRank.userId, - amount: bonus, - currency: promoRankTier.currency, - }) - .from(promoPlayerRank) - .innerJoin(promoRankTier, eq(promoRankTier.id, promoPlayerRank.tierId)); - const holdsAnAmount = [gt(promoPlayerRank.userId, after), isNotNull(bonus)]; - - if (!activity.requiresActivity) { - return ranked + const columns = { + userId: promoPlayerRank.userId, + amount: bonus.amount, + percent: bonus.percent, + currency: promoRankTier.currency, + }; + const holdsAnAmount = [ + gt(promoPlayerRank.userId, after), + isNotNull(this.periodicBasis === 'flat' ? bonus.amount : bonus.percent), + ]; + + if (this.periodicBasis === 'flat' && !activity.requiresActivity) { + return this.drizzle.db + .select({ ...columns, margin: sql`null` }) + .from(promoPlayerRank) + .innerJoin(promoRankTier, eq(promoRankTier.id, promoPlayerRank.tierId)) .where(and(...holdsAnAmount)) .orderBy(asc(promoPlayerRank.userId)) .limit(BATCH); @@ -295,7 +323,10 @@ export class RankPayoutService { // "Played in the period" is a question about the window that closed, answered by what the // player wagered inside it - not by whether they have played since. - return ranked + return this.drizzle.db + .select({ ...columns, margin: promoRankPeriodWager.margin }) + .from(promoPlayerRank) + .innerJoin(promoRankTier, eq(promoRankTier.id, promoPlayerRank.tierId)) .innerJoin( promoRankPeriodWager, and( @@ -525,6 +556,20 @@ export class RankPayoutService { return pruned; } + /** + * Deletes the round rows written more than the period-counter age ago, and returns how many. A + * rollback only reduces the period open when it lands, so this is measured from now, not from a + * watermark. A rollback of a pruned round takes no margin back. + */ + async pruneRoundWagers(now: Date): Promise { + // ponytail: one DELETE, fine daily on a table pruned as it grows; batch it if a long backlog + // ever makes a single statement hold its locks too long. + const result = await this.drizzle.db + .delete(promoRankRoundWager) + .where(lt(promoRankRoundWager.createdAt, new Date(now.getTime() - SETTLED_COUNTER_AGE_MS))); + return result.rowCount ?? 0; + } + /** Moves the kind's watermark forward, so no later run reaches back into a settled period. */ private async markPaid(kind: RankPeriodKind, end: Date) { await this.drizzle.db.update(promoRankConfig).set({ diff --git a/packages/core/src/promo/gamification/service/rank.service.ts b/packages/core/src/promo/gamification/service/rank.service.ts index 61ef44b17..6c6692e56 100644 --- a/packages/core/src/promo/gamification/service/rank.service.ts +++ b/packages/core/src/promo/gamification/service/rank.service.ts @@ -1,14 +1,19 @@ -import { asc, count, eq, inArray, sql } from 'drizzle-orm'; +import { and, asc, count, eq, inArray, sql } from 'drizzle-orm'; import type { AuditWritePort, ExchangeRateReader, + RankPeriodicBasis, + WagerReversalArgs, WagerTrackingArgs, WagerTrackingCommands, WagerTrackingWalletCredit, } from '@openora/core/contracts'; import { + houseEdge, makeNotFoundError, moneyCompare, + moneyMultiplyDivide, + moneySubtract, type DrizzleService, type DrizzleTx, } from '@openora/core/server'; @@ -25,6 +30,7 @@ import { promoRankConfig, promoRankLevelUp, promoRankPeriodWager, + promoRankRoundWager, promoRankTier, type PromoPlayerRank, type PromoRankTier, @@ -47,6 +53,7 @@ export class RankService implements WagerTrackingCommands { private readonly rates: ExchangeRateReader, private readonly audit: AuditWritePort, private readonly logger: { warn: (context: object, message: string) => void }, + private readonly periodicBasis: RankPeriodicBasis = 'flat', ) {} /** @@ -70,7 +77,7 @@ export class RankService implements WagerTrackingCommands { payoutAnchors: promoRankConfig.payoutAnchors, }) .from(promoRankConfig); - if (!config || !countsToward(config.eligibleProducts, args.context.product)) { + if (!config) { return []; } const ladder = await tx @@ -87,8 +94,10 @@ export class RankService implements WagerTrackingCommands { if (!lowest) { return []; } - const amount = - args.currency === lowest.currency + const eligible = countsToward(config.eligibleProducts, args.context.product); + const amount = !eligible + ? '0' + : args.currency === lowest.currency ? args.amount : await this.rates.convert(args.amount, args.currency, lowest.currency, tx); if (amount === null) { @@ -105,6 +114,42 @@ export class RankService implements WagerTrackingCommands { return []; } + // Counted under both periodic bases, so the counter is already full when an operator switches. + const nativeMargin = eligible ? houseEdge(args.realAmount, args.context.rtp) : '0'; + const margin = + args.currency === lowest.currency + ? nativeMargin + : moneyMultiplyDivide(amount, nativeMargin, args.amount); + + if (args.round && moneyCompare(args.realAmount, '0') > 0) { + await tx + .insert(promoRankRoundWager) + .values({ + userId: args.userId, + ...args.round, + currency: args.currency, + ladderCurrency: lowest.currency, + stake: args.realAmount, + margin, + }) + .onConflictDoUpdate({ + target: [ + promoRankRoundWager.userId, + promoRankRoundWager.providerName, + promoRankRoundWager.currency, + promoRankRoundWager.externalRoundId, + promoRankRoundWager.ladderCurrency, + ], + set: { + stake: sql`${promoRankRoundWager.stake} + ${args.realAmount}::numeric`, + margin: sql`${promoRankRoundWager.margin} + ${margin}::numeric`, + }, + }); + } + if (!eligible) { + return []; + } + // What the player wagered inside each open period, for the payouts that settle them. Upserted // per bet under the period's own key, so a bet placed after a period closed lands on the new // one and can never answer for the old. @@ -117,6 +162,7 @@ export class RankService implements WagerTrackingCommands { periodKey: openPeriodKey(kind, new Date(), config.payoutAnchors), currency: lowest.currency, wagered: amount, + margin, })), ) .onConflictDoUpdate({ @@ -127,6 +173,7 @@ export class RankService implements WagerTrackingCommands { ], set: { wagered: sql`${promoRankPeriodWager.wagered} + ${amount}::numeric`, + margin: sql`${promoRankPeriodWager.margin} + ${margin}::numeric`, updatedAt: sql`now()`, }, }); @@ -194,6 +241,76 @@ export class RankService implements WagerTrackingCommands { return []; } + /** + * Takes a rolled-back bet's margin out of the open period counters, never below zero. Wagered + * and lifetime wagered stay: a rank is never taken back. A rollback landing after its period + * closed reduces the period now open instead. + */ + async reverseWager(tx: DrizzleTx, args: WagerReversalArgs): Promise { + if (moneyCompare(args.realAmount, '0') <= 0) { + return; + } + const [config] = await tx + .select({ payoutAnchors: promoRankConfig.payoutAnchors }) + .from(promoRankConfig); + if (!config) { + return; + } + const rounds = await tx + .select() + .from(promoRankRoundWager) + .where( + and( + eq(promoRankRoundWager.userId, args.userId), + eq(promoRankRoundWager.providerName, args.round.providerName), + eq(promoRankRoundWager.currency, args.currency), + eq(promoRankRoundWager.externalRoundId, args.round.externalRoundId), + ), + ) + .orderBy(asc(promoRankRoundWager.ladderCurrency)) + .for('update'); + const now = new Date(); + let remaining = args.realAmount; + for (const round of rounds) { + if (moneyCompare(round.stake, '0') <= 0 || moneyCompare(remaining, '0') <= 0) { + continue; + } + const stake = moneyCompare(remaining, round.stake) >= 0 ? round.stake : remaining; + const margin = moneyMultiplyDivide(round.margin, stake, round.stake); + remaining = moneySubtract(remaining, stake); + if (moneyCompare(stake, round.stake) === 0) { + await tx.delete(promoRankRoundWager).where(eq(promoRankRoundWager.id, round.id)); + } else { + await tx + .update(promoRankRoundWager) + .set({ + stake: sql`${promoRankRoundWager.stake} - ${stake}::numeric`, + margin: sql`${promoRankRoundWager.margin} - ${margin}::numeric`, + }) + .where(eq(promoRankRoundWager.id, round.id)); + } + if (moneyCompare(margin, '0') <= 0) { + continue; + } + for (const kind of RANK_PERIOD_KINDS) { + await tx + .update(promoRankPeriodWager) + .set({ + margin: sql`GREATEST(0, ${promoRankPeriodWager.margin} - ${margin}::numeric)`, + updatedAt: sql`now()`, + }) + .where( + and( + eq(promoRankPeriodWager.userId, args.userId), + eq(promoRankPeriodWager.kind, kind), + eq(promoRankPeriodWager.periodKey, openPeriodKey(kind, now, config.payoutAnchors)), + eq(promoRankPeriodWager.currency, round.ladderCurrency), + ), + ); + } + } + } + /** The ladder as an operator configured it. No player data, so anyone may read it. */ async getLadder(): Promise { const tiers = await this.drizzle.db @@ -204,7 +321,7 @@ export class RankService implements WagerTrackingCommands { if (!lowest) { throw new RankLadderNotConfiguredError('default'); } - return toLadder(lowest.currency, tiers); + return toLadder(lowest.currency, tiers, this.periodicBasis); } /** @@ -255,6 +372,7 @@ export class RankService implements WagerTrackingCommands { const lifetimeWagered = row?.lifetimeWagered ?? '0'; return { currency: ladder.currency, + periodicBasis: ladder.periodicBasis, lifetimeWagered, tierId: row?.tierId ?? tierFor(ladder.tiers, lifetimeWagered)?.id ?? null, tiers: ladder.tiers, diff --git a/packages/core/src/promo/gamification/shared/rank-ladder.ts b/packages/core/src/promo/gamification/shared/rank-ladder.ts index 581a49718..6c19cb07d 100644 --- a/packages/core/src/promo/gamification/shared/rank-ladder.ts +++ b/packages/core/src/promo/gamification/shared/rank-ladder.ts @@ -1,3 +1,4 @@ +import type { RankPeriodicBasis } from '@openora/core/contracts'; import type { RankLadder } from '../contract/index.js'; import { promoRankTier, type PromoRankTier } from '../schema/index.js'; @@ -12,11 +13,19 @@ export const TIER_COLUMNS = { weeklyBonus: promoRankTier.weeklyBonus, monthlyBonus: promoRankTier.monthlyBonus, levelUpBonus: promoRankTier.levelUpBonus, + dailyBonusPercent: promoRankTier.dailyBonusPercent, + weeklyBonusPercent: promoRankTier.weeklyBonusPercent, + monthlyBonusPercent: promoRankTier.monthlyBonusPercent, }; type LadderRow = Pick; -export const toLadder = (currency: string, tiers: readonly LadderRow[]): RankLadder => ({ +export const toLadder = ( + currency: string, + tiers: readonly LadderRow[], + periodicBasis: RankPeriodicBasis, +): RankLadder => ({ currency, + periodicBasis, tiers: tiers.map(({ currency: _currency, ...tier }) => tier), }); diff --git a/packages/core/src/server/db/__tests__/query-helpers.test.ts b/packages/core/src/server/db/__tests__/query-helpers.test.ts index 8e4f88553..459f7b39b 100644 --- a/packages/core/src/server/db/__tests__/query-helpers.test.ts +++ b/packages/core/src/server/db/__tests__/query-helpers.test.ts @@ -13,6 +13,7 @@ import { moneyScaleBy, houseEdge, moneyDivide, + moneyMultiplyDivide, moneyAdd, moneySubtract, moneyFloorToScale, @@ -344,3 +345,17 @@ describe('moneyCeilToScale', () => { expect(moneyCeilToScale('0', 2)).toBe('0.00'); }); }); + +describe('moneyMultiplyDivide', () => { + it('retains precision until division', () => { + expect(moneyMultiplyDivide('0.0000000003', '0.0000000001', '0.00000001')).toBe( + '0.000000000003000000', + ); + expect(moneyMultiplyDivide('11', '25', '200')).toBe('1.375000000000000000'); + expect(moneyMultiplyDivide('1', '1', '3')).toBe('0.333333333333333333'); + expect(moneyMultiplyDivide('0', '1', '2')).toBe('0.000000000000000000'); + }); + it('rejects division by zero', () => { + expect(() => moneyMultiplyDivide('1', '2', '0')).toThrow(RangeError); + }); +}); diff --git a/packages/core/src/server/db/index.ts b/packages/core/src/server/db/index.ts index a35d01551..02254b2e7 100644 --- a/packages/core/src/server/db/index.ts +++ b/packages/core/src/server/db/index.ts @@ -17,6 +17,7 @@ export { isPositiveMoney, moneyScaleBy, moneyDivide, + moneyMultiplyDivide, moneyAdd, moneySubtract, moneyFloorToScale, diff --git a/packages/core/src/server/db/query-helpers.ts b/packages/core/src/server/db/query-helpers.ts index 6bffda0a5..74a1d109e 100644 --- a/packages/core/src/server/db/query-helpers.ts +++ b/packages/core/src/server/db/query-helpers.ts @@ -99,6 +99,15 @@ export function moneyScaleBy(amount: string, factor: string): string { return fromMinorUnits(product); } +/** Multiplies before dividing, truncating only the final result to money precision. */ +export function moneyMultiplyDivide(amount: string, multiplier: string, divisor: string): string { + const units = toMinorUnits(divisor); + if (units === 0n) { + throw new RangeError('moneyMultiplyDivide: division by zero'); + } + return fromMinorUnits((toMinorUnits(amount) * toMinorUnits(multiplier)) / units); +} + export function moneyDivide(dividend: string, divisor: string): string { const divisorUnits = toMinorUnits(divisor); if (divisorUnits === 0n) { diff --git a/packages/testing/src/__tests__/fixtures/test-rank-margin-config-plugin.ts b/packages/testing/src/__tests__/fixtures/test-rank-margin-config-plugin.ts new file mode 100644 index 000000000..90e527d79 --- /dev/null +++ b/packages/testing/src/__tests__/fixtures/test-rank-margin-config-plugin.ts @@ -0,0 +1,15 @@ +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; +import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; + +export default { + id: 'test-rank-margin-config', + dependsOn: ['identity'], + register(ctx) { + ctx.provide(PLATFORM_CONFIG, () => + definePlatformConfig({ + registration: { termsVersion: 'test-v1' }, + promo: { rankPeriodic: { basis: 'margin' } }, + }), + ); + }, +} satisfies Plugin; diff --git a/packages/testing/src/__tests__/promo-rank-admin.e2e.test.ts b/packages/testing/src/__tests__/promo-rank-admin.e2e.test.ts index 44ed54669..63ee91d76 100644 --- a/packages/testing/src/__tests__/promo-rank-admin.e2e.test.ts +++ b/packages/testing/src/__tests__/promo-rank-admin.e2e.test.ts @@ -49,6 +49,10 @@ const ladderRows = () => key: promoRankTier.key, wagerThreshold: promoRankTier.wagerThreshold, levelUpBonus: promoRankTier.levelUpBonus, + rakebackPercent: promoRankTier.rakebackPercent, + dailyBonusPercent: promoRankTier.dailyBonusPercent, + weeklyBonusPercent: promoRankTier.weeklyBonusPercent, + monthlyBonusPercent: promoRankTier.monthlyBonusPercent, }) .from(promoRankTier) .orderBy(promoRankTier.position); @@ -72,6 +76,9 @@ const editable = (tier: { dailyBonus: string | null; weeklyBonus: string | null; monthlyBonus: string | null; + dailyBonusPercent: string | null; + weeklyBonusPercent: string | null; + monthlyBonusPercent: string | null; levelUpBonus: string | null; }) => ({ id: tier.id, @@ -82,6 +89,9 @@ const editable = (tier: { dailyBonus: tier.dailyBonus, weeklyBonus: tier.weeklyBonus, monthlyBonus: tier.monthlyBonus, + dailyBonusPercent: tier.dailyBonusPercent, + weeklyBonusPercent: tier.weeklyBonusPercent, + monthlyBonusPercent: tier.monthlyBonusPercent, levelUpBonus: tier.levelUpBonus, }); @@ -171,6 +181,101 @@ describe('an operator configuring the rank ladder', () => { expect(rows.at(-1)?.after).toMatchObject({ tiers: expect.any(Array) }); }); + it('saves a periodic bonus percent per tier and shows it to the player with the basis', async () => { + const payload = await currentPayload(); + const { client } = await registerAndMaterializePlayer(app, { + email: `rank-percent-${randomUUID()}@example.test`, + }); + + const res = await admin.put('/backoffice/promo/ranks', { + currency: payload.currency, + tiers: payload.tiers.map((tier: object, index: number) => + index === 1 ? { ...tier, dailyBonusPercent: '12.5', monthlyBonusPercent: '50' } : tier, + ), + }); + const seenByPlayer = await readJson(await client.get('/promo/ranks')); + + expect(res.status).toBe(200); + expect(seenByPlayer.periodicBasis).toBe('flat'); + expect(seenByPlayer.tiers[1]).toMatchObject({ + dailyBonusPercent: '12.50', + weeklyBonusPercent: null, + monthlyBonusPercent: '50.00', + }); + }); + + it.each(['0', '100.01', '-5'])('refuses a periodic bonus percent of %s', async (percent) => { + const auditedBefore = await auditCount(); + const payload = await currentPayload(); + + const res = await admin.put('/backoffice/promo/ranks', { + currency: payload.currency, + tiers: payload.tiers.map((tier: object, index: number) => + index === 1 ? { ...tier, weeklyBonusPercent: percent } : tier, + ), + }); + + expect(res.status).toBe(400); + expect(await auditCount()).toBe(auditedBefore); + }); + + describe('a tier paying more than the house edge', () => { + const silverAs = async (percents: object, strip: readonly string[] = []) => { + const payload = await currentPayload(); + return admin.put('/backoffice/promo/ranks', { + currency: payload.currency, + tiers: payload.tiers.map((tier: Record, index: number) => + index === 1 + ? Object.fromEntries( + Object.entries({ ...tier, ...percents }).filter(([key]) => !strip.includes(key)), + ) + : tier, + ), + }); + }; + + it('refuses rakeback and bonus percents adding up to more than 100', async () => { + const auditedBefore = await auditCount(); + + const res = await silverAs({ + rakebackPercent: '50', + dailyBonusPercent: '30', + weeklyBonusPercent: '10', + monthlyBonusPercent: '10.01', + }); + + expect(res.status).toBe(400); + expect(await auditCount()).toBe(auditedBefore); + }); + + it('accepts percents adding up to exactly 100', async () => { + const res = await silverAs({ + rakebackPercent: '50', + dailyBonusPercent: '30', + weeklyBonusPercent: '10', + monthlyBonusPercent: '10', + }); + + expect(res.status).toBe(200); + }); + + it('counts a stored percent the save left out', async () => { + expect((await silverAs({ rakebackPercent: '10', dailyBonusPercent: '60' })).status).toBe(200); + const auditedBefore = await auditCount(); + const before = await ladderRows(); + + const res = await silverAs({ rakebackPercent: '50' }, [ + 'dailyBonusPercent', + 'weeklyBonusPercent', + 'monthlyBonusPercent', + ]); + + expect(res.status).toBe(400); + expect(await ladderRows()).toEqual(before); + expect(await auditCount()).toBe(auditedBefore); + }); + }); + it('refuses thresholds that stop increasing and changes nothing', async () => { const auditedBefore = await auditCount(); const payload = await currentPayload(); diff --git a/packages/testing/src/__tests__/promo-rank-level-up.e2e.test.ts b/packages/testing/src/__tests__/promo-rank-level-up.e2e.test.ts index 37d0f81e0..18c27716a 100644 --- a/packages/testing/src/__tests__/promo-rank-level-up.e2e.test.ts +++ b/packages/testing/src/__tests__/promo-rank-level-up.e2e.test.ts @@ -228,6 +228,7 @@ describe('a player ranking up and being told about it', () => { dailyBonus: '5.000000000000000000', monthlyBonus: '50.000000000000000000', levelUpBonus: '10.000000000000000000', + periodicBasis: 'flat', }, data: { tierId: await tierId('silver') }, }), diff --git a/packages/testing/src/__tests__/promo-rank-margin.e2e.test.ts b/packages/testing/src/__tests__/promo-rank-margin.e2e.test.ts new file mode 100644 index 000000000..5f99c7e03 --- /dev/null +++ b/packages/testing/src/__tests__/promo-rank-margin.e2e.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { and, eq } from 'drizzle-orm'; +import { DRIZZLE, loadExtensions } from '@openora/core/server'; +import { WALLET_COMMANDS, JOB_QUEUE, queue } from '@openora/core/contracts'; +import { promoGrant } from '@openora/core/promo/schema/bonus'; +import { promoRankPeriodWager, promoRankTier } from '@openora/core/promo/schema/gamification'; +import { registerAndMaterializePlayer, bootE2e, closeE2e, readJson, type E2e } from '../index.js'; + +let e2e: E2e; +beforeAll(async () => { + e2e = await bootE2e({ + plugins: [ + ...(await loadExtensions()), + { + id: 'test-rank-margin-config', + path: fileURLToPath( + new URL('./fixtures/test-rank-margin-config-plugin.ts', import.meta.url), + ), + }, + ], + }); +}, 60_000); +afterAll(async () => { + await closeE2e(e2e); +}); + +describe('configured rank margin through wallet settlement', () => { + it('exposes the configured basis and reverses cash bets through the real bonus engine', async () => { + const { app } = e2e; + const db = app.container.get(DRIZZLE).db; + const wallet = app.container.get(WALLET_COMMANDS); + const { userId, client } = await registerAndMaterializePlayer(app, { + email: `rank-margin-${randomUUID()}@example.test`, + }); + expect((await readJson(await client.get('/promo/ranks'))).periodicBasis).toBe('margin'); + expect((await readJson(await app.app.request('/promo/ranks/ladder'))).periodicBasis).toBe( + 'margin', + ); + const round = randomUUID(); + await db.transaction((tx) => + wallet.credit(tx, { + userId, + currency: 'USDT', + amount: '300', + type: 'deposit', + allowNewWallet: true, + allowNewCurrency: true, + providerRef: { providerName: 'aggregator', providerRefId: randomUUID() }, + }), + ); + for (const rtp of ['0.90', '0.99']) { + expect( + await db.transaction((tx) => + wallet.debit(tx, { + userId, + currency: 'USDT', + amount: '100', + type: 'bet', + context: { provider: 'aggregator', product: 'casino', rtp }, + providerRef: { + providerName: 'aggregator', + providerRefId: randomUUID(), + externalRoundId: round, + }, + }), + ), + ).toMatchObject({ ok: true }); + } + const margins = async () => + ( + await db + .select({ margin: promoRankPeriodWager.margin }) + .from(promoRankPeriodWager) + .where(eq(promoRankPeriodWager.userId, userId)) + ).map((row) => row.margin); + expect(await margins()).toEqual(Array(3).fill('11.000000000000000000')); + for (let attempt = 0; attempt < 2; attempt++) { + expect( + await db.transaction((tx) => + wallet.credit(tx, { + userId, + currency: 'USDT', + amount: '200', + type: 'bet_reversal', + allowNewCurrency: true, + providerRef: { + providerName: 'aggregator', + providerRefId: randomUUID(), + externalRoundId: round, + }, + }), + ), + ).toMatchObject({ ok: true }); + expect(await margins()).toEqual(Array(3).fill('0.000000000000000000')); + } + }); + it('uses the configured margin basis when the real periodic payout job runs', async () => { + const { app } = e2e; + const db = app.container.get(DRIZZLE).db; + const wallet = app.container.get(WALLET_COMMANDS); + const { userId } = await registerAndMaterializePlayer(app, { + email: `rank-periodic-${randomUUID()}@example.test`, + }); + await db + .update(promoRankTier) + .set({ dailyBonusPercent: '10', dailyBonus: '100' }) + .where(eq(promoRankTier.key, 'bronze')); + expect( + await db.transaction((tx) => + wallet.credit(tx, { + userId, + currency: 'USDT', + amount: '100', + type: 'deposit', + allowNewWallet: true, + allowNewCurrency: true, + providerRef: { providerName: 'aggregator', providerRefId: randomUUID() }, + }), + ), + ).toMatchObject({ ok: true }); + expect( + await db.transaction((tx) => + wallet.debit(tx, { + userId, + currency: 'USDT', + amount: '100', + type: 'bet', + context: { provider: 'aggregator', product: 'casino', rtp: '0.90' }, + providerRef: { + providerName: 'aggregator', + providerRefId: randomUUID(), + externalRoundId: randomUUID(), + }, + }), + ), + ).toMatchObject({ ok: true }); + const [counter] = await db + .select() + .from(promoRankPeriodWager) + .where(and(eq(promoRankPeriodWager.userId, userId), eq(promoRankPeriodWager.kind, 'daily'))); + if (!counter) { + throw new Error('missing daily counter'); + } + const start = new Date(`${counter.periodKey.slice('rank-daily:'.length)}:00:00Z`); + start.setUTCDate(start.getUTCDate() - 1); + const sourceRef = `rank-daily:${start.toISOString().slice(0, 13)}`; + await db + .update(promoRankPeriodWager) + .set({ periodKey: sourceRef }) + .where(eq(promoRankPeriodWager.id, counter.id)); + await app.container.get(JOB_QUEUE).enqueue(queue('promo-rank-payout'), { kind: 'daily' }); + await vi.waitFor( + async () => { + const [grant] = await db + .select({ amount: promoGrant.grantedAmount }) + .from(promoGrant) + .where(and(eq(promoGrant.userId, userId), eq(promoGrant.sourceRef, sourceRef))); + expect(grant?.amount).toBe('1.000000000000000000'); + }, + { timeout: 10_000 }, + ); + }); +}); diff --git a/packages/testing/src/__tests__/promo-ranks.e2e.test.ts b/packages/testing/src/__tests__/promo-ranks.e2e.test.ts index 68b76994b..ed318fc32 100644 --- a/packages/testing/src/__tests__/promo-ranks.e2e.test.ts +++ b/packages/testing/src/__tests__/promo-ranks.e2e.test.ts @@ -77,7 +77,7 @@ describe('a player reading their rank', () => { expect(res.status).toBe(200); expect(body.currency).toBe('USDT'); expect(body.tiers.map((tier: { key: string }) => tier.key)).toEqual(LADDER_KEYS); - expect(Object.keys(body)).toEqual(['currency', 'tiers']); + expect(Object.keys(body)).toEqual(['currency', 'periodicBasis', 'tiers']); }); });