diff --git a/packages/core/package.json b/packages/core/package.json index 89bf49280..8b0a24d62 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -294,6 +294,11 @@ "import": "./dist/engagement/notifications/contract/index.js", "default": "./dist/engagement/notifications/contract/index.js" }, + "./engagement/notifications": { + "types": "./dist/engagement/notifications/index.d.ts", + "import": "./dist/engagement/notifications/index.js", + "default": "./dist/engagement/notifications/index.js" + }, "./engagement/schema/chat": { "types": "./dist/engagement/chat/schema/index.d.ts", "import": "./dist/engagement/chat/schema/index.js", diff --git a/packages/core/src/casino/gaming/__tests__/gaming.service.int.test.ts b/packages/core/src/casino/gaming/__tests__/gaming.service.int.test.ts index d5693d750..00b661572 100644 --- a/packages/core/src/casino/gaming/__tests__/gaming.service.int.test.ts +++ b/packages/core/src/casino/gaming/__tests__/gaming.service.int.test.ts @@ -1018,6 +1018,49 @@ describe('GamingService.startRound wallet.balance.changed event (real PG)', () = expect(events.emit).not.toHaveBeenCalledWith('wallet.balance.changed', expect.anything()); }); + + it('also emits it for a WAGER_TRACKING credit (rank rakeback) alongside the bet debit', async () => { + const created = await seedGame({ id: '00000000-0000-0000-0000-0000000000a8', name: 'Aces' }); + const events = makeEventBus(); + const walletCommands = makeWalletCommands({ + ok: true, + moved: true, + newBalance: '90', + currency: 'USD', + transactionId: '00000000-0000-0000-0000-0000000000d2', + wagerTrackingCredits: [ + { + transactionId: '00000000-0000-0000-0000-0000000000d3', + amount: '0.10', + currency: 'USD', + }, + ], + }); + const svc = new GamingService( + db.drizzle, + events, + mock({ + launchGame: vi.fn().mockResolvedValue({ launchUrl: 'https://mock/play', token: 'tok' }), + endRound: vi.fn(), + }), + unrestricted, + walletCommands, + makeIdentityReader(), + ); + const userId = '00000000-0000-0000-0000-000000000408'; + + await startRound(svc, userId, created.id, 'USD', '10'); + + expect(events.emit).toHaveBeenCalledWith('wallet.balance.changed', { + userId, + playerId: null, + amount: '0.10', + currency: 'USD', + transactionId: '00000000-0000-0000-0000-0000000000d3', + type: 'cashback', + direction: 'credit', + }); + }); }); describe('GamingService updateGame (real PG)', () => { diff --git a/packages/core/src/casino/gaming/service/gaming.service.ts b/packages/core/src/casino/gaming/service/gaming.service.ts index 6336d745a..122b88abc 100644 --- a/packages/core/src/casino/gaming/service/gaming.service.ts +++ b/packages/core/src/casino/gaming/service/gaming.service.ts @@ -558,47 +558,49 @@ export class GamingService { // without one can never be settled back. const roundId = randomUUID(); - const { round, betTransactionId, completed } = await this.drizzle.db.transaction(async (tx) => { - // The same currency the RG pre-check above weighed. Left off, the debit falls on the - // player's active currency, and the two would then judge different moves. - const outcome = await this.walletCommands.debit(tx, { - userId, - amount: betAmount, - currency, - type: 'bet', - context: { provider: INTERNAL_ROUND_PROVIDER, product: INTERNAL_ROUND_PRODUCT, gameId }, - providerRef: { - providerName: INTERNAL_ROUND_PROVIDER, - providerRefId: `bet:${roundId}`, - externalRoundId: roundId, - }, + const { round, betTransactionId, completed, wagerTrackingCredits } = + await this.drizzle.db.transaction(async (tx) => { + // The same currency the RG pre-check above weighed. Left off, the debit falls on the + // player's active currency, and the two would then judge different moves. + const outcome = await this.walletCommands.debit(tx, { + userId, + amount: betAmount, + currency, + type: 'bet', + context: { provider: INTERNAL_ROUND_PROVIDER, product: INTERNAL_ROUND_PRODUCT, gameId }, + providerRef: { + providerName: INTERNAL_ROUND_PROVIDER, + providerRefId: `bet:${roundId}`, + externalRoundId: roundId, + }, + }); + if (!outcome.ok) { + throw new InsufficientBalanceError(outcome.available, betAmount); + } + const insertedRound = findOneOrThrow( + await tx + .insert(gameRound) + .values({ + id: roundId, + gameId, + userId, + currency, + betAmount, + status: 'active', + }) + .returning(), + new GameRoundNotFoundError(gameId), + ); + const moved = outcome.moved ? outcome : undefined; + return { + round: insertedRound, + completed: moved?.completed + ? { ...moved.completed, currency: outcome.currency } + : undefined, + betTransactionId: moved?.transactionId, + wagerTrackingCredits: moved?.wagerTrackingCredits ?? [], + }; }); - if (!outcome.ok) { - throw new InsufficientBalanceError(outcome.available, betAmount); - } - const insertedRound = findOneOrThrow( - await tx - .insert(gameRound) - .values({ - id: roundId, - gameId, - userId, - currency, - betAmount, - status: 'active', - }) - .returning(), - new GameRoundNotFoundError(gameId), - ); - const moved = outcome.moved ? outcome : undefined; - return { - round: insertedRound, - completed: moved?.completed - ? { ...moved.completed, currency: outcome.currency } - : undefined, - betTransactionId: moved?.transactionId, - }; - }); const playerId = await this.identityReader.getPlayerIdByUserIdSafe(userId); @@ -616,6 +618,20 @@ export class GamingService { }); } + // Whatever a WAGER_TRACKING consumer (rank rakeback) credited to the real balance inside the + // same transaction as the bet above - same post-commit rule as the bet debit itself. + for (const credit of wagerTrackingCredits) { + this.events.emit('wallet.balance.changed', { + userId, + playerId, + amount: credit.amount, + currency: credit.currency, + transactionId: credit.transactionId, + type: 'cashback', + direction: 'credit', + }); + } + // Post-commit: the money moved inside the transaction above, this only tells the player. if (completed) { this.events.emit('promo.bonus.completed', { diff --git a/packages/core/src/compliance/__tests__/kyc.service.int.test.ts b/packages/core/src/compliance/__tests__/kyc.service.int.test.ts index 5139682f8..305ff7bf6 100644 --- a/packages/core/src/compliance/__tests__/kyc.service.int.test.ts +++ b/packages/core/src/compliance/__tests__/kyc.service.int.test.ts @@ -480,7 +480,7 @@ describe('KycVerificationService.handleDeposit - threshold re-KYC (real PG)', () expect(statusWriter.setStatus).not.toHaveBeenCalled(); }); - it('ignores deposits in another currency than the player account', async () => { + it('skips evaluation (no report) when a deposit currency cannot be priced into the pivot', async () => { const { svc, statusWriter } = makeService({ config }); const { userId } = await seedPlayer({ currency: 'EUR' }); await seedDeposit(userId, '2000', 'EUR'); @@ -490,6 +490,24 @@ describe('KycVerificationService.handleDeposit - threshold re-KYC (real PG)', () expect(statusWriter.setStatus).not.toHaveBeenCalled(); }); + it('fires for a player whose own currency differs from the pivot, once their deposits price past the threshold', async () => { + const rates = makeRates({ EUR: '1.1' }); + const { svc, statusWriter } = makeService({ config, exchangeRateReader: rates }); + const { userId } = await seedPlayer({ currency: 'EUR' }); + await seedDeposit(userId, '1000', 'EUR'); + + await svc.handleDeposit(userId); + + expect(statusWriter.setStatus).toHaveBeenCalledWith( + userId, + 'resubmission_requested', + expect.objectContaining({ source: 'reverify' }), + expect.anything(), + ); + const [row] = await verificationsOf(userId); + expect(Number(row?.triggerDeposits)).toBe(1100); + }); + it('ignores a pending deposit that has not settled', async () => { const { svc, statusWriter } = makeService({ config }); const { userId } = await seedPlayer(); diff --git a/packages/core/src/compliance/__tests__/rg-self-service.int.test.ts b/packages/core/src/compliance/__tests__/rg-self-service.int.test.ts index 35b5c0adf..c83a64f65 100644 --- a/packages/core/src/compliance/__tests__/rg-self-service.int.test.ts +++ b/packages/core/src/compliance/__tests__/rg-self-service.int.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; import { randomUUID } from 'node:crypto'; -import { eq, sql } from 'drizzle-orm'; +import { and, eq, sql } from 'drizzle-orm'; import type { ExchangeRateReader, LoginEnforcementPort, @@ -22,7 +22,7 @@ import { player } from '@openora/core/pam/schema/profile'; import { makeIdentityReader, mock, makeEventBus } from '../../testing/mock.js'; import { migrate } from '../migrate.js'; import { userLimit, rgExclusion, rgFlag } from '../schema/index.js'; -import { RgService } from '../service/rg.service.js'; +import { RgService, LimitOrderingViolationError } from '../service/rg.service.js'; import { RgMonitoringService } from '../service/rg-monitoring.service.js'; import { RgSelfServiceService, @@ -249,6 +249,68 @@ describe('RgSelfServiceService.upsertLimit (real PG)', () => { }); }); +describe('RgSelfServiceService.upsertLimit ordering (real PG)', () => { + it('refuses an immediate write (a lowering) that would cross an existing sibling bound', async () => { + const { svc } = makeService(); + const userId = randomUUID(); + await svc.upsertLimit(userId, { ...deposit100, period: 'weekly', amount: '50' }); + + // A first-time daily write is immediate, and 80 > the weekly bound of 50. + await expect( + svc.upsertLimit(userId, { ...deposit100, period: 'daily', amount: '80' }), + ).rejects.toBeInstanceOf(LimitOrderingViolationError); + }); + + it('refuses a parked raise whose pending (effective) amount would cross an existing sibling bound', async () => { + const { svc } = makeService(); + const userId = randomUUID(); + await svc.upsertLimit(userId, { ...deposit100, period: 'daily', amount: '10' }); + await svc.upsertLimit(userId, { ...deposit100, period: 'weekly', amount: '20' }); + + // A raise on daily to 30 is parked (not applied), but 30 already exceeds the + // existing weekly=20 - it must be refused now, using the pending value. + await expect( + svc.upsertLimit(userId, { ...deposit100, period: 'daily', amount: '30' }), + ).rejects.toBeInstanceOf(LimitOrderingViolationError); + + const rows = await db.drizzle.db + .select() + .from(userLimit) + .where(and(eq(userLimit.userId, userId), eq(userLimit.period, 'daily'))); + expect(rows[0]?.pendingKind).toBeNull(); + }); + + it('accepts a valid ordered set across daily/weekly/monthly', async () => { + const { svc } = makeService(); + const userId = randomUUID(); + await svc.upsertLimit(userId, { ...deposit100, period: 'daily', amount: '10' }); + await svc.upsertLimit(userId, { ...deposit100, period: 'weekly', amount: '20' }); + const view = await svc.upsertLimit(userId, { ...deposit100, period: 'monthly', amount: '30' }); + + expect(view.amount).toBe('30.000000000000000000'); + }); + + it('refuses when a required cross-currency rate is missing, rather than skipping the check', async () => { + const { svc } = makeService(); + const userId = randomUUID(); + await svc.upsertLimit(userId, { + ...deposit100, + period: 'daily', + amount: '10', + currency: 'USD', + }); + + await expect( + svc.upsertLimit(userId, { + ...deposit100, + period: 'weekly', + amount: '20', + currency: 'EUR', + }), + ).rejects.toBeInstanceOf(LimitOrderingViolationError); + }); +}); + const BTC_USD_RATE = 50000; describe('RgSelfServiceService.getLimits - multi-currency usage', () => { @@ -346,6 +408,22 @@ describe('RgSelfServiceService.requestLimitRemoval (real PG)', () => { LimitOwnershipError, ); }); + + it('is never blocked by ordering - removing a bound only loosens it', async () => { + const { svc } = makeService(); + const userId = randomUUID(); + await svc.upsertLimit(userId, { ...deposit100, period: 'daily', amount: '20' }); + await svc.upsertLimit(userId, { ...deposit100, period: 'weekly', amount: '20' }); + const rows = await db.drizzle.db + .select() + .from(userLimit) + .where(and(eq(userLimit.userId, userId), eq(userLimit.period, 'weekly'))); + const weeklyRow = rows[0]!; + + const view = await svc.requestLimitRemoval(weeklyRow.id, userId); + + expect(view.pendingKind).toBe('removal'); + }); }); describe('RgSelfServiceService.confirmPendingChange (real PG)', () => { diff --git a/packages/core/src/compliance/__tests__/rg.service.int.test.ts b/packages/core/src/compliance/__tests__/rg.service.int.test.ts index 01fbe7e30..32c6c4505 100644 --- a/packages/core/src/compliance/__tests__/rg.service.int.test.ts +++ b/packages/core/src/compliance/__tests__/rg.service.int.test.ts @@ -20,6 +20,7 @@ import { ExclusionPeriodNotElapsedError, ExclusionNotFoundError, LimitRaiseNotAllowedError, + LimitOrderingViolationError, isWeakening, resolveLimitCurrency, RgLimitCurrencyUnresolvedError, @@ -490,6 +491,189 @@ describe('RgService.setPlayerLimit (real PG)', () => { }); }); +describe('RgService.setPlayerLimit ordering (real PG)', () => { + function limitInput(overrides: Partial[1]> = {}) { + return { + userId: '', + type: 'deposit' as const, + amount: '10', + minutes: null, + currency: 'USD', + period: 'daily' as const, + reason: 'admin override', + confirm: true as const, + ...overrides, + }; + } + + it('refuses setting daily above an existing weekly', async () => { + const { svc } = makeService(); + const userId = randomUUID(); + const actorId = randomUUID(); + await svc.setPlayerLimit( + userId, + limitInput({ userId, period: 'weekly', amount: '50' }), + actorId, + 'admin', + ); + + await expect( + svc.setPlayerLimit( + userId, + limitInput({ userId, period: 'daily', amount: '80' }), + actorId, + 'admin', + ), + ).rejects.toBeInstanceOf(LimitOrderingViolationError); + }); + + it('refuses setting weekly below an existing daily - even as a reduce-only override', async () => { + const { svc } = makeService(); + const userId = randomUUID(); + const actorId = randomUUID(); + await svc.setPlayerLimit( + userId, + limitInput({ userId, period: 'daily', amount: '50' }), + actorId, + 'admin', + ); + await svc.setPlayerLimit( + userId, + limitInput({ userId, period: 'weekly', amount: '80' }), + actorId, + 'admin', + ); + + // A decrease from 80 to 30 passes the reduce-only check but still violates ordering + // against the existing daily=50. + await expect( + svc.setPlayerLimit( + userId, + limitInput({ userId, period: 'weekly', amount: '30' }), + actorId, + 'admin', + ), + ).rejects.toBeInstanceOf(LimitOrderingViolationError); + }); + + it('refuses setting monthly below an existing weekly', async () => { + const { svc } = makeService(); + const userId = randomUUID(); + const actorId = randomUUID(); + await svc.setPlayerLimit( + userId, + limitInput({ userId, period: 'weekly', amount: '50' }), + actorId, + 'admin', + ); + + await expect( + svc.setPlayerLimit( + userId, + limitInput({ userId, period: 'monthly', amount: '20' }), + actorId, + 'admin', + ), + ).rejects.toBeInstanceOf(LimitOrderingViolationError); + }); + + it('accepts a valid ordered set: daily <= weekly <= monthly', async () => { + const { svc } = makeService(); + const userId = randomUUID(); + const actorId = randomUUID(); + await svc.setPlayerLimit( + userId, + limitInput({ userId, period: 'daily', amount: '10' }), + actorId, + 'admin', + ); + await svc.setPlayerLimit( + userId, + limitInput({ userId, period: 'weekly', amount: '20' }), + actorId, + 'admin', + ); + const dto = await svc.setPlayerLimit( + userId, + limitInput({ userId, period: 'monthly', amount: '30' }), + actorId, + 'admin', + ); + + expect(Number(dto.amount)).toBe(30); + }); + + it('converts across currencies when a rate is available', async () => { + const rates = mock({ + getRate: vi.fn(async () => null), + convert: vi.fn(async (amount: string, from: string, to: string) => { + if (from === to) { + return amount; + } + if (from === 'EUR' && to === 'USD') { + return String(Number(amount) * 1.1); + } + if (from === 'USD' && to === 'EUR') { + return String(Number(amount) / 1.1); + } + return null; + }), + }); + const { svc } = makeService(undefined, rates); + const userId = randomUUID(); + const actorId = randomUUID(); + await svc.setPlayerLimit( + userId, + limitInput({ userId, period: 'daily', amount: '10', currency: 'USD' }), + actorId, + 'admin', + ); + + // The daily bound (10 USD) converts to ~9.09 EUR, which is <= 20 EUR: allowed. + const dto = await svc.setPlayerLimit( + userId, + limitInput({ userId, period: 'weekly', amount: '20', currency: 'EUR' }), + actorId, + 'admin', + ); + expect(Number(dto.amount)).toBe(20); + + // The daily bound (10 USD) converts to ~9.09 EUR, above a 5 EUR weekly: refused. + await expect( + svc.setPlayerLimit( + userId, + limitInput({ userId, period: 'weekly', amount: '5', currency: 'EUR' }), + actorId, + 'admin', + ), + ).rejects.toBeInstanceOf(LimitOrderingViolationError); + }); + + it('refuses the whole operation when a required exchange rate is missing, rather than skipping the check', async () => { + const { svc } = makeService(undefined, identityRates()); + const userId = randomUUID(); + const actorId = randomUUID(); + await svc.setPlayerLimit( + userId, + limitInput({ userId, period: 'daily', amount: '10', currency: 'USD' }), + actorId, + 'admin', + ); + + await expect( + svc.setPlayerLimit( + userId, + limitInput({ userId, period: 'weekly', amount: '20', currency: 'EUR' }), + actorId, + 'admin', + ), + ).rejects.toBeInstanceOf(LimitOrderingViolationError); + + const rows = await db.drizzle.db.select().from(userLimit).where(eq(userLimit.userId, userId)); + expect(rows).toHaveLength(1); + }); +}); + describe('isWeakening across currencies', () => { const baseRow = (overrides: Partial = {}): ResolvedLimitRow => mock({ diff --git a/packages/core/src/compliance/router/index.ts b/packages/core/src/compliance/router/index.ts index fd11e3e62..f1d97e530 100644 --- a/packages/core/src/compliance/router/index.ts +++ b/packages/core/src/compliance/router/index.ts @@ -42,6 +42,7 @@ import { PermanentExclusionLiftError, ExclusionPeriodNotElapsedError, LimitRaiseNotAllowedError, + LimitOrderingViolationError, } from '../service/rg.service.js'; import { RgMonitoringService } from '../service/rg-monitoring.service.js'; import { @@ -99,7 +100,9 @@ export function createComplianceRouter({ getLimits: os.getLimits.handler(({ context }) => rgSelfService.getLimits(getUserId(context))), upsertLimit: os.upsertLimit.handler(({ input, context }) => { - return rgSelfService.upsertLimit(getUserId(context), input, context.clientMeta); + return mapErrors({ CONFLICT: LimitOrderingViolationError }, () => + rgSelfService.upsertLimit(getUserId(context), input, context.clientMeta), + ); }), deleteLimit: os.deleteLimit.handler(({ input, context }) => { @@ -327,7 +330,7 @@ export function createComplianceRouter({ setPlayerLimit: os.setPlayerLimit.handler(async ({ input, context }) => { const { userId, ip, userAgent } = await adminGuard.assert(context, 'compliance', 'manage-rg'); - return mapErrors({ CONFLICT: LimitRaiseNotAllowedError }, () => + return mapErrors({ CONFLICT: [LimitRaiseNotAllowedError, LimitOrderingViolationError] }, () => rg.setPlayerLimit(input.userId, input, userId, 'admin', { ip, userAgent }), ); }), diff --git a/packages/core/src/compliance/server.ts b/packages/core/src/compliance/server.ts index b977e01b5..ba25c49c9 100644 --- a/packages/core/src/compliance/server.ts +++ b/packages/core/src/compliance/server.ts @@ -13,5 +13,5 @@ export { type ReKycTrigger, type ReKycPlayerSnapshot, } from './service/re-kyc-trigger.js'; -export { createComplianceRouter } from './router/index.js'; +export { createComplianceRouter, kycStatusChannel } from './router/index.js'; export { default } from './plugin.js'; diff --git a/packages/core/src/compliance/service/kyc.service.ts b/packages/core/src/compliance/service/kyc.service.ts index 812222966..ab553704e 100644 --- a/packages/core/src/compliance/service/kyc.service.ts +++ b/packages/core/src/compliance/service/kyc.service.ts @@ -30,6 +30,7 @@ import { type Player, type User, ClientMeta, + resolveExchangeRatePivot, } from '@openora/core/contracts'; import { and, desc, eq, sql } from 'drizzle-orm'; import { kycVerification, type KycVerification } from '../schema/index.js'; @@ -629,10 +630,11 @@ export class KycVerificationService { * over: skips unless presently approved, and the watermark stops a re-approved * high-roller re-firing on every later deposit. * - * A player can deposit in several currencies (no platform base currency), so every - * completed deposit is priced into the player's reference currency before it is summed - - * filtering to that one currency instead (as this used to) reads $0 forever for a player - * who never deposits in it. + * A player can deposit in several currencies, so every completed deposit is priced into + * the platform's exchange-rate pivot currency before it is summed - filtering to the + * player's own currency instead (as this used to) reads $0 forever for a player who + * never deposits in it, and mismatches reverifyThresholds, which is keyed by pivot + * currency. */ async handleDeposit(userId: User['id']) { const [current] = await this.drizzle.db @@ -658,24 +660,29 @@ export class KycVerificationService { ), ) .groupBy(walletTransaction.currency); + // Priced into the platform's exchange-rate pivot (not the player's own currency) so + // it lines up with reverifyThresholds, which is configured per pivot currency (e.g. + // { USD: '10000' }) - a threshold keyed by player.currency would only ever fire for + // players whose wallet currency happens to match the config key. + const pivotCurrency = resolveExchangeRatePivot(this.platformConfig?.exchangeRate); const totalDeposits = await sumInPivot( depositsByCurrency, - current.currency, + pivotCurrency, this.exchangeRateReader, ); // Never write a watermark from a guess: a fabricated total would sum, compare and get // stored exactly like a real one, and no later deposit could tell the difference. Skip // this evaluation and let the next deposit re-check once every currency prices again. if (totalDeposits === null) { - const unpriced = await this.unpricedCurrencies(depositsByCurrency, current.currency); + const unpriced = await this.unpricedCurrencies(depositsByCurrency, pivotCurrency); const err = new Error( - `handleDeposit: no rate for ${unpriced.join(', ')} into ${current.currency}`, + `handleDeposit: no rate for ${unpriced.join(', ')} into ${pivotCurrency}`, ); logger.error( - { err, userId, unpriced, pivotCurrency: current.currency }, + { err, userId, unpriced, pivotCurrency }, 'handleDeposit: could not price a deposit currency, skipping re-KYC evaluation', ); - reportError(err, { userId, extra: { unpriced, pivotCurrency: current.currency } }); + reportError(err, { userId, extra: { unpriced, pivotCurrency } }); return; } @@ -694,7 +701,7 @@ export class KycVerificationService { const snapshot = { totalDeposits, - currency: current.currency, + currency: pivotCurrency, lastTriggeredDeposits: lastFire?.triggerDeposits ?? '0', }; const thresholds = this.platformConfig?.kyc?.reverifyThresholds; diff --git a/packages/core/src/compliance/service/rg-self-service.service.ts b/packages/core/src/compliance/service/rg-self-service.service.ts index aba8003b6..6f845bce1 100644 --- a/packages/core/src/compliance/service/rg-self-service.service.ts +++ b/packages/core/src/compliance/service/rg-self-service.service.ts @@ -38,6 +38,7 @@ import { resolveLimitCurrency, resolveLimitCurrencyInTx, writeLimitRow, + assertLimitOrdering, RgLimitCurrencyUnresolvedError, type LimitRow, } from './rg.service.js'; @@ -160,6 +161,9 @@ export class RgSelfServiceService { if (existing) { const resolvedExisting = await resolveLimitCurrencyInTx(tx, existing); if (await isWeakening(resolvedExisting, input, this.rates)) { + // The pending amount is the effective one being validated - it's what + // applies once the player confirms it. + await this.assertOrdering(tx, userId, input); return { applied: false as const, existing, @@ -167,6 +171,7 @@ export class RgSelfServiceService { }; } } + await this.assertOrdering(tx, userId, input); const row = await writeLimitRow(tx, userId, existing, input); return { applied: true as const, existing: existing ?? null, row }; }), @@ -456,6 +461,24 @@ export class RgSelfServiceService { } } + private async assertOrdering( + tx: DrizzleTx, + userId: User['id'], + input: Pick, + ): Promise { + if (input.type === 'session' || input.amount === null || input.currency === null) { + return; + } + await assertLimitOrdering( + tx, + userId, + input.type, + input.period, + { amount: input.amount, currency: toDbCurrency(input.type, input.currency) }, + this.rates, + ); + } + private async park( tx: DrizzleTx, existing: LimitRow, diff --git a/packages/core/src/compliance/service/rg.service.ts b/packages/core/src/compliance/service/rg.service.ts index de38b6ac1..f86f3486e 100644 --- a/packages/core/src/compliance/service/rg.service.ts +++ b/packages/core/src/compliance/service/rg.service.ts @@ -10,7 +10,7 @@ import { withAdvisoryXactLock, type EventBus, } from '@openora/core/server'; -import { and, eq, or, gt, lte, desc } from 'drizzle-orm'; +import { and, eq, or, gt, lte, desc, inArray } from 'drizzle-orm'; import type { AuditWritePort, LoginEnforcementPort, @@ -118,6 +118,113 @@ export class LimitRaiseNotAllowedError extends Error { } } +// Periods ordering is enforced over. `session` is a duration, not a money bound - never +// part of this chain. +const LIMIT_ORDERING_PERIODS = ['daily', 'weekly', 'monthly'] as const; +type OrderingPeriod = (typeof LIMIT_ORDERING_PERIODS)[number]; + +export type LimitOrderingViolationData = { + type: LimitType; + period: OrderingPeriod; + conflictingPeriod: OrderingPeriod; + bound: { amount: string; currency: string }; +}; + +export class LimitOrderingViolationError extends Error { + readonly data: LimitOrderingViolationData; + + constructor(data: LimitOrderingViolationData) { + super( + `The ${data.period} ${data.type} limit cannot cross the ${data.conflictingPeriod} bound of ${data.bound.amount} ${data.bound.currency}`, + ); + this.name = 'LimitOrderingViolationError'; + this.data = data; + } +} + +/** + * Enforces `daily <= weekly <= monthly` for a player's money limits (deposit/wager/loss), + * comparing every sibling in the currency of the row being changed. Call inside the same + * transaction/advisory lock that writes or parks `changedPeriod`, before that write - a + * missing exchange rate refuses the whole operation with the same typed error rather than + * silently skipping the check (see `RgRateUnavailableError` for the sibling pattern). + */ +export async function assertLimitOrdering( + tx: Tx, + userId: User['id'], + type: LimitType, + changedPeriod: LimitRow['period'], + changedValue: { amount: string; currency: string }, + rates: ExchangeRateReader, +): Promise { + if (type === 'session' || !isOrderingPeriod(changedPeriod)) { + return; + } + const siblingRows = await tx + .select() + .from(userLimit) + .where( + and( + eq(userLimit.userId, userId), + eq(userLimit.type, type), + inArray(userLimit.period, LIMIT_ORDERING_PERIODS), + ), + ); + + const entries = new Map< + OrderingPeriod, + { compare: string; own: { amount: string; currency: string } } + >(); + entries.set(changedPeriod, { compare: changedValue.amount, own: changedValue }); + + for (const row of siblingRows) { + const period = row.period; + if (!isOrderingPeriod(period) || period === changedPeriod || row.amount === null) { + continue; + } + const resolved = await resolveLimitCurrencyInTx(tx, row); + const own = { amount: resolved.amount as string, currency: resolved.currency }; + const compare = + resolved.currency === changedValue.currency + ? own.amount + : await rates.convert(own.amount, resolved.currency, changedValue.currency); + if (compare === null) { + throw new LimitOrderingViolationError({ + type, + period: changedPeriod, + conflictingPeriod: period, + bound: own, + }); + } + entries.set(period, { compare, own }); + } + + const ordered = LIMIT_ORDERING_PERIODS.filter((p) => entries.has(p)); + for (let i = 0; i < ordered.length - 1; i++) { + const a = ordered.at(i); + const b = ordered.at(i + 1); + const va = a === undefined ? undefined : entries.get(a); + const vb = b === undefined ? undefined : entries.get(b); + if (a === undefined || b === undefined || va === undefined || vb === undefined) { + continue; + } + if (moneyCompare(va.compare, vb.compare) > 0) { + const conflictingPeriod = a === changedPeriod ? b : a; + const bound = a === changedPeriod ? vb.own : va.own; + throw new LimitOrderingViolationError({ + type, + period: changedPeriod, + conflictingPeriod, + bound, + }); + } + } +} + +function isOrderingPeriod(period: LimitRow['period']): period is OrderingPeriod { + return (LIMIT_ORDERING_PERIODS as readonly string[]).includes(period); +} + const HOUR_MS = 60 * 60 * 1000; export const limitSlotKey = (userId: User['id'], type: string, period: string) => @@ -326,6 +433,18 @@ export class RgService { throw new LimitRaiseNotAllowedError(existing, input); } } + // Reduce-only doesn't exempt ordering: an admin can still drop e.g. weekly + // below an existing daily. + if (input.type !== 'session' && input.amount !== null && input.currency !== null) { + await assertLimitOrdering( + tx, + userId, + input.type, + input.period, + { amount: input.amount, currency: toDbCurrency(input.type, input.currency) }, + this.rates, + ); + } return { prior: existing, row: await writeLimitRow(tx, userId, existing, input) }; }), ); diff --git a/packages/core/src/contracts/adapters/bonus-lifecycle.ts b/packages/core/src/contracts/adapters/bonus-lifecycle.ts new file mode 100644 index 000000000..45f165f43 --- /dev/null +++ b/packages/core/src/contracts/adapters/bonus-lifecycle.ts @@ -0,0 +1,26 @@ +/** + * Bonus lifecycle command port: a single named grant taken away by a system/job context that has + * no admin session to assert - the shape `forfeitAllFor` (reached only from inside the bonus + * module, off `rg.*`/`player.account.closed` events) cannot give an external caller, since that + * sweeps every live grant a player holds rather than the one grant a rule decided on. Mirrors the + * BONUS_GRANTS idiom: a command port, not a class export, so the bonus module's own transaction + * and audit-row shape stay its own. + */ +import type { BonusForfeitReason } from '../schemas/promo.js'; +import { createToken, type Token } from './token.js'; + +export type BonusForfeitOutcome = + | { ok: true; grantId: string; userId: string; currency: string; forfeitedAmount: string } + | { ok: false; reason: 'not_found' | 'not_forfeitable' }; + +export type BonusLifecycleCommands = { + /** + * Takes one grant away, by id, for a reason that names no admin - a job's own rule broke, not + * an admin's decision or a responsible-gambling event (both already have their own path). The + * grant's own status guard makes a retry (or a grant that already closed some other way) + * resolve to `not_forfeitable` rather than a second ledger entry. + */ + forfeit(grantId: string, reason: BonusForfeitReason, note: string): Promise; +}; + +export const BONUS_LIFECYCLE: Token = createToken('BONUS_LIFECYCLE'); diff --git a/packages/core/src/contracts/adapters/bonus-wagering.ts b/packages/core/src/contracts/adapters/bonus-wagering.ts index 4f3ce94b1..eeb7783de 100644 --- a/packages/core/src/contracts/adapters/bonus-wagering.ts +++ b/packages/core/src/contracts/adapters/bonus-wagering.ts @@ -11,6 +11,7 @@ import type { WalletTransactionType } from '../schemas/wallet-tx.js'; import { createSealedToken, type SealedToken } from './token.js'; import type { WagerContext } from './wager-context.js'; +import type { WagerTrackingWalletCredit } from './wager-tracking.js'; export type BonusWagerArgs = { userId: string; @@ -57,6 +58,8 @@ export type BonusWagerOutcome = * performs the credit itself, so the two modules never call back into each other. */ completed: { grantId: string; convertedAmount: string } | null; + /** Whatever a `WAGER_TRACKING` consumer credited to the real balance alongside this bet - rank rakeback today. */ + walletCredits: WagerTrackingWalletCredit[]; } /** Bonus funds could not cover `fromBonus`. The wallet turns this into its own insufficient-funds outcome. */ | { ok: false; reason: 'insufficient_bonus'; bonusAvailable: string } diff --git a/packages/core/src/contracts/adapters/index.ts b/packages/core/src/contracts/adapters/index.ts index 9afbdf469..2c7c6a03c 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -57,6 +57,9 @@ export type { } from './bonus-grants.js'; export { BONUS_GRANTS } from './bonus-grants.js'; +export type { BonusLifecycleCommands, BonusForfeitOutcome } from './bonus-lifecycle.js'; +export { BONUS_LIFECYCLE } from './bonus-lifecycle.js'; + export type { BonusWageringCommands, BonusWagerArgs, @@ -66,7 +69,11 @@ export type { } from './bonus-wagering.js'; export { BONUS_WAGERING, MaxBetExceededError } from './bonus-wagering.js'; -export type { WagerTrackingCommands, WagerTrackingArgs } from './wager-tracking.js'; +export type { + WagerTrackingCommands, + WagerTrackingArgs, + WagerTrackingWalletCredit, +} from './wager-tracking.js'; export { WAGER_TRACKING } from './wager-tracking.js'; export type { diff --git a/packages/core/src/contracts/adapters/wager-tracking.ts b/packages/core/src/contracts/adapters/wager-tracking.ts index d64799946..2f6b25598 100644 --- a/packages/core/src/contracts/adapters/wager-tracking.ts +++ b/packages/core/src/contracts/adapters/wager-tracking.ts @@ -17,11 +17,33 @@ export type WagerTrackingArgs = { amount: string; /** Stake after the bonus engine's resolved weight, as a decimal string. */ 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, + * streak, races, the rank challenge - counts only this, since none of them may reward money + * the player never risked. + */ + realAmount: string; context: WagerContext; }; +/** + * A real-money wallet credit a `recordWager` consumer made inside the caller's own transaction - + * rank rakeback today. Reported back rather than fired as an event from inside the port, since a + * consumer here has no view of when the caller's transaction actually commits; the caller collects + * these and emits `wallet.balance.changed` itself once it does, the same rule every other wallet + * mover in `WalletCommandsService` follows. + */ +export type WagerTrackingWalletCredit = { + transactionId: string; + amount: string; + currency: string; +}; + export type WagerTrackingCommands = { - recordWager(tx: unknown, args: WagerTrackingArgs): Promise; + /** Empty array when nothing here moved real money - the common case. */ + recordWager(tx: unknown, args: WagerTrackingArgs): Promise; }; export const WAGER_TRACKING: Token = createToken('WAGER_TRACKING'); diff --git a/packages/core/src/contracts/adapters/wallet-commands.ts b/packages/core/src/contracts/adapters/wallet-commands.ts index 167be1b8b..7f61b9258 100644 --- a/packages/core/src/contracts/adapters/wallet-commands.ts +++ b/packages/core/src/contracts/adapters/wallet-commands.ts @@ -5,6 +5,8 @@ import type { WalletTransactionType } from '../schemas/wallet-tx.js'; import { createToken, type Token } from './token.js'; import type { WagerContext } from './wager-context.js'; +import type { BonusGrantTerms } from './bonus-grants.js'; +import type { WagerTrackingWalletCredit } from './wager-tracking.js'; export type WalletProviderRef = { providerName: string; @@ -49,6 +51,13 @@ export type WalletDebitOutcome = bonusBalance?: string; /** The grant this debit pushed over its requirement, and what it released into the real balance. */ completed?: { grantId: string; convertedAmount: string }; + /** + * Whatever `WAGER_TRACKING` credited to the real balance alongside this bet (rank rakeback), + * inside the same transaction. Empty when nothing was. The caller emits `wallet.balance. + * changed` for each once its own transaction commits, the same rule it follows for the bet + * debit itself - this port never emits it. + */ + wagerTrackingCredits?: WagerTrackingWalletCredit[]; } | { ok: true; moved: false; newBalance: string; currency: string } /** `available` is the real balance plus whatever bonus funds could have covered the rest. */ @@ -64,6 +73,12 @@ export type WalletCreditArgs = { /** Allow crediting a player who has no `wallet` row at all yet, creating it in the caller's transaction. Off by default. */ allowNewWallet?: boolean; providerRef?: WalletProviderRef; + /** + * Terms a `gift`/`rain` credit's underlying bonus grant is created under. Ignored by any other + * credit type. Omit it and the grant falls back to the bonus module's own default terms, same + * as before this field existed. + */ + terms?: BonusGrantTerms; }; /** `moved` as on `WalletDebitOutcome`; `moved: false` is a replayed `providerRef`. */ diff --git a/packages/core/src/contracts/schemas/__tests__/platform-config.test.ts b/packages/core/src/contracts/schemas/__tests__/platform-config.test.ts index f3a3a59a8..a421aea9b 100644 --- a/packages/core/src/contracts/schemas/__tests__/platform-config.test.ts +++ b/packages/core/src/contracts/schemas/__tests__/platform-config.test.ts @@ -1,5 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { definePlatformConfig } from '../platform-config.js'; +import { definePlatformConfig, resolveWalletDefaultCurrency } from '../platform-config.js'; + +describe('resolveWalletDefaultCurrency', () => { + it('falls back to USD when the operator has not set one', () => { + expect(resolveWalletDefaultCurrency(undefined)).toBe('USD'); + expect(resolveWalletDefaultCurrency({})).toBe('USD'); + }); + + it('uppercases the operator-configured currency', () => { + expect(resolveWalletDefaultCurrency({ defaultCurrency: 'usdt' })).toBe('USDT'); + }); +}); describe('definePlatformConfig', () => { it('canonicalizes attachment hosts before services consume the config', () => { diff --git a/packages/core/src/contracts/schemas/events.ts b/packages/core/src/contracts/schemas/events.ts index 906a00b4f..6effe37be 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -738,6 +738,27 @@ export const domainEventSchemas = { ), currency: CurrencyTickerSchema, }), + // One winner's own prize, emitted per player after the settlement transaction commits - the + // shape the in-app/email notification maps 1:1 (see engagement/notifications/plugin.ts). + 'promo.race.won': z.object({ + userId: UuidSchema, + raceId: UuidSchema, + raceName: z.string(), + position: z.number().int().positive(), + amount: MoneyAmountSchema, + currency: CurrencyTickerSchema, + }), + // One winner's own Rank Challenge tier, emitted per player after the settlement transaction + // commits - the shape the in-app/email notification maps 1:1, mirroring promo.race.won. + 'promo.rank-challenge.won': z.object({ + userId: UuidSchema, + tierId: UuidSchema, + tierKey: z.string(), + tierName: z.string(), + cashAmount: MoneyAmountSchema.nullable(), + physicalItem: z.string().nullable(), + currency: CurrencyTickerSchema, + }), 'chat.message.sent': z.object({ messageId: UuidSchema, diff --git a/packages/core/src/contracts/schemas/mail.ts b/packages/core/src/contracts/schemas/mail.ts index cf7b7a083..f9c59292b 100644 --- a/packages/core/src/contracts/schemas/mail.ts +++ b/packages/core/src/contracts/schemas/mail.ts @@ -26,6 +26,9 @@ export const MAIL_TEMPLATE_KEYS = [ 'emailChangeConfirmation', 'emailChanged', 'securityAntiPhishingCodeChanged', + 'raceWon', + 'rankChallengeWon', + 'bonusUnlocked', ] as const; export type EmailTemplateKey = (typeof MAIL_TEMPLATE_KEYS)[number]; @@ -90,6 +93,22 @@ export const EmailTemplateDataSchemas = { isNewAddress: z.boolean(), }), securityAntiPhishingCodeChanged: z.object({ previousAntiPhishingCode: z.string().nullable() }), + raceWon: z.object({ + raceName: z.string(), + position: z.number().int().positive(), + amount: MoneyAmountSchema, + currency: CurrencyTickerSchema, + }), + rankChallengeWon: z.object({ + tierName: z.string(), + cashAmount: MoneyAmountSchema.nullable(), + physicalItem: z.string().nullable(), + currency: CurrencyTickerSchema, + }), + bonusUnlocked: z.object({ + convertedAmount: MoneyAmountSchema, + currency: CurrencyTickerSchema, + }), } as const satisfies Record; export type EmailTemplateData = { @@ -123,6 +142,9 @@ export const MailTemplateSchema = z.discriminatedUnion('key', [ templateVariant('emailChangeConfirmation'), templateVariant('emailChanged'), templateVariant('securityAntiPhishingCodeChanged'), + templateVariant('raceWon'), + templateVariant('rankChallengeWon'), + templateVariant('bonusUnlocked'), ]); export type MailTemplate = { diff --git a/packages/core/src/contracts/schemas/platform-config.ts b/packages/core/src/contracts/schemas/platform-config.ts index 482f29d93..297672250 100644 --- a/packages/core/src/contracts/schemas/platform-config.ts +++ b/packages/core/src/contracts/schemas/platform-config.ts @@ -148,6 +148,19 @@ export const WalletConfigSchema = z export type WalletConfig = z.infer; +const DEFAULT_WALLET_CURRENCY = 'USD'; + +/** + * The currency a cash prize or cashback credit lands in when its own source (a rank ladder, a + * race, a streak milestone) is priced in something else - never the source's own currency + * unconditionally, or crediting a crypto-only player in a fiat ticker would open a balance the + * operator never offers. Absent `wallet.defaultCurrency` falls back to USD, the same default + * `readWalletBalances` uses for a player with no wallet row yet. + */ +export function resolveWalletDefaultCurrency(config: WalletConfig | undefined): string { + return (config?.defaultCurrency ?? DEFAULT_WALLET_CURRENCY).toUpperCase(); +} + const DEFAULT_EXCHANGE_RATE_PIVOT = 'USD'; export const ExchangeRateConfigSchema = z @@ -241,6 +254,9 @@ export const CHAT_MODERATION_EXPIRY_DEFAULT_CRON = '7,22,37,52 * * * *'; */ export const RANK_PAYOUT_DEFAULT_CRON = '3,13,23,33,43,53 * * * *'; export const RANK_PERIODIC_DEFAULT_CRON = '17 * * * *'; +export const STREAK_PAYOUT_DEFAULT_CRON = '7,27,47 * * * *'; +/** Once, shortly after the UTC day turns over - the boundary the streak's own "day" is defined by. */ +export const STREAK_CLOSE_DEFAULT_CRON = '5 0 * * *'; export const PromoConfigSchema = z .object({ @@ -253,6 +269,15 @@ export const PromoConfigSchema = z }) .strict() .prefault({}), + streaks: z + .object({ + /** How often owed milestone rewards are settled. */ + payoutCron: CronExpressionSchema.default(STREAK_PAYOUT_DEFAULT_CRON), + /** How often a UTC day is closed out, resetting anyone who missed it. */ + closeCron: CronExpressionSchema.default(STREAK_CLOSE_DEFAULT_CRON), + }) + .strict() + .prefault({}), }) .strict(); diff --git a/packages/core/src/contracts/schemas/player.ts b/packages/core/src/contracts/schemas/player.ts index 231522495..487863e11 100644 --- a/packages/core/src/contracts/schemas/player.ts +++ b/packages/core/src/contracts/schemas/player.ts @@ -66,6 +66,9 @@ export const PlayerSchema = z.object({ timezone: TimezoneSchema.nullable(), // Last confirmed by a device, not last changed: it moves on every accepted capture. timezoneUpdatedAt: TimestampSchema.nullable(), + /** Shows "Incognito" in place of this player's username on a public leaderboard instead of + * the platform's own partial masking. Never affects the player's own standing. */ + hideUsernameOnLeaderboards: z.boolean(), createdAt: TimestampSchema, updatedAt: TimestampSchema, }); @@ -142,6 +145,7 @@ export const UpdatePlayerProfileInputSchema = z country: CountryCodeSchema.nullable(), currency: CurrencyCodeSchema, timezone: TimezoneSchema, + hideUsernameOnLeaderboards: z.boolean(), }) .partial() .refine((v) => Object.values(v).some((x) => x !== undefined), { diff --git a/packages/core/src/contracts/schemas/promo.ts b/packages/core/src/contracts/schemas/promo.ts index f3deb895d..e3657c1b1 100644 --- a/packages/core/src/contracts/schemas/promo.ts +++ b/packages/core/src/contracts/schemas/promo.ts @@ -12,6 +12,10 @@ export const BONUS_GRANT_SOURCES = [ 'race', 'gift', 'rain', + // A VIP Cashback grant, computed and credited by a scheduled job off a player's net loss + // over a period rather than off a deposit - system-actor like 'streak'/'rank'/'race', not + // 'manual' (no admin issued it) and not 'deposit' (no deposit earned it). + 'cashback', ] as const; /** @@ -31,10 +35,14 @@ export const BONUS_GRANT_STATUSES = [ /** Why an active grant was taken away. Recorded on every forfeit, for the regulator. */ export const BONUS_FORFEIT_REASONS = [ 'self_exclusion', + 'cooling_off', 'account_closed', 'admin', 'player_opt_out', 'withdrawal_while_active', + // An offer's own terms breached by the player - e.g. an Activity Bonus that missed a + // required wagering day - closed by a scheduled job rather than an admin or an RG event. + 'terms_breach', ] as const; export const PROMO_OFFER_STATUSES = ['draft', 'active', 'paused', 'archived'] as const; diff --git a/packages/core/src/contracts/schemas/wallet-tx.ts b/packages/core/src/contracts/schemas/wallet-tx.ts index 442f29481..081486003 100644 --- a/packages/core/src/contracts/schemas/wallet-tx.ts +++ b/packages/core/src/contracts/schemas/wallet-tx.ts @@ -22,6 +22,8 @@ export const WALLET_TRANSACTION_TYPES = [ 'swap_out', 'swap_in', 'bet_reversal', + /** Rank rakeback and a streak's cash reward: an operator-funded real-money credit, no wagering requirement. */ + 'cashback', ] as const; export const WALLET_TRANSACTION_STATUSES = [ 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 75b10ad78..09e152872 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 @@ -392,6 +392,22 @@ describe('notificationEventMap', () => { expect(input.body).not.toContain('1234.500000000000000000'); }); + it('builds a bonusUnlocked mail alongside the promo.bonus.completed in-app notification', () => { + const entry = entryFor('promo.bonus.completed'); + const payload = { + userId: randomUUID(), + grantId: randomUUID(), + currency: 'EUR', + convertedAmount: '1234.500000000000000000', + }; + + expect(entry.buildEmail(payload, OCCURRED_AT)).toEqual({ + key: 'bonusUnlocked', + data: { convertedAmount: '1234.500000000000000000', currency: 'EUR' }, + }); + expect(entry.securityAlert).toBe(false); + }); + it('leaves currency and reason untouched while only the amount substring is reformatted', () => { const input = entryFor('wallet.manual_adjustment.created').buildNotification({ userId: randomUUID(), diff --git a/packages/core/src/engagement/notifications/__tests__/notification-types.test.ts b/packages/core/src/engagement/notifications/__tests__/notification-types.test.ts new file mode 100644 index 000000000..6afa2de51 --- /dev/null +++ b/packages/core/src/engagement/notifications/__tests__/notification-types.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { NotificationTypeSchema } from '../contract/index.js'; + +describe('NotificationTypeSchema', () => { + it('accepts the player-to-player transfer types', () => { + for (const type of [ + 'chat.rain.received', + 'chat.tip.received', + 'chat.gift.claimed', + 'chat.gift.expired', + ]) { + expect(NotificationTypeSchema.safeParse(type).success).toBe(true); + } + }); + + it('rejects an unknown type', () => { + expect(NotificationTypeSchema.safeParse('chat.gift.nonsense').success).toBe(false); + }); +}); diff --git a/packages/core/src/engagement/notifications/__tests__/notifications.service.int.test.ts b/packages/core/src/engagement/notifications/__tests__/notifications.service.int.test.ts index 2cf6b937a..97cc0078a 100644 --- a/packages/core/src/engagement/notifications/__tests__/notifications.service.int.test.ts +++ b/packages/core/src/engagement/notifications/__tests__/notifications.service.int.test.ts @@ -202,6 +202,27 @@ describe('NotificationsService.create (real PG)', () => { }); }); +describe('NotificationsService.getById (real PG)', () => { + it('returns the row a notifications.created subscriber would re-read to publish it live', async () => { + const { svc } = makeService(); + const created = await svc.create({ + userId: randomUUID(), + type: 'withdrawal.approved', + title: 'Payout approved', + body: 'Your withdrawal is on its way.', + }); + + const found = await svc.getById(created!.id); + + expect(found).toMatchObject({ id: created!.id, userId: created!.userId }); + }); + + it('returns null for an id nothing was ever inserted under', async () => { + const { svc } = makeService(); + expect(await svc.getById(randomUUID())).toBeNull(); + }); +}); + describe('NotificationsService.listForUser (real PG)', () => { it('returns only the requesting player rows, newest first, with a total count', async () => { const { svc } = makeService(); diff --git a/packages/core/src/engagement/notifications/contract/index.ts b/packages/core/src/engagement/notifications/contract/index.ts index d8bceb227..61531927e 100644 --- a/packages/core/src/engagement/notifications/contract/index.ts +++ b/packages/core/src/engagement/notifications/contract/index.ts @@ -11,7 +11,12 @@ export const NOTIFICATION_TYPES = [ 'social.friend_request.accepted', 'promo.bonus.granted', 'promo.bonus.completed', + 'promo.race.won', + 'promo.rank-challenge.won', 'chat.rain.received', + 'chat.tip.received', + 'chat.gift.claimed', + 'chat.gift.expired', 'rg.limit.admin_updated', 'deposit.completed', 'balance.adjusted', diff --git a/packages/core/src/engagement/notifications/plugin.ts b/packages/core/src/engagement/notifications/plugin.ts index facc76c57..e855c3b42 100644 --- a/packages/core/src/engagement/notifications/plugin.ts +++ b/packages/core/src/engagement/notifications/plugin.ts @@ -32,6 +32,20 @@ import { CreateNotificationInputSchema, type CreateNotificationInput } from './c const describeLimitValue = (amount: string | null, minutes: number | null): string => amount !== null ? amount : `${minutes} minutes`; +// A Rank Challenge tier may carry a cash amount, a physical item, or both (master/titan) - the +// body text names whichever the player actually won. +const describeRankChallengePrize = (p: { + cashAmount: string | null; + physicalItem: string | null; + currency: string; +}): string => { + const parts = [ + p.cashAmount !== null ? `${formatMoneyAmount(p.cashAmount)} ${p.currency}` : null, + p.physicalItem, + ].filter((part): part is string => part !== null); + return parts.join(' + '); +}; + const KYC_RESUBMISSION_NOTIFY_QUEUE = queue('kyc-resubmission-notify'); const NOTIFICATIONS_RETENTION_PURGE_QUEUE = queue('notifications-retention-purge'); const NOTIFICATIONS_DISPATCH_QUEUE = queue('notifications-dispatch'); @@ -255,13 +269,66 @@ export const notificationEventMap: NotificationMapEntry[] = [ data: { grantId: p.grantId }, })), - mapEvent('promo.bonus.completed', (p) => ({ - userId: p.userId, - type: 'promo.bonus.completed', - title: 'Bonus unlocked', - body: `Your ${formatMoneyAmount(p.convertedAmount)} ${p.currency} bonus has cleared its wagering requirement and is now fully withdrawable.`, - data: { grantId: p.grantId }, - })), + mapEvent( + 'promo.bonus.completed', + (p) => ({ + userId: p.userId, + type: 'promo.bonus.completed', + title: 'Bonus unlocked', + body: `Your ${formatMoneyAmount(p.convertedAmount)} ${p.currency} bonus has cleared its wagering requirement and is now fully withdrawable.`, + data: { grantId: p.grantId }, + }), + { + email: (p) => ({ + key: 'bonusUnlocked', + data: { convertedAmount: p.convertedAmount, currency: p.currency }, + }), + }, + ), + + mapEvent( + 'promo.race.won', + (p) => ({ + userId: p.userId, + type: 'promo.race.won', + title: 'You placed in a race', + body: `You placed #${p.position} in ${p.raceName} and won ${formatMoneyAmount(p.amount)} ${p.currency}.`, + data: { raceId: p.raceId }, + }), + { + email: (p) => ({ + key: 'raceWon', + data: { + raceName: p.raceName, + position: p.position, + amount: p.amount, + currency: p.currency, + }, + }), + }, + ), + + mapEvent( + 'promo.rank-challenge.won', + (p) => ({ + userId: p.userId, + type: 'promo.rank-challenge.won', + title: 'You won a Rank Challenge tier', + body: `You reached the ${p.tierName} tier and won ${describeRankChallengePrize(p)}.`, + data: { tierId: p.tierId }, + }), + { + email: (p) => ({ + key: 'rankChallengeWon' as const, + data: { + tierName: p.tierName, + cashAmount: p.cashAmount, + physicalItem: p.physicalItem, + currency: p.currency, + }, + }), + }, + ), mapEvent('chat.user.mentioned', (p) => ({ userId: p.mentionedUserId, @@ -396,6 +463,27 @@ export default { } }; + // The one place every `create()` call ends up on the realtime channel from - this + // module's own dispatch jobs below, and an overlay's own `NotificationsService` instance + // over a consumer-specific event `domainEventSchemas` has no entry for, since both share + // this event bus and `create()` always emits this topic. Re-reads the row rather than + // carrying it on the event: the event is `{ notificationId, userId }` only, the same shape + // every other consumer of this topic (the audit log) already treats as the full payload. + ctx.events.on('notifications.created', (payload) => { + const parsed = domainEventSchemas['notifications.created'].safeParse(payload); + if (!parsed.success || !svcRef) { + return; + } + void svcRef + .getById(parsed.data.notificationId) + .then((record) => { + if (record) { + publishNotification(record); + } + }) + .catch((err: unknown) => logger.error({ err }, 'notification realtime lookup failed')); + }); + for (const entry of notificationEventMap) { ctx.events.on(entry.event, (payload, envelope) => { if (!jobQueueRef || !envelope) { @@ -574,7 +662,6 @@ export default { if (!record) { return; } - publishNotification(record); if (payload.eventId) { await dispatchMail( payload.userId, @@ -605,7 +692,6 @@ export default { } return; } - publishNotification(record); if (payload.email) { const mailKey = payload.input.eventId ?? record.id; await (payload.securityAlert diff --git a/packages/core/src/engagement/notifications/service/notifications.service.ts b/packages/core/src/engagement/notifications/service/notifications.service.ts index 85a6c3c06..ff27b9098 100644 --- a/packages/core/src/engagement/notifications/service/notifications.service.ts +++ b/packages/core/src/engagement/notifications/service/notifications.service.ts @@ -28,6 +28,21 @@ export class NotificationsService { private readonly events: EventBus, ) {} + /** + * Looked up by the plugin's `notifications.created` subscriber, the one place every + * `create()` call - core's own dispatch jobs and an overlay's own `NotificationsService` + * instance alike, since both share this module's event bus - ends up pushed onto the + * realtime channel from. `create()` cannot publish itself: it runs before the router factory + * has bound a realtime transport into this instance. + */ + async getById(id: string): Promise { + const [record] = await this.drizzle.db + .select() + .from(notification) + .where(eq(notification.id, id)); + return record ?? null; + } + async create(input: CreateNotificationInput): Promise { const [record] = await this.drizzle.db .insert(notification) diff --git a/packages/core/src/engagement/server.ts b/packages/core/src/engagement/server.ts index be1481d78..67a66ec64 100644 --- a/packages/core/src/engagement/server.ts +++ b/packages/core/src/engagement/server.ts @@ -1,4 +1,5 @@ export { default as chatPlugin } from './chat/plugin.js'; export { default as chatCommandsPlugin } from './chat-commands/plugin.js'; export { default as notificationsPlugin } from './notifications/plugin.js'; +export { notificationsChannel } from './notifications/router/index.js'; export { default as socialPlugin } from './social/plugin.js'; diff --git a/packages/core/src/fx/exchange-rate/service/exchange-rate.service.ts b/packages/core/src/fx/exchange-rate/service/exchange-rate.service.ts index 801a46a31..5bff6f3c8 100644 --- a/packages/core/src/fx/exchange-rate/service/exchange-rate.service.ts +++ b/packages/core/src/fx/exchange-rate/service/exchange-rate.service.ts @@ -12,7 +12,8 @@ export const UnsupportedExchangeCurrencyError = createDomainError<[currency: str * The route-facing half of the exchange-rate seam. Every code is checked against the * operator's configured currency list first: an unknown code otherwise reaches the reader, * misses the cache, and buys a vendor call, so an arbitrary code space would be - * attacker-controlled vendor spend. + * attacker-controlled vendor spend. A batch answers an unknown source code with a null quote + * instead of failing the whole batch. */ export class ExchangeRateService { private readonly supported: ReadonlySet; @@ -32,17 +33,18 @@ export class ExchangeRateService { getRates(to: string, from: readonly string[]) { this.assertSupported(to); - for (const currency of from) { - this.assertSupported(currency); - } return mapConcurrent(from, GET_RATES_CONCURRENCY, async (currency) => ({ from: currency, - quote: await this.reader.getRate(currency, to), + quote: this.isSupported(currency) ? await this.reader.getRate(currency, to) : null, })); } + private isSupported(currency: string): boolean { + return this.supported.has(currency.toUpperCase()); + } + private assertSupported(currency: string): void { - if (!this.supported.has(currency.toUpperCase())) { + if (!this.isSupported(currency)) { throw new UnsupportedExchangeCurrencyError(currency); } } diff --git a/packages/core/src/mail/__tests__/default-email-template-renderer.test.ts b/packages/core/src/mail/__tests__/default-email-template-renderer.test.ts index b73bc34b9..07a464e08 100644 --- a/packages/core/src/mail/__tests__/default-email-template-renderer.test.ts +++ b/packages/core/src/mail/__tests__/default-email-template-renderer.test.ts @@ -124,6 +124,7 @@ describe('DefaultEmailTemplateRenderer', () => { occurredAt: '2026-01-01T00:00:00.000Z', }, }, + { key: 'bonusUnlocked', data: { convertedAmount: '250.00', currency: 'USDT' } }, ]; for (const template of samples) { diff --git a/packages/core/src/mail/adapters/default-email-template-renderer.ts b/packages/core/src/mail/adapters/default-email-template-renderer.ts index f4cbee8c8..9c4a420ca 100644 --- a/packages/core/src/mail/adapters/default-email-template-renderer.ts +++ b/packages/core/src/mail/adapters/default-email-template-renderer.ts @@ -180,6 +180,26 @@ const PLAIN_EMAIL_TEMPLATES: { [K in EmailTemplateKey]: PlainTemplate } = { ? 'An anti-phishing code was added to your account. If this was not you, secure your account immediately.' : `Your anti-phishing code was changed. Your previous code was: ${data.previousAntiPhishingCode}\n\nIf this was not you, secure your account immediately.`, }), + raceWon: (data) => ({ + subject: `You placed #${data.position} in ${data.raceName}`, + text: `You placed #${data.position} in ${data.raceName} and won ${formatMoney(data.amount, data.currency)}. The prize has been credited to your balance.`, + }), + rankChallengeWon: (data) => { + const prize = [ + data.cashAmount !== null ? formatMoney(data.cashAmount, data.currency) : null, + data.physicalItem, + ] + .filter((part): part is string => part !== null) + .join(' + '); + return { + subject: `You won the ${data.tierName} Rank Challenge tier`, + text: `You reached the ${data.tierName} tier and won ${prize}. ${data.cashAmount !== null ? 'The cash prize has been credited to your balance. ' : ''}Our team will be in touch about fulfilling any physical prize.`, + }; + }, + bonusUnlocked: (data) => ({ + subject: 'Your bonus has cleared its wagering requirement', + text: `Your ${formatMoney(data.convertedAmount, data.currency)} bonus has cleared its wagering requirement and is now part of your withdrawable balance.`, + }), }; const renderDefaultEmail = ( diff --git a/packages/core/src/pam/profile/drizzle/migrations/0006_natural_inertia.sql b/packages/core/src/pam/profile/drizzle/migrations/0006_natural_inertia.sql new file mode 100644 index 000000000..9ead840e1 --- /dev/null +++ b/packages/core/src/pam/profile/drizzle/migrations/0006_natural_inertia.sql @@ -0,0 +1 @@ +ALTER TABLE "player" ADD COLUMN "hide_username_on_leaderboards" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/packages/core/src/pam/profile/drizzle/migrations/meta/0006_snapshot.json b/packages/core/src/pam/profile/drizzle/migrations/meta/0006_snapshot.json new file mode 100644 index 000000000..6d802b8d9 --- /dev/null +++ b/packages/core/src/pam/profile/drizzle/migrations/meta/0006_snapshot.json @@ -0,0 +1,249 @@ +{ + "id": "73322d0c-6488-4aed-96ac-854cf450d7b3", + "prevId": "6e3160aa-9a20-465c-a2af-3dffe2808367", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.player": { + "name": "player", + "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 + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date_of_birth": { + "name": "date_of_birth", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "display_currency": { + "name": "display_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "player_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "kyc_status": { + "name": "kyc_status", + "type": "kyc_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "total_wagered": { + "name": "total_wagered", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_deposits": { + "name": "total_deposits", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone_updated_at": { + "name": "timezone_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "terms_version": { + "name": "terms_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terms_accepted_at": { + "name": "terms_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "age_accepted_at": { + "name": "age_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "registration_ip": { + "name": "registration_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_user_agent": { + "name": "registration_user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hide_username_on_leaderboards": { + "name": "hide_username_on_leaderboards", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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 + } + }, + "indexes": { + "player_status_idx": { + "name": "player_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "player_created_at_idx": { + "name": "player_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "player_user_id_unique": { + "name": "player_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.kyc_status": { + "name": "kyc_status", + "schema": "public", + "values": [ + "not_started", + "pending", + "approved", + "verified", + "rejected", + "resubmission_requested", + "manually_overridden" + ] + }, + "public.player_status": { + "name": "player_status", + "schema": "public", + "values": ["active", "dormant", "self_excluded", "suspended", "closed"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/pam/profile/drizzle/migrations/meta/_journal.json b/packages/core/src/pam/profile/drizzle/migrations/meta/_journal.json index 0bb636294..66c77b5e5 100644 --- a/packages/core/src/pam/profile/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/pam/profile/drizzle/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1788344429027, "tag": "0005_moaning_rachel_grey", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1790313745574, + "tag": "0006_natural_inertia", + "breakpoints": true } ] } diff --git a/packages/core/src/pam/profile/schema/index.ts b/packages/core/src/pam/profile/schema/index.ts index aaf335e1d..52dae19af 100644 --- a/packages/core/src/pam/profile/schema/index.ts +++ b/packages/core/src/pam/profile/schema/index.ts @@ -8,6 +8,7 @@ import { timestamp, pgEnum, index, + boolean, } from 'drizzle-orm/pg-core'; import { PLAYER_STATUSES, KYC_STATUSES } from '@openora/core/contracts'; @@ -47,6 +48,10 @@ export const player = pgTable( ageAcceptedAt: timestamp({ withTimezone: true }), registrationIp: text(), registrationUserAgent: text(), + /** Shows "Incognito" in place of this player's username on a public leaderboard (a wager + * race, say) instead of the platform's own partial masking. Never affects the player's own + * standing, wagered total, or prize eligibility - only what other players see. */ + hideUsernameOnLeaderboards: boolean().notNull().default(false), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp({ withTimezone: true }) .notNull() diff --git a/packages/core/src/pam/profile/service/profile.service.ts b/packages/core/src/pam/profile/service/profile.service.ts index d9c18fa32..40373147f 100644 --- a/packages/core/src/pam/profile/service/profile.service.ts +++ b/packages/core/src/pam/profile/service/profile.service.ts @@ -217,7 +217,7 @@ export class ProfileService implements PlayerProvisioning { userId: User['id'], row: { displayCurrency: string | null }, ): Promise { - if (row.displayCurrency) { + if (row.displayCurrency && this.supportedDisplayCurrencies.includes(row.displayCurrency)) { return row.displayCurrency; } diff --git a/packages/core/src/pam/server.ts b/packages/core/src/pam/server.ts index 0d670b579..28bec54e3 100644 --- a/packages/core/src/pam/server.ts +++ b/packages/core/src/pam/server.ts @@ -1,4 +1,5 @@ export { default as identityPlugin } from './identity/plugin.js'; +export { sessionEventsChannel, type SessionEventsPush } from './identity/router/index.js'; export { default as profilePlugin } from './profile/plugin.js'; export { default as tagPlugin } from './tag/plugin.js'; export { default as playerManagementPlugin } from './player-management/plugin.js'; diff --git a/packages/core/src/pam/shared/player-mapper.ts b/packages/core/src/pam/shared/player-mapper.ts index fea398e6f..5f4238df3 100644 --- a/packages/core/src/pam/shared/player-mapper.ts +++ b/packages/core/src/pam/shared/player-mapper.ts @@ -26,6 +26,7 @@ export function toPlayer(row: typeof player.$inferSelect, email: string, usernam lastSeenAt: row.lastSeenAt ? row.lastSeenAt.toISOString() : null, timezone: row.timezone, timezoneUpdatedAt: row.timezoneUpdatedAt ? row.timezoneUpdatedAt.toISOString() : null, + hideUsernameOnLeaderboards: row.hideUsernameOnLeaderboards, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; diff --git a/packages/core/src/promo/bonus/__tests__/bonus-lifecycle-port.test.ts b/packages/core/src/promo/bonus/__tests__/bonus-lifecycle-port.test.ts new file mode 100644 index 000000000..a9e96a73d --- /dev/null +++ b/packages/core/src/promo/bonus/__tests__/bonus-lifecycle-port.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import type { EventBus } from '@openora/core/server'; +import { createBonusLifecyclePort } from '../service/bonus-lifecycle-port.service.js'; +import { + GrantNotFoundError, + GrantNotForfeitableError, + type GrantLifecycleService, +} from '../service/grant-lifecycle.service.js'; + +const fakeBus = () => ({ emit: vi.fn() }) as unknown as EventBus; + +const fakeService = (forfeit: GrantLifecycleService['forfeit']) => + ({ forfeit }) as unknown as GrantLifecycleService; + +describe('createBonusLifecyclePort', () => { + it('announces promo.bonus.forfeited and returns the closed grant on success', async () => { + const grantId = randomUUID(); + const userId = randomUUID(); + const bus = fakeBus(); + const port = createBonusLifecyclePort( + fakeService(async () => ({ + grantId, + userId, + currency: 'USD', + forfeitedAmount: '42', + actorId: null, + })), + bus, + ); + + const outcome = await port.forfeit(grantId, 'terms_breach', 'missed a required wagering day'); + + expect(outcome).toEqual({ ok: true, grantId, userId, currency: 'USD', forfeitedAmount: '42' }); + expect(bus.emit).toHaveBeenCalledWith('promo.bonus.forfeited', { + userId, + grantId, + currency: 'USD', + forfeitedAmount: '42', + reason: 'terms_breach', + actorId: null, + }); + }); + + it('maps a not-found grant to ok:false without emitting anything', async () => { + const bus = fakeBus(); + const port = createBonusLifecyclePort( + fakeService(async () => { + throw new GrantNotFoundError(randomUUID()); + }), + bus, + ); + + const outcome = await port.forfeit(randomUUID(), 'terms_breach', 'no such grant'); + + expect(outcome).toEqual({ ok: false, reason: 'not_found' }); + expect(bus.emit).not.toHaveBeenCalled(); + }); + + it('maps a grant that already closed some other way to ok:false, not-forfeitable', async () => { + const bus = fakeBus(); + const port = createBonusLifecyclePort( + fakeService(async () => { + throw new GrantNotForfeitableError(); + }), + bus, + ); + + const outcome = await port.forfeit(randomUUID(), 'terms_breach', 'already closed'); + + expect(outcome).toEqual({ ok: false, reason: 'not_forfeitable' }); + expect(bus.emit).not.toHaveBeenCalled(); + }); + + it('rethrows an error the service did not define', async () => { + const bus = fakeBus(); + const port = createBonusLifecyclePort( + fakeService(async () => { + throw new Error('db exploded'); + }), + bus, + ); + + await expect(port.forfeit(randomUUID(), 'terms_breach', 'note')).rejects.toThrow('db exploded'); + }); +}); diff --git a/packages/core/src/promo/bonus/__tests__/grant-lifecycle.int.test.ts b/packages/core/src/promo/bonus/__tests__/grant-lifecycle.int.test.ts index 0a4b16310..9b42b45ae 100644 --- a/packages/core/src/promo/bonus/__tests__/grant-lifecycle.int.test.ts +++ b/packages/core/src/promo/bonus/__tests__/grant-lifecycle.int.test.ts @@ -209,6 +209,18 @@ describe('forfeiting every grant a player holds', () => { }); }); + it('takes everything for a cooling-off period, the same as a self-exclusion', async () => { + const userId = randomUUID(); + const grantId = await grant({ userId }); + + await lifecycle.forfeitAllFor(userId, 'cooling_off'); + + expect(await rowOf(grantId)).toMatchObject({ + status: 'forfeited', + forfeitReason: 'cooling_off', + }); + }); + it('leaves another player’s grants untouched', async () => { const mine = await grant({ userId: randomUUID() }); const theirs = await grant({ userId: randomUUID() }); @@ -263,3 +275,47 @@ describe('forfeiting every grant a player holds', () => { expect(closed[0]?.actorId).toBeNull(); }); }); + +describe('forfeiting a single grant by id', () => { + it('closes it with no actor when a scheduled job forfeits it, not an admin', async () => { + const grantId = await grant(); + + const closed = await lifecycle.forfeit( + grantId, + 'terms_breach', + undefined, + 'missed a required wagering day', + ); + + expect(closed).toMatchObject({ grantId }); + expect(await rowOf(grantId)).toMatchObject({ + status: 'forfeited', + forfeitReason: 'terms_breach', + }); + expect(audit.recordInTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ actorType: 'system', action: 'promo.bonus.forfeited' }), + ); + }); + + it('still records the admin when one is given', async () => { + const grantId = await grant(); + const actorId = randomUUID(); + + await lifecycle.forfeit(grantId, 'admin', { id: actorId, isAdmin: true }, 'manual takedown'); + + expect(audit.recordInTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ actorType: 'admin', actorId }), + ); + }); + + it('throws when the grant does not exist, recording the refusal with no actor', async () => { + await expect( + lifecycle.forfeit(randomUUID(), 'terms_breach', undefined, 'missed a required wagering day'), + ).rejects.toThrow(); + expect(audit.record).toHaveBeenCalledWith( + expect.objectContaining({ actorType: 'system', action: 'promo.bonus.forfeit_refused' }), + ); + }); +}); diff --git a/packages/core/src/promo/bonus/__tests__/wagering.service.int.test.ts b/packages/core/src/promo/bonus/__tests__/wagering.service.int.test.ts new file mode 100644 index 000000000..340080f0d --- /dev/null +++ b/packages/core/src/promo/bonus/__tests__/wagering.service.int.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { createTestDb, type TestDb } from '@openora/core/testing'; +import type { WagerContext, WagerTrackingCommands } from '@openora/core/contracts'; +import { migrate } from '../migrate.js'; +import { WageringService } from '../service/wagering.service.js'; + +let db: TestDb; + +const CASINO: WagerContext = { provider: 'aggregator', product: 'casino' }; + +beforeAll(async () => { + db = await createTestDb([migrate]); +}); + +afterAll(async () => { + await db.drop(); +}); + +describe('wager() with no attributed bonus grant', () => { + it('still reports the bet to wager tracking at its full stake', async () => { + const wagerTracking: WagerTrackingCommands = { recordWager: vi.fn(async () => []) }; + const wagering = new WageringService(wagerTracking); + const userId = randomUUID(); + + const outcome = await db.drizzle.db.transaction((tx) => + wagering.wager(tx, { + userId, + currency: 'USD', + stake: '25', + fromBonus: '0', + context: CASINO, + providerName: 'dice', + externalRoundId: randomUUID(), + }), + ); + + expect(outcome).toMatchObject({ ok: true, grantId: null }); + expect(wagerTracking.recordWager).toHaveBeenCalledTimes(1); + expect(wagerTracking.recordWager).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ userId, currency: 'USD', amount: '25', weightedAmount: '25' }), + ); + }); + + it('does not report a bet requesting bonus funds it has no grant for', async () => { + const wagerTracking: WagerTrackingCommands = { recordWager: vi.fn(async () => []) }; + const wagering = new WageringService(wagerTracking); + + const outcome = await db.drizzle.db.transaction((tx) => + wagering.wager(tx, { + userId: randomUUID(), + currency: 'USD', + stake: '25', + fromBonus: '10', + context: CASINO, + providerName: 'dice', + externalRoundId: randomUUID(), + }), + ); + + expect(outcome).toMatchObject({ ok: false, reason: 'insufficient_bonus' }); + expect(wagerTracking.recordWager).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/promo/bonus/contract/index.ts b/packages/core/src/promo/bonus/contract/index.ts index 53ecd1197..afc8f4f84 100644 --- a/packages/core/src/promo/bonus/contract/index.ts +++ b/packages/core/src/promo/bonus/contract/index.ts @@ -90,13 +90,26 @@ export const SetWagerWeightsInputSchema = z.object({ export type SetWagerWeightsInput = z.infer; /** - * Who an offer is for. Kept as jsonb on the row rather than as columns: every one of these is a - * predicate an operator turns on or off, and a new one should not cost a migration - which is - * why a rule with no way to answer it yet is absent rather than present and never firing. + * Who an offer is for, and the small per-offer knobs a mechanic needs that core has no grant + * shape for. Kept as jsonb on the row rather than as columns: every one of these is a predicate + * or a setting an operator turns on, off or tunes, and a new one should not cost a migration - + * which is why a rule with no way to answer it yet is absent rather than present and never + * firing. */ export const PromoOfferRulesSchema = z.object({ /** Only the player's first confirmed deposit qualifies. */ firstDepositOnly: z.boolean().default(false), + /** + * Free spins a grant of this offer entitles the player to. No provider-crediting API exists + * yet, so a job sets these pending on grant rather than never asking for a count at all - + * absent means this offer grants no spins. + */ + freeSpins: z.number().int().positive().optional(), + /** + * How many days a period-close job (e.g. a net-loss cashback sweep) looks back. Absent means + * the job's own default period. + */ + periodDays: z.number().int().positive().optional(), }); export type PromoOfferRules = z.infer; diff --git a/packages/core/src/promo/bonus/drizzle/migrations/0009_bonus_offer_rules_cashback_forfeit.sql b/packages/core/src/promo/bonus/drizzle/migrations/0009_bonus_offer_rules_cashback_forfeit.sql new file mode 100644 index 000000000..733619d7c --- /dev/null +++ b/packages/core/src/promo/bonus/drizzle/migrations/0009_bonus_offer_rules_cashback_forfeit.sql @@ -0,0 +1,3 @@ +ALTER TYPE "public"."promo_forfeit_reason" ADD VALUE 'cooling_off' BEFORE 'account_closed';--> statement-breakpoint +ALTER TYPE "public"."promo_forfeit_reason" ADD VALUE 'terms_breach';--> statement-breakpoint +ALTER TYPE "public"."promo_grant_source" ADD VALUE 'cashback'; \ No newline at end of file diff --git a/packages/core/src/promo/bonus/drizzle/migrations/meta/0009_snapshot.json b/packages/core/src/promo/bonus/drizzle/migrations/meta/0009_snapshot.json new file mode 100644 index 000000000..494ea1da8 --- /dev/null +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/0009_snapshot.json @@ -0,0 +1,969 @@ +{ + "id": "10e6f7a1-a889-4ce1-9367-250771b78ce6", + "prevId": "113e30a1-ffac-401a-ab0c-f194efc58d82", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.promo_grant": { + "name": "promo_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 + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "promo_grant_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "offer_id": { + "name": "offer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "terms": { + "name": "terms", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "granted_amount": { + "name": "granted_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "bonus_balance": { + "name": "bonus_balance", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "wagering_required": { + "name": "wagering_required", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "wagering_progress": { + "name": "wagering_progress", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "promo_grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "forfeit_reason": { + "name": "forfeit_reason", + "type": "promo_forfeit_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_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()" + } + }, + "indexes": { + "promo_grant_user_id_source_source_ref_idx": { + "name": "promo_grant_user_id_source_source_ref_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_grant_id_user_id_currency_idx": { + "name": "promo_grant_id_user_id_currency_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_grant_user_id_currency_expires_at_idx": { + "name": "promo_grant_user_id_currency_expires_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_grant\".\"status\" in ('pending', 'active')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_grant_expires_at_idx": { + "name": "promo_grant_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_grant\".\"status\" in ('pending', 'active')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_grant_user_id_created_at_idx": { + "name": "promo_grant_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_grant_bonus_balance_non_negative": { + "name": "promo_grant_bonus_balance_non_negative", + "value": "\"promo_grant\".\"bonus_balance\" >= 0" + }, + "promo_grant_progress_within_requirement": { + "name": "promo_grant_progress_within_requirement", + "value": "\"promo_grant\".\"wagering_progress\" >= 0 AND \"promo_grant\".\"wagering_progress\" <= \"promo_grant\".\"wagering_required\"" + }, + "promo_grant_forfeit_reason_matches_status": { + "name": "promo_grant_forfeit_reason_matches_status", + "value": "(\"promo_grant\".\"status\" = 'forfeited') = (\"promo_grant\".\"forfeit_reason\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.promo_grant_entry": { + "name": "promo_grant_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "grant_id": { + "name": "grant_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 + }, + "type": { + "name": "type", + "type": "promo_grant_entry_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "bonus_amount": { + "name": "bonus_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "real_amount": { + "name": "real_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "wagering_delta": { + "name": "wagering_delta", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "balance_after": { + "name": "balance_after", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "external_round_id": { + "name": "external_round_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wallet_transaction_id": { + "name": "wallet_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_grant_entry_user_id_currency_provider_name_external_round_id_idx": { + "name": "promo_grant_entry_user_id_currency_provider_name_external_round_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_grant_entry\".\"external_round_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_grant_entry_grant_id_created_at_idx": { + "name": "promo_grant_entry_grant_id_created_at_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_grant_entry_user_id_created_at_idx": { + "name": "promo_grant_entry_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_grant_entry_grant_id_user_id_currency_promo_grant_id_user_id_currency_fk": { + "name": "promo_grant_entry_grant_id_user_id_currency_promo_grant_id_user_id_currency_fk", + "tableFrom": "promo_grant_entry", + "tableTo": "promo_grant", + "columnsFrom": ["grant_id", "user_id", "currency"], + "columnsTo": ["id", "user_id", "currency"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promo_offer": { + "name": "promo_offer", + "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 + }, + "status": { + "name": "status", + "type": "promo_offer_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_percent": { + "name": "match_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "max_grant_amount": { + "name": "max_grant_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "min_deposit": { + "name": "min_deposit", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "terms": { + "name": "terms", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "requires_opt_in": { + "name": "requires_opt_in", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "valid_from": { + "name": "valid_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "valid_until": { + "name": "valid_until", + "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_offer_status_valid_from_valid_until_idx": { + "name": "promo_offer_status_valid_from_valid_until_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "valid_from", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "valid_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_offer_key_unique": { + "name": "promo_offer_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "promo_offer_match_percent_positive": { + "name": "promo_offer_match_percent_positive", + "value": "\"promo_offer\".\"match_percent\" > 0 AND \"promo_offer\".\"max_grant_amount\" > 0 AND \"promo_offer\".\"min_deposit\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_opt_in": { + "name": "promo_opt_in", + "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 + }, + "offer_id": { + "name": "offer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "accumulated_deposit": { + "name": "accumulated_deposit", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_opt_in_user_id_offer_id_idx": { + "name": "promo_opt_in_user_id_offer_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "offer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_opt_in_offer_id_promo_offer_id_fk": { + "name": "promo_opt_in_offer_id_promo_offer_id_fk", + "tableFrom": "promo_opt_in", + "tableTo": "promo_offer", + "columnsFrom": ["offer_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promo_opt_in_deposit": { + "name": "promo_opt_in_deposit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "opt_in_id": { + "name": "opt_in_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "transaction_id": { + "name": "transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "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_opt_in_deposit_opt_in_id_transaction_id_idx": { + "name": "promo_opt_in_deposit_opt_in_id_transaction_id_idx", + "columns": [ + { + "expression": "opt_in_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_opt_in_deposit_opt_in_id_promo_opt_in_id_fk": { + "name": "promo_opt_in_deposit_opt_in_id_promo_opt_in_id_fk", + "tableFrom": "promo_opt_in_deposit", + "tableTo": "promo_opt_in", + "columnsFrom": ["opt_in_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promo_weight": { + "name": "promo_weight", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "promo_weight_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "scope_ref": { + "name": "scope_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contribution_percent": { + "name": "contribution_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_weight_profile_id_scope_scope_ref_idx": { + "name": "promo_weight_profile_id_scope_scope_ref_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_weight_profile_id_default_idx": { + "name": "promo_weight_profile_id_default_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"promo_weight\".\"scope\" = 'default'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_weight_profile_id_promo_weight_profile_id_fk": { + "name": "promo_weight_profile_id_promo_weight_profile_id_fk", + "tableFrom": "promo_weight", + "tableTo": "promo_weight_profile", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_weight_contribution_percent_range": { + "name": "promo_weight_contribution_percent_range", + "value": "\"promo_weight\".\"contribution_percent\" >= 0 AND \"promo_weight\".\"contribution_percent\" <= 100" + } + }, + "isRLSEnabled": false + }, + "public.promo_weight_profile": { + "name": "promo_weight_profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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_weight_profile_name_unique": { + "name": "promo_weight_profile_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.promo_forfeit_reason": { + "name": "promo_forfeit_reason", + "schema": "public", + "values": [ + "self_exclusion", + "cooling_off", + "account_closed", + "admin", + "player_opt_out", + "withdrawal_while_active", + "terms_breach" + ] + }, + "public.promo_grant_entry_type": { + "name": "promo_grant_entry_type", + "schema": "public", + "values": ["grant", "stake", "win", "reversal", "convert", "forfeit", "expire"] + }, + "public.promo_grant_source": { + "name": "promo_grant_source", + "schema": "public", + "values": ["deposit", "manual", "streak", "rank", "race", "gift", "rain", "cashback"] + }, + "public.promo_grant_status": { + "name": "promo_grant_status", + "schema": "public", + "values": ["pending", "active", "completed", "expired", "forfeited", "cancelled"] + }, + "public.promo_offer_status": { + "name": "promo_offer_status", + "schema": "public", + "values": ["draft", "active", "paused", "archived"] + }, + "public.promo_weight_scope": { + "name": "promo_weight_scope", + "schema": "public", + "values": ["game", "category", "product", "default"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json b/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json index 073588e48..0af20e63a 100644 --- a/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1790217101459, "tag": "0008_dear_mystique", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1790343346597, + "tag": "0009_bonus_offer_rules_cashback_forfeit", + "breakpoints": true } ] } diff --git a/packages/core/src/promo/bonus/plugin.ts b/packages/core/src/promo/bonus/plugin.ts index 5310e7847..1eb69df07 100644 --- a/packages/core/src/promo/bonus/plugin.ts +++ b/packages/core/src/promo/bonus/plugin.ts @@ -2,9 +2,11 @@ import * as z from 'zod'; import { AUDIT_WRITER, BONUS_GRANTS, + BONUS_LIFECYCLE, BONUS_WAGERING, BonusForfeitReasonSchema, type BonusForfeitReason, + type BonusLifecycleCommands, CurrencyTickerSchema, JOB_QUEUE, MoneyAmountSchema, @@ -30,6 +32,7 @@ import { type Plugin, } from '@openora/core/server'; import { GrantLifecycleService } from './service/grant-lifecycle.service.js'; +import { createBonusLifecyclePort } from './service/bonus-lifecycle-port.service.js'; import { GrantReaderService } from './service/grant-reader.service.js'; import { GrantService } from './service/grant.service.js'; import { OfferService } from './service/offer.service.js'; @@ -49,6 +52,16 @@ const EXPIRY_CRON = '*/15 * * * *'; // never receives. Each handler's own database guard is what makes the retry itself safe. const MONEY_JOB_RETRY = { attempts: 5, backoff: { type: 'exponential', delayMs: 1000 } } as const; +// Trust-boundary narrowing for a zod-parsed event payload whose shape varies by topic: some +// forfeit-triggering events carry `initiatedBy`, some do not. `unknown` in, a known initiator out. +function initiatedByOf(data: unknown): 'player' | 'admin' | 'system' | undefined { + if (typeof data !== 'object' || data === null || !('initiatedBy' in data)) { + return undefined; + } + const value = (data as { initiatedBy: unknown }).initiatedBy; + return value === 'player' || value === 'admin' || value === 'system' ? value : undefined; +} + const EmptyJobPayloadSchema = z.object({}); /** @@ -86,6 +99,19 @@ export default { BONUS_WAGERING, (c) => new WageringService(c.has(WAGER_TRACKING) ? c.get(WAGER_TRACKING) : undefined), ); + // A thin command port over GrantLifecycleService.forfeit - the shape an external + // system/job context needs (no admin session to assert, one named grant rather than every + // grant a player holds). Constructed off the container directly, not the module-scoped + // `lifecycle` variable below: that one is only built lazily when the router resolves, and a + // job calling this port must not depend on the router having been requested first. + ctx.provide( + BONUS_LIFECYCLE, + (c): BonusLifecycleCommands => + createBonusLifecyclePort( + new GrantLifecycleService(c.get(DRIZZLE), c.get(AUDIT_WRITER)), + c.get(EVENT_BUS), + ), + ); let lifecycle: GrantLifecycleService | null = null; let offers: OfferService | null = null; @@ -177,12 +203,19 @@ export default { }, }); - // A bonus is money a player may not keep once they have excluded themselves or closed the - // account, and the rule is immediate rather than "by the next sweep". + // A bonus is money a player may not keep once they have excluded themselves, entered a + // cooling-off period, closed the account or been banned, and the rule is immediate rather + // than "by the next sweep". const forfeitEverything = - ( + < + K extends + | 'rg.self_exclusion.activated' + | 'rg.cooling_off.activated' + | 'player.account.closed' + | 'identity.user.deactivated', + >( topic: K, - reason: 'self_exclusion' | 'account_closed', + reason: 'self_exclusion' | 'cooling_off' | 'account_closed' | 'admin', ) => (payload: unknown) => { const parsed = domainEventSchemas[topic].safeParse(payload); @@ -195,15 +228,17 @@ export default { return; } const { userId, actorId } = parsed.data; - // Account closure is always an admin action. A self-exclusion names its own initiator, - // which is 'player', 'admin' or 'system' - a rule-triggered exclusion is nobody's admin - // action, and recording it as one puts the wrong name on a regulator-facing audit row. + // Account closure and a ban (identity.user.deactivated) are always an admin action. A + // self-exclusion names its own initiator, which is 'player', 'admin' or 'system' - a + // rule-triggered exclusion is nobody's admin action, and recording it as one puts the + // wrong name on a regulator-facing audit row. Read via a type guard rather than the `in` + // narrowing the three-topic version used - the fourth topic in this union has no + // `initiatedBy` field at all, which collapses `parsed.data`'s inferred type to a point + // the compiler can no longer narrow through property presence alone. const initiatedBy: 'player' | 'admin' | 'system' = - topic === 'player.account.closed' + topic === 'player.account.closed' || topic === 'identity.user.deactivated' ? 'admin' - : 'initiatedBy' in parsed.data - ? parsed.data.initiatedBy - : 'system'; + : (initiatedByOf(parsed.data) ?? 'system'); // No queue idempotency key on purpose. It would have to be derived from the player and // the reason, and a player who excludes themselves, lets the cool-off lapse, takes a new // bonus and excludes themselves again produces the same key - which BullMQ drops @@ -229,10 +264,20 @@ export default { 'rg.self_exclusion.activated', forfeitEverything('rg.self_exclusion.activated', 'self_exclusion'), ); + ctx.events.on( + 'rg.cooling_off.activated', + forfeitEverything('rg.cooling_off.activated', 'cooling_off'), + ); ctx.events.on( 'player.account.closed', forfeitEverything('player.account.closed', 'account_closed'), ); + // A ban is `identity.user.deactivated` - the admin-console "Ban" action flips `isActive` + // false, same event chat's own membership service already reacts to for room removal. + ctx.events.on( + 'identity.user.deactivated', + forfeitEverything('identity.user.deactivated', 'admin'), + ); ctx.jobs.worker({ queue: DEPOSIT_QUEUE, diff --git a/packages/core/src/promo/bonus/service/bonus-lifecycle-port.service.ts b/packages/core/src/promo/bonus/service/bonus-lifecycle-port.service.ts new file mode 100644 index 000000000..3c9d21180 --- /dev/null +++ b/packages/core/src/promo/bonus/service/bonus-lifecycle-port.service.ts @@ -0,0 +1,49 @@ +import type { BonusForfeitOutcome, BonusLifecycleCommands } from '@openora/core/contracts'; +import type { EventBus } from '@openora/core/server'; +import { + GrantLifecycleService, + GrantNotFoundError, + GrantNotForfeitableError, +} from './grant-lifecycle.service.js'; + +/** + * Adapts `GrantLifecycleService.forfeit` (throws on refusal) to the BONUS_LIFECYCLE command port + * shape (returns an outcome) and announces the same `promo.bonus.forfeited` topic a router- or + * rule-driven forfeit already emits, so a job-driven one gets the same realtime balance signal + * and player notification for free rather than a second announcement path to keep in sync. + */ +export function createBonusLifecyclePort( + service: GrantLifecycleService, + bus: EventBus, +): BonusLifecycleCommands { + return { + async forfeit(grantId, reason, note): Promise { + try { + const closed = await service.forfeit(grantId, reason, undefined, note); + bus.emit('promo.bonus.forfeited', { + userId: closed.userId, + grantId: closed.grantId, + currency: closed.currency, + forfeitedAmount: closed.forfeitedAmount, + reason, + actorId: null, + }); + return { + ok: true, + grantId: closed.grantId, + userId: closed.userId, + currency: closed.currency, + forfeitedAmount: closed.forfeitedAmount, + }; + } catch (err) { + if (err instanceof GrantNotFoundError) { + return { ok: false, reason: 'not_found' }; + } + if (err instanceof GrantNotForfeitableError) { + return { ok: false, reason: 'not_forfeitable' }; + } + throw err; + } + }, + }; +} diff --git a/packages/core/src/promo/bonus/service/grant-lifecycle.service.ts b/packages/core/src/promo/bonus/service/grant-lifecycle.service.ts index d2e20657f..3e2940ffc 100644 --- a/packages/core/src/promo/bonus/service/grant-lifecycle.service.ts +++ b/packages/core/src/promo/bonus/service/grant-lifecycle.service.ts @@ -99,10 +99,17 @@ export class GrantLifecycleService { return closed; } + /** + * A single grant, taken away by name rather than by player - the shape a scheduled job needs + * (an Activity Bonus grant that missed its daily wager) that `forfeitAllFor` cannot give it, + * since that sweeps every live grant a player holds. `actor` is optional for the same reason + * `close` already allows it: a job has no admin session to assert, and recording one it does + * not have would misname the regulator-facing audit row as an admin action. + */ async forfeit( grantId: PromoGrant['id'], reason: BonusForfeitReason, - actor: { id: Uuid; isAdmin: boolean }, + actor: { id: Uuid; isAdmin: boolean } | undefined, note: string, ): Promise { const [exists] = await this.drizzle.db @@ -119,7 +126,7 @@ export class GrantLifecycleService { status: 'forfeited', action: 'promo.bonus.forfeited', reason, - actor, + ...(actor === undefined ? {} : { actor }), note, }), ); @@ -143,13 +150,17 @@ export class GrantLifecycleService { */ private async recordRefusal( grantId: PromoGrant['id'], - actor: { id: Uuid; isAdmin: boolean }, + actor: { id: Uuid; isAdmin: boolean } | undefined, note: string, status: string, ): Promise { await this.audit.record({ - actorId: actor.id, - actorType: actor.isAdmin ? 'admin' : 'player', + ...(actor === undefined + ? { actorType: 'system' as const } + : { + actorId: actor.id, + actorType: actor.isAdmin ? ('admin' as const) : ('player' as const), + }), // Its own action, not the successful one. A denial shares the resource type, so counting // forfeitures - or reading a grant's history in a dispute - would otherwise mix probes at // ids that do not exist in with the movements that destroyed real money. diff --git a/packages/core/src/promo/bonus/service/wagering.service.ts b/packages/core/src/promo/bonus/service/wagering.service.ts index eb7a5007e..fa3c9edd9 100644 --- a/packages/core/src/promo/bonus/service/wagering.service.ts +++ b/packages/core/src/promo/bonus/service/wagering.service.ts @@ -72,16 +72,30 @@ export class WageringService implements BonusWageringCommands { const grant = await this.lockAttributedGrant(tx, args.userId, args.currency); if (!grant) { - return moneyCompare(args.fromBonus, ZERO) > 0 - ? { ok: false, reason: 'insufficient_bonus', bonusAvailable: ZERO } - : { - ok: true, - grantId: null, - bonusSpent: ZERO, - weightedAmount: ZERO, - bonusBalanceAfter: ZERO, - completed: null, - }; + if (moneyCompare(args.fromBonus, ZERO) > 0) { + return { ok: false, reason: 'insufficient_bonus', bonusAvailable: ZERO }; + } + // No bonus attributed to this bet: it is a plain real-money wager, still counted toward + // rank and rank-adjacent tracking (streaks, etc) at its full stake - nothing here weights + // it down the way a bonus's contribution percent would. + const walletCredits = + (await this.wagerTracking?.recordWager(tx, { + userId: args.userId, + currency: args.currency, + amount: args.stake, + weightedAmount: args.stake, + realAmount: args.stake, + context: args.context, + })) ?? []; + return { + ok: true, + grantId: null, + bonusSpent: ZERO, + weightedAmount: ZERO, + bonusBalanceAfter: ZERO, + completed: null, + walletCredits, + }; } // Against the terms snapshot, inside the transaction that moves the money. It is an @@ -155,13 +169,15 @@ export class WageringService implements BonusWageringCommands { } } - await this.wagerTracking?.recordWager(tx, { - userId: args.userId, - currency: args.currency, - amount: args.stake, - weightedAmount: weighted, - context: args.context, - }); + const walletCredits = + (await this.wagerTracking?.recordWager(tx, { + userId: args.userId, + currency: args.currency, + amount: args.stake, + weightedAmount: weighted, + realAmount: moneySubtract(args.stake, args.fromBonus), + context: args.context, + })) ?? []; return { ok: true, @@ -170,6 +186,7 @@ export class WageringService implements BonusWageringCommands { weightedAmount: weighted, bonusBalanceAfter: balanceAfter, completed: completed ? { grantId: grant.id, convertedAmount } : null, + walletCredits, }; } diff --git a/packages/core/src/promo/gamification/__tests__/payout-currency.test.ts b/packages/core/src/promo/gamification/__tests__/payout-currency.test.ts new file mode 100644 index 000000000..8ea2bff37 --- /dev/null +++ b/packages/core/src/promo/gamification/__tests__/payout-currency.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { ExchangeRateReader } from '@openora/core/contracts'; +import { mock } from '../../../testing/mock.js'; +import { priceForPayout } from '../shared/payout-currency.js'; + +const convert = vi.fn(); +const rates = mock({ convert }); + +describe('pricing a payout into the operator payout currency', () => { + it('skips the rate lookup entirely when the source is already the payout currency', async () => { + const priced = await priceForPayout(rates, '50', 'USD', 'USD'); + + expect(priced).toEqual({ amount: '50', currency: 'USD' }); + expect(convert).not.toHaveBeenCalled(); + }); + + it('converts at the current rate when the source differs from the payout currency', async () => { + convert.mockResolvedValue('48'); + + const priced = await priceForPayout(rates, '50', 'USD', 'USDT'); + + expect(convert).toHaveBeenCalledWith('50', 'USD', 'USDT'); + expect(priced).toEqual({ amount: '48', currency: 'USDT' }); + }); + + it('throws rather than falling back to the source currency when no rate is available', async () => { + convert.mockResolvedValue(null); + + await expect(priceForPayout(rates, '50', 'USD', 'USDT')).rejects.toThrow( + 'no exchange rate from USD to USDT', + ); + }); +}); diff --git a/packages/core/src/promo/gamification/__tests__/race-admin.int.test.ts b/packages/core/src/promo/gamification/__tests__/race-admin.int.test.ts new file mode 100644 index 000000000..33d404dcc --- /dev/null +++ b/packages/core/src/promo/gamification/__tests__/race-admin.int.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { createTestDb, type TestDb } from '@openora/core/testing'; +import { makeAuditWriter } from '../../../testing/mock.js'; +import { migrate } from '../migrate.js'; +import { promoRace } from '../schema/index.js'; +import { + RaceAdminService, + RaceClosedError, + RaceNotFoundError, + RacePositionsInvalidError, +} from '../service/race-admin.service.js'; +import type { CreateRaceInput } from '../contract/index.js'; + +let db: TestDb; +const audit = makeAuditWriter(); +let admin: RaceAdminService; +const adminId = randomUUID(); + +const hoursFromNow = (h: number) => new Date(Date.now() + h * 3_600_000).toISOString(); + +const baseInput: CreateRaceInput = { + name: 'Weekly Race', + currency: 'USDT', + startAt: hoursFromNow(-1), + endAt: hoursFromNow(167), + prizePool: '1000', + positions: [ + { position: 1, prize: '500' }, + { position: 2, prize: '250' }, + { position: 3, prize: '100' }, + ], + eligibleProducts: [], +}; + +beforeAll(async () => { + db = await createTestDb([migrate]); + admin = new RaceAdminService(db.drizzle, audit); +}); + +afterAll(() => db.drop()); + +beforeEach(async () => { + audit.record.mockClear(); + audit.recordInTransaction.mockClear(); + await db.drizzle.db.delete(promoRace); +}); + +describe('creating a race', () => { + it('creates a race and records an audit row', async () => { + const race = await admin.create(adminId, baseInput); + + expect(race.name).toBe('Weekly Race'); + expect(race.closedAt).toBeNull(); + expect(audit.recordInTransaction).toHaveBeenCalledTimes(1); + expect(audit.recordInTransaction.mock.calls[0]?.[1]).toMatchObject({ + action: 'promo.race.created', + before: null, + }); + }); + + it('rejects positions whose prizes sum to more than the prize pool', async () => { + await expect( + admin.create(adminId, { + ...baseInput, + prizePool: '100', + positions: [{ position: 1, prize: '500' }], + }), + ).rejects.toThrow(RacePositionsInvalidError); + }); +}); + +describe('updating a race', () => { + it('replaces the config on an upcoming race and records a before/after audit row', async () => { + const race = await admin.create(adminId, baseInput); + + const updated = await admin.update(adminId, { + raceId: race.id, + ...baseInput, + prizePool: '2000', + positions: [ + { position: 1, prize: '1000' }, + { position: 2, prize: '500' }, + ], + }); + + expect(updated.prizePool).toBe('2000.000000000000000000'); + expect(updated.positions).toHaveLength(2); + expect(audit.recordInTransaction).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ action: 'promo.race.updated' }), + ); + }); + + it('rejects an update once the race has closed', async () => { + const race = await admin.create(adminId, baseInput); + // Close it directly - RaceAdminService itself never sets closedAt; that is the + // settle job's job (RacePayoutService). + await db.drizzle.db + .update(promoRace) + .set({ closedAt: new Date() }) + .where(eq(promoRace.id, race.id)); + + await expect(admin.update(adminId, { raceId: race.id, ...baseInput })).rejects.toThrow( + RaceClosedError, + ); + }); + + it('rejects an update to a race that does not exist', async () => { + await expect(admin.update(adminId, { raceId: randomUUID(), ...baseInput })).rejects.toThrow( + RaceNotFoundError, + ); + }); +}); + +describe('reading races', () => { + it('lists and gets a race', async () => { + const race = await admin.create(adminId, baseInput); + + await expect(admin.list()).resolves.toEqual( + expect.arrayContaining([expect.objectContaining({ id: race.id })]), + ); + await expect(admin.get(race.id)).resolves.toMatchObject({ id: race.id }); + await expect(admin.get(randomUUID())).rejects.toThrow(RaceNotFoundError); + }); +}); diff --git a/packages/core/src/promo/gamification/__tests__/race-payout.int.test.ts b/packages/core/src/promo/gamification/__tests__/race-payout.int.test.ts new file mode 100644 index 000000000..f4a7b72d4 --- /dev/null +++ b/packages/core/src/promo/gamification/__tests__/race-payout.int.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { createTestDb, type TestDb } from '@openora/core/testing'; +import { mock } from '../../../testing/mock.js'; +import type { + ExchangeRateReader, + PlayEligibilityPort, + WalletCommands, +} from '@openora/core/contracts'; +import { migrate } from '../migrate.js'; +import { promoRace, promoRacePayout, promoRaceWager } from '../schema/index.js'; +import { RacePayoutService } from '../service/race-payout.service.js'; +import type { RacePosition } from '../contract/index.js'; + +let db: TestDb; +const isRestricted = vi.fn(); +const credit = vi.fn(); +const convert = vi.fn(); +const logger = { warn: vi.fn(), error: vi.fn() }; + +const POSITIONS: RacePosition[] = [ + { position: 1, prize: '500' }, + { position: 2, prize: '250' }, +]; + +// The race's own currency and the payout currency match by default, so most tests exercise the +// no-conversion path - the currency-conversion behaviour has its own describe block below. +const service = (payoutCurrency = 'USDT') => + new RacePayoutService( + db.drizzle, + mock({ isRestricted }), + mock({ credit }), + mock({ convert }), + payoutCurrency, + logger, + ); + +const hoursFromNow = (h: number) => new Date(Date.now() + h * 3_600_000); + +const insertRace = async (overrides: Partial = {}) => { + const [row] = await db.drizzle.db + .insert(promoRace) + .values({ + name: 'Weekly Race', + currency: 'USDT', + startAt: hoursFromNow(-2), + endAt: hoursFromNow(-1), + prizePool: '1000', + positions: POSITIONS, + eligibleProducts: [], + ...overrides, + }) + .returning({ id: promoRace.id }); + if (!row) { + throw new Error('insertRace: insert returned no row'); + } + return row.id; +}; + +const insertWager = (raceId: string, userId: string, wagered: string) => + db.drizzle.db.insert(promoRaceWager).values({ raceId, userId, currency: 'USDT', wagered }); + +const payoutsFor = (raceId: string) => + db.drizzle.db + .select({ + userId: promoRacePayout.userId, + position: promoRacePayout.position, + amount: promoRacePayout.amount, + outcome: promoRacePayout.outcome, + }) + .from(promoRacePayout) + .where(eq(promoRacePayout.raceId, raceId)) + .orderBy(promoRacePayout.position); + +beforeAll(async () => { + db = await createTestDb([migrate]); +}); + +afterAll(() => db.drop()); + +beforeEach(async () => { + vi.clearAllMocks(); + isRestricted.mockResolvedValue(false); + credit.mockResolvedValue({ + ok: true, + moved: true, + transactionId: randomUUID(), + newBalance: '500', + }); + await db.drizzle.db.delete(promoRacePayout); + await db.drizzle.db.delete(promoRaceWager); + await db.drizzle.db.delete(promoRace); +}); + +describe('settling a closed race', () => { + it('pays every paid position, credits real cash, and closes the race once', async () => { + const raceId = await insertRace(); + const first = randomUUID(); + const second = randomUUID(); + const third = randomUUID(); + await insertWager(raceId, first, '500'); + await insertWager(raceId, second, '300'); + await insertWager(raceId, third, '100'); + + const won = await service().closeDue(new Date()); + + expect(won).toEqual([ + expect.objectContaining({ userId: first, raceId, position: 1, amount: '500' }), + expect.objectContaining({ userId: second, raceId, position: 2, amount: '250' }), + ]); + expect(credit).toHaveBeenCalledTimes(2); + expect(credit.mock.calls[0]?.[1]).toMatchObject({ + userId: first, + amount: '500', + currency: 'USDT', + type: 'cashback', + providerRef: { providerName: 'promo-race', providerRefId: `race-payout:${raceId}:${first}` }, + }); + + const payouts = await payoutsFor(raceId); + expect(payouts).toHaveLength(2); + expect(payouts.every((p) => p.outcome === 'granted')).toBe(true); + + const [race] = await db.drizzle.db + .select({ closedAt: promoRace.closedAt }) + .from(promoRace) + .where(eq(promoRace.id, raceId)); + expect(race?.closedAt).not.toBeNull(); + }); + + it('ranks a tie by whoever reached the total first', async () => { + const raceId = await insertRace(); + const early = randomUUID(); + const late = randomUUID(); + await insertWager(raceId, early, '200'); + // A distinct later `updatedAt` for the tie-break: insert then update so the row's own + // timestamp actually moves forward of `early`'s. + await insertWager(raceId, late, '100'); + await db.drizzle.db + .update(promoRaceWager) + .set({ wagered: '200' }) + .where(eq(promoRaceWager.userId, late)); + + const won = await service().closeDue(new Date()); + + expect(won[0]).toMatchObject({ userId: early, position: 1 }); + expect(won[1]).toMatchObject({ userId: late, position: 2 }); + }); + + it('withholds cash from a player under a responsible-gambling restriction, but still ranks and records them', async () => { + const raceId = await insertRace(); + const winner = randomUUID(); + isRestricted.mockResolvedValue(true); + await insertWager(raceId, winner, '500'); + + const won = await service().closeDue(new Date()); + + expect(won).toHaveLength(0); + expect(credit).not.toHaveBeenCalled(); + const payouts = await payoutsFor(raceId); + expect(payouts).toEqual([expect.objectContaining({ userId: winner, outcome: 'restricted' })]); + }); + + it('is idempotent: a retried settle does not credit or re-rank twice', async () => { + const raceId = await insertRace(); + const winner = randomUUID(); + await insertWager(raceId, winner, '500'); + + await service().closeDue(new Date()); + // Re-open closedAt as a retry after a crash between payouts and the close flag would find it. + await db.drizzle.db.update(promoRace).set({ closedAt: null }).where(eq(promoRace.id, raceId)); + const won = await service().closeDue(new Date()); + + expect(won).toHaveLength(0); + expect(credit).toHaveBeenCalledTimes(1); + const payouts = await payoutsFor(raceId); + expect(payouts).toHaveLength(1); + }); + + it('skips a race not yet past its end time', async () => { + await insertRace({ startAt: hoursFromNow(-1), endAt: hoursFromNow(1) }); + + const won = await service().closeDue(new Date()); + + expect(won).toHaveLength(0); + expect(credit).not.toHaveBeenCalled(); + }); +}); + +describe('crediting a prize in a currency the player can actually hold', () => { + it('converts a prize priced in the race currency into the payout currency before crediting', async () => { + const raceId = await insertRace(); + const winner = randomUUID(); + await insertWager(raceId, winner, '500'); + convert.mockResolvedValue('480'); + + const won = await service('USD').closeDue(new Date()); + + expect(convert).toHaveBeenCalledWith('500', 'USDT', 'USD'); + expect(credit.mock.calls[0]?.[1]).toMatchObject({ amount: '480', currency: 'USD' }); + expect(won[0]).toMatchObject({ amount: '480', currency: 'USD' }); + const payouts = await payoutsFor(raceId); + expect(Number(payouts[0]?.amount)).toBe(480); + }); + + it('refuses the credit and leaves the race open to retry when no rate is available', async () => { + const raceId = await insertRace(); + const winner = randomUUID(); + await insertWager(raceId, winner, '500'); + convert.mockResolvedValue(null); + + const won = await service('USD').closeDue(new Date()); + + expect(won).toHaveLength(0); + expect(credit).not.toHaveBeenCalled(); + const payouts = await payoutsFor(raceId); + expect(payouts).toHaveLength(0); + const [race] = await db.drizzle.db + .select({ closedAt: promoRace.closedAt }) + .from(promoRace) + .where(eq(promoRace.id, raceId)); + expect(race?.closedAt).toBeNull(); + }); + + it('rolls back the whole race, granting nothing, when the wallet credit fails', async () => { + const raceId = await insertRace(); + const winner = randomUUID(); + const runnerUp = randomUUID(); + await insertWager(raceId, winner, '500'); + await insertWager(raceId, runnerUp, '300'); + credit.mockResolvedValueOnce({ ok: false, reason: 'wallet not found' }); + + const won = await service().closeDue(new Date()); + + expect(won).toHaveLength(0); + const payouts = await payoutsFor(raceId); + expect(payouts).toHaveLength(0); + const [race] = await db.drizzle.db + .select({ closedAt: promoRace.closedAt }) + .from(promoRace) + .where(eq(promoRace.id, raceId)); + expect(race?.closedAt).toBeNull(); + }); +}); diff --git a/packages/core/src/promo/gamification/__tests__/race.int.test.ts b/packages/core/src/promo/gamification/__tests__/race.int.test.ts new file mode 100644 index 000000000..850d19f2d --- /dev/null +++ b/packages/core/src/promo/gamification/__tests__/race.int.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { and, eq } from 'drizzle-orm'; +import { createTestDb, seedPlayerWithUser, type TestDb } from '@openora/core/testing'; +import { mock } from '../../../testing/mock.js'; +import type { ExchangeRateReader, WagerContext } from '@openora/core/contracts'; +import { moneyEquals } from '@openora/core/server'; +import { migrate } from '../migrate.js'; +import { migrate as identityMigrate } from '@openora/core/pam/migrate/identity'; +import { migrate as profileMigrate } from '@openora/core/pam/migrate/profile'; +import { promoRace, promoRaceWager } from '../schema/index.js'; +import { RaceService } from '../service/race.service.js'; +import type { RacePosition } from '../contract/index.js'; + +let db: TestDb; +const convert = vi.fn(); +const logger = { warn: vi.fn() }; +let races: RaceService; + +const CASINO: WagerContext = { provider: 'aggregator', product: 'casino' }; +const POSITIONS: RacePosition[] = [ + { position: 1, prize: '500' }, + { position: 2, prize: '250' }, + { position: 3, prize: '100' }, +]; + +const now = () => new Date(); +const hoursFromNow = (h: number) => new Date(Date.now() + h * 3_600_000); + +const insertRace = async (overrides: Partial = {}) => { + const [row] = await db.drizzle.db + .insert(promoRace) + .values({ + name: 'Weekly Race', + currency: 'USDT', + startAt: hoursFromNow(-1), + endAt: hoursFromNow(1), + prizePool: '1000', + positions: POSITIONS, + eligibleProducts: [], + ...overrides, + }) + .returning({ id: promoRace.id }); + if (!row) { + throw new Error('insertRace: insert returned no row'); + } + return row.id; +}; + +const wager = ( + userId: string, + amount: string, + realAmount: string, + currency = 'USDT', + context: WagerContext = CASINO, +) => + db.drizzle.db.transaction((tx) => + races.recordWager(tx, { + userId, + currency, + amount, + weightedAmount: amount, + realAmount, + context, + }), + ); + +const wageredOf = async (raceId: string, userId: string) => { + const [row] = await db.drizzle.db + .select({ wagered: promoRaceWager.wagered }) + .from(promoRaceWager) + .where(and(eq(promoRaceWager.raceId, raceId), eq(promoRaceWager.userId, userId))); + return row?.wagered ?? null; +}; + +beforeAll(async () => { + db = await createTestDb([migrate, identityMigrate, profileMigrate]); + races = new RaceService(db.drizzle, mock({ convert }), logger); +}); + +afterAll(() => db.drop()); + +beforeEach(async () => { + vi.clearAllMocks(); + await db.drizzle.db.delete(promoRaceWager); + await db.drizzle.db.delete(promoRace); +}); + +describe('recording a wager toward an open race', () => { + it('accrues the real-money stake, not the full or bonus-weighted amount', async () => { + const raceId = await insertRace(); + const userId = randomUUID(); + + await wager(userId, '100', '60'); + + const [row] = await db.drizzle.db + .select({ wagered: promoRaceWager.wagered }) + .from(promoRaceWager) + .where(eq(promoRaceWager.raceId, raceId)); + expect(moneyEquals(row?.wagered ?? '0', '60')).toBe(true); + expect(convert).not.toHaveBeenCalled(); + }); + + it('ignores a wager funded entirely by a bonus', async () => { + await insertRace(); + const userId = randomUUID(); + + await wager(userId, '100', '0'); + + const rows = await db.drizzle.db.select().from(promoRaceWager); + expect(rows).toHaveLength(0); + }); + + it('ignores a race that has not started yet', async () => { + await insertRace({ startAt: hoursFromNow(1), endAt: hoursFromNow(2) }); + const userId = randomUUID(); + + await wager(userId, '100', '100'); + + const rows = await db.drizzle.db.select().from(promoRaceWager); + expect(rows).toHaveLength(0); + }); + + it('ignores a race that has already ended', async () => { + await insertRace({ startAt: hoursFromNow(-2), endAt: hoursFromNow(-1) }); + const userId = randomUUID(); + + await wager(userId, '100', '100'); + + const rows = await db.drizzle.db.select().from(promoRaceWager); + expect(rows).toHaveLength(0); + }); + + it('ignores a closed race even inside its own window', async () => { + await insertRace({ closedAt: now() }); + const userId = randomUUID(); + + await wager(userId, '100', '100'); + + const rows = await db.drizzle.db.select().from(promoRaceWager); + expect(rows).toHaveLength(0); + }); + + it('skips a product the race does not count', async () => { + await insertRace({ eligibleProducts: ['sportsbook'] }); + const userId = randomUUID(); + + await wager(userId, '100', '100', 'USDT', CASINO); + + const rows = await db.drizzle.db.select().from(promoRaceWager); + expect(rows).toHaveLength(0); + }); + + it('converts a wager placed in another currency into the race currency', async () => { + const raceId = await insertRace({ currency: 'USDT' }); + const userId = randomUUID(); + convert.mockResolvedValue('50'); + + await wager(userId, '100', '100', 'EUR'); + + expect(convert).toHaveBeenCalledWith('100', 'EUR', 'USDT'); + expect(moneyEquals((await wageredOf(raceId, userId)) ?? '0', '50')).toBe(true); + }); + + it('accumulates across two bets for the same player', async () => { + const raceId = await insertRace(); + const userId = randomUUID(); + + await wager(userId, '40', '40'); + await wager(userId, '10', '10'); + + expect(moneyEquals((await wageredOf(raceId, userId)) ?? '0', '50')).toBe(true); + }); +}); + +describe('reading a race for a player', () => { + it('masks another player using the platform masking rule, never masks the caller themselves', async () => { + const raceId = await insertRace(); + const { account: leader } = await seedPlayerWithUser(db, { username: 'YOLOKing' }); + const { account: caller } = await seedPlayerWithUser(db, { username: 'pvp_Slayer420' }); + await wager(leader.id, '500', '500'); + await wager(caller.id, '10', '10'); + + const view = await races.getForPlayer(raceId, caller.id); + + const leaderRow = [...view.podium, ...view.leaderboard].find((r) => r.userId === leader.id); + expect(leaderRow?.username).not.toBe('YOLOKing'); + expect(leaderRow?.username.startsWith('YO')).toBe(true); + expect(leaderRow?.username).toContain('*'); + const ownRow = [...view.podium, ...view.leaderboard].find((r) => r.userId === caller.id); + expect(ownRow?.username).toBe('pvp_Slayer420'); + }); + + it('shows "Incognito" for a player who hid their username, without affecting their own entry', async () => { + const raceId = await insertRace(); + const { account: ghost } = await seedPlayerWithUser(db, { + username: 'GhostRider', + hideUsernameOnLeaderboards: true, + }); + const { account: caller } = await seedPlayerWithUser(db, { username: 'Onlooker' }); + await wager(ghost.id, '500', '500'); + await wager(caller.id, '10', '10'); + + const view = await races.getForPlayer(raceId, caller.id); + + const ghostRow = [...view.podium, ...view.leaderboard].find((r) => r.userId === ghost.id); + expect(ghostRow?.username).toBe('Incognito'); + + const ownView = await races.getForPlayer(raceId, ghost.id); + expect(moneyEquals(ownView.own.wagered, '500')).toBe(true); + expect(ownView.own.position).toBe(1); + }); + + it('reports the amount still needed to reach the next paid position, and null once already paid', async () => { + // 3 paid positions; a 4th player sits just outside them. + const raceId = await insertRace(); + const { account: first } = await seedPlayerWithUser(db); + const { account: second } = await seedPlayerWithUser(db); + const { account: third } = await seedPlayerWithUser(db); + const { account: fourth } = await seedPlayerWithUser(db); + await wager(first.id, '500', '500'); + await wager(second.id, '400', '400'); + await wager(third.id, '300', '300'); + await wager(fourth.id, '250', '250'); + + const chasing = await races.getForPlayer(raceId, fourth.id); + expect(chasing.own.position).toBe(4); + expect(moneyEquals(chasing.own.amountToNextPaidPosition ?? '0', '50')).toBe(true); + + const paid = await races.getForPlayer(raceId, third.id); + expect(paid.own.position).toBe(3); + expect(paid.own.amountToNextPaidPosition).toBeNull(); + }); + + it('returns a null position and no gap for a player who has not wagered in the race', async () => { + const raceId = await insertRace(); + const { account: bystander } = await seedPlayerWithUser(db); + + const view = await races.getForPlayer(raceId, bystander.id); + + expect(view.own.position).toBeNull(); + expect(view.own.amountToNextPaidPosition).toBeNull(); + expect(moneyEquals(view.own.wagered, '0')).toBe(true); + }); +}); diff --git a/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts b/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts new file mode 100644 index 000000000..2e527346f --- /dev/null +++ b/packages/core/src/promo/gamification/__tests__/rakeback.int.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { createTestDb, type TestDb } from '@openora/core/testing'; +import { mock } from '../../../testing/mock.js'; +import type { WagerContext, WalletCommands, WalletCreditArgs } from '@openora/core/contracts'; +import { migrate } from '../migrate.js'; +import { + promoPlayerRank, + promoRankConfig, + promoRankLevelUp, + promoRankPeriodWager, + promoRankTier, +} from '../schema/index.js'; +import { DEFAULT_PAYOUT_ANCHORS } from '../contract/index.js'; +import { seedRankLadder } from '../seed/index.js'; +import { RakebackService } from '../service/rakeback.service.js'; + +let db: TestDb; +const logger = { warn: vi.fn() }; +const credit = vi.fn(); +let rakeback: RakebackService; + +const CASINO: WagerContext = { provider: 'aggregator', product: 'casino' }; +const LADDER = { + currency: 'USDT', + tiers: [ + { key: 'bronze', name: 'Bronze', wagerThreshold: '0', rakebackPercent: '1' }, + { key: 'silver', name: 'Silver', wagerThreshold: '10000', rakebackPercent: '3' }, + ], + config: { + eligibleProducts: [], + rewards: {}, + payoutAnchors: DEFAULT_PAYOUT_ANCHORS, + payInPlayerCurrency: false, + periodicRequiresActivity: true, + }, +}; + +const wager = (userId: string, amount: string, realAmount: string = amount) => + db.drizzle.db.transaction((tx) => + rakeback.recordWager(tx, { + userId, + currency: 'USDT', + amount, + weightedAmount: amount, + realAmount, + context: CASINO, + }), + ); + +const givePlayerTier = async (userId: string, tierKey: string) => { + const [tier] = await db.drizzle.db + .select({ id: promoRankTier.id }) + .from(promoRankTier) + .where(eq(promoRankTier.key, tierKey)); + await db.drizzle.db + .insert(promoPlayerRank) + .values({ userId, currency: 'USDT', tierId: tier?.id }) + .onConflictDoUpdate({ target: promoPlayerRank.userId, set: { tierId: tier?.id } }); +}; + +beforeAll(async () => { + db = await createTestDb([migrate]); + rakeback = new RakebackService(() => mock({ credit }), logger); +}); + +afterAll(() => db.drop()); + +beforeEach(async () => { + vi.clearAllMocks(); + credit.mockResolvedValue({ ok: true, moved: true, transactionId: randomUUID(), newBalance: '0' }); + await db.drizzle.db.delete(promoRankLevelUp); + await db.drizzle.db.delete(promoRankPeriodWager); + await db.drizzle.db.delete(promoPlayerRank); + await db.drizzle.db.delete(promoRankTier); + await db.drizzle.db.delete(promoRankConfig); + await seedRankLadder(db.drizzle.db, LADDER); +}); + +describe('instant rakeback on a qualifying bet', () => { + it('credits real balance at the tier rakeback percentage, off the own-money stake only', async () => { + const userId = randomUUID(); + await givePlayerTier(userId, 'bronze'); + + // Staked 100, but only 60 of it was the player's own money - the rest was bonus-funded. + await wager(userId, '100', '60'); + + expect(credit).toHaveBeenCalledTimes(1); + const args = credit.mock.calls[0]?.[1] as WalletCreditArgs; + expect(args).toMatchObject({ + userId, + currency: 'USDT', + amount: '0.600000000000000000', + type: 'cashback', + }); + }); + + it('adds an active streak boost on top of the tier rate', async () => { + const userId = randomUUID(); + await givePlayerTier(userId, 'bronze'); + await db.drizzle.db + .update(promoPlayerRank) + .set({ rakebackBoostPercent: '2', rakebackBoostExpiresAt: new Date(Date.now() + 86_400_000) }) + .where(eq(promoPlayerRank.userId, userId)); + + await wager(userId, '100'); + + const args = credit.mock.calls[0]?.[1] as WalletCreditArgs; + // bronze 1% + boost 2% = 3% of 100 + expect(args.amount).toBe('3.000000000000000000'); + }); + + it('ignores an expired streak boost', async () => { + const userId = randomUUID(); + await givePlayerTier(userId, 'bronze'); + await db.drizzle.db + .update(promoPlayerRank) + .set({ rakebackBoostPercent: '2', rakebackBoostExpiresAt: new Date(Date.now() - 1000) }) + .where(eq(promoPlayerRank.userId, userId)); + + await wager(userId, '100'); + + const args = credit.mock.calls[0]?.[1] as WalletCreditArgs; + expect(args.amount).toBe('1.000000000000000000'); + }); + + it('pays nothing for a bet fully funded by bonus money', async () => { + const userId = randomUUID(); + await givePlayerTier(userId, 'bronze'); + + await wager(userId, '100', '0'); + + expect(credit).not.toHaveBeenCalled(); + }); + + it('pays nothing for a player who has not been assigned a rank yet', async () => { + const userId = randomUUID(); + + await wager(userId, '100'); + + expect(credit).not.toHaveBeenCalled(); + }); + + it('reports the credit for the caller to announce once its own transaction commits', async () => { + const userId = randomUUID(); + await givePlayerTier(userId, 'bronze'); + const transactionId = randomUUID(); + credit.mockResolvedValue({ ok: true, moved: true, transactionId, newBalance: '1' }); + + const credits = await wager(userId, '100'); + + expect(credits).toEqual([{ transactionId, amount: '1.000000000000000000', currency: 'USDT' }]); + }); + + it('reports nothing for a replayed credit that moved no money', async () => { + const userId = randomUUID(); + await givePlayerTier(userId, 'bronze'); + credit.mockResolvedValue({ ok: true, moved: false, newBalance: '1' }); + + const credits = await wager(userId, '100'); + + expect(credits).toEqual([]); + }); + + it('reports nothing when the wallet refuses the credit', async () => { + const userId = randomUUID(); + await givePlayerTier(userId, 'bronze'); + credit.mockResolvedValue({ ok: false, reason: 'wallet not found' }); + + const credits = await wager(userId, '100'); + + expect(credits).toEqual([]); + }); +}); diff --git a/packages/core/src/promo/gamification/__tests__/rank-challenge.int.test.ts b/packages/core/src/promo/gamification/__tests__/rank-challenge.int.test.ts new file mode 100644 index 000000000..a2ccaf45d --- /dev/null +++ b/packages/core/src/promo/gamification/__tests__/rank-challenge.int.test.ts @@ -0,0 +1,344 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { createTestDb, seedPlayerWithUser, type TestDb } from '@openora/core/testing'; +import { mock, makeAuditWriter } from '../../../testing/mock.js'; +import type { + ExchangeRateReader, + PlayEligibilityPort, + WagerContext, + WalletCommands, +} from '@openora/core/contracts'; +import { migrate } from '../migrate.js'; +import { migrate as identityMigrate } from '@openora/core/pam/migrate/identity'; +import { migrate as profileMigrate } from '@openora/core/pam/migrate/profile'; +import { + promoRankChallengeClaim, + promoRankChallengeTier, + promoRankChallengeWager, +} from '../schema/index.js'; +import { RankChallengeService } from '../service/rank-challenge.service.js'; +import { + RankChallengeAdminService, + RankChallengeLadderCurrencyHeldError, +} from '../service/rank-challenge-admin.service.js'; +import { RankChallengePayoutService } from '../service/rank-challenge-payout.service.js'; + +let db: TestDb; +const convert = vi.fn(); +const logger = { warn: vi.fn(), error: vi.fn() }; +const isRestricted = vi.fn(); +const credit = vi.fn(); +const audit = makeAuditWriter(); +let service: RankChallengeService; +let adminService: RankChallengeAdminService; + +const CASINO: WagerContext = { provider: 'aggregator', product: 'casino' }; + +const TIERS = [ + { + key: 'bronze', + name: 'Bronze', + position: 0, + wagerThreshold: '0', + cashAmount: '50.000000000000000000', + physicalItem: null, + }, + { + key: 'silver', + name: 'Silver', + position: 1, + wagerThreshold: '10000', + cashAmount: null, + physicalItem: 'AirPods Pro', + }, +]; + +const seedLadder = () => + db.drizzle.db + .insert(promoRankChallengeTier) + .values(TIERS.map((t) => ({ ...t, currency: 'USDT' }))); + +const wager = (userId: string, realAmount: string, currency = 'USDT') => + db.drizzle.db.transaction((tx) => + service.recordWager(tx, { + userId, + currency, + amount: realAmount, + weightedAmount: realAmount, + realAmount, + context: CASINO, + }), + ); + +const claimsOf = () => + db.drizzle.db + .select({ + tierId: promoRankChallengeClaim.tierId, + userId: promoRankChallengeClaim.userId, + cashAmount: promoRankChallengeClaim.cashAmount, + physicalItem: promoRankChallengeClaim.physicalItem, + }) + .from(promoRankChallengeClaim); + +// The tier ladder and the payout currency match by default, so most tests exercise the +// no-conversion path - the currency-conversion behaviour has its own describe block below. +const payoutService = (payoutCurrency = 'USDT') => + new RankChallengePayoutService( + db.drizzle, + mock({ isRestricted }), + mock({ credit }), + mock({ convert }), + payoutCurrency, + audit, + logger, + ); + +beforeAll(async () => { + db = await createTestDb([migrate, identityMigrate, profileMigrate]); + service = new RankChallengeService(db.drizzle, mock({ convert }), logger); + adminService = new RankChallengeAdminService(db.drizzle, audit); +}); + +afterAll(() => db.drop()); + +beforeEach(async () => { + vi.clearAllMocks(); + isRestricted.mockResolvedValue(false); + credit.mockResolvedValue({ ok: true, moved: true, transactionId: 'tx-1', newBalance: '50' }); + await db.drizzle.db.delete(promoRankChallengeClaim); + await db.drizzle.db.delete(promoRankChallengeWager); + await db.drizzle.db.delete(promoRankChallengeTier); + await seedLadder(); +}); + +describe('recording a wager toward the Rank Challenge', () => { + it('a first real-money wager wins bronze, even though its threshold is zero', async () => { + const { account } = await seedPlayerWithUser(db); + const userId = account.id; + + await wager(userId, '1'); + + const claims = await claimsOf(); + expect(claims).toEqual([ + expect.objectContaining({ userId, cashAmount: '50.000000000000000000', physicalItem: null }), + ]); + }); + + it('leaves no claim for a zero-amount wager', async () => { + const { account } = await seedPlayerWithUser(db); + const userId = account.id; + + await wager(userId, '0'); + + expect(await claimsOf()).toEqual([]); + }); + + it('a physical-only tier claims without a cash amount', async () => { + const { account } = await seedPlayerWithUser(db); + const userId = account.id; + + await wager(userId, '10000'); + + const claims = await claimsOf(); + expect(claims).toContainEqual( + expect.objectContaining({ physicalItem: 'AirPods Pro', cashAmount: null }), + ); + }); + + it('only the first of two concurrent crossings wins the tier', async () => { + const { account: firstAcc } = await seedPlayerWithUser(db); + const first = firstAcc.id; + const { account: secondAcc } = await seedPlayerWithUser(db); + const second = secondAcc.id; + + await Promise.all([wager(first, '1'), wager(second, '1')]); + + const bronzeClaims = (await claimsOf()).filter((c) => + TIERS.some((t) => t.key === 'bronze' && c.tierId), + ); + const winners = new Set((await claimsOf()).map((c) => c.userId)); + // Exactly one winner for bronze, whichever transaction's insert landed first. + expect(winners.size).toBe(1); + void bronzeClaims; + }); + + it('a second player crossing an already-claimed tier wins nothing', async () => { + const { account: firstAcc } = await seedPlayerWithUser(db); + const first = firstAcc.id; + const { account: secondAcc } = await seedPlayerWithUser(db); + const second = secondAcc.id; + await wager(first, '1'); + + await wager(second, '1'); + + const claims = await claimsOf(); + expect(claims).toHaveLength(1); + expect(claims[0]?.userId).toBe(first); + }); +}); + +describe('settling a claim', () => { + it('credits the cash portion once and reports the win', async () => { + const { account } = await seedPlayerWithUser(db); + const userId = account.id; + await wager(userId, '1'); + + const won = await payoutService().settlePending(); + + expect(won).toEqual([ + expect.objectContaining({ + userId, + tierKey: 'bronze', + cashAmount: '50.000000000000000000', + physicalItem: null, + }), + ]); + expect(credit).toHaveBeenCalledTimes(1); + expect(credit.mock.calls[0]?.[1]).toMatchObject({ + userId, + amount: '50.000000000000000000', + type: 'cashback', + }); + }); + + it('is idempotent: settling twice credits once', async () => { + const { account } = await seedPlayerWithUser(db); + const userId = account.id; + await wager(userId, '1'); + + await payoutService().settlePending(); + const secondPass = await payoutService().settlePending(); + + expect(secondPass).toEqual([]); + expect(credit).toHaveBeenCalledTimes(1); + }); + + it('withholds cash from a restricted player but still settles the claim', async () => { + const { account } = await seedPlayerWithUser(db); + const userId = account.id; + isRestricted.mockResolvedValue(true); + await wager(userId, '1'); + + const won = await payoutService().settlePending(); + + expect(won).toEqual([]); + expect(credit).not.toHaveBeenCalled(); + const [claim] = await claimsOf(); + expect(claim).toBeDefined(); + }); + + it('a physical-only claim settles without crediting anything', async () => { + const { account } = await seedPlayerWithUser(db); + const userId = account.id; + // First wager claims bronze (cash) and settles it - only the silver (physical-only) claim + // this test cares about should stay uncredited. + await wager(userId, '1'); + await payoutService().settlePending(); + credit.mockClear(); + + await wager(userId, '9999'); + await payoutService().settlePending(); + + expect(credit).not.toHaveBeenCalled(); + }); +}); + +describe('crediting a prize in a currency the player can actually hold', () => { + it('converts the tier cash amount into the payout currency before crediting', async () => { + const { account } = await seedPlayerWithUser(db); + const userId = account.id; + await wager(userId, '1'); + convert.mockResolvedValue('48'); + + const won = await payoutService('USD').settlePending(); + + expect(convert).toHaveBeenCalledWith('50.000000000000000000', 'USDT', 'USD'); + expect(credit.mock.calls[0]?.[1]).toMatchObject({ amount: '48', currency: 'USD' }); + expect(won).toEqual([expect.objectContaining({ cashAmount: '48', currency: 'USD' })]); + }); + + it('leaves the claim unsettled to retry when no rate is available', async () => { + const { account } = await seedPlayerWithUser(db); + const userId = account.id; + await wager(userId, '1'); + convert.mockResolvedValue(null); + + const won = await payoutService('USD').settlePending(); + + expect(won).toEqual([]); + expect(credit).not.toHaveBeenCalled(); + const [claim] = await db.drizzle.db + .select({ settledAt: promoRankChallengeClaim.settledAt }) + .from(promoRankChallengeClaim); + expect(claim?.settledAt).toBeNull(); + }); + + it('leaves the claim unsettled, granting nothing, when the wallet credit fails', async () => { + const { account } = await seedPlayerWithUser(db); + const userId = account.id; + await wager(userId, '1'); + credit.mockResolvedValue({ ok: false, reason: 'wallet not found' }); + + const won = await payoutService().settlePending(); + + expect(won).toEqual([]); + const [claim] = await db.drizzle.db + .select({ settledAt: promoRankChallengeClaim.settledAt }) + .from(promoRankChallengeClaim); + expect(claim?.settledAt).toBeNull(); + }); +}); + +describe('the admin ladder', () => { + it('editing a tier prospectively never changes an already-claimed snapshot', async () => { + const { account } = await seedPlayerWithUser(db); + const userId = account.id; + await wager(userId, '1'); + const before = await claimsOf(); + + const ladder = await adminService.getLadder(); + await adminService.setLadder(randomUUID(), { + currency: 'USDT', + tiers: ladder.tiers.map((t) => ({ + ...t, + cashAmount: t.key === 'bronze' ? '999' : t.cashAmount, + })), + }); + + const after = await claimsOf(); + expect(after.find((c) => c.userId === userId)?.cashAmount).toBe( + before.find((c) => c.userId === userId)?.cashAmount, + ); + }); + + it('refuses to change the ladder currency once anyone has wagered', async () => { + const { account } = await seedPlayerWithUser(db); + const userId = account.id; + await wager(userId, '1'); + const ladder = await adminService.getLadder(); + + await expect( + adminService.setLadder(randomUUID(), { currency: 'USD', tiers: ladder.tiers }), + ).rejects.toThrow(RankChallengeLadderCurrencyHeldError); + }); +}); + +describe('the fulfilment queue', () => { + it('lists an unfulfilled physical claim and marking it fulfilled removes it and audits', async () => { + const { account } = await seedPlayerWithUser(db); + const userId = account.id; + await wager(userId, '10000'); + + const queue = await adminService.listFulfilmentQueue(); + expect(queue).toHaveLength(1); + const tierId = queue[0]?.tierId; + if (!tierId) { + throw new Error('claim not found'); + } + + const fulfilled = await adminService.markFulfilled(randomUUID(), tierId, 'Shipped via FedEx'); + + expect(fulfilled.physicalFulfillmentNote).toBe('Shipped via FedEx'); + expect(await adminService.listFulfilmentQueue()).toHaveLength(0); + }); +}); 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 58e07fbf6..d6da14e03 100644 --- a/packages/core/src/promo/gamification/__tests__/rank.int.test.ts +++ b/packages/core/src/promo/gamification/__tests__/rank.int.test.ts @@ -54,7 +54,14 @@ const PARALLEL_BETS = 20; const wager = (userId: string, amount: string, currency = 'USDT', context: WagerContext = CASINO) => db.drizzle.db.transaction((tx) => - ranks.recordWager(tx, { userId, currency, amount, weightedAmount: amount, context }), + ranks.recordWager(tx, { + userId, + currency, + amount, + weightedAmount: amount, + realAmount: amount, + context, + }), ); const levelUpsOf = (userId: string) => @@ -101,6 +108,7 @@ describe('recording a wager toward the rank ladder', () => { currency: 'USDT', amount: '12.345678901234567891', weightedAmount: '0', + realAmount: '12.345678901234567891', context: CASINO, }), ); @@ -169,7 +177,7 @@ describe('recording a wager toward the rank ladder', () => { const userId = randomUUID(); convert.mockResolvedValue(null); - await expect(wager(userId, '10', 'BTC')).resolves.toBeUndefined(); + await expect(wager(userId, '10', 'BTC')).resolves.toEqual([]); expect(await rankOf(userId)).toBeUndefined(); expect(logger.warn).toHaveBeenCalledWith( @@ -293,6 +301,7 @@ describe('recording a wager toward the rank ladder', () => { currency: 'USDT', amount: '100', weightedAmount: '100', + realAmount: '100', bonusAmount: '40', context: CASINO, }; @@ -302,3 +311,22 @@ describe('recording a wager toward the rank ladder', () => { expect((await rankOf(userId))?.lifetimeWagered).toBe('100.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(); + const unranked = randomUUID(); + await wager(ranked, '1'); + + const result = await ranks.lookup([ranked, unranked]); + + expect(result).toEqual([ + { userId: ranked, tierKey: 'bronze', tierName: 'Bronze' }, + { userId: unranked, tierKey: null, tierName: null }, + ]); + }); + + it('returns an empty array for an empty input, without querying', async () => { + expect(await ranks.lookup([])).toEqual([]); + }); +}); diff --git a/packages/core/src/promo/gamification/__tests__/streak-payout.int.test.ts b/packages/core/src/promo/gamification/__tests__/streak-payout.int.test.ts new file mode 100644 index 000000000..35a98fae2 --- /dev/null +++ b/packages/core/src/promo/gamification/__tests__/streak-payout.int.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { createTestDb, type TestDb } from '@openora/core/testing'; +import { mock } from '../../../testing/mock.js'; +import type { + BonusGrantCommands, + ExchangeRateReader, + PlayEligibilityPort, + WalletCommands, +} from '@openora/core/contracts'; +import { migrate } from '../migrate.js'; +import { promoStreakConfig, promoStreakMilestoneGrant } from '../schema/index.js'; +import { StreakPayoutService } from '../service/streak-payout.service.js'; +import type { StreakMilestone } from '../contract/index.js'; + +let db: TestDb; +const grant = vi.fn(); +const isRestricted = vi.fn(); +const credit = vi.fn(); +const convert = vi.fn(); +const logger = { warn: vi.fn(), error: vi.fn() }; + +const MILESTONES: StreakMilestone[] = [{ day: 3, rewards: [{ kind: 'cash', amount: '5' }] }]; + +// The streak's own currency and the payout currency match by default, so most tests exercise +// the no-conversion path - the currency-conversion behaviour has its own describe block below. +const service = (payoutCurrency = 'USD') => + new StreakPayoutService( + db.drizzle, + mock({ grant }), + mock({ isRestricted }), + mock({ convert }), + payoutCurrency, + logger, + mock({ credit }), + ); + +const owe = async (userId: string, day: number) => { + const [row] = await db.drizzle.db + .insert(promoStreakMilestoneGrant) + .values({ userId, day }) + .returning({ id: promoStreakMilestoneGrant.id }); + return row?.id ?? ''; +}; + +const milestoneGrant = async (id: string) => { + const [row] = await db.drizzle.db + .select({ + settledAt: promoStreakMilestoneGrant.settledAt, + outcome: promoStreakMilestoneGrant.outcome, + }) + .from(promoStreakMilestoneGrant) + .where(eq(promoStreakMilestoneGrant.id, id)); + return row; +}; + +beforeAll(async () => { + db = await createTestDb([migrate]); +}); + +afterAll(() => db.drop()); + +beforeEach(async () => { + vi.clearAllMocks(); + isRestricted.mockResolvedValue(false); + credit.mockResolvedValue({ ok: true, moved: true, transactionId: randomUUID(), newBalance: '5' }); + await db.drizzle.db.delete(promoStreakMilestoneGrant); + await db.drizzle.db.delete(promoStreakConfig); + await db.drizzle.db.insert(promoStreakConfig).values({ + currency: 'USD', + dailyMinWager: '1', + eligibleProducts: [], + milestones: MILESTONES, + resetAfterDay: 30, + }); +}); + +describe('settling a cash streak reward', () => { + it('credits real balance directly, with no bonus grant', async () => { + const userId = randomUUID(); + const id = await owe(userId, 3); + + await service().settlePending(); + + expect(grant).not.toHaveBeenCalled(); + expect(credit).toHaveBeenCalledTimes(1); + expect(credit.mock.calls[0]?.[1]).toMatchObject({ + userId, + amount: '5', + currency: 'USD', + type: 'cashback', + providerRef: { providerName: 'promo-streak', providerRefId: `streak-milestone:${id}:0` }, + }); + await expect(milestoneGrant(id)).resolves.toMatchObject({ outcome: 'granted' }); + }); + + it('is idempotent: a retried settlement does not credit twice', async () => { + const userId = randomUUID(); + const id = await owe(userId, 3); + + await service().settlePending(); + // Re-open the row as unsettled, as a retry after a partial failure would find it. + await db.drizzle.db + .update(promoStreakMilestoneGrant) + .set({ settledAt: null, outcome: null }) + .where(eq(promoStreakMilestoneGrant.id, id)); + credit.mockResolvedValueOnce({ ok: true, moved: false, newBalance: '5' }); + await service().settlePending(); + + expect(credit).toHaveBeenCalledTimes(2); + // Both calls carry the same providerRefId, so the wallet's own idempotency guard is what + // stops the second call from moving money twice - `moved: false` on the second is that guard. + expect(credit.mock.calls[1]?.[1]).toMatchObject({ + providerRef: { providerRefId: `streak-milestone:${id}:0` }, + }); + }); + + it('gives nothing to a player under a responsible-gambling restriction', async () => { + isRestricted.mockResolvedValue(true); + const userId = randomUUID(); + const id = await owe(userId, 3); + + await service().settlePending(); + + expect(credit).not.toHaveBeenCalled(); + await expect(milestoneGrant(id)).resolves.toMatchObject({ outcome: 'restricted' }); + }); +}); + +describe('crediting a cash reward in a currency the player can actually hold', () => { + it('converts the reward into the payout currency before crediting', async () => { + const userId = randomUUID(); + await owe(userId, 3); + convert.mockResolvedValue('4.5'); + + const { cashPaid } = await service('USDT').settlePending(); + + expect(convert).toHaveBeenCalledWith('5', 'USD', 'USDT'); + expect(credit.mock.calls[0]?.[1]).toMatchObject({ amount: '4.5', currency: 'USDT' }); + expect(cashPaid).toEqual([ + expect.objectContaining({ userId, amount: '4.5', currency: 'USDT' }), + ]); + }); + + it('leaves the milestone unsettled to retry when no rate is available', async () => { + const userId = randomUUID(); + const id = await owe(userId, 3); + convert.mockResolvedValue(null); + + const { cashPaid } = await service('USDT').settlePending(); + + expect(cashPaid).toEqual([]); + expect(credit).not.toHaveBeenCalled(); + await expect(milestoneGrant(id)).resolves.toMatchObject({ settledAt: null }); + }); + + it('leaves the milestone unsettled, granting nothing, when the wallet credit fails', async () => { + const userId = randomUUID(); + const id = await owe(userId, 3); + credit.mockResolvedValue({ ok: false, reason: 'wallet not found' }); + + const { cashPaid } = await service().settlePending(); + + expect(cashPaid).toEqual([]); + await expect(milestoneGrant(id)).resolves.toMatchObject({ settledAt: null }); + }); +}); diff --git a/packages/core/src/promo/gamification/__tests__/streak.int.test.ts b/packages/core/src/promo/gamification/__tests__/streak.int.test.ts new file mode 100644 index 000000000..e3a0c03f8 --- /dev/null +++ b/packages/core/src/promo/gamification/__tests__/streak.int.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { createTestDb, type TestDb } from '@openora/core/testing'; +import type { ExchangeRateReader, WagerContext } from '@openora/core/contracts'; +import { mock } from '../../../testing/mock.js'; +import { migrate } from '../migrate.js'; +import { + promoPlayerStreak, + promoStreakConfig, + promoStreakMilestoneGrant, +} from '../schema/index.js'; +import { StreakService } from '../service/streak.service.js'; + +let db: TestDb; +const convert = vi.fn(); +const logger = { warn: vi.fn() }; +let streaks: StreakService; + +const CASINO: WagerContext = { provider: 'aggregator', product: 'casino' }; +const SPORTSBOOK: WagerContext = { provider: 'aggregator', product: 'sportsbook' }; + +const CONFIG = { + currency: 'USD', + dailyMinWager: '10', + eligibleProducts: ['casino'], + milestones: [ + { + day: 3, + rewards: [ + { + kind: 'bonus' as const, + amount: '5', + terms: { wageringMultiplier: '0.01', expiryDays: 30 }, + }, + ], + }, + { + day: 30, + rewards: [ + { + kind: 'bonus' as const, + amount: '500', + terms: { wageringMultiplier: '1', expiryDays: 30 }, + }, + ], + }, + ], + resetAfterDay: 30, +}; + +beforeAll(async () => { + db = await createTestDb([migrate]); + streaks = new StreakService(db.drizzle, mock({ convert }), logger); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.delete(promoStreakMilestoneGrant); + await db.drizzle.db.delete(promoPlayerStreak); + await db.drizzle.db.delete(promoStreakConfig); + vi.clearAllMocks(); + await db.drizzle.db.insert(promoStreakConfig).values(CONFIG); +}); + +const record = ( + userId: string, + amount: string, + context: WagerContext = CASINO, + realAmount: string = amount, +) => + db.drizzle.db.transaction((tx) => + streaks.recordWager(tx, { + userId, + currency: 'USD', + amount, + weightedAmount: amount, + realAmount, + context, + }), + ); + +describe('recordWager', () => { + it('does not advance the streak below the daily minimum', async () => { + const userId = randomUUID(); + await record(userId, '5'); + const state = await streaks.getForPlayer(userId); + expect(state).toMatchObject({ current: 0, todayWagered: '5.000000000000000000' }); + }); + + it('advances the streak by one once the daily minimum is crossed, and only once per day', async () => { + const userId = randomUUID(); + await record(userId, '6'); + await record(userId, '6'); + const state = await streaks.getForPlayer(userId); + expect(state.current).toBe(1); + }); + + it('ignores a sportsbook wager - only eligible products count', async () => { + const userId = randomUUID(); + await record(userId, '50', SPORTSBOOK); + const state = await streaks.getForPlayer(userId); + expect(state.current).toBe(0); + }); + + it('counts only the real-money part of a bonus-funded stake toward the daily minimum', async () => { + const userId = randomUUID(); + // A 20 stake with only 5 out of the player's own funds - real money alone misses the + // 10 daily minimum, so the streak must not advance even though the full stake would clear it. + await record(userId, '20', CASINO, '5'); + const state = await streaks.getForPlayer(userId); + expect(state).toMatchObject({ current: 0, todayWagered: '5.000000000000000000' }); + }); + + it('advances the streak off real-money stake alone once it crosses the minimum', async () => { + const userId = randomUUID(); + await record(userId, '20', CASINO, '11'); + const state = await streaks.getForPlayer(userId); + expect(state.current).toBe(1); + }); + + it('records an unsettled milestone grant on the day it is reached', async () => { + const userId = randomUUID(); + for (let day = 0; day < 3; day++) { + const row = await db.drizzle.db + .insert(promoPlayerStreak) + .values({ userId, current: day, best: day, lastQualifyingDay: null }) + .onConflictDoUpdate({ target: promoPlayerStreak.userId, set: { current: day, best: day } }) + .returning(); + expect(row).toHaveLength(1); + } + await db.drizzle.db + .update(promoPlayerStreak) + .set({ lastQualifyingDay: null }) + .where(eq(promoPlayerStreak.userId, userId)); + await record(userId, '10'); + const [grant] = await db.drizzle.db + .select() + .from(promoStreakMilestoneGrant) + .where(eq(promoStreakMilestoneGrant.userId, userId)); + expect(grant).toMatchObject({ day: 3, settledAt: null }); + }); +}); + +describe('closeDay', () => { + it('resets a player who missed the previous UTC day, keeping their best', async () => { + const userId = randomUUID(); + await db.drizzle.db.insert(promoPlayerStreak).values({ + userId, + current: 5, + best: 5, + lastQualifyingDay: '2020-01-01', + }); + const reset = await streaks.closeDay(new Date('2020-01-05T00:05:00Z')); + expect(reset).toBe(1); + const state = await streaks.getForPlayer(userId); + expect(state).toMatchObject({ current: 0, best: 5 }); + }); + + it('leaves a player who qualified yesterday untouched', async () => { + const userId = randomUUID(); + await db.drizzle.db.insert(promoPlayerStreak).values({ + userId, + current: 5, + best: 5, + lastQualifyingDay: '2020-01-04', + }); + await streaks.closeDay(new Date('2020-01-05T00:05:00Z')); + const state = await streaks.getForPlayer(userId); + expect(state.current).toBe(5); + }); +}); diff --git a/packages/core/src/promo/gamification/contract/index.ts b/packages/core/src/promo/gamification/contract/index.ts index 01c91aed9..ecf591b2b 100644 --- a/packages/core/src/promo/gamification/contract/index.ts +++ b/packages/core/src/promo/gamification/contract/index.ts @@ -4,6 +4,7 @@ import { ContributionPercentSchema, CurrencyTickerSchema, MoneyAmountSchema, + TimestampSchema, UuidSchema, } from '@openora/core/contracts'; @@ -90,7 +91,7 @@ export type RankPayoutKind = z.infer; const MAX_EXPIRY_DAYS = 365; -const RankRewardTermsSchema = z.object({ +export const RankRewardTermsSchema = z.object({ /** * Wagering requirement as a multiple of the reward, as a decimal string above zero. Its upper * bound is the bonus engine's, checked by the service, since comparing it needs decimal math. @@ -184,6 +185,281 @@ export const RankConfigSchema = z.object({ export type RankConfig = z.infer; +/** + * A daily-streak milestone reward. `bonus` and `giftDrop` both credit through BONUS_GRANTS; + * `giftDrop` is a `bonus` whose amount is rolled fresh, between `min` and `max`, at settlement + * time rather than fixed in the config - an operator names the range, not the number. + * `rakebackBoost` is not a bonus grant: it raises the player's rank rakeback by `percentPoints` + * for `days`, recorded on the rank the streak payout settles against. `cash` is not a bonus grant + * either: real money, no wagering requirement, no expiry, credited to the balance directly - the + * same wallet transaction type (`cashback`) rank rakeback uses, since both are an operator-funded + * real-money credit that never carries a wagering requirement. + */ +export const StreakRewardSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('bonus'), amount: MoneyAmountSchema, terms: RankRewardTermsSchema }), + z.object({ + kind: z.literal('giftDrop'), + min: MoneyAmountSchema.refine(isAbsentOrPositive, 'must be above zero'), + max: MoneyAmountSchema.refine(isAbsentOrPositive, 'must be above zero'), + terms: RankRewardTermsSchema, + }), + z.object({ + kind: z.literal('rakebackBoost'), + percentPoints: ContributionPercentSchema, + days: z.number().int().positive().max(90), + }), + z.object({ + kind: z.literal('cash'), + amount: MoneyAmountSchema.refine(isAbsentOrPositive, 'must be above zero'), + }), +]); +export type StreakReward = z.infer; + +export const StreakMilestoneSchema = z.object({ + /** The streak length this milestone pays at. Unique within the milestone list. */ + day: z.number().int().positive().max(365), + rewards: z.array(StreakRewardSchema).min(1), +}); +export type StreakMilestone = z.infer; + +export const StreakConfigSchema = z.object({ + /** What counts toward a qualifying day, and in what currency the threshold is priced. */ + currency: CurrencyTickerSchema, + dailyMinWager: MoneyAmountSchema.refine(isAbsentOrPositive, 'must be above zero'), + /** Products whose stakes count toward the streak. Empty counts every product. */ + eligibleProducts: z.array(z.string().trim().min(1).max(64)).max(50), + milestones: z + .array(StreakMilestoneSchema) + .max(50) + .refine( + (milestones) => new Set(milestones.map((m) => m.day)).size === milestones.length, + 'two milestones share a day', + ), + /** The day a streak that reaches its last milestone resets to, and pays its final reward. */ + resetAfterDay: z.number().int().positive().max(365), +}); +export type StreakConfig = z.infer; + +export const PlayerStreakSchema = z.object({ + current: z.number().int().nonnegative(), + best: z.number().int().nonnegative(), + todayWagered: MoneyAmountSchema, + dailyMinWager: MoneyAmountSchema, + currency: CurrencyTickerSchema, + milestones: z.array(StreakMilestoneSchema), +}); +export type PlayerStreak = z.infer; + +export const StreakLeaderboardEntrySchema = z.object({ + userId: UuidSchema, + username: z.string(), + streak: z.number().int().nonnegative(), +}); +export type StreakLeaderboardEntry = z.infer; + +export const StreakLeaderboardSchema = z.object({ + top: z.array(StreakLeaderboardEntrySchema).max(5), + /** The requesting player's own rank on the board, 1-based; null when they have no streak. */ + ownPosition: z.number().int().positive().nullable(), +}); +export type StreakLeaderboard = z.infer; + +const MAX_LOOKUP_IDS = 100; + +export const RankLookupInputSchema = z.object({ + userIds: z.array(UuidSchema).min(1).max(MAX_LOOKUP_IDS), +}); +export type RankLookupInput = z.infer; + +export const RankLookupEntrySchema = z.object({ + userId: UuidSchema, + tierKey: z.string().nullable(), + tierName: z.string().nullable(), +}); +export type RankLookupEntry = z.infer; + +// A race's leaderboard is capped rather than paginated - see RaceService.getForPlayer. +const MAX_RACE_POSITIONS = 100; + +export const RacePositionSchema = z.object({ + position: z.number().int().positive(), + prize: MoneyAmountSchema.refine(isAbsentOrPositive, 'must be above zero'), +}); +export type RacePosition = z.infer; + +export const RacePositionsSchema = z + .array(RacePositionSchema) + .min(1) + .max(MAX_RACE_POSITIONS) + .refine((positions) => { + const sorted = positions.map((p) => p.position).sort((a, b) => a - b); + return sorted.every((position, index) => position === index + 1); + }, 'positions must run 1..N with no gap or duplicate'); +export type RacePositions = z.infer; + +export const RaceEligibleProductsSchema = z.array(z.string().trim().min(1).max(64)).max(50); +export type RaceEligibleProducts = z.infer; + +const RaceFieldsShape = { + name: z.string().min(1).max(200), + currency: CurrencyTickerSchema, + startAt: TimestampSchema, + endAt: TimestampSchema, + prizePool: MoneyAmountSchema.refine(isAbsentOrPositive, 'must be above zero'), + positions: RacePositionsSchema, + /** Products whose stakes count toward this race. Empty counts every product. */ + eligibleProducts: RaceEligibleProductsSchema, +} as const; + +const raceDatesOrdered = (v: { startAt: string; endAt: string }) => + new Date(v.endAt).getTime() > new Date(v.startAt).getTime(); + +export const CreateRaceInputSchema = z + .object(RaceFieldsShape) + .refine(raceDatesOrdered, { message: 'endAt must be after startAt', path: ['endAt'] }); +export type CreateRaceInput = z.infer; + +export const UpdateRaceInputSchema = z + .object({ raceId: UuidSchema, ...RaceFieldsShape }) + .refine(raceDatesOrdered, { message: 'endAt must be after startAt', path: ['endAt'] }); +export type UpdateRaceInput = z.infer; + +export const RaceSchema = z.object({ + id: UuidSchema, + ...RaceFieldsShape, + closedAt: TimestampSchema.nullable(), + createdAt: TimestampSchema, + updatedAt: TimestampSchema, +}); +export type Race = z.infer; + +/** A public leaderboard row: masked or "Incognito" per the player's own privacy setting. */ +export const RaceLeaderboardEntrySchema = z.object({ + userId: UuidSchema, + username: z.string(), + wagered: MoneyAmountSchema, + position: z.number().int().positive(), +}); +export type RaceLeaderboardEntry = z.infer; + +export const RaceOwnEntrySchema = z.object({ + userId: UuidSchema, + wagered: MoneyAmountSchema, + /** Null when the player has not wagered in this race at all. */ + position: z.number().int().positive().nullable(), + /** How much more the player must wager to reach the next paid position. Null once they are + * already in a paid position, or the race pays no positions. */ + amountToNextPaidPosition: MoneyAmountSchema.nullable(), +}); +export type RaceOwnEntry = z.infer; + +export const RaceForPlayerSchema = z.object({ + race: RaceSchema, + podium: z.array(RaceLeaderboardEntrySchema).max(3), + /** Positions 4 and below, capped - see RaceService.getForPlayer. */ + leaderboard: z.array(RaceLeaderboardEntrySchema).max(MAX_RACE_POSITIONS - 3), + own: RaceOwnEntrySchema, +}); +export type RaceForPlayer = z.infer; + +// A tier may carry a cash amount, a physical item, or both (master/titan) - at least one. +const rankChallengeHasAPrize = (t: { cashAmount: string | null; physicalItem: string | null }) => + t.cashAmount !== null || (t.physicalItem !== null && t.physicalItem.trim().length > 0); + +export const RankChallengeTierSchema = z.object({ + id: UuidSchema, + key: z.string().min(1), + name: z.string().min(1), + position: z.number().int().nonnegative(), + wagerThreshold: MoneyAmountSchema, + cashAmount: MoneyAmountSchema.nullable(), + physicalItem: z.string().min(1).max(200).nullable(), +}); +export type RankChallengeTier = z.infer; + +export const RankChallengeClaimSchema = z.object({ + tierId: UuidSchema, + tierKey: z.string(), + userId: UuidSchema, + username: z.string(), + cashAmount: MoneyAmountSchema.nullable(), + physicalItem: z.string().nullable(), + claimedAt: TimestampSchema, + physicalFulfilledAt: TimestampSchema.nullable(), + physicalFulfillmentNote: z.string().nullable(), +}); +export type RankChallengeClaim = z.infer; + +export const RankChallengeLadderTierSchema = RankChallengeTierSchema.extend({ + winnerUserId: UuidSchema.nullable(), + winnerUsername: z.string().nullable(), + claimedAt: TimestampSchema.nullable(), +}); +export type RankChallengeLadderTier = z.infer; + +export const RankChallengeLadderSchema = z.object({ + currency: CurrencyTickerSchema, + tiers: z.array(RankChallengeLadderTierSchema), +}); +export type RankChallengeLadder = z.infer; + +export const RankChallengeLeaderboardEntrySchema = z.object({ + userId: UuidSchema, + username: z.string(), + lifetimeWagered: MoneyAmountSchema, + position: z.number().int().positive(), +}); +export type RankChallengeLeaderboardEntry = z.infer; + +export const PlayerRankChallengeSchema = z.object({ + currency: CurrencyTickerSchema, + lifetimeWagered: MoneyAmountSchema, + /** The next tier the player has not yet claimed and nobody else has either. Null once every + * tier is claimed. */ + nextTier: RankChallengeTierSchema.nullable(), + leaderboard: z.array(RankChallengeLeaderboardEntrySchema).max(5), + /** The player's own 1-based position; null when outside the top 5 shown, or no wagers yet. */ + ownPosition: z.number().int().positive().nullable(), +}); +export type PlayerRankChallenge = z.infer; + +const MAX_CHALLENGE_TIERS = 50; + +const SubmittedRankChallengeTierSchema = RankChallengeTierSchema.omit({ id: true }).extend({ + id: UuidSchema.optional(), +}); +export type SubmittedRankChallengeTier = z.infer; + +export const SetRankChallengeLadderInputSchema = z.object({ + currency: CurrencyTickerSchema, + tiers: z + .array(SubmittedRankChallengeTierSchema) + .min(1) + .max(MAX_CHALLENGE_TIERS) + .refine( + (tiers) => new Set(tiers.map((t) => t.key)).size === tiers.length, + 'two tiers share a key', + ) + .refine( + (tiers) => new Set(tiers.map((t) => t.position)).size === tiers.length, + 'two tiers share a position', + ) + .refine( + (tiers) => tiers.every(rankChallengeHasAPrize), + 'every tier needs a cash amount, a physical item, or both', + ), +}); +export type SetRankChallengeLadderInput = z.infer; + +// Keyed by tierId, not a claim row id: `promoRankChallengeClaim.tierId` is unique per claim +// (see schema/index.ts) and the API's own RankChallengeClaimSchema never exposes a row id, so +// this is the one identifier the admin UI already has on hand from every claim/queue listing. +export const MarkRankChallengeFulfilledInputSchema = z.object({ + tierId: UuidSchema, + note: z.string().min(1).max(1000), +}); +export type MarkRankChallengeFulfilledInput = z.infer; + export const gamificationContract = { ranks: { get: oc.route({ method: 'GET', path: '/promo/ranks' }).output(PlayerRankSchema), @@ -193,9 +469,60 @@ export const gamificationContract = { * marketing, and the page that shows it is public. Carries no player data at all. */ ladder: oc.route({ method: 'GET', path: '/promo/ranks/ladder' }).output(RankLadderSchema), + + /** + * Another player's rank badge, batched - a chat avatar or profile card names whose rank it + * wants rather than firing one request per avatar on screen. Public fields only: a tier's + * key and display name, never wagered amounts or rakeback. + */ + lookup: oc + .route({ method: 'POST', path: '/promo/ranks/lookup' }) + .input(RankLookupInputSchema) + .output(z.array(RankLookupEntrySchema)), + }, + + streaks: { + get: oc.route({ method: 'GET', path: '/promo/streaks' }).output(PlayerStreakSchema), + leaderboard: oc + .route({ method: 'GET', path: '/promo/streaks/leaderboard' }) + .output(StreakLeaderboardSchema), + }, + + races: { + /** Races open right now, for the race switcher. */ + listActive: oc.route({ method: 'GET', path: '/promo/races' }).output(z.array(RaceSchema)), + + get: oc + .route({ method: 'GET', path: '/promo/races/{raceId}' }) + .input(z.object({ raceId: UuidSchema })) + .output(RaceForPlayerSchema), + }, + + rankChallenge: { + get: oc + .route({ method: 'GET', path: '/promo/rank-challenge' }) + .output(PlayerRankChallengeSchema), + + /** The tiers and who has won each so far - public, no wagered totals. */ + ladder: oc + .route({ method: 'GET', path: '/promo/rank-challenge/ladder' }) + .output(RankChallengeLadderSchema), }, admin: { + streaks: { + config: { + get: oc + .route({ method: 'GET', path: '/backoffice/promo/streaks/config' }) + .output(StreakConfigSchema), + + set: oc + .route({ method: 'PUT', path: '/backoffice/promo/streaks/config' }) + .input(StreakConfigSchema) + .output(StreakConfigSchema), + }, + }, + ranks: { get: oc.route({ method: 'GET', path: '/backoffice/promo/ranks' }).output(RankLadderSchema), @@ -215,5 +542,56 @@ export const gamificationContract = { .output(RankConfigSchema), }, }, + + races: { + list: oc + .route({ method: 'GET', path: '/backoffice/promo/races' }) + .output(z.array(RaceSchema)), + + get: oc + .route({ method: 'GET', path: '/backoffice/promo/races/{raceId}' }) + .input(z.object({ raceId: UuidSchema })) + .output(RaceSchema), + + create: oc + .route({ method: 'POST', path: '/backoffice/promo/races' }) + .input(CreateRaceInputSchema) + .output(RaceSchema), + + update: oc + .route({ method: 'PUT', path: '/backoffice/promo/races/{raceId}' }) + .input(UpdateRaceInputSchema) + .output(RaceSchema), + }, + + rankChallenge: { + config: { + get: oc + .route({ method: 'GET', path: '/backoffice/promo/rank-challenge' }) + .output(RankChallengeLadderSchema), + + set: oc + .route({ method: 'PUT', path: '/backoffice/promo/rank-challenge' }) + .input(SetRankChallengeLadderInputSchema) + .output(RankChallengeLadderSchema), + }, + + claims: { + list: oc + .route({ method: 'GET', path: '/backoffice/promo/rank-challenge/claims' }) + .output(z.array(RankChallengeClaimSchema)), + }, + + fulfilment: { + list: oc + .route({ method: 'GET', path: '/backoffice/promo/rank-challenge/fulfilment' }) + .output(z.array(RankChallengeClaimSchema)), + + markFulfilled: oc + .route({ method: 'POST', path: '/backoffice/promo/rank-challenge/fulfilment/{tierId}' }) + .input(MarkRankChallengeFulfilledInputSchema) + .output(RankChallengeClaimSchema), + }, + }, }, }; diff --git a/packages/core/src/promo/gamification/drizzle/migrations/0012_gamification_rakeback_streaks_races_rank_challenge.sql b/packages/core/src/promo/gamification/drizzle/migrations/0012_gamification_rakeback_streaks_races_rank_challenge.sql new file mode 100644 index 000000000..c5ea88414 --- /dev/null +++ b/packages/core/src/promo/gamification/drizzle/migrations/0012_gamification_rakeback_streaks_races_rank_challenge.sql @@ -0,0 +1,147 @@ +CREATE TABLE "promo_player_streak" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "current" integer DEFAULT 0 NOT NULL, + "best" integer DEFAULT 0 NOT NULL, + "last_qualifying_day" date, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "promo_player_streak_userId_unique" UNIQUE("user_id"), + CONSTRAINT "promo_player_streak_counts_non_negative" CHECK ("promo_player_streak"."current" >= 0 AND "promo_player_streak"."best" >= 0 AND "promo_player_streak"."current" <= "promo_player_streak"."best") +); +--> statement-breakpoint +CREATE TABLE "promo_race" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "currency" text NOT NULL, + "start_at" timestamp with time zone NOT NULL, + "end_at" timestamp with time zone NOT NULL, + "prize_pool" numeric(38, 18) NOT NULL, + "positions" jsonb NOT NULL, + "eligible_products" text[] DEFAULT '{}' NOT NULL, + "closed_at" timestamp with time zone, + "created_by" uuid, + "updated_by" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "promo_race_prize_pool_positive" CHECK ("promo_race"."prize_pool" > 0), + CONSTRAINT "promo_race_dates_ordered" CHECK ("promo_race"."end_at" > "promo_race"."start_at") +); +--> statement-breakpoint +CREATE TABLE "promo_race_payout" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "race_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "position" integer NOT NULL, + "amount" numeric(38, 18) NOT NULL, + "currency" text NOT NULL, + "settled_at" timestamp with time zone DEFAULT now() NOT NULL, + "grant_id" uuid, + "outcome" text NOT NULL, + CONSTRAINT "promo_race_payout_position_positive" CHECK ("promo_race_payout"."position" > 0), + CONSTRAINT "promo_race_payout_amount_non_negative" CHECK ("promo_race_payout"."amount" >= 0) +); +--> statement-breakpoint +CREATE TABLE "promo_race_wager" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "race_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "currency" text NOT NULL, + "wagered" numeric(38, 18) DEFAULT '0' NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "promo_race_wager_non_negative" CHECK ("promo_race_wager"."wagered" >= 0) +); +--> statement-breakpoint +CREATE TABLE "promo_rank_challenge_claim" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tier_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "currency" text NOT NULL, + "cash_amount" numeric(38, 18), + "physical_item" text, + "claimed_at" timestamp with time zone DEFAULT now() NOT NULL, + "settled_at" timestamp with time zone, + "outcome" text, + "cash_grant_id" uuid, + "physical_fulfilled_at" timestamp with time zone, + "physical_fulfilled_by" uuid, + "physical_fulfillment_note" text +); +--> statement-breakpoint +CREATE TABLE "promo_rank_challenge_tier" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "key" text NOT NULL, + "name" text NOT NULL, + "position" integer NOT NULL, + "currency" text NOT NULL, + "wager_threshold" numeric(38, 18) NOT NULL, + "cash_amount" numeric(38, 18), + "physical_item" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "promo_rank_challenge_tier_key_unique" UNIQUE("key"), + CONSTRAINT "promo_rank_challenge_tier_position_unique" UNIQUE("position"), + CONSTRAINT "promo_rank_challenge_tier_bounds" CHECK ("promo_rank_challenge_tier"."position" >= 0 AND "promo_rank_challenge_tier"."wager_threshold" >= 0 + AND ("promo_rank_challenge_tier"."cash_amount" is null OR "promo_rank_challenge_tier"."cash_amount" > 0) + AND ("promo_rank_challenge_tier"."physical_item" is not null OR "promo_rank_challenge_tier"."cash_amount" is not null)) +); +--> statement-breakpoint +CREATE TABLE "promo_rank_challenge_wager" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "currency" text NOT NULL, + "lifetime_wagered" numeric(38, 18) DEFAULT '0' NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "promo_rank_challenge_wager_userId_unique" UNIQUE("user_id"), + CONSTRAINT "promo_rank_challenge_wager_non_negative" CHECK ("promo_rank_challenge_wager"."lifetime_wagered" >= 0) +); +--> statement-breakpoint +CREATE TABLE "promo_streak_config" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "singleton_key" text DEFAULT 'global' NOT NULL, + "currency" text NOT NULL, + "daily_min_wager" numeric(38, 18) NOT NULL, + "eligible_products" text[] DEFAULT '{}' NOT NULL, + "milestones" jsonb DEFAULT '[]'::jsonb NOT NULL, + "reset_after_day" integer DEFAULT 30 NOT NULL, + "updated_by" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "promo_streak_config_singletonKey_unique" UNIQUE("singleton_key") +); +--> statement-breakpoint +CREATE TABLE "promo_streak_daily_wager" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "day" date NOT NULL, + "currency" text NOT NULL, + "wagered" numeric(38, 18) DEFAULT '0' NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "promo_streak_daily_wager_non_negative" CHECK ("promo_streak_daily_wager"."wagered" >= 0) +); +--> statement-breakpoint +CREATE TABLE "promo_streak_milestone_grant" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "day" integer NOT NULL, + "reached_at" timestamp with time zone DEFAULT now() NOT NULL, + "settled_at" timestamp with time zone, + "outcome" text, + CONSTRAINT "promo_streak_milestone_grant_day_positive" CHECK ("promo_streak_milestone_grant"."day" > 0) +); +--> statement-breakpoint +ALTER TABLE "promo_player_rank" ADD COLUMN "rakeback_boost_percent" numeric(5, 2);--> statement-breakpoint +ALTER TABLE "promo_player_rank" ADD COLUMN "rakeback_boost_expires_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "promo_race_payout" ADD CONSTRAINT "promo_race_payout_race_id_promo_race_id_fk" FOREIGN KEY ("race_id") REFERENCES "public"."promo_race"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "promo_race_wager" ADD CONSTRAINT "promo_race_wager_race_id_promo_race_id_fk" FOREIGN KEY ("race_id") REFERENCES "public"."promo_race"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "promo_race_open_idx" ON "promo_race" USING btree ("start_at","end_at") WHERE "promo_race"."closed_at" is null;--> statement-breakpoint +CREATE UNIQUE INDEX "promo_race_payout_race_id_user_id_idx" ON "promo_race_payout" USING btree ("race_id","user_id");--> statement-breakpoint +CREATE INDEX "promo_race_payout_race_id_idx" ON "promo_race_payout" USING btree ("race_id");--> statement-breakpoint +CREATE UNIQUE INDEX "promo_race_wager_race_id_user_id_idx" ON "promo_race_wager" USING btree ("race_id","user_id");--> statement-breakpoint +CREATE INDEX "promo_race_wager_race_id_wagered_idx" ON "promo_race_wager" USING btree ("race_id","wagered");--> statement-breakpoint +CREATE UNIQUE INDEX "promo_rank_challenge_claim_tier_id_idx" ON "promo_rank_challenge_claim" USING btree ("tier_id");--> statement-breakpoint +CREATE INDEX "promo_rank_challenge_claim_unsettled_idx" ON "promo_rank_challenge_claim" USING btree ("claimed_at") WHERE "promo_rank_challenge_claim"."settled_at" is null;--> statement-breakpoint +CREATE INDEX "promo_rank_challenge_claim_fulfilment_queue_idx" ON "promo_rank_challenge_claim" USING btree ("claimed_at") WHERE "promo_rank_challenge_claim"."physical_item" is not null AND "promo_rank_challenge_claim"."physical_fulfilled_at" is null;--> statement-breakpoint +CREATE UNIQUE INDEX "promo_streak_daily_wager_user_id_day_idx" ON "promo_streak_daily_wager" USING btree ("user_id","day");--> statement-breakpoint +CREATE UNIQUE INDEX "promo_streak_milestone_grant_user_id_day_idx" ON "promo_streak_milestone_grant" USING btree ("user_id","day");--> statement-breakpoint +CREATE INDEX "promo_streak_milestone_grant_unsettled_idx" ON "promo_streak_milestone_grant" USING btree ("reached_at") WHERE "promo_streak_milestone_grant"."settled_at" is null; \ No newline at end of file diff --git a/packages/core/src/promo/gamification/drizzle/migrations/meta/0012_snapshot.json b/packages/core/src/promo/gamification/drizzle/migrations/meta/0012_snapshot.json new file mode 100644 index 000000000..415482af3 --- /dev/null +++ b/packages/core/src/promo/gamification/drizzle/migrations/meta/0012_snapshot.json @@ -0,0 +1,1535 @@ +{ + "id": "62e3ef7b-3e83-47d2-a4a5-5d896204e20b", + "prevId": "005b663c-47a9-46e8-acaf-68895ec889c6", + "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 + }, + "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 + }, + "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": { + "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, + "default": "'{}'" + }, + "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\"" + } + }, + "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_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_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": "'{}'" + }, + "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'" + }, + "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": {} + } + }, + "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" + } + }, + "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 + }, + "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)" + } + }, + "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 + } + }, + "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 + } + }, + "enums": {}, + "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 9c8855e6b..6895b67ed 100644 --- a/packages/core/src/promo/gamification/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/promo/gamification/drizzle/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1790174237316, "tag": "0011_rank_period_wager", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1790343347170, + "tag": "0012_gamification_rakeback_streaks_races_rank_challenge", + "breakpoints": true } ] } diff --git a/packages/core/src/promo/gamification/index.ts b/packages/core/src/promo/gamification/index.ts index dfc115985..a4fa2d1a4 100644 --- a/packages/core/src/promo/gamification/index.ts +++ b/packages/core/src/promo/gamification/index.ts @@ -9,4 +9,20 @@ export { RankTierHeldError, RankTierKeyTakenError, } from './service/rank-admin.service.js'; +export { RaceService, RaceNotFoundError } from './service/race.service.js'; +export { + RaceAdminService, + RaceClosedError, + RacePositionsInvalidError, +} from './service/race-admin.service.js'; +export { RacePayoutService, type RaceWon } from './service/race-payout.service.js'; +export { RankChallengeService } from './service/rank-challenge.service.js'; +export { + RankChallengeAdminService, + RankChallengeLadderCurrencyHeldError, +} from './service/rank-challenge-admin.service.js'; +export { + RankChallengePayoutService, + type RankChallengeWon, +} from './service/rank-challenge-payout.service.js'; export { createGamificationRouter } from './router/index.js'; diff --git a/packages/core/src/promo/gamification/plugin.ts b/packages/core/src/promo/gamification/plugin.ts index bef505ea6..1a1468caf 100644 --- a/packages/core/src/promo/gamification/plugin.ts +++ b/packages/core/src/promo/gamification/plugin.ts @@ -3,13 +3,20 @@ import { AUDIT_WRITER, BONUS_GRANTS, EXCHANGE_RATE_READER, + IDENTITY_READER, JOB_QUEUE, PLATFORM_CONFIG, PLAY_ELIGIBILITY, + WALLET_COMMANDS, WALLET_READER, PromoConfigSchema, WAGER_TRACKING, queue, + resolveWalletDefaultCurrency, + type IdentityReader, + type WagerTrackingArgs, + type WagerTrackingCommands, + type WagerTrackingWalletCredit, } from '@openora/core/contracts'; import { ADMIN_GUARD, @@ -17,47 +24,131 @@ import { EVENT_BUS, createLogger, type CoreTokenCatalog, + type DrizzleTx, type EventBus, type Plugin, type TypedContainer, } from '@openora/core/server'; +import { RakebackService } from './service/rakeback.service.js'; import { RankAdminService } from './service/rank-admin.service.js'; import { RankPayoutService } from './service/rank-payout.service.js'; import { RankService } from './service/rank.service.js'; +import { StreakAdminService } from './service/streak-admin.service.js'; +import { StreakPayoutService } from './service/streak-payout.service.js'; +import { StreakService } from './service/streak.service.js'; +import { RaceAdminService } from './service/race-admin.service.js'; +import { RacePayoutService } from './service/race-payout.service.js'; +import { RaceService } from './service/race.service.js'; +import { RankChallengeService } from './service/rank-challenge.service.js'; +import { RankChallengeAdminService } from './service/rank-challenge-admin.service.js'; +import { RankChallengePayoutService } from './service/rank-challenge-payout.service.js'; import { createGamificationRouter } from './router/index.js'; import { RankPayoutKindSchema } from './contract/index.js'; const logger = createLogger('promo-gamification'); -const PAYOUT_QUEUE = queue('promo-rank-payout'); +const RANK_PAYOUT_QUEUE = queue('promo-rank-payout'); +const STREAK_PAYOUT_QUEUE = queue('promo-streak-payout'); +const STREAK_CLOSE_QUEUE = queue('promo-streak-close'); +const RACE_PAYOUT_QUEUE = queue('promo-race-payout'); +const RANK_CHALLENGE_PAYOUT_QUEUE = queue('promo-rank-challenge-payout'); +// Races close at whatever timestamp the operator configured, not a shared daily/weekly/monthly +// anchor - a short recurring tick is what makes "closed within a minute of endAt" true. +const RACE_PAYOUT_CRON = '*/1 * * * *'; +// A claim can land at any moment (it is detected inline on the bet that crosses a threshold), +// so settling it - the cash credit and the win announcement - runs on the same short tick. +const RANK_CHALLENGE_PAYOUT_CRON = '*/1 * * * *'; // The cron tick carries only which payout to run; what is owed is read from the database. -const PayoutJobSchema = z.object({ kind: RankPayoutKindSchema }); +const RankPayoutJobSchema = z.object({ kind: RankPayoutKindSchema }); +const EmptyJobSchema = z.object({}); + +/** Fans a bet out to every wager-tracking consumer this module owns, on the one sealed port. */ +class CompositeWagerTracking implements WagerTrackingCommands { + constructor(private readonly consumers: readonly WagerTrackingCommands[]) {} + + async recordWager(tx: DrizzleTx, args: WagerTrackingArgs): Promise { + const credits: WagerTrackingWalletCredit[] = []; + for (const consumer of this.consumers) { + credits.push(...(await consumer.recordWager(tx, args))); + } + return credits; + } +} const rankService = (c: TypedContainer) => new RankService(c.get(DRIZZLE), c.get(EXCHANGE_RATE_READER), c.get(AUDIT_WRITER), logger); +const streakService = (c: TypedContainer) => + new StreakService(c.get(DRIZZLE), c.get(EXCHANGE_RATE_READER), logger); + +const raceService = (c: TypedContainer) => + new RaceService(c.get(DRIZZLE), c.get(EXCHANGE_RATE_READER), logger); + +const rakebackService = (c: TypedContainer) => + new RakebackService(() => (c.has(WALLET_COMMANDS) ? c.get(WALLET_COMMANDS) : undefined), logger); + +const rankChallengeService = (c: TypedContainer) => + new RankChallengeService(c.get(DRIZZLE), c.get(EXCHANGE_RATE_READER), logger); + export default { id: 'gamification', dependsOn: ['exchange-rate', 'audit'], register(ctx) { - ctx.provide(WAGER_TRACKING, rankService); + ctx.provide( + WAGER_TRACKING, + (c) => + new CompositeWagerTracking([ + rankService(c), + rakebackService(c), + streakService(c), + raceService(c), + rankChallengeService(c), + ]), + ); - let payouts: RankPayoutService | null = null; + let rankPayouts: RankPayoutService | null = null; + let streakPayouts: StreakPayoutService | null = null; + let racePayouts: RacePayoutService | null = null; + let rankChallengePayouts: RankChallengePayoutService | null = null; + let streaks: StreakService | null = null; let events: EventBus | null = null; + let identityReader: IdentityReader | null = null; + + const emitCashCredit = async (args: { + userId: string; + amount: string; + currency: string; + transactionId: string | null; + }) => { + // Null when nothing moved (a replayed credit already announced the first time). + if (!args.transactionId) { + return; + } + const playerId = (await identityReader?.getPlayerIdByUserIdSafe(args.userId)) ?? null; + events?.emit('wallet.balance.changed', { + userId: args.userId, + playerId, + amount: args.amount, + currency: args.currency, + transactionId: args.transactionId, + type: 'cashback', + direction: 'credit', + }); + }; ctx.jobs.worker({ - queue: PAYOUT_QUEUE, - schema: PayoutJobSchema, + queue: RANK_PAYOUT_QUEUE, + schema: RankPayoutJobSchema, handler: async ({ payload }) => { - if (!payouts) { + if (!rankPayouts) { logger.warn({ kind: payload.kind }, 'rank payout skipped - service not constructed'); return; } const granted = payload.kind === 'levelUp' - ? await payouts.settleLevelUps() - : await payouts.payPeriodic(payload.kind, new Date()); + ? await rankPayouts.settleLevelUps() + : await rankPayouts.payPeriodic(payload.kind, new Date()); // After each grant's own commit: announcing a bonus the transaction then rolled back // would tell a player about money they do not have. for (const grant of granted) { @@ -66,8 +157,90 @@ export default { }, }); + ctx.jobs.worker({ + queue: STREAK_PAYOUT_QUEUE, + schema: EmptyJobSchema, + handler: async () => { + if (!streakPayouts) { + logger.warn({}, 'streak payout skipped - service not constructed'); + return; + } + const { granted, cashPaid } = await streakPayouts.settlePending(); + for (const grant of granted) { + events?.emit('promo.bonus.granted', grant); + } + for (const paid of cashPaid) { + await emitCashCredit(paid); + } + }, + }); + + ctx.jobs.worker({ + queue: STREAK_CLOSE_QUEUE, + schema: EmptyJobSchema, + handler: async () => { + if (!streaks) { + logger.warn({}, 'streak close skipped - service not constructed'); + return; + } + const reset = await streaks.closeDay(new Date()); + if (reset > 0) { + logger.warn({ reset }, 'streak close reset players who missed their qualifying day'); + } + }, + }); + + ctx.jobs.worker({ + queue: RACE_PAYOUT_QUEUE, + schema: EmptyJobSchema, + handler: async () => { + if (!racePayouts) { + logger.warn({}, 'race payout skipped - service not constructed'); + return; + } + const won = await racePayouts.closeDue(new Date()); + // After the settlement transaction's own commit, the same rule the rank/streak payouts + // follow: a winner announced before the credit lands would tell a player about a prize + // they do not have yet. + for (const win of won) { + events?.emit('promo.race.won', win); + await emitCashCredit(win); + } + }, + }); + + ctx.jobs.worker({ + queue: RANK_CHALLENGE_PAYOUT_QUEUE, + schema: EmptyJobSchema, + handler: async () => { + if (!rankChallengePayouts) { + logger.warn({}, 'rank challenge payout skipped - service not constructed'); + return; + } + const won = await rankChallengePayouts.settlePending(); + // Same rule as every other payout job here: announce only after the settlement + // transaction that credited the cash (if any) has committed. + for (const win of won) { + events?.emit('promo.rank-challenge.won', win); + if (win.cashAmount !== null) { + await emitCashCredit({ + userId: win.userId, + amount: win.cashAmount, + currency: win.currency, + transactionId: win.transactionId, + }); + } + } + }, + }); + ctx.routers.add('promo-gamification', (c) => { - payouts = new RankPayoutService( + // A cash prize/cashback credit lands here when its own source (a race, a rank challenge + // tier, a streak milestone) is priced in something else - see `priceForPayout`. + const payoutCurrency = resolveWalletDefaultCurrency( + c.has(PLATFORM_CONFIG) ? c.get(PLATFORM_CONFIG).wallet : undefined, + ); + rankPayouts = new RankPayoutService( c.get(DRIZZLE), c.has(BONUS_GRANTS) ? c.get(BONUS_GRANTS) : undefined, c.has(PLAY_ELIGIBILITY) ? c.get(PLAY_ELIGIBILITY) : undefined, @@ -75,27 +248,88 @@ export default { c.get(WALLET_READER), logger, ); + streakPayouts = new StreakPayoutService( + c.get(DRIZZLE), + c.has(BONUS_GRANTS) ? c.get(BONUS_GRANTS) : undefined, + c.has(PLAY_ELIGIBILITY) ? c.get(PLAY_ELIGIBILITY) : undefined, + c.get(EXCHANGE_RATE_READER), + payoutCurrency, + logger, + c.has(WALLET_COMMANDS) ? c.get(WALLET_COMMANDS) : undefined, + ); + racePayouts = new RacePayoutService( + c.get(DRIZZLE), + c.has(PLAY_ELIGIBILITY) ? c.get(PLAY_ELIGIBILITY) : undefined, + c.has(WALLET_COMMANDS) ? c.get(WALLET_COMMANDS) : undefined, + c.get(EXCHANGE_RATE_READER), + payoutCurrency, + logger, + ); + rankChallengePayouts = new RankChallengePayoutService( + c.get(DRIZZLE), + c.has(PLAY_ELIGIBILITY) ? c.get(PLAY_ELIGIBILITY) : undefined, + c.has(WALLET_COMMANDS) ? c.get(WALLET_COMMANDS) : undefined, + c.get(EXCHANGE_RATE_READER), + payoutCurrency, + c.get(AUDIT_WRITER), + logger, + ); + streaks = streakService(c); events = c.get(EVENT_BUS); + identityReader = c.get(IDENTITY_READER); const schedule = PromoConfigSchema.parse( c.has(PLATFORM_CONFIG) ? c.get(PLATFORM_CONFIG).promo : {}, - ).ranks; + ); const jobs = c.get(JOB_QUEUE); // The periodic kinds share one tick: each run pays only a period that has closed since // the last, so when a player is actually paid is the anchor in the ladder's settings. for (const [kind, cron] of [ - ['levelUp', schedule.payoutCron], - ['daily', schedule.periodicCron], - ['weekly', schedule.periodicCron], - ['monthly', schedule.periodicCron], + ['levelUp', schedule.ranks.payoutCron], + ['daily', schedule.ranks.periodicCron], + ['weekly', schedule.ranks.periodicCron], + ['monthly', schedule.ranks.periodicCron], ] as const) { void jobs - .schedule(PAYOUT_QUEUE, `promo-rank-payout.${kind}.cron`, { kind }, { cron }) + .schedule(RANK_PAYOUT_QUEUE, `promo-rank-payout.${kind}.cron`, { kind }, { cron }) .catch((err: unknown) => logger.error({ err, kind }, 'rank payout schedule failed')); } + void jobs + .schedule( + STREAK_PAYOUT_QUEUE, + 'promo-streak-payout.cron', + {}, + { cron: schedule.streaks.payoutCron }, + ) + .catch((err: unknown) => logger.error({ err }, 'streak payout schedule failed')); + void jobs + .schedule( + STREAK_CLOSE_QUEUE, + 'promo-streak-close.cron', + {}, + { cron: schedule.streaks.closeCron }, + ) + .catch((err: unknown) => logger.error({ err }, 'streak close schedule failed')); + void jobs + .schedule(RACE_PAYOUT_QUEUE, 'promo-race-payout.cron', {}, { cron: RACE_PAYOUT_CRON }) + .catch((err: unknown) => logger.error({ err }, 'race payout schedule failed')); + void jobs + .schedule( + RANK_CHALLENGE_PAYOUT_QUEUE, + 'promo-rank-challenge-payout.cron', + {}, + { cron: RANK_CHALLENGE_PAYOUT_CRON }, + ) + .catch((err: unknown) => logger.error({ err }, 'rank challenge payout schedule failed')); return createGamificationRouter({ ranks: rankService(c), admin: new RankAdminService(c.get(DRIZZLE), c.get(AUDIT_WRITER)), + streaks, + streakAdmin: new StreakAdminService(c.get(DRIZZLE), c.get(AUDIT_WRITER)), + races: raceService(c), + raceAdmin: new RaceAdminService(c.get(DRIZZLE), c.get(AUDIT_WRITER)), + rankChallenge: rankChallengeService(c), + rankChallengeAdmin: new RankChallengeAdminService(c.get(DRIZZLE), c.get(AUDIT_WRITER)), adminGuard: c.get(ADMIN_GUARD), }); }); diff --git a/packages/core/src/promo/gamification/router/index.ts b/packages/core/src/promo/gamification/router/index.ts index c47dab5b2..fc30fc538 100644 --- a/packages/core/src/promo/gamification/router/index.ts +++ b/packages/core/src/promo/gamification/router/index.ts @@ -12,14 +12,39 @@ import { RankTierKeyTakenError, } from '../service/rank-admin.service.js'; import { RankLadderNotConfiguredError, RankService } from '../service/rank.service.js'; +import { StreakAdminService } from '../service/streak-admin.service.js'; +import { StreakConfigNotSetError, StreakService } from '../service/streak.service.js'; +import { RaceNotFoundError, RaceService } from '../service/race.service.js'; +import { + RaceAdminService, + RaceClosedError, + RacePositionsInvalidError, +} from '../service/race-admin.service.js'; +import { RankChallengeService } from '../service/rank-challenge.service.js'; +import { + RankChallengeAdminService, + RankChallengeLadderCurrencyHeldError, +} from '../service/rank-challenge-admin.service.js'; export function createGamificationRouter({ ranks, admin, + streaks, + streakAdmin, + races, + raceAdmin, + rankChallenge, + rankChallengeAdmin, adminGuard, }: { ranks: RankService; admin: RankAdminService; + streaks: StreakService; + streakAdmin: StreakAdminService; + races: RaceService; + raceAdmin: RaceAdminService; + rankChallenge: RankChallengeService; + rankChallengeAdmin: RankChallengeAdminService; adminGuard: AdminGuard; }) { const os = implement(gamificationContract).$context(); @@ -36,9 +61,57 @@ export function createGamificationRouter({ ranks.getForPlayer(getUserId(context)), ), ), + + // No `getUserId`: public, so a chat avatar can show another player's rank badge. + lookup: os.ranks.lookup.handler(({ input }) => ranks.lookup([...new Set(input.userIds)])), + }, + + streaks: { + get: os.streaks.get.handler(({ context }) => + mapErrors({ NOT_FOUND: StreakConfigNotSetError }, () => + streaks.getForPlayer(getUserId(context)), + ), + ), + + leaderboard: os.streaks.leaderboard.handler(({ context }) => + streaks.leaderboard(getUserId(context)), + ), + }, + + races: { + listActive: os.races.listActive.handler(() => races.listActive(new Date())), + + get: os.races.get.handler(({ input, context }) => + mapErrors({ NOT_FOUND: RaceNotFoundError }, () => + races.getForPlayer(input.raceId, getUserId(context)), + ), + ), + }, + + rankChallenge: { + get: os.rankChallenge.get.handler(({ context }) => + rankChallenge.getForPlayer(getUserId(context)), + ), + + // No `getUserId`: public, the same rule `ranks.ladder` follows. + ladder: os.rankChallenge.ladder.handler(() => rankChallenge.getLadder()), }, admin: { + streaks: { + config: { + get: os.admin.streaks.config.get.handler(async ({ context }) => { + await adminGuard.assert(context, 'bonus', 'view'); + return mapErrors({ NOT_FOUND: StreakConfigNotSetError }, () => streakAdmin.getConfig()); + }), + + set: os.admin.streaks.config.set.handler(async ({ input, context }) => { + const { userId } = await adminGuard.assert(context, 'bonus', 'update'); + return streakAdmin.setConfig(userId, input); + }), + }, + }, + ranks: { get: os.admin.ranks.get.handler(async ({ context }) => { await adminGuard.assert(context, 'bonus', 'view'); @@ -71,6 +144,74 @@ export function createGamificationRouter({ }), }, }, + + races: { + list: os.admin.races.list.handler(async ({ context }) => { + await adminGuard.assert(context, 'bonus', 'view'); + return raceAdmin.list(); + }), + + get: os.admin.races.get.handler(async ({ input, context }) => { + await adminGuard.assert(context, 'bonus', 'view'); + return mapErrors({ NOT_FOUND: RaceNotFoundError }, () => raceAdmin.get(input.raceId)); + }), + + create: os.admin.races.create.handler(async ({ input, context }) => { + const { userId } = await adminGuard.assert(context, 'bonus', 'update'); + return mapErrors({ BAD_REQUEST: RacePositionsInvalidError }, () => + raceAdmin.create(userId, input), + ); + }), + + update: os.admin.races.update.handler(async ({ input, context }) => { + const { userId } = await adminGuard.assert(context, 'bonus', 'update'); + return mapErrors( + { + BAD_REQUEST: RacePositionsInvalidError, + CONFLICT: RaceClosedError, + NOT_FOUND: RaceNotFoundError, + }, + () => raceAdmin.update(userId, input), + ); + }), + }, + + rankChallenge: { + config: { + get: os.admin.rankChallenge.config.get.handler(async ({ context }) => { + await adminGuard.assert(context, 'bonus', 'view'); + return rankChallengeAdmin.getLadder(); + }), + + set: os.admin.rankChallenge.config.set.handler(async ({ input, context }) => { + const { userId } = await adminGuard.assert(context, 'bonus', 'update'); + return mapErrors({ CONFLICT: RankChallengeLadderCurrencyHeldError }, () => + rankChallengeAdmin.setLadder(userId, input), + ); + }), + }, + + claims: { + list: os.admin.rankChallenge.claims.list.handler(async ({ context }) => { + await adminGuard.assert(context, 'bonus', 'view'); + return rankChallengeAdmin.listClaims(); + }), + }, + + fulfilment: { + list: os.admin.rankChallenge.fulfilment.list.handler(async ({ context }) => { + await adminGuard.assert(context, 'bonus', 'view'); + return rankChallengeAdmin.listFulfilmentQueue(); + }), + + markFulfilled: os.admin.rankChallenge.fulfilment.markFulfilled.handler( + async ({ input, context }) => { + const { userId } = await adminGuard.assert(context, 'bonus', 'update'); + return rankChallengeAdmin.markFulfilled(userId, input.tierId, input.note); + }, + ), + }, + }, }, }); } diff --git a/packages/core/src/promo/gamification/schema/index.ts b/packages/core/src/promo/gamification/schema/index.ts index 3cc12a8c4..864874c45 100644 --- a/packages/core/src/promo/gamification/schema/index.ts +++ b/packages/core/src/promo/gamification/schema/index.ts @@ -2,6 +2,7 @@ import { sql } from 'drizzle-orm'; import { boolean, check, + date, decimal, index, integer, @@ -18,7 +19,14 @@ import { MONEY_PRECISION, MONEY_SCALE, } from '@openora/core/contracts'; -import type { RankConfig, RankPayoutAnchors, RankPayoutKind } from '../contract/index.js'; +import type { + RaceEligibleProducts, + RacePositions, + RankConfig, + RankPayoutAnchors, + RankPayoutKind, + StreakMilestone, +} from '../contract/index.js'; const money = () => decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }); @@ -70,6 +78,16 @@ export const promoPlayerRank = pgTable( tierId: uuid().references(() => promoRankTier.id), /** Last counted wager. A periodic bonus goes only to a player active in the period it pays. */ lastWageredAt: timestamp({ withTimezone: true }), + /** + * A streak milestone's temporary lift on top of the tier's own `rakebackPercent`. Additive, + * and gone once `rakebackBoostExpiresAt` passes - read together, never `rakebackPercent` + * alone, by anything that pays rakeback. + */ + rakebackBoostPercent: decimal({ + precision: CONTRIBUTION_PERCENT_PRECISION, + scale: CONTRIBUTION_PERCENT_SCALE, + }), + rakebackBoostExpiresAt: timestamp({ withTimezone: true }), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp({ withTimezone: true }) .notNull() @@ -193,3 +211,321 @@ export const promoRankLevelUp = pgTable( check('promo_rank_level_up_amount_positive', sql`${t.amount} > 0`), ], ); + +export type PromoRankLevelUp = typeof promoRankLevelUp.$inferSelect; + +/** + * Streak-wide settings, one row - the same singleton shape as `promoRankConfig`. Absent means no + * day counts and no milestone pays: an operator who has not decided what qualifies has not + * launched the streak. + */ +export const promoStreakConfig = pgTable('promo_streak_config', { + id: uuid().primaryKey().defaultRandom(), + singletonKey: text().notNull().unique().default('global'), + currency: text().notNull(), + dailyMinWager: money().notNull(), + /** Products whose stakes count toward the streak. Empty counts every product. */ + eligibleProducts: text().array().notNull().default([]), + milestones: jsonb().$type().notNull().default([]), + resetAfterDay: integer().notNull().default(30), + updatedBy: uuid(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp({ withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), +}); + +export type PromoStreakConfig = typeof promoStreakConfig.$inferSelect; + +/** + * A player's own streak state: the current run, the best run ever held, and the UTC calendar day + * it last advanced on - the guard that keeps one qualifying day from being counted twice no + * matter how many qualifying bets land inside it. + */ +export const promoPlayerStreak = pgTable( + 'promo_player_streak', + { + id: uuid().primaryKey().defaultRandom(), + userId: uuid().notNull().unique(), + current: integer().notNull().default(0), + best: integer().notNull().default(0), + lastQualifyingDay: date(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp({ withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => [ + check( + 'promo_player_streak_counts_non_negative', + sql`${t.current} >= 0 AND ${t.best} >= 0 AND ${t.current} <= ${t.best}`, + ), + ], +); + +export type PromoPlayerStreak = typeof promoPlayerStreak.$inferSelect; + +/** + * What a player has wagered inside one UTC calendar day, toward that day's qualifying threshold. + * Upserted per bet, mirroring `promoRankPeriodWager` - the accumulator the daily close job and + * the leaderboard never need, since a day answers for itself in `promoPlayerStreak` once closed. + */ +export const promoStreakDailyWager = pgTable( + 'promo_streak_daily_wager', + { + id: uuid().primaryKey().defaultRandom(), + userId: uuid().notNull(), + day: date().notNull(), + currency: text().notNull(), + wagered: money().notNull().default('0'), + updatedAt: timestamp({ withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => [ + uniqueIndex('promo_streak_daily_wager_user_id_day_idx').on(t.userId, t.day), + check('promo_streak_daily_wager_non_negative', sql`${t.wagered} >= 0`), + ], +); + +export type PromoStreakDailyWager = typeof promoStreakDailyWager.$inferSelect; + +/** + * A milestone a player has reached and the payout job has yet to settle, one row per player per + * milestone day. Deleted whenever `promo_player_streak.current` resets to zero - a missed day or + * the milestone at `resetAfterDay` completing - so the same day can be earned again on the next + * run without a second dimension threading every query that reads this table. + */ +export const promoStreakMilestoneGrant = pgTable( + 'promo_streak_milestone_grant', + { + id: uuid().primaryKey().defaultRandom(), + userId: uuid().notNull(), + day: integer().notNull(), + reachedAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + settledAt: timestamp({ withTimezone: true }), + /** `granted`, or why nothing was: `restricted` for a player under an RG block. */ + outcome: text(), + }, + (t) => [ + uniqueIndex('promo_streak_milestone_grant_user_id_day_idx').on(t.userId, t.day), + index('promo_streak_milestone_grant_unsettled_idx') + .on(t.reachedAt) + .where(sql`${t.settledAt} is null`), + check('promo_streak_milestone_grant_day_positive', sql`${t.day} > 0`), + ], +); + +export type PromoStreakMilestoneGrant = typeof promoStreakMilestoneGrant.$inferSelect; + +/** + * A wager challenge: a fixed window over which wagering volume is ranked and a prize pool split + * across the paid positions. Unlike the rank ladder and streak, a race carries its own explicit + * `startAt`/`endAt` rather than an operator-wide anchor, since races are run one at a time (or + * overlapping) on whatever schedule the operator likes. + * + * `closedAt` is the hard settlement flag: standings are frozen and prizes paid once, and a late + * or retried settle-job tick after `endAt` must never recompute them - `endAt < now()` alone + * cannot express "already settled", since a crash could leave it null after payouts landed. + * Prospective-only editing (`RaceAdminService`) is enforced by refusing any change once this is + * set, mirroring how `RankAdminService.set` blocks a ladder edit once a player holds state. + */ +export const promoRace = pgTable( + 'promo_race', + { + id: uuid().primaryKey().defaultRandom(), + name: text().notNull(), + currency: text().notNull(), + startAt: timestamp({ withTimezone: true }).notNull(), + endAt: timestamp({ withTimezone: true }).notNull(), + prizePool: money().notNull(), + /** `{ position, prize }[]`, validated 1..N contiguous, prizes summing to at most `prizePool`. */ + positions: jsonb().$type().notNull(), + /** Products whose stakes count toward this race. Empty counts every product. */ + eligibleProducts: text().array().notNull().default([]).$type(), + /** Set once, by the settle job, when standings are frozen and prizes granted. */ + closedAt: timestamp({ withTimezone: true }), + createdBy: uuid(), + updatedBy: uuid(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp({ withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => [ + check('promo_race_prize_pool_positive', sql`${t.prizePool} > 0`), + check('promo_race_dates_ordered', sql`${t.endAt} > ${t.startAt}`), + index('promo_race_open_idx') + .on(t.startAt, t.endAt) + .where(sql`${t.closedAt} is null`), + ], +); + +export type PromoRace = typeof promoRace.$inferSelect; + +/** + * What a player has wagered inside one race, in the race's own currency - one row per player per + * race, upserted per bet, the same accumulator shape as `promoRankPeriodWager`. `updatedAt` + * doubles as the tie-break clock the payout job reads: two players tied on `wagered` are ranked + * by whoever's row last moved, ie whoever reached the total first. + */ +export const promoRaceWager = pgTable( + 'promo_race_wager', + { + id: uuid().primaryKey().defaultRandom(), + raceId: uuid() + .notNull() + .references(() => promoRace.id), + userId: uuid().notNull(), + currency: text().notNull(), + wagered: money().notNull().default('0'), + updatedAt: timestamp({ withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => [ + uniqueIndex('promo_race_wager_race_id_user_id_idx').on(t.raceId, t.userId), + // The leaderboard's own query: everyone in one race, ranked by what they wagered. + index('promo_race_wager_race_id_wagered_idx').on(t.raceId, t.wagered), + check('promo_race_wager_non_negative', sql`${t.wagered} >= 0`), + ], +); + +export type PromoRaceWager = typeof promoRaceWager.$inferSelect; + +/** + * One prize paid to one player in one race, written once by the settle job - + * `unique(raceId, userId)` is the idempotency guard a retried or re-ticked settlement reads + * before crediting anything, the same "insert once, skip if present" shape + * `promoRankLevelUp`/`promoStreakMilestoneGrant` use for their own settlement. + */ +export const promoRacePayout = pgTable( + 'promo_race_payout', + { + id: uuid().primaryKey().defaultRandom(), + raceId: uuid() + .notNull() + .references(() => promoRace.id), + userId: uuid().notNull(), + position: integer().notNull(), + amount: money().notNull(), + currency: text().notNull(), + settledAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + grantId: uuid(), + /** `granted`, or why nothing was: `restricted` for a player under an RG block. */ + outcome: text().notNull(), + }, + (t) => [ + uniqueIndex('promo_race_payout_race_id_user_id_idx').on(t.raceId, t.userId), + index('promo_race_payout_race_id_idx').on(t.raceId), + check('promo_race_payout_position_positive', sql`${t.position} > 0`), + check('promo_race_payout_amount_non_negative', sql`${t.amount} >= 0`), + ], +); + +export type PromoRacePayout = typeof promoRacePayout.$inferSelect; + +/** + * A Rank Challenge tier: a lifetime real-money wagering threshold and the prize the first player + * to cross it wins, once, forever - unlike `promoRankTier` (a repeatable ladder every player + * climbs) or `promoRace` (a repeating leaderboard window), this is a race-to-threshold with a + * single winner per tier. A tier carries a cash amount, a physical item description, or both + * (`master`/`titan` combine them) - at least one of the two is required. + */ +export const promoRankChallengeTier = pgTable( + 'promo_rank_challenge_tier', + { + id: uuid().primaryKey().defaultRandom(), + key: text().notNull().unique(), + name: text().notNull(), + position: integer().notNull().unique(), + currency: text().notNull(), + wagerThreshold: money().notNull(), + cashAmount: money(), + physicalItem: text(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp({ withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => [ + check( + 'promo_rank_challenge_tier_bounds', + sql`${t.position} >= 0 AND ${t.wagerThreshold} >= 0 + AND (${t.cashAmount} is null OR ${t.cashAmount} > 0) + AND (${t.physicalItem} is not null OR ${t.cashAmount} is not null)`, + ), + ], +); + +export type PromoRankChallengeTier = typeof promoRankChallengeTier.$inferSelect; + +/** + * A player's lifetime real-money wagering total toward the Rank Challenge - independent of + * `promoPlayerRank.lifetimeWagered` (the rank ladder's own accumulator, filtered by that + * ladder's `eligibleProducts`). Every real-money wager counts here, no eligibility filter, per + * the challenge's own "lifetime real-money wagering" rule. + */ +export const promoRankChallengeWager = pgTable( + 'promo_rank_challenge_wager', + { + id: uuid().primaryKey().defaultRandom(), + userId: uuid().notNull().unique(), + currency: text().notNull(), + lifetimeWagered: money().notNull().default('0'), + updatedAt: timestamp({ withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => [check('promo_rank_challenge_wager_non_negative', sql`${t.lifetimeWagered} >= 0`)], +); + +export type PromoRankChallengeWager = typeof promoRankChallengeWager.$inferSelect; + +/** + * The winner record for one tier - `unique(tierId)` is the whole mechanic's atomicity guard: two + * players crossing the same tier concurrently both attempt this insert, and the unique index + * lets exactly one land (`onConflictDoNothing`, checked via `.returning()`). `cashAmount`/ + * `physicalItem` are snapshotted from the tier at claim time so a later admin edit to the tier's + * prize never changes what a past winner was actually granted (prospective-only, the same rule + * `promoRankLevelUp` follows for its own amount). Settled by a payout job, mirroring + * `promoRankLevelUp`/`promoStreakMilestoneGrant`'s own unsettled-row pattern, rather than being + * credited inline in the same transaction that detects the crossing. + */ +export const promoRankChallengeClaim = pgTable( + 'promo_rank_challenge_claim', + { + id: uuid().primaryKey().defaultRandom(), + tierId: uuid().notNull(), + userId: uuid().notNull(), + currency: text().notNull(), + cashAmount: money(), + physicalItem: text(), + claimedAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + settledAt: timestamp({ withTimezone: true }), + /** `granted`, or why the cash part was not: `restricted` for a player under an RG block. */ + outcome: text(), + cashGrantId: uuid(), + physicalFulfilledAt: timestamp({ withTimezone: true }), + physicalFulfilledBy: uuid(), + physicalFulfillmentNote: text(), + }, + (t) => [ + uniqueIndex('promo_rank_challenge_claim_tier_id_idx').on(t.tierId), + index('promo_rank_challenge_claim_unsettled_idx') + .on(t.claimedAt) + .where(sql`${t.settledAt} is null`), + index('promo_rank_challenge_claim_fulfilment_queue_idx') + .on(t.claimedAt) + .where(sql`${t.physicalItem} is not null AND ${t.physicalFulfilledAt} is null`), + ], +); + +export type PromoRankChallengeClaim = typeof promoRankChallengeClaim.$inferSelect; diff --git a/packages/core/src/promo/gamification/seed/index.ts b/packages/core/src/promo/gamification/seed/index.ts index a9e53286b..318e250e6 100644 --- a/packages/core/src/promo/gamification/seed/index.ts +++ b/packages/core/src/promo/gamification/seed/index.ts @@ -1,6 +1,11 @@ import type { DrizzleDb } from '@openora/core/server'; -import type { RankConfig } from '../contract/index.js'; -import { promoRankConfig, promoRankTier } from '../schema/index.js'; +import type { RankConfig, StreakConfig } from '../contract/index.js'; +import { + promoRankChallengeTier, + promoRankConfig, + promoRankTier, + promoStreakConfig, +} from '../schema/index.js'; export type RankTierSeed = { key: string; @@ -39,3 +44,52 @@ export async function seedRankLadder(db: DrizzleDb, ladder: RankLadderSeed): Pro .onConflictDoNothing(); await db.insert(promoRankConfig).values(ladder.config).onConflictDoNothing(); } + +export type StreakSeed = StreakConfig; + +/** + * Seeds the streak config, mirroring `seedRankLadder`: idempotent, never overwrites a setting an + * operator has already edited. + */ +export async function seedStreakConfig(db: DrizzleDb, config: StreakSeed): Promise { + await db.insert(promoStreakConfig).values(config).onConflictDoNothing(); +} + +export type RankChallengeTierSeed = { + key: string; + name: string; + wagerThreshold: string; + cashAmount?: string | null; + physicalItem?: string | null; +}; + +export type RankChallengeLadderSeed = { + currency: string; + /** Lowest threshold first. */ + tiers: RankChallengeTierSeed[]; +}; + +/** + * Seeds the Rank Challenge ladder, mirroring `seedRankLadder`: idempotent (unique on `key` and + * `position`), never overwrites a tier an operator has already edited. + */ +export async function seedRankChallengeLadder( + db: DrizzleDb, + ladder: RankChallengeLadderSeed, +): Promise { + if (ladder.tiers.length === 0) { + return; + } + await db + .insert(promoRankChallengeTier) + .values( + ladder.tiers.map((tier, position) => ({ + ...tier, + position, + currency: ladder.currency, + cashAmount: tier.cashAmount ?? null, + physicalItem: tier.physicalItem ?? null, + })), + ) + .onConflictDoNothing(); +} diff --git a/packages/core/src/promo/gamification/service/race-admin.service.ts b/packages/core/src/promo/gamification/service/race-admin.service.ts new file mode 100644 index 000000000..538450fd5 --- /dev/null +++ b/packages/core/src/promo/gamification/service/race-admin.service.ts @@ -0,0 +1,181 @@ +import { desc, eq } from 'drizzle-orm'; +import type { AuditWritePort, Uuid } from '@openora/core/contracts'; +import { + createDomainError, + makeConflictError, + moneyAdd, + moneyCompare, + type DrizzleService, +} from '@openora/core/server'; +import type { CreateRaceInput, Race, UpdateRaceInput } from '../contract/index.js'; +import { promoRace } from '../schema/index.js'; +import { RaceNotFoundError } from './race.service.js'; + +export { RaceNotFoundError }; + +export const RacePositionsInvalidError = createDomainError<[reason: string]>( + 'RacePositionsInvalidError', + (reason) => `the race's positions would be invalid: ${reason}`, +); + +export const RaceClosedError = makeConflictError( + 'RaceClosedError', + 'a closed race cannot be edited - standings are already frozen', +); + +const RACE_COLUMNS = { + id: promoRace.id, + name: promoRace.name, + currency: promoRace.currency, + startAt: promoRace.startAt, + endAt: promoRace.endAt, + prizePool: promoRace.prizePool, + positions: promoRace.positions, + eligibleProducts: promoRace.eligibleProducts, + closedAt: promoRace.closedAt, + createdAt: promoRace.createdAt, + updatedAt: promoRace.updatedAt, +}; + +const toRace = (row: { + id: string; + name: string; + currency: string; + startAt: Date; + endAt: Date; + prizePool: string; + positions: Race['positions']; + eligibleProducts: string[]; + closedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}): Race => ({ + id: row.id, + name: row.name, + currency: row.currency, + startAt: row.startAt.toISOString(), + endAt: row.endAt.toISOString(), + prizePool: row.prizePool, + positions: row.positions, + eligibleProducts: row.eligibleProducts, + closedAt: row.closedAt ? row.closedAt.toISOString() : null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), +}); + +function assertPositionsAffordable(prizePool: string, positions: readonly { prize: string }[]) { + const sum = positions.reduce((total, entry) => moneyAdd(total, entry.prize), '0'); + if (moneyCompare(sum, prizePool) > 0) { + throw new RacePositionsInvalidError('the prizes sum to more than the prize pool'); + } +} + +/** + * The operator's side of a race: create, list, and read one, and replace its config as one + * validated set - the same shape `RankAdminService.set`/`setConfig` use. Every write is audited + * with a before/after. + * + * Prospective only: editing dates, the prize pool, positions or eligible products on a race that + * has already closed is refused outright, the same conservative rule `RankAdminService` applies + * once players hold ladder state. An active/upcoming race may still be edited - its leaderboard + * and payout are computed from live data at settle time, so a mid-race edit only ever affects + * standings going forward. + */ +export class RaceAdminService { + constructor( + private readonly drizzle: DrizzleService, + private readonly audit: AuditWritePort, + ) {} + + async list(): Promise { + const rows = await this.drizzle.db + .select(RACE_COLUMNS) + .from(promoRace) + .orderBy(desc(promoRace.startAt)); + return rows.map(toRace); + } + + async get(raceId: Uuid): Promise { + const [row] = await this.drizzle.db + .select(RACE_COLUMNS) + .from(promoRace) + .where(eq(promoRace.id, raceId)); + if (!row) { + throw new RaceNotFoundError(raceId); + } + return toRace(row); + } + + async create(adminId: Uuid, input: CreateRaceInput): Promise { + assertPositionsAffordable(input.prizePool, input.positions); + return this.drizzle.db.transaction(async (tx) => { + const [created] = await tx + .insert(promoRace) + .values({ + ...input, + startAt: new Date(input.startAt), + endAt: new Date(input.endAt), + createdBy: adminId, + updatedBy: adminId, + }) + .returning(RACE_COLUMNS); + if (!created) { + throw new Error('race insert returned no row'); + } + const race = toRace(created); + await this.audit.recordInTransaction(tx, { + actorId: adminId, + actorType: 'admin', + action: 'promo.race.created', + resourceType: 'promo_race', + resourceId: race.id, + before: null, + after: race, + }); + return race; + }); + } + + async update(adminId: Uuid, input: UpdateRaceInput): Promise { + const { raceId, ...fields } = input; + assertPositionsAffordable(fields.prizePool, fields.positions); + return this.drizzle.db.transaction(async (tx) => { + const [locked] = await tx + .select(RACE_COLUMNS) + .from(promoRace) + .where(eq(promoRace.id, raceId)) + .for('update'); + if (!locked) { + throw new RaceNotFoundError(raceId); + } + if (locked.closedAt !== null) { + throw new RaceClosedError(); + } + const before = toRace(locked); + const [updated] = await tx + .update(promoRace) + .set({ + ...fields, + startAt: new Date(fields.startAt), + endAt: new Date(fields.endAt), + updatedBy: adminId, + }) + .where(eq(promoRace.id, raceId)) + .returning(RACE_COLUMNS); + if (!updated) { + throw new Error('race update returned no row'); + } + const after = toRace(updated); + await this.audit.recordInTransaction(tx, { + actorId: adminId, + actorType: 'admin', + action: 'promo.race.updated', + resourceType: 'promo_race', + resourceId: raceId, + before, + after, + }); + return after; + }); + } +} diff --git a/packages/core/src/promo/gamification/service/race-payout.service.ts b/packages/core/src/promo/gamification/service/race-payout.service.ts new file mode 100644 index 000000000..fb83570e4 --- /dev/null +++ b/packages/core/src/promo/gamification/service/race-payout.service.ts @@ -0,0 +1,181 @@ +import { and, asc, desc, eq, isNull, lte } from 'drizzle-orm'; +import type { + ExchangeRateReader, + PlayEligibilityPort, + Uuid, + WalletCommands, +} from '@openora/core/contracts'; +import type { DrizzleService, DrizzleTx } from '@openora/core/server'; +import { promoRace, promoRacePayout, promoRaceWager } from '../schema/index.js'; +import { priceForPayout } from '../shared/payout-currency.js'; + +/** What `plugin.ts` announces per winner, once its own settlement transaction has committed. */ +export type RaceWon = { + userId: Uuid; + raceId: Uuid; + raceName: string; + position: number; + /** As actually credited, after `priceForPayout` - may differ from the race's own currency. */ + amount: string; + currency: string; + /** Null on a replayed credit (the balance already moved and was already announced). */ + transactionId: string | null; +}; + +type Logger = { + warn: (context: object, message: string) => void; + error: (context: object, message: string) => void; +}; + +/** + * Settles a race once its window has closed: freezes final standings, pays every position the + * operator funded, and marks the race `closedAt` so it is never recomputed - the same "insert + * once, skip if present" idempotency `RankPayoutService`/`StreakPayoutService` use for their own + * settlement rows, guarded here by `promo_race_payout`'s `unique(raceId, userId)`. + * + * Races close at arbitrary configured timestamps rather than a shared daily/weekly/monthly + * anchor, so this runs on a short recurring tick (see `plugin.ts`) instead of `RankPayoutAnchors`. + * + * A player under a responsible-gambling block is still ranked and still gets a payout row, so the + * standings and history stay accurate - only the cash credit is withheld, the same rule + * `RankPayoutService`/`StreakPayoutService` apply to their own grants. + */ +export class RacePayoutService { + constructor( + private readonly drizzle: DrizzleService, + private readonly eligibility: PlayEligibilityPort | undefined, + private readonly wallet: WalletCommands | undefined, + private readonly rates: ExchangeRateReader, + private readonly payoutCurrency: string, + private readonly logger: Logger, + ) {} + + async closeDue(now: Date): Promise { + const due = await this.drizzle.db + .select({ id: promoRace.id }) + .from(promoRace) + .where(and(lte(promoRace.endAt, now), isNull(promoRace.closedAt))); + + const won: RaceWon[] = []; + for (const { id } of due) { + try { + won.push(...(await this.drizzle.db.transaction((tx) => this.settleOne(tx, id, now)))); + } catch (err) { + // One race's failure must not stop the next tick from settling the others due. + this.logger.error({ err, raceId: id }, 'race settlement failed'); + } + } + return won; + } + + private async settleOne(tx: DrizzleTx, raceId: Uuid, settledAt: Date): Promise { + const [race] = await tx + .select({ + id: promoRace.id, + name: promoRace.name, + currency: promoRace.currency, + positions: promoRace.positions, + closedAt: promoRace.closedAt, + }) + .from(promoRace) + .where(eq(promoRace.id, raceId)) + .for('update', { skipLocked: true }); + if (!race || race.closedAt !== null) { + return []; + } + + const paidPositions = race.positions.length; + const standings = + paidPositions === 0 + ? [] + : await tx + .select({ userId: promoRaceWager.userId, wagered: promoRaceWager.wagered }) + .from(promoRaceWager) + .where(eq(promoRaceWager.raceId, raceId)) + // Tie-break: whoever's accumulator last moved at that total reached it first. + .orderBy(desc(promoRaceWager.wagered), asc(promoRaceWager.updatedAt)) + .limit(paidPositions); + + const already = await tx + .select({ userId: promoRacePayout.userId }) + .from(promoRacePayout) + .where(eq(promoRacePayout.raceId, raceId)); + const settled = new Set(already.map((row) => row.userId)); + + const won: RaceWon[] = []; + for (const [index, standing] of standings.entries()) { + if (settled.has(standing.userId)) { + continue; + } + const position = race.positions[index]; + if (!position) { + continue; + } + const restricted = (await this.eligibility?.isRestricted(standing.userId)) ?? true; + if (restricted) { + await tx.insert(promoRacePayout).values({ + raceId, + userId: standing.userId, + position: index + 1, + amount: position.prize, + currency: race.currency, + grantId: null, + outcome: 'restricted', + }); + continue; + } + if (!this.wallet) { + throw new Error('WALLET_COMMANDS is not bound'); + } + // Never the race's own currency unconditionally - a crypto-only player must not have a + // balance opened in whatever the prize pool is priced in. `priceForPayout` throws when no + // rate is available; this whole settlement rolls back and the job's next tick retries it, + // the same "not credited yet, retried later" rule a wallet credit failure follows below. + const priced = await priceForPayout( + this.rates, + position.prize, + race.currency, + this.payoutCurrency, + ); + const sourceRef = `race-payout:${raceId}:${standing.userId}`; + const credited = await this.wallet.credit(tx, { + userId: standing.userId, + amount: priced.amount, + currency: priced.currency, + type: 'cashback', + allowNewCurrency: true, + providerRef: { providerName: 'promo-race', providerRefId: sourceRef }, + }); + if (!credited.ok) { + // Thrown rather than logged-and-recorded-as-granted: a payout row must never claim + // `outcome: 'granted'` for money that never moved. The whole race's settlement rolls + // back and `closeDue`'s own catch retries it on the next tick. + throw new Error(`race prize credit failed: ${credited.reason}`); + } + await tx.insert(promoRacePayout).values({ + raceId, + userId: standing.userId, + position: index + 1, + amount: priced.amount, + currency: priced.currency, + grantId: null, + outcome: 'granted', + }); + won.push({ + userId: standing.userId, + raceId, + raceName: race.name, + position: index + 1, + amount: priced.amount, + currency: priced.currency, + transactionId: credited.moved ? credited.transactionId : null, + }); + } + + await tx + .update(promoRace) + .set({ closedAt: settledAt, updatedAt: settledAt }) + .where(eq(promoRace.id, raceId)); + return won; + } +} diff --git a/packages/core/src/promo/gamification/service/race.service.ts b/packages/core/src/promo/gamification/service/race.service.ts new file mode 100644 index 000000000..a336fe18c --- /dev/null +++ b/packages/core/src/promo/gamification/service/race.service.ts @@ -0,0 +1,235 @@ +import { and, asc, desc, eq, gt, isNull, lte, sql } from 'drizzle-orm'; +import { player } from '@openora/core/pam/schema/profile'; +import { user } from '@openora/core/pam/schema/identity'; +import type { + ExchangeRateReader, + Uuid, + WagerTrackingArgs, + WagerTrackingCommands, + WagerTrackingWalletCredit, +} from '@openora/core/contracts'; +import { + makeNotFoundError, + moneyCompare, + moneySubtract, + type DrizzleService, + type DrizzleTx, +} from '@openora/core/server'; +import type { Race, RaceForPlayer, RaceLeaderboardEntry } from '../contract/index.js'; +import { promoRace, promoRaceWager } from '../schema/index.js'; + +export const RaceNotFoundError = makeNotFoundError('Race'); + +// An empty list counts every bet - the same convention RankService/StreakService use. +const countsToward = (eligibleProducts: readonly string[], product: string) => + eligibleProducts.length === 0 || eligibleProducts.includes(product); + +const INCOGNITO = 'Incognito'; + +/** + * The server-side source of truth for a masked leaderboard username, so a client's own copy of + * this rule (if it has one) never disagrees with what the payload already carries - a leaderboard + * response reflects masking itself rather than leaving it to be applied client-side. + */ +function maskUsername(name: string): string { + const visible = Math.min(3, Math.max(1, Math.floor(name.length / 3))); + return `${name.slice(0, visible)}${'*'.repeat(Math.max(4, name.length - visible + 4))}`; +} + +const RACE_COLUMNS = { + id: promoRace.id, + name: promoRace.name, + currency: promoRace.currency, + startAt: promoRace.startAt, + endAt: promoRace.endAt, + prizePool: promoRace.prizePool, + positions: promoRace.positions, + eligibleProducts: promoRace.eligibleProducts, + closedAt: promoRace.closedAt, + createdAt: promoRace.createdAt, + updatedAt: promoRace.updatedAt, +}; + +const toRace = (row: { + id: string; + name: string; + currency: string; + startAt: Date; + endAt: Date; + prizePool: string; + positions: Race['positions']; + eligibleProducts: string[]; + closedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}): Race => ({ + id: row.id, + name: row.name, + currency: row.currency, + startAt: row.startAt.toISOString(), + endAt: row.endAt.toISOString(), + prizePool: row.prizePool, + positions: row.positions, + eligibleProducts: row.eligibleProducts, + closedAt: row.closedAt ? row.closedAt.toISOString() : null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), +}); + +const LEADERBOARD_CAP = 100; + +/** + * The wager-challenge / leaderboard-race engine: a fourth `WAGER_TRACKING` consumer alongside + * `RankService`, `RakebackService` and `StreakService`, plus the player-facing reads the race + * page renders from. + * + * Own-money only, the same rule `RakebackService` applies: `args.realAmount` already excludes + * whatever part of a stake a bonus grant covered, so wagering a bonus never climbs a race whose + * prize is real cash. + */ +export class RaceService implements WagerTrackingCommands { + constructor( + private readonly drizzle: DrizzleService, + private readonly rates: ExchangeRateReader, + private readonly logger: { warn: (context: object, message: string) => void }, + ) {} + + async recordWager(tx: DrizzleTx, args: WagerTrackingArgs): Promise { + if (moneyCompare(args.realAmount, '0') <= 0) { + return []; + } + const now = new Date(); + const open = await tx + .select({ + id: promoRace.id, + currency: promoRace.currency, + eligibleProducts: promoRace.eligibleProducts, + }) + .from(promoRace) + .where( + and(lte(promoRace.startAt, now), gt(promoRace.endAt, now), isNull(promoRace.closedAt)), + ); + + for (const race of open) { + if (!countsToward(race.eligibleProducts, args.context.product)) { + continue; + } + const amount = + args.currency === race.currency + ? args.realAmount + : await this.rates.convert(args.realAmount, args.currency, race.currency); + if (amount === null) { + // ponytail: a wager with no rate is not counted toward the race; revisit if this shows + // up in logs the way the equivalent rank-side skip would. + this.logger.warn( + { userId: args.userId, raceId: race.id, from: args.currency, to: race.currency }, + 'race wager skipped - no exchange rate', + ); + continue; + } + await tx + .insert(promoRaceWager) + .values({ raceId: race.id, userId: args.userId, currency: race.currency, wagered: amount }) + .onConflictDoUpdate({ + target: [promoRaceWager.raceId, promoRaceWager.userId], + set: { + wagered: sql`${promoRaceWager.wagered} + ${amount}::numeric`, + updatedAt: sql`now()`, + }, + }); + } + return []; + } + + async listActive(now: Date): Promise { + const rows = await this.drizzle.db + .select(RACE_COLUMNS) + .from(promoRace) + .where(and(lte(promoRace.startAt, now), gt(promoRace.endAt, now), isNull(promoRace.closedAt))) + .orderBy(asc(promoRace.endAt)); + return rows.map(toRace); + } + + async getForPlayer(raceId: Uuid, userId: Uuid): Promise { + const [row] = await this.drizzle.db + .select(RACE_COLUMNS) + .from(promoRace) + .where(eq(promoRace.id, raceId)); + if (!row) { + throw new RaceNotFoundError(raceId); + } + const race = toRace(row); + + // One capped query, ranked by wagered desc - a page size of 100 is enough for a paid/ranked + // leaderboard; add real pagination if a race ever needs more than that (ponytail). + const ranked = await this.drizzle.db + .select({ + userId: promoRaceWager.userId, + wagered: promoRaceWager.wagered, + username: user.username, + hideUsername: player.hideUsernameOnLeaderboards, + }) + .from(promoRaceWager) + .innerJoin(user, eq(user.id, promoRaceWager.userId)) + .leftJoin(player, eq(player.userId, promoRaceWager.userId)) + .where(eq(promoRaceWager.raceId, raceId)) + .orderBy(desc(promoRaceWager.wagered)) + .limit(LEADERBOARD_CAP); + + const entries: RaceLeaderboardEntry[] = ranked.map((row, index) => ({ + userId: row.userId, + username: + row.userId === userId + ? row.username + : (row.hideUsername ?? false) + ? INCOGNITO + : maskUsername(row.username), + wagered: row.wagered, + position: index + 1, + })); + + const ownIndex = entries.findIndex((entry) => entry.userId === userId); + const ownEntry = ownIndex === -1 ? null : entries[ownIndex]; + const own = + ownEntry === null || ownEntry === undefined + ? { userId, wagered: '0', position: null, amountToNextPaidPosition: null } + : { + userId, + wagered: ownEntry.wagered, + position: ownEntry.position, + amountToNextPaidPosition: this.amountToNextPaidPosition(race, entries, ownIndex), + }; + + return { + race, + podium: entries.slice(0, 3), + leaderboard: entries.slice(3), + own, + }; + } + + /** + * How much more the player must wager to reach the next-better paid position - the wagered + * amount of whoever currently holds it, minus the player's own. Null once the player already + * holds a paid position, or the race pays no positions at all. + */ + private amountToNextPaidPosition( + race: Race, + entries: readonly RaceLeaderboardEntry[], + ownIndex: number, + ): string | null { + const paidPositions = race.positions.length; + if (paidPositions === 0 || ownIndex < paidPositions) { + return null; + } + const nextBetter = entries[paidPositions - 1]; + const ownEntry = entries[ownIndex]; + if (!nextBetter || !ownEntry) { + return null; + } + const own = ownEntry.wagered; + return moneyCompare(nextBetter.wagered, own) <= 0 + ? '0' + : moneySubtract(nextBetter.wagered, own); + } +} diff --git a/packages/core/src/promo/gamification/service/rakeback.service.ts b/packages/core/src/promo/gamification/service/rakeback.service.ts new file mode 100644 index 000000000..7d7d75161 --- /dev/null +++ b/packages/core/src/promo/gamification/service/rakeback.service.ts @@ -0,0 +1,109 @@ +import { eq } from 'drizzle-orm'; +import type { + WagerTrackingArgs, + WagerTrackingCommands, + WagerTrackingWalletCredit, + WalletCommands, +} from '@openora/core/contracts'; +import { + moneyAdd, + moneyCompare, + moneyDivide, + moneyScaleBy, + type DrizzleTx, +} from '@openora/core/server'; +import { promoPlayerRank, promoRankConfig, promoRankTier } from '../schema/index.js'; + +const ZERO = '0'; + +// An empty list counts every bet, the same rule RankService applies to what counts toward the +// ladder - rakeback rides on the same eligibility, not a second setting an operator has to keep +// in sync with it. +const countsToward = (eligibleProducts: readonly string[], product: string) => + eligibleProducts.length === 0 || eligibleProducts.includes(product); + +/** + * Instant rakeback - Confluence "Rank Bonuses" Scenario 3: a qualifying bet's own-money stake + * times the player's rank rakeback percentage (tier rate plus any active streak boost), credited + * straight to the player's real balance in the same transaction as the bet. No house edge factor + * and no claim step - the spec has rakeback land on the balance as it accrues, so the wallet + * transaction it writes (type `cashback`) is the whole record; there is no separate ledger here. + * + * Runs after `RankService` in the composite, so a bet that also crosses a rank threshold pays + * rakeback at the tier just reached. + * + * Own-money only: `args.realAmount` already excludes whatever part of the stake a bonus grant + * paid for, so wagering a bonus never earns real-money rakeback on funds the player never risked. + * + * Takes a `getWallet` thunk rather than a resolved `WalletCommands`: `WALLET_COMMANDS`'s own + * factory resolves `WAGER_TRACKING` transitively (through the bonus module's wagering service), + * so resolving `WALLET_COMMANDS` eagerly while `WAGER_TRACKING` itself is still being built is a + * circular `Container.get`. Deferring the lookup to the first bet - long after both tokens have + * finished resolving - breaks the cycle. + */ +export class RakebackService implements WagerTrackingCommands { + constructor( + private readonly getWallet: () => WalletCommands | undefined, + private readonly logger: { warn: (context: object, message: string) => void }, + ) {} + + async recordWager(tx: DrizzleTx, args: WagerTrackingArgs): Promise { + const wallet = this.getWallet(); + if (!wallet || moneyCompare(args.realAmount, ZERO) <= 0) { + return []; + } + const [config] = await tx + .select({ eligibleProducts: promoRankConfig.eligibleProducts }) + .from(promoRankConfig); + if (!config || !countsToward(config.eligibleProducts, args.context.product)) { + return []; + } + const [rank] = await tx + .select({ + tierId: promoPlayerRank.tierId, + rakebackBoostPercent: promoPlayerRank.rakebackBoostPercent, + rakebackBoostExpiresAt: promoPlayerRank.rakebackBoostExpiresAt, + }) + .from(promoPlayerRank) + .where(eq(promoPlayerRank.userId, args.userId)); + if (!rank?.tierId) { + return []; + } + const [tier] = await tx + .select({ rakebackPercent: promoRankTier.rakebackPercent }) + .from(promoRankTier) + .where(eq(promoRankTier.id, rank.tierId)); + if (!tier) { + return []; + } + const boostActive = + rank.rakebackBoostExpiresAt !== null && rank.rakebackBoostExpiresAt > new Date(); + const rate = moneyAdd( + tier.rakebackPercent, + boostActive ? (rank.rakebackBoostPercent ?? ZERO) : ZERO, + ); + const rakeback = moneyDivide(moneyScaleBy(args.realAmount, rate), '100'); + if (moneyCompare(rakeback, ZERO) <= 0) { + return []; + } + // Own-money only, priced in the bet's own currency - the currency the player is already + // holding a balance in, so unlike a race/streak/rank-challenge prize (priced in whatever + // currency their own config carries) this never opens a balance in one the player does not + // already use. + const outcome = await wallet.credit(tx, { + userId: args.userId, + amount: rakeback, + currency: args.currency, + type: 'cashback', + }); + if (!outcome.ok) { + this.logger.warn({ userId: args.userId, reason: outcome.reason }, 'rakeback credit failed'); + return []; + } + // `moved: false` is a replayed credit (a duplicate bet-tracking call for the same bet) - + // the balance already changed and was already announced the first time. + return outcome.moved + ? [{ transactionId: outcome.transactionId, amount: rakeback, currency: args.currency }] + : []; + } +} diff --git a/packages/core/src/promo/gamification/service/rank-challenge-admin.service.ts b/packages/core/src/promo/gamification/service/rank-challenge-admin.service.ts new file mode 100644 index 000000000..3b1bd243f --- /dev/null +++ b/packages/core/src/promo/gamification/service/rank-challenge-admin.service.ts @@ -0,0 +1,279 @@ +import { and, asc, desc, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm'; +import { user } from '@openora/core/pam/schema/identity'; +import type { AuditWritePort, Uuid } from '@openora/core/contracts'; +import { createDomainError, type DrizzleService, type DrizzleTx } from '@openora/core/server'; +import type { + RankChallengeClaim, + RankChallengeLadder, + SetRankChallengeLadderInput, +} from '../contract/index.js'; +import { promoRankChallengeClaim, promoRankChallengeTier } from '../schema/index.js'; + +export const RankChallengeLadderCurrencyHeldError = createDomainError( + 'RankChallengeLadderCurrencyHeldError', + () => 'the ladder currency cannot change once a player has wagered toward it', +); + +const TIER_COLUMNS = { + id: promoRankChallengeTier.id, + key: promoRankChallengeTier.key, + name: promoRankChallengeTier.name, + position: promoRankChallengeTier.position, + wagerThreshold: promoRankChallengeTier.wagerThreshold, + cashAmount: promoRankChallengeTier.cashAmount, + physicalItem: promoRankChallengeTier.physicalItem, +}; + +async function toLadder(tx: DrizzleTx, currency: string): Promise { + const tiers = await tx + .select(TIER_COLUMNS) + .from(promoRankChallengeTier) + .orderBy(asc(promoRankChallengeTier.position)); + const claims = await tx + .select({ + tierId: promoRankChallengeClaim.tierId, + userId: promoRankChallengeClaim.userId, + username: user.username, + claimedAt: promoRankChallengeClaim.claimedAt, + }) + .from(promoRankChallengeClaim) + .innerJoin(user, eq(user.id, promoRankChallengeClaim.userId)); + const byTier = new Map(claims.map((c) => [c.tierId, c])); + return { + currency, + tiers: tiers.map((tier) => { + const claim = byTier.get(tier.id); + return { + ...tier, + winnerUserId: claim?.userId ?? null, + winnerUsername: claim?.username ?? null, + claimedAt: claim ? claim.claimedAt.toISOString() : null, + }; + }), + }; +} + +const toClaim = (row: { + tierId: Uuid; + tierKey: string; + userId: Uuid; + username: string; + cashAmount: string | null; + physicalItem: string | null; + claimedAt: Date; + physicalFulfilledAt: Date | null; + physicalFulfillmentNote: string | null; +}): RankChallengeClaim => ({ + tierId: row.tierId, + tierKey: row.tierKey, + userId: row.userId, + username: row.username, + cashAmount: row.cashAmount, + physicalItem: row.physicalItem, + claimedAt: row.claimedAt.toISOString(), + physicalFulfilledAt: row.physicalFulfilledAt ? row.physicalFulfilledAt.toISOString() : null, + physicalFulfillmentNote: row.physicalFulfillmentNote, +}); + +/** + * The operator's side of the Rank Challenge: read/replace the ladder, list winner records, and + * run the physical-prize fulfilment queue. Prospective by construction - a claim snapshots its + * own `cashAmount`/`physicalItem` at the moment it is won, so editing or even removing a tier's + * config afterward can never change what a past winner was granted (see `promoRankChallengeClaim` + * in schema/index.ts). No "held" guard like `RankAdminService`'s tier-removal check is needed for + * that reason; the only thing still enforced is the ladder-currency lock once anyone has wagered, + * the same rule `RankAdminService.set` applies. + */ +export class RankChallengeAdminService { + constructor( + private readonly drizzle: DrizzleService, + private readonly audit: AuditWritePort, + ) {} + + async getLadder(): Promise { + return this.drizzle.db.transaction((tx) => + toLadder(tx, 'USD').then(async (ladder) => { + const [row] = await tx + .select({ currency: promoRankChallengeTier.currency }) + .from(promoRankChallengeTier) + .limit(1); + return { ...ladder, currency: row?.currency ?? ladder.currency }; + }), + ); + } + + async setLadder(adminId: Uuid, input: SetRankChallengeLadderInput): Promise { + return this.drizzle.db.transaction(async (tx) => { + const [existing] = await tx + .select({ currency: promoRankChallengeTier.currency }) + .from(promoRankChallengeTier) + .limit(1); + if (existing && existing.currency !== input.currency) { + const [{ count }] = await tx + .select({ count: sql`count(*)::int` }) + .from(promoRankChallengeClaim); + if ((count ?? 0) > 0) { + throw new RankChallengeLadderCurrencyHeldError(); + } + } + + const before = await toLadder(tx, existing?.currency ?? input.currency); + + const kept = new Set(input.tiers.flatMap((t) => (t.id === undefined ? [] : [t.id]))); + const currentIds = before.tiers.map((t) => t.id); + const removed = currentIds.filter((id) => !kept.has(id)); + if (removed.length > 0) { + await tx.delete(promoRankChallengeTier).where(inArray(promoRankChallengeTier.id, removed)); + } + + for (const tier of input.tiers) { + if (tier.id === undefined) { + await tx.insert(promoRankChallengeTier).values({ + key: tier.key, + name: tier.name, + position: tier.position, + currency: input.currency, + wagerThreshold: tier.wagerThreshold, + cashAmount: tier.cashAmount, + physicalItem: tier.physicalItem, + }); + } else { + await tx + .update(promoRankChallengeTier) + .set({ + key: tier.key, + name: tier.name, + position: tier.position, + currency: input.currency, + wagerThreshold: tier.wagerThreshold, + cashAmount: tier.cashAmount, + physicalItem: tier.physicalItem, + }) + .where(eq(promoRankChallengeTier.id, tier.id)); + } + } + + const after = await toLadder(tx, input.currency); + await this.audit.recordInTransaction(tx, { + actorId: adminId, + actorType: 'admin', + action: 'promo.rankChallenge.ladder.updated', + resourceType: 'promo_rank_challenge_tier', + resourceId: adminId, + before, + after, + }); + return after; + }); + } + + async listClaims(): Promise { + const rows = await this.drizzle.db + .select({ + tierId: promoRankChallengeClaim.tierId, + tierKey: promoRankChallengeTier.key, + userId: promoRankChallengeClaim.userId, + username: user.username, + cashAmount: promoRankChallengeClaim.cashAmount, + physicalItem: promoRankChallengeClaim.physicalItem, + claimedAt: promoRankChallengeClaim.claimedAt, + physicalFulfilledAt: promoRankChallengeClaim.physicalFulfilledAt, + physicalFulfillmentNote: promoRankChallengeClaim.physicalFulfillmentNote, + }) + .from(promoRankChallengeClaim) + .innerJoin( + promoRankChallengeTier, + eq(promoRankChallengeTier.id, promoRankChallengeClaim.tierId), + ) + .innerJoin(user, eq(user.id, promoRankChallengeClaim.userId)) + .orderBy(desc(promoRankChallengeClaim.claimedAt)); + return rows.map(toClaim); + } + + async listFulfilmentQueue(): Promise { + const rows = await this.drizzle.db + .select({ + tierId: promoRankChallengeClaim.tierId, + tierKey: promoRankChallengeTier.key, + userId: promoRankChallengeClaim.userId, + username: user.username, + cashAmount: promoRankChallengeClaim.cashAmount, + physicalItem: promoRankChallengeClaim.physicalItem, + claimedAt: promoRankChallengeClaim.claimedAt, + physicalFulfilledAt: promoRankChallengeClaim.physicalFulfilledAt, + physicalFulfillmentNote: promoRankChallengeClaim.physicalFulfillmentNote, + }) + .from(promoRankChallengeClaim) + .innerJoin( + promoRankChallengeTier, + eq(promoRankChallengeTier.id, promoRankChallengeClaim.tierId), + ) + .innerJoin(user, eq(user.id, promoRankChallengeClaim.userId)) + .where( + and( + isNotNull(promoRankChallengeClaim.physicalItem), + isNull(promoRankChallengeClaim.physicalFulfilledAt), + ), + ) + .orderBy(desc(promoRankChallengeClaim.claimedAt)); + return rows.map(toClaim); + } + + /** + * Keyed by `tierId`, not the claim's own row id: `promoRankChallengeClaim.tierId` is unique + * per claim, and it is the one identifier `RankChallengeClaimSchema` actually exposes to the + * admin UI (see contract/index.ts). + */ + async markFulfilled(adminId: Uuid, tierId: Uuid, note: string): Promise { + return this.drizzle.db.transaction(async (tx) => { + const [claim] = await tx + .select() + .from(promoRankChallengeClaim) + .where(eq(promoRankChallengeClaim.tierId, tierId)) + .for('update'); + if (!claim) { + throw new Error('rank challenge claim not found'); + } + await tx + .update(promoRankChallengeClaim) + .set({ + physicalFulfilledAt: sql`now()`, + physicalFulfilledBy: adminId, + physicalFulfillmentNote: note, + }) + .where(eq(promoRankChallengeClaim.tierId, tierId)); + await this.audit.recordInTransaction(tx, { + actorId: adminId, + actorType: 'admin', + action: 'promo.rankChallenge.fulfilled', + resourceType: 'promo_rank_challenge_claim', + resourceId: tierId, + before: { physicalFulfilledAt: null }, + after: { physicalFulfilledAt: new Date().toISOString(), note }, + }); + const [row] = await tx + .select({ + tierId: promoRankChallengeClaim.tierId, + tierKey: promoRankChallengeTier.key, + userId: promoRankChallengeClaim.userId, + username: user.username, + cashAmount: promoRankChallengeClaim.cashAmount, + physicalItem: promoRankChallengeClaim.physicalItem, + claimedAt: promoRankChallengeClaim.claimedAt, + physicalFulfilledAt: promoRankChallengeClaim.physicalFulfilledAt, + physicalFulfillmentNote: promoRankChallengeClaim.physicalFulfillmentNote, + }) + .from(promoRankChallengeClaim) + .innerJoin( + promoRankChallengeTier, + eq(promoRankChallengeTier.id, promoRankChallengeClaim.tierId), + ) + .innerJoin(user, eq(user.id, promoRankChallengeClaim.userId)) + .where(eq(promoRankChallengeClaim.tierId, tierId)); + if (!row) { + throw new Error('rank challenge claim vanished after update'); + } + return toClaim(row); + }); + } +} diff --git a/packages/core/src/promo/gamification/service/rank-challenge-payout.service.ts b/packages/core/src/promo/gamification/service/rank-challenge-payout.service.ts new file mode 100644 index 000000000..2da8dabc0 --- /dev/null +++ b/packages/core/src/promo/gamification/service/rank-challenge-payout.service.ts @@ -0,0 +1,180 @@ +import { asc, eq, isNull } from 'drizzle-orm'; +import type { + AuditWritePort, + ExchangeRateReader, + PlayEligibilityPort, + Uuid, + WalletCommands, +} from '@openora/core/contracts'; +import type { DrizzleService, DrizzleTx } from '@openora/core/server'; +import { promoRankChallengeClaim, promoRankChallengeTier } from '../schema/index.js'; +import { priceForPayout } from '../shared/payout-currency.js'; + +/** What `plugin.ts` announces per winner, once its own settlement transaction has committed. */ +export type RankChallengeWon = { + userId: Uuid; + tierId: Uuid; + tierKey: string; + tierName: string; + /** As actually credited, after `priceForPayout` - may differ from the tier's own currency. */ + cashAmount: string | null; + physicalItem: string | null; + currency: string; + /** Null when there was no cash part, or on a replayed credit. */ + transactionId: string | null; +}; + +type Logger = { + warn: (context: object, message: string) => void; + error: (context: object, message: string) => void; +}; + +/** + * Settles a claim `RankChallengeService.recordWager` already inserted: credits the cash portion + * (if any) once, marks the claim settled, and reports the win for `plugin.ts` to announce - + * mirroring `RankPayoutService.settleLevelUps`/`StreakPayoutService.settlePending`'s own + * "read unsettled rows, credit, mark settled, return what to announce" shape, guarded by the + * same `settledAt is null` idiom `promoRankLevelUp`/`promoStreakMilestoneGrant` use. + * + * A player under a responsible-gambling block still gets the claim settled (so a physical prize + * still reaches the fulfilment queue) - only the cash credit is withheld, the same rule + * `RacePayoutService` applies to its own grants. + */ +export class RankChallengePayoutService { + constructor( + private readonly drizzle: DrizzleService, + private readonly eligibility: PlayEligibilityPort | undefined, + private readonly wallet: WalletCommands | undefined, + private readonly rates: ExchangeRateReader, + private readonly payoutCurrency: string, + private readonly audit: AuditWritePort, + private readonly logger: Logger, + ) {} + + async settlePending(): Promise { + const pending = await this.drizzle.db + .select({ id: promoRankChallengeClaim.id }) + .from(promoRankChallengeClaim) + .where(isNull(promoRankChallengeClaim.settledAt)) + .orderBy(asc(promoRankChallengeClaim.claimedAt)); + + const won: RankChallengeWon[] = []; + for (const { id } of pending) { + try { + const result = await this.drizzle.db.transaction((tx) => this.settleOne(tx, id)); + if (result) { + won.push(result); + } + } catch (err) { + // One claim's failure must not stop the next tick from settling the others due. + this.logger.error({ err, claimId: id }, 'rank challenge settlement failed'); + } + } + return won; + } + + private async settleOne(tx: DrizzleTx, claimId: Uuid): Promise { + const [claim] = await tx + .select({ + id: promoRankChallengeClaim.id, + tierId: promoRankChallengeClaim.tierId, + userId: promoRankChallengeClaim.userId, + currency: promoRankChallengeClaim.currency, + cashAmount: promoRankChallengeClaim.cashAmount, + physicalItem: promoRankChallengeClaim.physicalItem, + settledAt: promoRankChallengeClaim.settledAt, + }) + .from(promoRankChallengeClaim) + .where(eq(promoRankChallengeClaim.id, claimId)) + .for('update', { skipLocked: true }); + if (!claim || claim.settledAt !== null) { + return null; + } + const [tier] = await tx + .select({ key: promoRankChallengeTier.key, name: promoRankChallengeTier.name }) + .from(promoRankChallengeTier) + .where(eq(promoRankChallengeTier.id, claim.tierId)); + + const restricted = (await this.eligibility?.isRestricted(claim.userId)) ?? true; + if (claim.cashAmount !== null && restricted) { + this.logger.warn( + { userId: claim.userId, tierId: claim.tierId }, + 'rank challenge cash withheld - player restricted', + ); + } + + let paidCashAmount = claim.cashAmount; + let paidCurrency = claim.currency; + let transactionId: string | null = null; + if (claim.cashAmount !== null && !restricted) { + if (!this.wallet) { + throw new Error('WALLET_COMMANDS is not bound'); + } + // Never the tier's own currency unconditionally - see RacePayoutService's own use of + // `priceForPayout` for why. Throws on no rate, rolling back this claim's settlement so the + // payout job's next tick retries it. + const priced = await priceForPayout( + this.rates, + claim.cashAmount, + claim.currency, + this.payoutCurrency, + ); + const sourceRef = `rank-challenge-payout:${claim.tierId}:${claim.userId}`; + const credited = await this.wallet.credit(tx, { + userId: claim.userId, + amount: priced.amount, + currency: priced.currency, + type: 'cashback', + allowNewCurrency: true, + providerRef: { providerName: 'promo-rank-challenge', providerRefId: sourceRef }, + }); + if (!credited.ok) { + // Thrown rather than logged-and-recorded-as-granted: a claim must never settle + // `outcome: 'granted'` for cash that never moved. See RacePayoutService for the same + // "roll back and let the job retry" rule. + throw new Error(`rank challenge cash credit failed: ${credited.reason}`); + } + paidCashAmount = priced.amount; + paidCurrency = priced.currency; + transactionId = credited.moved ? credited.transactionId : null; + } + + await tx + .update(promoRankChallengeClaim) + .set({ + settledAt: new Date(), + outcome: restricted ? 'restricted' : 'granted', + cashGrantId: null, + }) + .where(eq(promoRankChallengeClaim.id, claimId)); + + await this.audit.recordInTransaction(tx, { + actorType: 'system', + action: 'promo.rankChallenge.settled', + resourceType: 'promo_rank_challenge_claim', + resourceId: claimId, + before: { settledAt: null }, + after: { + settledAt: new Date().toISOString(), + outcome: restricted ? 'restricted' : 'granted', + }, + }); + + // Same rule RacePayoutService applies: a restricted player is still ranked/settled, but + // nothing is announced - the win event exists to tell a player about money or a prize they + // can act on, and a restricted player's claim is on hold either way. + if (restricted) { + return null; + } + return { + userId: claim.userId, + tierId: claim.tierId, + tierKey: tier?.key ?? '', + tierName: tier?.name ?? '', + cashAmount: paidCashAmount, + physicalItem: claim.physicalItem, + currency: paidCurrency, + transactionId, + }; + } +} diff --git a/packages/core/src/promo/gamification/service/rank-challenge.service.ts b/packages/core/src/promo/gamification/service/rank-challenge.service.ts new file mode 100644 index 000000000..c1086de42 --- /dev/null +++ b/packages/core/src/promo/gamification/service/rank-challenge.service.ts @@ -0,0 +1,238 @@ +import { asc, desc, eq, gt, sql } from 'drizzle-orm'; +import { user } from '@openora/core/pam/schema/identity'; +import type { + ExchangeRateReader, + Uuid, + WagerTrackingArgs, + WagerTrackingCommands, + WagerTrackingWalletCredit, +} from '@openora/core/contracts'; +import { moneyCompare, type DrizzleService, type DrizzleTx } from '@openora/core/server'; +import type { + PlayerRankChallenge, + RankChallengeLadder, + RankChallengeTier, +} from '../contract/index.js'; +import { + promoRankChallengeClaim, + promoRankChallengeTier, + promoRankChallengeWager, +} from '../schema/index.js'; + +const TIER_COLUMNS = { + id: promoRankChallengeTier.id, + key: promoRankChallengeTier.key, + name: promoRankChallengeTier.name, + position: promoRankChallengeTier.position, + wagerThreshold: promoRankChallengeTier.wagerThreshold, + cashAmount: promoRankChallengeTier.cashAmount, + physicalItem: promoRankChallengeTier.physicalItem, +}; + +/** + * The Rank Challenge engine: a fifth `WAGER_TRACKING` consumer alongside `RankService`, + * `RakebackService`, `StreakService` and `RaceService` - a race-to-threshold rather than a + * repeating ladder or window. The FIRST player whose lifetime real-money wagering crosses a + * tier's threshold wins it, once, forever; every other player who later crosses the same + * threshold wins nothing. + * + * Own-money only, the same rule `RaceService` applies: `args.realAmount` already excludes + * whatever part of a stake a bonus grant covered, so wagering a bonus never wins a prize funded + * by the operator's own cash pool. + * + * Claiming happens here, inline in the same transaction every other `WAGER_TRACKING` consumer + * runs in (below the wallet's duplicate-bet guard) - the atomic part is the unique index on + * `promoRankChallengeClaim.tierId`, not the transaction boundary. Crediting the cash prize and + * emitting the win event are deferred to `RankChallengePayoutService`, mirroring how + * `promoRankLevelUp`/`promoStreakMilestoneGrant` settle out-of-band from the bet that earned + * them - a domain event announced before its own commit could tell a player about a prize a + * rolled-back transaction never granted. + */ +export class RankChallengeService implements WagerTrackingCommands { + constructor( + private readonly drizzle: DrizzleService, + private readonly rates: ExchangeRateReader, + private readonly logger: { warn: (context: object, message: string) => void }, + ) {} + + async recordWager(tx: DrizzleTx, args: WagerTrackingArgs): Promise { + if (moneyCompare(args.realAmount, '0') <= 0) { + return []; + } + const ladder = await tx + .select(TIER_COLUMNS) + .from(promoRankChallengeTier) + .orderBy(asc(promoRankChallengeTier.position)); + const [lowest] = ladder; + if (!lowest) { + return []; + } + const currency = await tx + .select({ currency: promoRankChallengeTier.currency }) + .from(promoRankChallengeTier) + .where(eq(promoRankChallengeTier.id, lowest.id)); + const ladderCurrency = currency[0]?.currency; + if (!ladderCurrency) { + return []; + } + const amount = + args.currency === ladderCurrency + ? args.realAmount + : await this.rates.convert(args.realAmount, args.currency, ladderCurrency); + if (amount === null) { + // ponytail: a wager with no rate is not counted toward the challenge; revisit if this + // shows up in logs the way the equivalent race-side skip would. + this.logger.warn( + { userId: args.userId, from: args.currency, to: ladderCurrency }, + 'rank challenge wager skipped - no exchange rate', + ); + return []; + } + + const [wager] = await tx + .insert(promoRankChallengeWager) + .values({ userId: args.userId, currency: ladderCurrency, lifetimeWagered: amount }) + .onConflictDoUpdate({ + target: promoRankChallengeWager.userId, + set: { + lifetimeWagered: sql`${promoRankChallengeWager.lifetimeWagered} + ${amount}::numeric`, + updatedAt: sql`now()`, + }, + }) + .returning({ lifetimeWagered: promoRankChallengeWager.lifetimeWagered }); + if (!wager) { + return []; + } + + const crossed = ladder.filter( + (tier) => moneyCompare(tier.wagerThreshold, wager.lifetimeWagered) <= 0, + ); + if (crossed.length === 0) { + return []; + } + const alreadyClaimed = await tx + .select({ tierId: promoRankChallengeClaim.tierId }) + .from(promoRankChallengeClaim) + .where(sql`${promoRankChallengeClaim.tierId} in ${crossed.map((t) => t.id)}`); + const claimedIds = new Set(alreadyClaimed.map((c) => c.tierId)); + const contestable = crossed.filter((t) => !claimedIds.has(t.id)); + if (contestable.length === 0) { + return []; + } + + for (const tier of contestable.sort((a, b) => a.position - b.position)) { + // The claim guard: two concurrent transactions racing the same tier both attempt this + // insert; the unique index on tierId lets exactly one land. `onConflictDoNothing` plus a + // `.returning()` check (not a pre-check select) is what makes this race-safe - a + // select-then-insert has a TOCTOU gap this does not. + const [won] = await tx + .insert(promoRankChallengeClaim) + .values({ + tierId: tier.id, + userId: args.userId, + currency: ladderCurrency, + cashAmount: tier.cashAmount, + physicalItem: tier.physicalItem, + }) + .onConflictDoNothing({ target: promoRankChallengeClaim.tierId }) + .returning({ id: promoRankChallengeClaim.id }); + // Nothing to audit here beyond the claim row itself - RankChallengePayoutService carries + // the AuditWritePort and records the settlement (cash credit, win event) once it runs, + // the same split RankService uses between "rank changed" (recorded inline) and the + // level-up bonus (settled later, audited by RankPayoutService's own caller). + void won; + } + return []; + } + + /** The ladder's tiers alone - what `recordWager`/`getForPlayer` need, no winner join. */ + private async getTiers(): Promise<{ currency: string; tiers: RankChallengeTier[] }> { + const tiers = await this.drizzle.db + .select({ ...TIER_COLUMNS, currency: promoRankChallengeTier.currency }) + .from(promoRankChallengeTier) + .orderBy(asc(promoRankChallengeTier.position)); + const [lowest] = tiers; + return { + currency: lowest?.currency ?? 'USD', + tiers: tiers.map(({ currency: _currency, ...tier }) => tier), + }; + } + + /** The ladder as an operator configured it, plus who has won each tier so far. Public. */ + async getLadder(): Promise { + const { currency, tiers } = await this.getTiers(); + const claims = await this.drizzle.db + .select({ + tierId: promoRankChallengeClaim.tierId, + userId: promoRankChallengeClaim.userId, + username: user.username, + claimedAt: promoRankChallengeClaim.claimedAt, + }) + .from(promoRankChallengeClaim) + .innerJoin(user, eq(user.id, promoRankChallengeClaim.userId)); + const byTier = new Map(claims.map((c) => [c.tierId, c])); + return { + currency, + tiers: tiers.map((tier) => { + const claim = byTier.get(tier.id); + return { + ...tier, + winnerUserId: claim?.userId ?? null, + winnerUsername: claim?.username ?? null, + claimedAt: claim ? claim.claimedAt.toISOString() : null, + }; + }), + }; + } + + async getForPlayer(userId: Uuid): Promise { + const ladder = await this.getTiers(); + const claims = await this.drizzle.db + .select({ tierId: promoRankChallengeClaim.tierId }) + .from(promoRankChallengeClaim); + const claimedIds = new Set(claims.map((c) => c.tierId)); + const nextTier = ladder.tiers.find((t) => !claimedIds.has(t.id)) ?? null; + + const [row] = await this.drizzle.db + .select({ lifetimeWagered: promoRankChallengeWager.lifetimeWagered }) + .from(promoRankChallengeWager) + .where(eq(promoRankChallengeWager.userId, userId)); + const lifetimeWagered = row?.lifetimeWagered ?? '0'; + + const top = await this.drizzle.db + .select({ + userId: promoRankChallengeWager.userId, + lifetimeWagered: promoRankChallengeWager.lifetimeWagered, + username: user.username, + }) + .from(promoRankChallengeWager) + .innerJoin(user, eq(user.id, promoRankChallengeWager.userId)) + .orderBy(desc(promoRankChallengeWager.lifetimeWagered)) + .limit(5); + + const leaderboard = top.map((r, index) => ({ + userId: r.userId, + username: r.username, + lifetimeWagered: r.lifetimeWagered, + position: index + 1, + })); + + const ownIndex = leaderboard.findIndex((e) => e.userId === userId); + let ownPosition: number | null = ownIndex === -1 ? null : ownIndex + 1; + if (ownPosition === null && moneyCompare(lifetimeWagered, '0') > 0) { + const [{ count }] = await this.drizzle.db + .select({ count: sql`count(*)::int` }) + .from(promoRankChallengeWager) + .where(gt(promoRankChallengeWager.lifetimeWagered, lifetimeWagered)); + ownPosition = (count ?? 0) + 1; + } + + return { + currency: ladder.currency, + lifetimeWagered, + nextTier, + leaderboard, + ownPosition, + }; + } +} diff --git a/packages/core/src/promo/gamification/service/rank.service.ts b/packages/core/src/promo/gamification/service/rank.service.ts index b0de21433..c7828bb35 100644 --- a/packages/core/src/promo/gamification/service/rank.service.ts +++ b/packages/core/src/promo/gamification/service/rank.service.ts @@ -1,9 +1,10 @@ -import { asc, eq, sql } from 'drizzle-orm'; +import { asc, eq, inArray, sql } from 'drizzle-orm'; import type { AuditWritePort, ExchangeRateReader, WagerTrackingArgs, WagerTrackingCommands, + WagerTrackingWalletCredit, } from '@openora/core/contracts'; import { makeNotFoundError, @@ -11,7 +12,7 @@ import { type DrizzleService, type DrizzleTx, } from '@openora/core/server'; -import type { PlayerRank, RankLadder } from '../contract/index.js'; +import type { PlayerRank, RankLadder, RankLookupEntry } from '../contract/index.js'; import { openPeriodKey, RANK_PERIOD_KINDS } from '../shared/rank-period.js'; import { promoPlayerRank, @@ -66,9 +67,9 @@ export class RankService implements WagerTrackingCommands { * Not idempotent on its own: call it only inside the wallet's debit transaction, below its * duplicate-bet guard, so a replayed bet never reaches it. */ - async recordWager(tx: DrizzleTx, args: WagerTrackingArgs) { + async recordWager(tx: DrizzleTx, args: WagerTrackingArgs): Promise { if (moneyCompare(args.amount, '0') <= 0) { - return; + return []; } const [config] = await tx .select({ @@ -77,7 +78,7 @@ export class RankService implements WagerTrackingCommands { }) .from(promoRankConfig); if (!config || !countsToward(config.eligibleProducts, args.context.product)) { - return; + return []; } const ladder = await tx .select({ @@ -91,7 +92,7 @@ export class RankService implements WagerTrackingCommands { .orderBy(asc(promoRankTier.position)); const [lowest] = ladder; if (!lowest) { - return; + return []; } const amount = args.currency === lowest.currency @@ -108,7 +109,7 @@ export class RankService implements WagerTrackingCommands { }, 'rank wager skipped - no exchange rate', ); - return; + return []; } // What the player wagered inside each open period, for the payouts that settle them. Upserted @@ -160,7 +161,7 @@ export class RankService implements WagerTrackingCommands { const reached = rank && tierFor(ladder, rank.lifetimeWagered); const current = ladder.find((tier) => tier.id === rank?.tierId); if (!reached || (current && reached.position <= current.position)) { - return; + return []; } await tx .update(promoPlayerRank) @@ -197,6 +198,7 @@ export class RankService implements WagerTrackingCommands { before: { tierId: current?.id ?? null }, after: { tierId: reached.id }, }); + return []; } /** The ladder as an operator configured it. No player data, so anyone may read it. */ @@ -215,6 +217,35 @@ export class RankService implements WagerTrackingCommands { }; } + /** + * Public rank badges for a set of other players, one query, no N+1 - a chat avatar list or + * friends panel looks these up batched for whoever is on screen. A user with no rank row yet, + * or none held (tierId null), comes back with `tierKey`/`tierName` both null rather than + * omitted, so a caller can tell "looked up, no rank" from "not in the response". + * + * There is no hidden-profile/ghost-mode setting anywhere in the platform today (checked pam + * and social modules), so nothing here has one to respect. + */ + async lookup(userIds: readonly PromoPlayerRank['userId'][]): Promise { + if (userIds.length === 0) { + return []; + } + const rows = await this.drizzle.db + .select({ + userId: promoPlayerRank.userId, + tierKey: promoRankTier.key, + tierName: promoRankTier.name, + }) + .from(promoPlayerRank) + .leftJoin(promoRankTier, eq(promoRankTier.id, promoPlayerRank.tierId)) + .where(inArray(promoPlayerRank.userId, userIds)); + const byUser = new Map(rows.map((row) => [row.userId, row])); + return userIds.map((userId) => { + const row = byUser.get(userId); + return { userId, tierKey: row?.tierKey ?? null, tierName: row?.tierName ?? null }; + }); + } + async getForPlayer(userId: PromoPlayerRank['userId']): Promise { const ladder = await this.getLadder(); const [row] = await this.drizzle.db diff --git a/packages/core/src/promo/gamification/service/streak-admin.service.ts b/packages/core/src/promo/gamification/service/streak-admin.service.ts new file mode 100644 index 000000000..36c13e1b6 --- /dev/null +++ b/packages/core/src/promo/gamification/service/streak-admin.service.ts @@ -0,0 +1,66 @@ +import type { AuditWritePort, Uuid } from '@openora/core/contracts'; +import type { DrizzleService } from '@openora/core/server'; +import { promoStreakConfig } from '../schema/index.js'; +import type { StreakConfig } from '../contract/index.js'; +import { StreakConfigNotSetError } from './streak.service.js'; + +const CONFIG_COLUMNS = { + currency: promoStreakConfig.currency, + dailyMinWager: promoStreakConfig.dailyMinWager, + eligibleProducts: promoStreakConfig.eligibleProducts, + milestones: promoStreakConfig.milestones, + resetAfterDay: promoStreakConfig.resetAfterDay, +}; + +/** + * The operator's side of the streak: the daily threshold, which products count, and the + * milestone list, read and replaced as one config - the same singleton shape + * `RankAdminService` uses for `promoRankConfig`. Every change is audited with a before/after, so + * an operator's change to what a milestone pays is traceable the way a change to a rank tier is. + * + * An edit applies from the next qualifying bet onward; it never rewrites a player's own state. + */ +export class StreakAdminService { + constructor( + private readonly drizzle: DrizzleService, + private readonly audit: AuditWritePort, + ) {} + + async getConfig(): Promise { + const [config] = await this.drizzle.db.select(CONFIG_COLUMNS).from(promoStreakConfig); + if (!config) { + throw new StreakConfigNotSetError('global'); + } + return config; + } + + async setConfig(adminId: Uuid, input: StreakConfig): Promise { + const config = { + currency: input.currency, + dailyMinWager: input.dailyMinWager, + eligibleProducts: [...new Set(input.eligibleProducts)], + milestones: input.milestones, + resetAfterDay: input.resetAfterDay, + }; + return this.drizzle.db.transaction(async (tx) => { + const [before] = await tx.select(CONFIG_COLUMNS).from(promoStreakConfig).for('update'); + await tx + .insert(promoStreakConfig) + .values({ ...config, updatedBy: adminId }) + .onConflictDoUpdate({ + target: promoStreakConfig.singletonKey, + set: { ...config, updatedBy: adminId }, + }); + await this.audit.recordInTransaction(tx, { + actorId: adminId, + actorType: 'admin', + action: 'promo.streak_config.set', + resourceType: 'promo_streak_config', + resourceId: null, + before: before ?? null, + after: config, + }); + return config; + }); + } +} diff --git a/packages/core/src/promo/gamification/service/streak-payout.service.ts b/packages/core/src/promo/gamification/service/streak-payout.service.ts new file mode 100644 index 000000000..9f28d3532 --- /dev/null +++ b/packages/core/src/promo/gamification/service/streak-payout.service.ts @@ -0,0 +1,262 @@ +import { randomInt } from 'node:crypto'; +import { and, asc, eq, isNull } from 'drizzle-orm'; +import type { + BonusGrantCommands, + DomainEventPayload, + ExchangeRateReader, + PlayEligibilityPort, + Uuid, + WalletCommands, +} from '@openora/core/contracts'; +import { moneyScaleBy, type DrizzleService, type DrizzleTx } from '@openora/core/server'; +import { promoPlayerRank, promoStreakConfig, promoStreakMilestoneGrant } from '../schema/index.js'; +import type { StreakReward } from '../contract/index.js'; +import { priceForPayout } from '../shared/payout-currency.js'; + +type Granted = DomainEventPayload<'promo.bonus.granted'>; + +/** What `plugin.ts` announces per cash reward, once its own settlement transaction has committed. */ +export type StreakCashPaid = { + userId: Uuid; + /** As actually credited, after `priceForPayout` - may differ from the streak's own currency. */ + amount: string; + currency: string; + /** Null on a replayed credit (the balance already moved and was already announced). */ + transactionId: string | null; +}; + +export type StreakPayoutResult = { granted: Granted[]; cashPaid: StreakCashPaid[] }; + +type Logger = { + warn: (context: object, message: string) => void; + error: (context: object, message: string) => void; +}; + +const BATCH = 500; +const DAY_MS = 86_400_000; + +/** A "gift drop": an amount rolled fresh at settlement, cents precision, uniform over the range. */ +function rollGiftDrop(min: string, max: string): string { + const lowCents = Math.round(Number(min) * 100); + const highCents = Math.round(Number(max) * 100); + const cents = highCents > lowCents ? randomInt(lowCents, highCents + 1) : lowCents; + return (cents / 100).toFixed(2); +} + +/** + * Settles the milestones `StreakService.recordWager` recorded: every `bonus`/`giftDrop` reward + * through `BONUS_GRANTS`, every `rakebackBoost` onto the player's own rank row. One milestone can + * carry several rewards (day 21 pays two gift drops and a fixed bonus); each gets its own + * `sourceRef` off the milestone row's id, so a retry after a partial failure never pays a reward + * that already landed twice. + * + * A player under a responsible-gambling block gets nothing settled, the same rule + * `RankPayoutService` applies - a bonus waiting at the end of a block is an incentive to return. + */ +export class StreakPayoutService { + constructor( + private readonly drizzle: DrizzleService, + private readonly grants: BonusGrantCommands | undefined, + private readonly eligibility: PlayEligibilityPort | undefined, + private readonly rates: ExchangeRateReader, + private readonly payoutCurrency: string, + private readonly logger: Logger, + private readonly wallet?: WalletCommands, + ) {} + + async settlePending(): Promise { + if (!this.grants || !this.eligibility) { + this.logger.warn({}, 'streak payout skipped - bonus grants or play eligibility not bound'); + return { granted: [], cashPaid: [] }; + } + const [config] = await this.drizzle.db + .select({ milestones: promoStreakConfig.milestones }) + .from(promoStreakConfig); + if (!config) { + return { granted: [], cashPaid: [] }; + } + const owed = await this.drizzle.db + .select({ id: promoStreakMilestoneGrant.id }) + .from(promoStreakMilestoneGrant) + .where(isNull(promoStreakMilestoneGrant.settledAt)) + .orderBy(asc(promoStreakMilestoneGrant.reachedAt)) + .limit(BATCH); + + const granted: Granted[] = []; + const cashPaid: StreakCashPaid[] = []; + for (const { id } of owed) { + try { + const paid = await this.drizzle.db.transaction((tx) => + this.settleOne(tx, id, config.milestones), + ); + granted.push(...paid.granted); + cashPaid.push(...paid.cashPaid); + } catch (err) { + // ponytail: a milestone that keeps failing is retried every run; add a failure count if + // one ever sticks, the same deferral `RankPayoutService.settleLevelUps` takes. + this.logger.error({ err, milestoneGrantId: id }, 'streak milestone payout failed'); + } + } + return { granted, cashPaid }; + } + + private async settleOne( + tx: DrizzleTx, + id: Uuid, + milestones: readonly { day: number; rewards: readonly StreakReward[] }[], + ): Promise { + const [row] = await tx + .select({ userId: promoStreakMilestoneGrant.userId, day: promoStreakMilestoneGrant.day }) + .from(promoStreakMilestoneGrant) + .where(and(eq(promoStreakMilestoneGrant.id, id), isNull(promoStreakMilestoneGrant.settledAt))) + .for('update', { skipLocked: true }); + if (!row) { + return { granted: [], cashPaid: [] }; + } + const settle = (outcome: string) => + tx + .update(promoStreakMilestoneGrant) + .set({ settledAt: new Date(), outcome }) + .where(eq(promoStreakMilestoneGrant.id, id)); + + if ((await this.eligibility?.isRestricted(row.userId)) ?? true) { + await settle('restricted'); + return { granted: [], cashPaid: [] }; + } + const rewards = milestones.find((m) => m.day === row.day)?.rewards ?? []; + const granted: Granted[] = []; + const cashPaid: StreakCashPaid[] = []; + for (const [index, reward] of rewards.entries()) { + const sourceRef = `streak-milestone:${id}:${index}`; + if (reward.kind === 'rakebackBoost') { + await this.applyRakebackBoost(tx, row.userId, reward); + continue; + } + if (reward.kind === 'cash') { + const paid = await this.grantCash(tx, row.userId, reward, sourceRef); + if (paid) { + cashPaid.push(paid); + } + continue; + } + const paid = await this.grantOne(tx, row.userId, reward, sourceRef); + if (paid) { + granted.push(paid); + } + } + await settle('granted'); + return { granted, cashPaid }; + } + + private async grantOne( + tx: DrizzleTx, + userId: Uuid, + reward: Extract, + sourceRef: string, + ): Promise { + if (!this.grants) { + throw new Error('BONUS_GRANTS is not bound'); + } + const amount = reward.kind === 'bonus' ? reward.amount : rollGiftDrop(reward.min, reward.max); + const currency = await this.currencyFor(); + const outcome = await this.grants.grant(tx, { + userId, + currency, + amount, + source: 'streak', + sourceRef, + actor: { type: 'system' }, + terms: { + wageringMultiplier: reward.terms.wageringMultiplier, + expiryDays: reward.terms.expiryDays, + ...(reward.terms.maxBet === null || reward.terms.maxBet === undefined + ? {} + : { maxBet: reward.terms.maxBet }), + ...(reward.terms.maxWinMultiplier === null || reward.terms.maxWinMultiplier === undefined + ? {} + : { maxWinMultiplier: reward.terms.maxWinMultiplier }), + }, + }); + if (!outcome.ok) { + throw new Error(`grant refused: ${outcome.reason}`); + } + if (!outcome.created) { + return null; + } + return { + userId, + grantId: outcome.grantId, + currency, + grantedAmount: amount, + wageringRequired: moneyScaleBy(amount, reward.terms.wageringMultiplier), + source: 'streak', + offerId: null, + }; + } + + /** + * `cash`: real money, no bonus grant, no wagering requirement - credited through the same + * `cashback` wallet transaction type rank rakeback uses. `sourceRef` is the milestone's own + * `providerRefId`, so a retried settlement (the failed-milestone retry loop in `settlePending`) + * can never pay the same milestone's cash reward twice. + */ + private async grantCash( + tx: DrizzleTx, + userId: Uuid, + reward: Extract, + sourceRef: string, + ): Promise { + if (!this.wallet) { + throw new Error('WALLET_COMMANDS is not bound'); + } + const currency = await this.currencyFor(); + // Never the streak's own currency unconditionally - see RacePayoutService's own use of + // `priceForPayout` for why. Throws on no rate, rolling back this milestone's settlement so + // the payout job's next tick retries it. + const priced = await priceForPayout(this.rates, reward.amount, currency, this.payoutCurrency); + const outcome = await this.wallet.credit(tx, { + userId, + amount: priced.amount, + currency: priced.currency, + type: 'cashback', + allowNewCurrency: true, + providerRef: { providerName: 'promo-streak', providerRefId: sourceRef }, + }); + if (!outcome.ok) { + // Thrown rather than logged-and-settled-as-granted: a milestone must never settle + // `outcome: 'granted'` for cash that never moved. See RacePayoutService for the same + // "roll back and let the job retry" rule. + throw new Error(`streak cash reward failed: ${outcome.reason}`); + } + return { + userId, + amount: priced.amount, + currency: priced.currency, + transactionId: outcome.moved ? outcome.transactionId : null, + }; + } + + private async currencyFor() { + const [config] = await this.drizzle.db + .select({ currency: promoStreakConfig.currency }) + .from(promoStreakConfig); + return config?.currency ?? 'USD'; + } + + /** + * Additive on top of whatever `rakebackPercent` the player's tier already carries; there is no + * rakeback payout engine in core yet to consume it, so this only records the boost for one to + * read later. See the streak module's README for the deferral. + */ + private async applyRakebackBoost( + tx: DrizzleTx, + userId: Uuid, + reward: Extract, + ) { + const expiresAt = new Date(Date.now() + reward.days * DAY_MS); + await tx + .update(promoPlayerRank) + .set({ rakebackBoostPercent: reward.percentPoints, rakebackBoostExpiresAt: expiresAt }) + .where(eq(promoPlayerRank.userId, userId)); + } +} diff --git a/packages/core/src/promo/gamification/service/streak.service.ts b/packages/core/src/promo/gamification/service/streak.service.ts new file mode 100644 index 000000000..b0e5827b3 --- /dev/null +++ b/packages/core/src/promo/gamification/service/streak.service.ts @@ -0,0 +1,228 @@ +import { and, desc, eq, gt, sql } from 'drizzle-orm'; +import { user } from '@openora/core/pam/schema/identity'; +import type { + ExchangeRateReader, + Uuid, + WagerTrackingArgs, + WagerTrackingCommands, + WagerTrackingWalletCredit, +} from '@openora/core/contracts'; +import { + makeNotFoundError, + moneyCompare, + type DrizzleService, + type DrizzleTx, +} from '@openora/core/server'; +import type { PlayerStreak, StreakLeaderboard } from '../contract/index.js'; +import { + promoPlayerStreak, + promoStreakConfig, + promoStreakDailyWager, + promoStreakMilestoneGrant, +} from '../schema/index.js'; + +export const StreakConfigNotSetError = makeNotFoundError('StreakConfig'); + +// An empty list counts every bet - the same convention `RankService` uses for eligibleProducts. +const countsToward = (eligibleProducts: readonly string[], product: string) => + eligibleProducts.length === 0 || eligibleProducts.includes(product); + +const isoDate = (date: Date) => date.toISOString().slice(0, 10); + +type Logger = { warn: (context: object, message: string) => void }; + +/** + * The daily streak: a player who wagers the operator's minimum in an eligible product on a UTC + * calendar day keeps their streak, and the milestone list in `promoStreakConfig` is what pays + * for it. Bound alongside `RankService` on the same `WAGER_TRACKING` port through + * `CompositeWagerTracking` - both read the same bet, on the same transaction, for different + * ledgers. + * + * A day is counted once no matter how many qualifying bets land inside it: the daily wager + * accumulator (`promoStreakDailyWager`) only tells this service when the threshold has been + * crossed; `promoPlayerStreak.lastQualifyingDay` is the guard that stops a second bet the same + * day from advancing the counter twice. + * + * A missed day is never observed here - only the close job (`closeDay`) sees the absence of a + * qualifying bet, because nothing else can. + * + * Own-money only, the same rule `RaceService`/`RankChallengeService` apply: `args.realAmount` + * already excludes whatever part of a stake a bonus grant covered, so wagering a bonus never + * advances the streak. + */ +export class StreakService implements WagerTrackingCommands { + constructor( + private readonly drizzle: DrizzleService, + private readonly rates: ExchangeRateReader, + private readonly logger: Logger, + ) {} + + async recordWager(tx: DrizzleTx, args: WagerTrackingArgs): Promise { + if (moneyCompare(args.realAmount, '0') <= 0) { + return []; + } + const [config] = await tx + .select({ + currency: promoStreakConfig.currency, + dailyMinWager: promoStreakConfig.dailyMinWager, + eligibleProducts: promoStreakConfig.eligibleProducts, + milestones: promoStreakConfig.milestones, + resetAfterDay: promoStreakConfig.resetAfterDay, + }) + .from(promoStreakConfig); + if (!config || !countsToward(config.eligibleProducts, args.context.product)) { + return []; + } + const amount = + args.currency === config.currency + ? args.realAmount + : await this.rates.convert(args.realAmount, args.currency, config.currency); + if (amount === null) { + // ponytail: a wager with no rate does not count toward the streak; revisit if this shows + // up in logs the way the equivalent rank-side skip would. + this.logger.warn( + { userId: args.userId, from: args.currency, to: config.currency, amount: args.realAmount }, + 'streak wager skipped - no exchange rate', + ); + return []; + } + + const today = isoDate(new Date()); + const [day] = await tx + .insert(promoStreakDailyWager) + .values({ userId: args.userId, day: today, currency: config.currency, wagered: amount }) + .onConflictDoUpdate({ + target: [promoStreakDailyWager.userId, promoStreakDailyWager.day], + set: { + wagered: sql`${promoStreakDailyWager.wagered} + ${amount}::numeric`, + updatedAt: sql`now()`, + }, + }) + .returning({ wagered: promoStreakDailyWager.wagered }); + if (!day || moneyCompare(day.wagered, config.dailyMinWager) < 0) { + return []; + } + + // One row per player, upserted per bet - `where` skips the update entirely once today has + // already advanced the counter, so a second qualifying bet the same day is a no-op here. + const [advanced] = await tx + .insert(promoPlayerStreak) + .values({ userId: args.userId, current: 1, best: 1, lastQualifyingDay: today }) + .onConflictDoUpdate({ + target: promoPlayerStreak.userId, + set: { + current: sql`${promoPlayerStreak.current} + 1`, + best: sql`GREATEST(${promoPlayerStreak.best}, ${promoPlayerStreak.current} + 1)`, + lastQualifyingDay: today, + updatedAt: sql`now()`, + }, + where: sql`${promoPlayerStreak.lastQualifyingDay} is distinct from ${today}::date`, + }) + .returning({ current: promoPlayerStreak.current }); + if (!advanced) { + return []; + } + + if (config.milestones.some((milestone) => milestone.day === advanced.current)) { + // `onConflictDoUpdate` rather than `onConflictDoNothing`: a milestone day reached on an + // earlier streak, already settled, is reactivated rather than refused - the unique index + // is per player and day, not per streak attempt. + await tx + .insert(promoStreakMilestoneGrant) + .values({ userId: args.userId, day: advanced.current }) + .onConflictDoUpdate({ + target: [promoStreakMilestoneGrant.userId, promoStreakMilestoneGrant.day], + set: { reachedAt: sql`now()`, settledAt: null, outcome: null }, + where: sql`${promoStreakMilestoneGrant.settledAt} is not null`, + }); + } + + if (advanced.current >= config.resetAfterDay) { + await tx + .update(promoPlayerStreak) + .set({ current: 0, updatedAt: sql`now()` }) + .where(eq(promoPlayerStreak.userId, args.userId)); + } + return []; + } + + async getForPlayer(userId: Uuid): Promise { + const [config] = await this.drizzle.db + .select({ + currency: promoStreakConfig.currency, + dailyMinWager: promoStreakConfig.dailyMinWager, + milestones: promoStreakConfig.milestones, + }) + .from(promoStreakConfig); + if (!config) { + throw new StreakConfigNotSetError('global'); + } + const today = isoDate(new Date()); + const [streak] = await this.drizzle.db + .select({ current: promoPlayerStreak.current, best: promoPlayerStreak.best }) + .from(promoPlayerStreak) + .where(eq(promoPlayerStreak.userId, userId)); + const [wagered] = await this.drizzle.db + .select({ wagered: promoStreakDailyWager.wagered }) + .from(promoStreakDailyWager) + .where(and(eq(promoStreakDailyWager.userId, userId), eq(promoStreakDailyWager.day, today))); + return { + current: streak?.current ?? 0, + best: streak?.best ?? 0, + todayWagered: wagered?.wagered ?? '0', + dailyMinWager: config.dailyMinWager, + currency: config.currency, + milestones: config.milestones, + }; + } + + async leaderboard(userId: Uuid): Promise { + const top = await this.drizzle.db + .select({ + userId: promoPlayerStreak.userId, + streak: promoPlayerStreak.current, + username: user.username, + }) + .from(promoPlayerStreak) + .innerJoin(user, eq(user.id, promoPlayerStreak.userId)) + .where(gt(promoPlayerStreak.current, 0)) + .orderBy(desc(promoPlayerStreak.current)) + .limit(5); + + if (top.some((row) => row.userId === userId)) { + return { top, ownPosition: top.findIndex((row) => row.userId === userId) + 1 }; + } + const [own] = await this.drizzle.db + .select({ current: promoPlayerStreak.current }) + .from(promoPlayerStreak) + .where(eq(promoPlayerStreak.userId, userId)); + if (!own || own.current <= 0) { + return { top, ownPosition: null }; + } + const [{ ahead }] = await this.drizzle.db + .select({ ahead: sql`count(*)::int` }) + .from(promoPlayerStreak) + .where(gt(promoPlayerStreak.current, own.current)); + return { top, ownPosition: ahead + 1 }; + } + + /** + * Daily UTC close: a player whose last qualifying day was not yesterday (and who is not + * already at zero) missed a day, so their run resets - `best` is untouched, it is the record, + * not the current attempt. Run once per UTC day, after it turns over; safe to run again, since + * a player already at zero or already qualified for the new day is left alone. + */ + async closeDay(now: Date): Promise { + const yesterday = isoDate(new Date(now.getTime() - 86_400_000)); + const result = await this.drizzle.db + .update(promoPlayerStreak) + .set({ current: 0, updatedAt: sql`now()` }) + .where( + and( + gt(promoPlayerStreak.current, 0), + sql`(${promoPlayerStreak.lastQualifyingDay} is null or ${promoPlayerStreak.lastQualifyingDay} < ${yesterday}::date)`, + ), + ); + return result.rowCount ?? 0; + } +} diff --git a/packages/core/src/promo/gamification/shared/payout-currency.ts b/packages/core/src/promo/gamification/shared/payout-currency.ts new file mode 100644 index 000000000..fb18cd2a1 --- /dev/null +++ b/packages/core/src/promo/gamification/shared/payout-currency.ts @@ -0,0 +1,35 @@ +import type { ExchangeRateReader } from '@openora/core/contracts'; + +/** + * Prices a cash prize or cashback credit into the operator's payout currency - `RankPayoutService` + * already does this per-player-currency dance for grants; a race prize, a rank challenge tier, and + * a streak milestone's `cash` reward are all priced in whatever currency their own config carries + * (a ladder, a tier, a milestone), which need not be a currency the player's wallet holds. Crediting + * that source currency unconditionally would open a balance for it on a wallet that otherwise never + * sees one. + * + * Same currency skips the rate lookup entirely, the same short-circuit `RankPayoutService. + * inPayoutCurrency` takes. No rate throws rather than falling back to the source currency: a + * missing rate is a transient vendor condition, and crediting the wrong currency to "make progress" + * would be the defect this function exists to close. The caller's own settlement transaction is + * expected to roll back on the throw, so the job's next tick retries the whole thing once a rate is + * available - see `handleDeposit` in the compliance module for the same "skip and let a later run + * retry" idiom. + */ +export async function priceForPayout( + rates: ExchangeRateReader, + amount: string, + currency: string, + payoutCurrency: string, +): Promise<{ amount: string; currency: string }> { + if (currency === payoutCurrency) { + return { amount, currency }; + } + const converted = await rates.convert(amount, currency, payoutCurrency); + if (converted === null) { + throw new Error( + `no exchange rate from ${currency} to ${payoutCurrency} - retry once available`, + ); + } + return { amount: converted, currency: payoutCurrency }; +} diff --git a/packages/core/src/promo/server.ts b/packages/core/src/promo/server.ts index 4b3670565..aea49ff67 100644 --- a/packages/core/src/promo/server.ts +++ b/packages/core/src/promo/server.ts @@ -1,2 +1,3 @@ export { default as bonusPlugin } from './bonus/plugin.js'; export { default as gamificationPlugin } from './gamification/plugin.js'; +export { bonusBalanceChannel } from './bonus/router/index.js'; diff --git a/packages/core/src/server/kernel/__tests__/event-stream.test.ts b/packages/core/src/server/kernel/__tests__/event-stream.test.ts new file mode 100644 index 000000000..76e0bc7bc --- /dev/null +++ b/packages/core/src/server/kernel/__tests__/event-stream.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { createMultiplexedEventStreamGenerator } from '../event-stream.js'; + +async function collect(iterable: AsyncGenerator, count: number): Promise { + const results: T[] = []; + for await (const event of iterable) { + results.push(event); + if (results.length >= count) { + break; + } + } + return results; +} + +describe('createMultiplexedEventStreamGenerator', () => { + it('tags each event with the channel name that produced it', async () => { + let pushA: ((event: string) => void) | undefined; + let pushB: ((event: string) => void) | undefined; + + const generator = createMultiplexedEventStreamGenerator([ + { name: 'a', subscribe: (push) => ((pushA = push), () => {}) }, + { name: 'b', subscribe: (push) => ((pushB = push), () => {}) }, + ]); + + const resultPromise = collect(generator, 2); + pushA?.('from-a'); + pushB?.('from-b'); + const results = await resultPromise; + + expect(results).toEqual([ + { channel: 'a', payload: 'from-a' }, + { channel: 'b', payload: 'from-b' }, + ]); + }); + + it('unsubscribes every folded channel when the abort signal fires', async () => { + const unsubscribed: string[] = []; + const controller = new AbortController(); + const generator = createMultiplexedEventStreamGenerator( + [ + { name: 'a', subscribe: () => () => unsubscribed.push('a') }, + { name: 'b', subscribe: () => () => unsubscribed.push('b') }, + ], + { signal: controller.signal }, + ); + + const nextPromise = generator.next(); + controller.abort(); + await nextPromise; + + expect(unsubscribed.sort()).toEqual(['a', 'b']); + }); + + it('stops yielding once the abort signal fires', async () => { + const controller = new AbortController(); + const generator = createMultiplexedEventStreamGenerator( + [{ name: 'a', subscribe: () => () => {} }], + { signal: controller.signal }, + ); + + controller.abort(); + const next = await generator.next(); + + expect(next.done).toBe(true); + }); +}); diff --git a/packages/core/src/server/kernel/event-stream.ts b/packages/core/src/server/kernel/event-stream.ts index 7c2cf69ec..e43b207fc 100644 --- a/packages/core/src/server/kernel/event-stream.ts +++ b/packages/core/src/server/kernel/event-stream.ts @@ -67,3 +67,36 @@ export async function* createEventStreamGenerator( cleanup(); } } + +/** One channel folded into a multiplexed stream: a name the client dispatches on, plus how to subscribe to it. */ +export type MultiplexedChannel = { + name: string; + subscribe: (push: (event: T) => void) => () => void; +}; + +/** An event from a multiplexed stream, tagged with the channel name that produced it. */ +export type MultiplexedEvent = { channel: string; payload: T }; + +/** + * Folds several independent push-subscriptions (eg one per-user realtime channel each) into a + * single SSE-servable generator, so a browser opens one HTTP connection instead of one per + * channel - the browser's per-origin connection cap otherwise starves ordinary API fetches + * behind N permanently-open streams. Each event is tagged with the channel name that produced + * it so the client can fan it back out to the right handler. Every channel subscribes for the + * lifetime of the generator and is torn down together on `signal` abort or consumer stop. + */ +export function createMultiplexedEventStreamGenerator( + channels: ReadonlyArray>, + options: EventStreamOptions> = {}, +): AsyncGenerator> { + return createEventStreamGenerator>((push) => { + const unsubscribes = channels.map(({ name, subscribe }) => + subscribe((event) => push({ channel: name, payload: event })), + ); + return () => { + for (const unsubscribe of unsubscribes) { + unsubscribe(); + } + }; + }, options); +} diff --git a/packages/core/src/server/kernel/index.ts b/packages/core/src/server/kernel/index.ts index 5283521ed..13e09988e 100644 --- a/packages/core/src/server/kernel/index.ts +++ b/packages/core/src/server/kernel/index.ts @@ -42,8 +42,11 @@ export { mapErrors } from './orpc-error-map.js'; export { assertOwnership } from './ownership.js'; export { serializeRow } from './serialize-row.js'; export type { SerializedRow } from './serialize-row.js'; -export { createEventStreamGenerator } from './event-stream.js'; -export type { EventStreamOptions } from './event-stream.js'; +export { + createEventStreamGenerator, + createMultiplexedEventStreamGenerator, +} from './event-stream.js'; +export type { EventStreamOptions, MultiplexedChannel, MultiplexedEvent } from './event-stream.js'; // T0 PlatformConfig loader. See ADR-0013 Tier 0. export { loadPlatformConfig, resolvePlatformConfigPath } from './platform-config-loader.js'; diff --git a/packages/core/src/server/runtime/core-token-catalog.ts b/packages/core/src/server/runtime/core-token-catalog.ts index fe097a608..53a208831 100644 --- a/packages/core/src/server/runtime/core-token-catalog.ts +++ b/packages/core/src/server/runtime/core-token-catalog.ts @@ -9,6 +9,7 @@ import { ADMIN_WALLET_REPORTING, AUDIT_WRITER, BONUS_GRANTS, + BONUS_LIFECYCLE, BONUS_WAGERING, CACHE, CHAT_BLOCK_WRITER, @@ -86,6 +87,7 @@ const coreTokenCatalog = { AUDIT_WRITER, AUTH_SESSION, BONUS_GRANTS, + BONUS_LIFECYCLE, BONUS_WAGERING, CACHE, CHAT_BLOCK_WRITER, diff --git a/packages/core/src/wallet/__tests__/wallet-commands.service.int.test.ts b/packages/core/src/wallet/__tests__/wallet-commands.service.int.test.ts index 9efc1801a..acee9266a 100644 --- a/packages/core/src/wallet/__tests__/wallet-commands.service.int.test.ts +++ b/packages/core/src/wallet/__tests__/wallet-commands.service.int.test.ts @@ -2,7 +2,11 @@ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vites import { findOneOrThrow } from '@openora/core/server'; import { randomUUID } from 'node:crypto'; import { eq, sql } from 'drizzle-orm'; -import type { PlayEligibilityPort, RgLimitsPort } from '@openora/core/contracts'; +import type { + BonusGrantCommands, + PlayEligibilityPort, + RgLimitsPort, +} from '@openora/core/contracts'; import { createTestDb, type TestDb } from '@openora/core/testing'; import { mock, makeAuditWriter } from '../../testing/mock.js'; import { migrate } from '../migrate.js'; @@ -350,6 +354,51 @@ describe('WalletCommandsService.credit (real PG)', () => { }); }); +describe('WalletCommandsService.credit gift/rain grant terms (real PG)', () => { + it('forwards explicit terms to the bonus grant', async () => { + const w = await seedWallet({ balance: '0' }); + const grant = vi.fn().mockResolvedValue({ ok: true, grantId: randomUUID(), created: true }); + const svcWithGrants = new WalletCommandsService(eligibility(false), audit, { + bonusGrants: mock({ grant }), + }); + + await svcWithGrants.credit(db.drizzle.db, { + userId: w.userId, + amount: '20', + currency: 'USD', + type: 'gift', + providerRef: { providerName: 'social-transfers', providerRefId: randomUUID() }, + terms: { wageringMultiplier: '3', expiryDays: 7 }, + }); + + expect(grant).toHaveBeenCalledWith( + db.drizzle.db, + expect.objectContaining({ terms: { wageringMultiplier: '3', expiryDays: 7 } }), + ); + }); + + it('omits terms and leaves the bonus module to fall back to its own default', async () => { + const w = await seedWallet({ balance: '0' }); + const grant = vi.fn().mockResolvedValue({ ok: true, grantId: randomUUID(), created: true }); + const svcWithGrants = new WalletCommandsService(eligibility(false), audit, { + bonusGrants: mock({ grant }), + }); + + await svcWithGrants.credit(db.drizzle.db, { + userId: w.userId, + amount: '20', + currency: 'USD', + type: 'rain', + providerRef: { providerName: 'social-transfers', providerRefId: randomUUID() }, + }); + + expect(grant).toHaveBeenCalledWith( + db.drizzle.db, + expect.objectContaining({ terms: undefined }), + ); + }); +}); + describe('WalletCommandsService ledger direction (real PG)', () => { // gift/rain/tip write the SAME `type` for both legs of a transfer - direction is the // only column that tells the sender's debit apart from the recipient's credit. diff --git a/packages/core/src/wallet/__tests__/wallet-transaction-direction-backfill.test.ts b/packages/core/src/wallet/__tests__/wallet-transaction-direction-backfill.test.ts index 487e41e50..bc1848cfa 100644 --- a/packages/core/src/wallet/__tests__/wallet-transaction-direction-backfill.test.ts +++ b/packages/core/src/wallet/__tests__/wallet-transaction-direction-backfill.test.ts @@ -12,6 +12,7 @@ const BACKFILL_CREDIT_TYPES: WalletTransactionType[] = [ 'manual_credit', 'swap_in', 'bet_reversal', + 'cashback', ]; const BACKFILL_DEBIT_TYPES: WalletTransactionType[] = [ 'withdrawal', diff --git a/packages/core/src/wallet/drizzle/migrations/0023_colossal_doctor_doom.sql b/packages/core/src/wallet/drizzle/migrations/0023_colossal_doctor_doom.sql new file mode 100644 index 000000000..1ca1f2495 --- /dev/null +++ b/packages/core/src/wallet/drizzle/migrations/0023_colossal_doctor_doom.sql @@ -0,0 +1 @@ +ALTER TYPE "public"."wallet_transaction_type" ADD VALUE 'cashback'; \ No newline at end of file diff --git a/packages/core/src/wallet/drizzle/migrations/meta/0023_snapshot.json b/packages/core/src/wallet/drizzle/migrations/meta/0023_snapshot.json new file mode 100644 index 000000000..4eb18ec17 --- /dev/null +++ b/packages/core/src/wallet/drizzle/migrations/meta/0023_snapshot.json @@ -0,0 +1,1988 @@ +{ + "id": "52918051-3934-44a3-b4cf-01a9cb488196", + "prevId": "66cb94b1-6e45-4b2c-866e-92ba072730f4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auto_withdrawal_rule": { + "name": "auto_withdrawal_rule", + "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 + }, + "threshold": { + "name": "threshold", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auto_withdrawal_rule_user_id_unique": { + "name": "auto_withdrawal_rule_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet": { + "name": "wallet", + "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, + "default": "'USD'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_user_id_unique": { + "name": "wallet_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_asset": { + "name": "wallet_asset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_asset_id": { + "name": "provider_asset_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "min_deposit": { + "name": "min_deposit", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "min_withdrawal": { + "name": "min_withdrawal", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "withdrawal_fee": { + "name": "withdrawal_fee", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "deposit_enabled": { + "name": "deposit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "withdrawal_enabled": { + "name": "withdrawal_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sweep_dust_threshold": { + "name": "sweep_dust_threshold", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "sweep_fee_ceiling": { + "name": "sweep_fee_ceiling", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "pool_liquidity_floor": { + "name": "pool_liquidity_floor", + "type": "numeric(38, 18)", + "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": { + "wallet_asset_currency_network_idx": { + "name": "wallet_asset_currency_network_idx", + "columns": [ + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_auto_withdrawal_config": { + "name": "wallet_auto_withdrawal_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'" + }, + "fiat_threshold": { + "name": "fiat_threshold", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "crypto_threshold": { + "name": "crypto_threshold", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "exclude_risk_flags": { + "name": "exclude_risk_flags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['high_risk','bonus_abuser','kyc_rejected','withdrawal_review','multi_account']::text[]" + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_auto_withdrawal_config_singletonKey_unique": { + "name": "wallet_auto_withdrawal_config_singletonKey_unique", + "nullsNotDistinct": false, + "columns": ["singleton_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_balance": { + "name": "wallet_balance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_id": { + "name": "wallet_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, + "default": "'0'" + }, + "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": { + "wallet_balance_wallet_id_currency_idx": { + "name": "wallet_balance_wallet_id_currency_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_balance_wallet_id_wallet_id_fk": { + "name": "wallet_balance_wallet_id_wallet_id_fk", + "tableFrom": "wallet_balance", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_bonus_credit": { + "name": "wallet_bonus_credit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_id": { + "name": "wallet_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 + }, + "source_type": { + "name": "source_type", + "type": "wallet_bonus_credit_source_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credited_amount": { + "name": "credited_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "rollover_multiplier": { + "name": "rollover_multiplier", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "rollover_required": { + "name": "rollover_required", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "rollover_progress": { + "name": "rollover_progress", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "wallet_bonus_credit_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "wallet_bonus_credit_user_id_currency_status_idx": { + "name": "wallet_bonus_credit_user_id_currency_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_bonus_credit_wallet_id_idx": { + "name": "wallet_bonus_credit_wallet_id_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_bonus_credit_wallet_id_wallet_id_fk": { + "name": "wallet_bonus_credit_wallet_id_wallet_id_fk", + "tableFrom": "wallet_bonus_credit", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_bonus_rollover_config": { + "name": "wallet_bonus_rollover_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'" + }, + "multiplier": { + "name": "multiplier", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_bonus_rollover_config_singletonKey_unique": { + "name": "wallet_bonus_rollover_config_singletonKey_unique", + "nullsNotDistinct": false, + "columns": ["singleton_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_custody_sweep": { + "name": "wallet_custody_sweep", + "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 + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "estimated_fee": { + "name": "estimated_fee", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pool_ref": { + "name": "pool_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "wallet_custody_sweep_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "run_id": { + "name": "run_id", + "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": { + "wallet_custody_sweep_user_id_currency_network_idx": { + "name": "wallet_custody_sweep_user_id_currency_network_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status IN ('pending','processing','unknown')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_custody_sweep_external_id_idx": { + "name": "wallet_custody_sweep_external_id_idx", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_custody_sweep\".\"external_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_custody_sweep_status_created_at_idx": { + "name": "wallet_custody_sweep_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_custody_sweep_provider_name_created_at_idx": { + "name": "wallet_custody_sweep_provider_name_created_at_idx", + "columns": [ + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_deposit_address": { + "name": "wallet_deposit_address", + "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 + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_deposit_address_user_id_currency_network_idx": { + "name": "wallet_deposit_address_user_id_currency_network_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_deposit_address\".\"network\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_deposit_address_user_id_currency_idx": { + "name": "wallet_deposit_address_user_id_currency_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_deposit_address\".\"network\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_deposit_address_address_tag_idx": { + "name": "wallet_deposit_address_address_tag_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_deposit_address\".\"tag\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_deposit_address_address_network_currency_idx": { + "name": "wallet_deposit_address_address_network_currency_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_deposit_address\".\"tag\" IS NULL AND \"wallet_deposit_address\".\"network\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_deposit_address_address_idx": { + "name": "wallet_deposit_address_address_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_job_run": { + "name": "wallet_job_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_name": { + "name": "job_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "wallet_job_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_job_run_job_name_idx": { + "name": "wallet_job_run_job_name_idx", + "columns": [ + { + "expression": "job_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_job_run\".\"finished_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_job_run_job_name_started_at_idx": { + "name": "wallet_job_run_job_name_started_at_idx", + "columns": [ + { + "expression": "job_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_provider_vault": { + "name": "wallet_provider_vault", + "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 + }, + "vault_account_id": { + "name": "vault_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_provider_vault_user_id_provider_name_idx": { + "name": "wallet_provider_vault_user_id_provider_name_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_provider_vault_provider_name_vault_account_id_idx": { + "name": "wallet_provider_vault_provider_name_vault_account_id_idx", + "columns": [ + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vault_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_reconciliation_finding": { + "name": "wallet_reconciliation_finding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "wallet_reconciliation_finding_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transaction_id": { + "name": "transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "wallet_reconciliation_finding_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_reconciliation_finding_kind_provider_external_id_idx": { + "name": "wallet_reconciliation_finding_kind_provider_external_id_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_reconciliation_finding\".\"external_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_reconciliation_finding_status_created_at_idx": { + "name": "wallet_reconciliation_finding_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_reconciliation_finding_run_id_idx": { + "name": "wallet_reconciliation_finding_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_transaction": { + "name": "wallet_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "wallet_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "wallet_transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "rail": { + "name": "rail", + "type": "wallet_rail", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "wallet_transaction_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_reason": { + "name": "review_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_approval_pivot_amount": { + "name": "auto_approval_pivot_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_ref_id": { + "name": "provider_ref_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_round_id": { + "name": "external_round_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_address": { + "name": "destination_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_tag": { + "name": "destination_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_wallet_id": { + "name": "destination_wallet_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_transaction_wallet_id_idx": { + "name": "wallet_transaction_wallet_id_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_created_at_idx": { + "name": "wallet_transaction_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_type_idx": { + "name": "wallet_transaction_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_status_idx": { + "name": "wallet_transaction_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_rail_idx": { + "name": "wallet_transaction_rail_idx", + "columns": [ + { + "expression": "rail", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_currency_idx": { + "name": "wallet_transaction_currency_idx", + "columns": [ + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_currency_network_idx": { + "name": "wallet_transaction_currency_network_idx", + "columns": [ + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_tx_hash_idx": { + "name": "wallet_transaction_tx_hash_idx", + "columns": [ + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_status_type_created_at_idx": { + "name": "wallet_transaction_status_type_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_wallet_id_type_status_idx": { + "name": "wallet_transaction_wallet_id_type_status_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_provider_ref_id_idx": { + "name": "wallet_transaction_provider_ref_id_idx", + "columns": [ + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_transaction\".\"provider_ref_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_external_round_id_idx": { + "name": "wallet_transaction_external_round_id_idx", + "columns": [ + { + "expression": "external_round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_wallet_id_idempotency_key_idx": { + "name": "wallet_transaction_wallet_id_idempotency_key_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_transaction\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_transaction_wallet_id_wallet_id_fk": { + "name": "wallet_transaction_wallet_id_wallet_id_fk", + "tableFrom": "wallet_transaction", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_withdrawal_address": { + "name": "wallet_withdrawal_address", + "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 + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_tag": { + "name": "destination_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_wallet_id": { + "name": "provider_wallet_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_withdrawal_address_user_id_currency_network_address_idx": { + "name": "wallet_withdrawal_address_user_id_currency_network_address_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"destination_tag\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_withdrawal_address_user_id_created_at_idx": { + "name": "wallet_withdrawal_address_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.wallet_bonus_credit_source_type": { + "name": "wallet_bonus_credit_source_type", + "schema": "public", + "values": ["gift", "rain"] + }, + "public.wallet_bonus_credit_status": { + "name": "wallet_bonus_credit_status", + "schema": "public", + "values": ["active", "completed"] + }, + "public.wallet_custody_sweep_status": { + "name": "wallet_custody_sweep_status", + "schema": "public", + "values": ["pending", "processing", "completed", "failed", "unknown"] + }, + "public.wallet_job_run_status": { + "name": "wallet_job_run_status", + "schema": "public", + "values": ["running", "completed", "skipped", "failed", "abandoned"] + }, + "public.wallet_rail": { + "name": "wallet_rail", + "schema": "public", + "values": ["crypto", "fiat"] + }, + "public.wallet_reconciliation_finding_kind": { + "name": "wallet_reconciliation_finding_kind", + "schema": "public", + "values": [ + "missing_deposit", + "unattributed_deposit", + "amount_mismatch", + "currency_mismatch", + "status_mismatch", + "unknown_at_provider", + "unconfigured_asset", + "stuck_sweep", + "rg_limit_breach", + "stuck_swap" + ] + }, + "public.wallet_reconciliation_finding_status": { + "name": "wallet_reconciliation_finding_status", + "schema": "public", + "values": ["open", "resolved"] + }, + "public.wallet_transaction_direction": { + "name": "wallet_transaction_direction", + "schema": "public", + "values": ["credit", "debit"] + }, + "public.wallet_transaction_status": { + "name": "wallet_transaction_status", + "schema": "public", + "values": ["pending", "processing", "completed", "failed", "rejected", "on_hold", "cancelled"] + }, + "public.wallet_transaction_type": { + "name": "wallet_transaction_type", + "schema": "public", + "values": [ + "deposit", + "withdrawal", + "bet", + "win", + "loss", + "bonus", + "tip", + "gift", + "rain", + "manual_credit", + "manual_debit", + "swap_out", + "swap_in", + "bet_reversal", + "cashback" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/wallet/drizzle/migrations/meta/_journal.json b/packages/core/src/wallet/drizzle/migrations/meta/_journal.json index 6bd33447e..3777ec912 100644 --- a/packages/core/src/wallet/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/wallet/drizzle/migrations/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1789328275743, "tag": "0022_natural_exodus", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1790310753944, + "tag": "0023_colossal_doctor_doom", + "breakpoints": true } ] } diff --git a/packages/core/src/wallet/server.ts b/packages/core/src/wallet/server.ts index 306183509..2c6a2119a 100644 --- a/packages/core/src/wallet/server.ts +++ b/packages/core/src/wallet/server.ts @@ -3,5 +3,5 @@ export { WalletNotFoundError, InsufficientBalanceError, } from './service/wallet.service.js'; -export { createWalletRouter } from './router/index.js'; +export { createWalletRouter, walletBalanceChannel } from './router/index.js'; export { default } from './plugin.js'; diff --git a/packages/core/src/wallet/service/wallet-commands.service.ts b/packages/core/src/wallet/service/wallet-commands.service.ts index 464edf0ad..885a45201 100644 --- a/packages/core/src/wallet/service/wallet-commands.service.ts +++ b/packages/core/src/wallet/service/wallet-commands.service.ts @@ -288,6 +288,7 @@ export class WalletCommandsService implements WalletCommands { bonusSpent: wagered.bonusSpent, bonusBalance: wagered.bonusBalanceAfter, ...(wagered.completed === null ? {} : { completed: wagered.completed }), + wagerTrackingCredits: wagered.walletCredits, }; } @@ -336,6 +337,7 @@ export class WalletCommandsService implements WalletCommands { allowNewCurrency, allowNewWallet, providerRef, + terms, }: WalletCreditArgs, ): Promise { const txn = tx as DrizzleDb; @@ -386,6 +388,7 @@ export class WalletCommandsService implements WalletCommands { source: type, sourceRef: providerRef.providerRefId, actor: { type: 'system' }, + terms, }); if (!granted.ok) { throw new WalletBonusGrantRefusedError(granted.reason); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 035a4736d..0ef064cf9 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -59,6 +59,7 @@ "@openora/core/engagement/migrate/chat-commands": ["./src/engagement/chat-commands/migrate.ts"], "@openora/core/engagement/migrate/notifications": ["./src/engagement/notifications/migrate.ts"], "@openora/core/engagement/migrate/social": ["./src/engagement/social/migrate.ts"], + "@openora/core/engagement/notifications": ["./src/engagement/notifications/index.ts"], "@openora/core/engagement/plugins/chat": ["./src/engagement/chat/plugin.ts"], "@openora/core/engagement/plugins/chat-commands": ["./src/engagement/chat-commands/plugin.ts"], "@openora/core/engagement/plugins/notifications": ["./src/engagement/notifications/plugin.ts"], diff --git a/packages/testing/src/__tests__/display-currency.e2e.test.ts b/packages/testing/src/__tests__/display-currency.e2e.test.ts index 953f92071..2ab3c3f0f 100644 --- a/packages/testing/src/__tests__/display-currency.e2e.test.ts +++ b/packages/testing/src/__tests__/display-currency.e2e.test.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'; import { eq, and } from 'drizzle-orm'; import { loadExtensions, DRIZZLE } from '@openora/core/server'; import { auditLog } from '@openora/core/audit/schema'; +import { player as playerTable } from '@openora/core/pam/schema/profile'; import { setupTestDb, bootTestApp, @@ -47,6 +48,21 @@ describe('GET /profile/display-currency', () => { expect(body.supported).toContain('USD'); expect(body.supported).toContain('BTC'); }); + + it('ignores a saved pick the operator no longer offers instead of serving it', async () => { + await app.container + .get(DRIZZLE) + .db.update(playerTable) + .set({ displayCurrency: 'ZZZ' }) + .where(eq(playerTable.id, playerId)); + + const res = await player.get('/profile/display-currency'); + + expect(res.status).toBe(200); + const body = (await res.json()) as { currency: string; supported: string[] }; + expect(body.currency).not.toBe('ZZZ'); + expect(body.supported).toContain(body.currency); + }); }); describe('PUT /profile/display-currency', () => { diff --git a/packages/testing/src/__tests__/exchange-rate.e2e.test.ts b/packages/testing/src/__tests__/exchange-rate.e2e.test.ts index 230f3d20b..959ff38a2 100644 --- a/packages/testing/src/__tests__/exchange-rate.e2e.test.ts +++ b/packages/testing/src/__tests__/exchange-rate.e2e.test.ts @@ -58,3 +58,22 @@ describe('GET /exchange-rate/rate', () => { expect(res.status).toBeLessThan(500); }); }); + +describe('GET /exchange-rate/rates', () => { + it('answers a code the operator does not offer with a null quote and keeps the rest', async () => { + const res = await player.get('/exchange-rate/rates?to=USD&from=USD&from=ZZZ'); + + expect(res.status).toBe(200); + const body = (await res.json()) as { from: string; quote: { rate: string } | null }[]; + expect(body).toEqual([ + { from: 'USD', quote: expect.objectContaining({ rate: '1.000000000000000000' }) }, + { from: 'ZZZ', quote: null }, + ]); + }); + + it('still rejects a target the operator does not offer', async () => { + const res = await player.get('/exchange-rate/rates?to=ZZZ&from=USD'); + + expect(res.status).toBe(400); + }); +}); 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 ef4b913ed..bb0cb7410 100644 --- a/packages/testing/src/__tests__/promo-rank-admin.e2e.test.ts +++ b/packages/testing/src/__tests__/promo-rank-admin.e2e.test.ts @@ -102,6 +102,7 @@ const recordWager = (userId: string, weightedAmount: string) => currency: 'USDT', amount: weightedAmount, weightedAmount, + realAmount: weightedAmount, context: { provider: 'aggregator', product: 'casino' }, }), ); @@ -331,6 +332,7 @@ describe('an operator configuring the rank ladder', () => { currency: 'USDT', amount: '15000', weightedAmount: '15000', + realAmount: '15000', context: { provider: 'aggregator', product: 'casino' }, }), ); diff --git a/packages/testing/src/__tests__/promo-rank-config.e2e.test.ts b/packages/testing/src/__tests__/promo-rank-config.e2e.test.ts index a57dbeef6..4ada5645a 100644 --- a/packages/testing/src/__tests__/promo-rank-config.e2e.test.ts +++ b/packages/testing/src/__tests__/promo-rank-config.e2e.test.ts @@ -69,6 +69,7 @@ const wager = (userId: string, amount: string, product: WagerProduct) => currency: 'USDT', amount, weightedAmount: amount, + realAmount: amount, context: { provider: 'aggregator', product }, }), ); 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 5516b6ba3..7cb009132 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 @@ -41,6 +41,7 @@ const wager = (userId: string, amount: string) => currency: 'USDT', amount, weightedAmount: amount, + realAmount: amount, context: { provider: 'aggregator', product: 'casino' }, }), ); diff --git a/packages/testing/src/__tests__/promo-ranks.e2e.test.ts b/packages/testing/src/__tests__/promo-ranks.e2e.test.ts index 39b9d715e..bd7cfa3ba 100644 --- a/packages/testing/src/__tests__/promo-ranks.e2e.test.ts +++ b/packages/testing/src/__tests__/promo-ranks.e2e.test.ts @@ -67,6 +67,7 @@ describe('a player reading their rank', () => { currency: 'USDT', amount: '12000', weightedAmount: '12000', + realAmount: '12000', context: { provider: 'aggregator', product: 'casino' }, }), );